Compare commits

..

29 Commits

Author SHA1 Message Date
Joseph Doherty c4b4046832 test: verify missing-snapshot ServiceLevel timer behavior
v2-ci / unit-tests (pull_request) Successful in 1h0m42s
2026-10-13 17:19:50 -04:00
Joseph Doherty e1feb9fbb6 Merge remote-tracking branch 'origin/master' into fix/dbless-servicelevel-freshness
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 15:58:35 -04:00
dohertj2 1bf8c614a9 Merge pull request 'Retain ServiceLevel requests across binding and publisher failures' (#720) from fix/servicelevel-binding-replay into master
v2-ci / unit-tests (push) Waiting to run
2026-10-13 15:58:19 -04:00
Joseph Doherty bc80b53e0e Merge ServiceLevel replay fixes for freshness integration 2026-10-13 15:11:20 -04:00
Joseph Doherty f478b844a1 docs: clarify anonymous session ACL enforcement 2026-10-13 14:29:00 -04:00
Joseph Doherty 1e71a25e3d fix: age DB-less ServiceLevel snapshots periodically 2026-10-13 14:29:00 -04:00
Joseph Doherty 8828e999c4 Merge remote-tracking branch 'origin/master' into fix/servicelevel-binding-replay
v2-ci / unit-tests (pull_request) Successful in 1h1m55s
2026-10-13 13:56:26 -04:00
dohertj2 4f7a07fbd3 Merge pull request 'Count individual skipped TRX results' (#714) from fix/trx-skip-reporting into master
v2-ci / unit-tests (push) Has been cancelled
2026-10-13 13:56:04 -04:00
Joseph Doherty da2e441bb9 Merge branch 'fix/trx-skip-reporting' of https://gitea.dohertylan.com/dohertj2/lmxopcua into fix/servicelevel-binding-replay
v2-ci / unit-tests (pull_request) Has been cancelled
2026-10-13 13:54:54 -04:00
Joseph Doherty c8e3e279b4 Retain and replay ServiceLevel across publisher binding and failures
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 13:26:27 -04:00
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
Joseph Doherty 3ec6006f57 test(historian): cover concurrent gateway tag cache lookups
v2-ci / build (pull_request) Successful in 6m52s
v2-ci / unit-tests (pull_request) Failing after 3h12m20s
2026-10-13 05:21:46 -04:00
dohertj2 618d98e822 Merge pull request 'Fix historian missing-tag latency, paging limits and response contracts' (#699) from fix/historian-653-followups into master
v2-ci / build (push) Successful in 6m15s
v2-ci / unit-tests (push) Failing after 3h11m48s
2026-10-13 04:45:00 -04:00
Joseph Doherty 5897764024 fix(historian): fail fast for missing tags and reduce gateway read cost
v2-ci / build (pull_request) Successful in 5m6s
v2-ci / unit-tests (pull_request) Failing after 29m54s
2026-10-13 04:31:52 -04:00
Joseph Doherty 97668d26d3 fix(historian): respect gateway ceilings and history value contracts
v2-ci / build (pull_request) Successful in 5m10s
v2-ci / unit-tests (pull_request) Failing after 28m34s
2026-10-13 03:51:04 -04:00
69 changed files with 2676 additions and 208 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`,
+31 -7
View File
@@ -573,7 +573,8 @@ node without `isHistorized` has no HistoryRead access bit and answers
| Historized node, historian configured, time range empty | `GoodNoData` |
| Historized node, historian NOT configured (`Enabled=false` / Null source) | `GoodNoData` (empty) for Raw / AtTime; Processed ⇒ `BadAggregateNotSupported` (#682) |
| Non-historized node | `BadHistoryOperationUnsupported` |
| Backend timeout or exception | `BadHistoryOperationUnsupported` per node; other nodes in the same batch are unaffected |
| Backend transport outage (#697) | `BadNoCommunication` per node across Raw, Modified, Processed, AtTime and Events; cancelled/deadline-limited server reads retain `BadTimeout` |
| Other backend exception / unsupported operation | `BadHistoryOperationUnsupported` per node; other nodes in the same batch are unaffected |
| `Backend=SqlServer`, `AutoCreateSchema=false`, schema missing/behind | every read/write fails with a clear error naming the missing schema version (health `LastError`); reads answer `BadHistoryOperationUnsupported` per node; no host crash |
A historized node with no historian configured never returns an error status — it returns
@@ -764,8 +765,11 @@ data.
refusal of the remembered one. A **raised** ceiling (or a switch to streaming) is noticed by the only reads it matters to: when a
read bounded by the remembered ceiling comes back full without proving completeness, it is re-probed **once** at its full budget
— served ⇒ the ceiling is forgotten; refused naming a larger ceiling ⇒ that one is learned and the read re-issued at it; refused
naming the same ceiling ⇒ the bounded result stands. No timer: a TTL would only add refusals for reads that already fit; the cost
of the rule is one refused re-probe per genuinely over-ceiling read (which fails either way). So an uncapped read (`NumValuesPerNode = 0`, which used to be refused outright on a strict gateway, even for a 10-row
naming the same ceiling ⇒ the bounded result stands. Raw and AtTime re-probe immediately. Per-sample aggregates (#696) reuse
a refusal for **30 seconds** before re-probing a full stream; within that period an oversized window still fails honestly with
`BadTooManyOperations`. A lowered ceiling is still re-learned immediately; a raise becomes visible on the next incomplete
aggregate read after the cooldown. So an uncapped read (`NumValuesPerNode = 0`, which used to be refused outright on a strict
gateway, even for a 10-row
window) and a page size within 16 of the ceiling are now served when the window fits. A ceiling-bounded read is answered only
when it is **provably complete** — the stream came back short of the ceiling, or reached `EndTime` (or, capped, filled the page);
a stream that delivered the whole ceiling short of `EndTime` may be truncated and fails **`BadTooManyOperations`** (not a backend
@@ -823,6 +827,21 @@ data.
it could only ever be a Part 11 *interpolated* bound. The start bound on the Gateway is the real prior sample, found by the
#686 walk-back.
**Warm crowding ceiling (#695).** A raw crowding re-read spends the remaining room below a learned gateway ceiling before
refusing; doubling the look-back allowance does not skip that final usable budget. Reverse paging retries a crowded segment
with a smaller in-window budget derived from the learned ceiling and the observed pre-window rows.
**Missing tags (#653).** Gateway Raw, Processed and AtTime first check the tag catalog. An empty exact-name catalog result or an explicit
`NotFound` returns no data without a history RPC: Raw is empty; AtTime and Processed return null/`BadNoData` at the
requested times or bucket timestamps. Unsupported aggregate validation still runs first. A bounded per-data-source cache
holds at most 4,096 tag names, with **5 seconds** for absence and **60 seconds** for presence, so newly provisioned tags become
visible after the short negative expiry. Catalog faults and cancellation never become missing-tag cache entries. The adapter drains the filtered catalog stream and checks exact names, avoiding older gateways that misparse a missing `GetTagMetadata` response as `Internal`. Empty
AtTime/Processed requests need no check. Events and modified-history capability checks are unaffected.
**Stopped-tag reverse discovery (#696).** On a strict Gateway, a truncated remainder probe retains its oldest-row evidence
and density. Gap discovery uses the learned ceiling's available budget; ordinary page reads keep their page-sized budget.
Only complete segments contribute to a page, preserving newest-first order, bounds, ties and continuation hints.
### Gateway AtTime is evaluated from raw reads (#693)
> ⚠️ **Client-visible (#693).** Gateway AtTime used to pass AVEVA's own interpolated answer (the gateway's `ReadAtTime`) through,
@@ -880,9 +899,14 @@ data.
here — the SQL backends store DateTime as its own kind and return a `DateTime`. Under an integer hint a NaN, ±∞ or
out-of-Int64-range stored value is no integer: it is treated as unusable (as `BadNoData`), so a neighbour like that is never
interpolated or saturated into a Good value.
- **What differs between backends (by design, not changed by #693).** Only the **Gateway** converts an AtTime result to the node's
integer type (`Int32` ⇒ `int`): the SQL backends store and return integer nodes as `Int64` (and Boolean as Boolean). Gateway
**Raw** reads still return a Boolean node's samples as the stored Double 0/1 (the hint is passed only on AtTime).
- **Client value types (#698, all backends).** Before returning Raw, Modified or AtTime history, the node manager converts non-null
scalar values to the node's declared DataType (including Boolean, integer widths and Float). Arrays retain their stored values.
Conversion failures produce a null value with `BadTypeMismatch` for that sample, preserving timestamps and modification metadata.
DateTime requires an actual DateTime value: the gateway's numeric DateTime encoding is unknown and cannot be inferred.
Processed results retain each aggregate's result type, so Count and averages can remain Double on a Boolean or integer node.
- **Future timestamps (#696).** A bounded raw probe checks for stored samples between now and the requested future time. An empty
future interval starts the prior-sample search near now; a full probe seeds the existing walk-back with a stored future sample.
The result still carries the requested timestamp and the evaluator's Uncertain status when no later sample exists.
- ⚠️ **Shared-evaluator fix (all backends that use it):** `AtTimeEvaluator` returned every interpolated `Int64` boxed as a
**Double** (a C# `cond ? (long)x : double` conditional widens the long); its "keeping the Int64 type" tests passed because
Shouldly's `ShouldBe` compares numbers across types. SqlServer / QuestDB interpolated `Int64` AtTime values are now `Int64`,
@@ -1764,7 +1788,7 @@ this family and **are** capped (they used to be pushed down). Raise the cap, or
### Health
The read client reports `GetHealthSnapshot()` (query/failure counters, consecutive failures, `LastError` — e.g. a missing schema under
`AutoCreateSchema=false`). A SQL error on a read surfaces as `BadHistoryOperationUnsupported` for **that node only**; a cancelled read
`AutoCreateSchema=false`). A SQL connection outage on a read surfaces as `BadNoCommunication`; other SQL errors retain `BadHistoryOperationUnsupported` for **that node only**; a cancelled read
(deadline, client abort) is not counted as a backend fault.
### Retention (`RetentionDays`)
+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 @@
# Gateway tag-presence concurrency coverage — 2026-10-13
Approved tests-only fix for #701. Added 15 deterministic cases for overlapping positive/negative cold lookups, expiry refreshes, capacity crossing, successful-result isolation from faults/cancellation, and cancellation after asynchronous completion. Fake presence calls support an async hook and atomic counter. Production cache is unchanged; no single-flight promise.
The synthetic 16-caller cold/expiry bursts produced 16 catalog RPCs. Assertions permit one through16; this records amplification without making it a requirement or a live-cost claim. Newclass15/15 and Gateway449/449 passed. Independent review found no actionable defects; diff check clean.
First full solution run:14,614passed/609skipped/1failed across62suites. Existing restart/reconnect wire test failed, then isolated retry passed1/1. Full repeat with detailed console logging passed14,615/609skip/0fail across62suites, exit0, including OPC UA integration122/122 and host integration268passed/12skip. #702 tracks the unresolved intermittent failure; it is not closed merely because the repeat passed.
Both full runs excluded configured live fixture variables from the environment; environment-gated tests skipped. Existing NU1510/xUnit duplicate-InlineData warnings remain pre-existing. No production behavior or deployment changed by this patch.
@@ -0,0 +1,36 @@
# Missing-tag and Gateway performance fixes — 2026-10-13
Follow-up to pushed `97668d26` / PR #699. Implements #653 and the remaining #696 items 2/3 under the approved bounded design.
## Changes
- **#653:** Raw, Processed and AtTime check tag presence before history RPCs. The bounded 4,096-name cache uses a 5-second negative and 60-second positive lifetime. Only explicit absence or NotFound is cached as missing; failures and cancellation propagate. Raw is empty; AtTime and Processed retain requested/bucket timestamps with null/BadNoData. Unsupported aggregate validation runs first.
- **Live compatibility adjustment:** the deployed gateway misparses missing `GetTagMetadata` replies as `Internal` (`InvalidDataException: GetTagInfo response ended unexpectedly`). The adapter therefore checks the filtered tag catalog and compares exact names, fully draining the stream before declaring presence/absence. It never turns the parser error into a missing tag. Wildcard matches alone do not prove presence, and a trailing stream fault cannot enter the cache.
- **#696 item 2:** a truncated remainder probe seeds strict-ceiling reverse discovery with its observed rows/density; gap searches use the available discovery budget. Complete segments alone contribute to the newest-first page. Regression: a 7,200-row stopped 1 Hz tag, ceiling 1,000, requested latest three one day later, previously 17 raw calls; now meets a maximum of 12 with exactly 7199/7198/7197 returned. Ordinary page sizing and event discovery behavior are unchanged.
- **#696 item 3:** per-sample aggregates reuse a ceiling refusal for 30 seconds instead of immediately re-probing on every full stream. The next incomplete aggregate read after expiry notices a raised ceiling; a lowered ceiling is re-learned immediately. Raw and AtTime re-probes remain immediate. During the cooldown an oversized aggregate remains BadTooManyOperations, never a truncated result.
## Verification evidence
New regressions failed before fixes: all initial 12 cases failed (missing preflight/caching, 17 discovery reads, duplicate aggregate refusal). The four catalog wire cases failed with the metadata adapter and pass with the catalog adapter. Final Gateway suite: 434 passed, six environment-gated skips.
Independent read-only review found no important defects. Concurrent cold requests can duplicate a metadata/catalog check; cache access and capacity mutations are locked. No single-flight guarantee was introduced. Catalog RPC contract streams all matches; the upstream SDK pages the native tag-query results, with no configured browse result ceiling.
## Live #653 confirmation
Measured the updated GatewayHistorianDataSource through the real adapter and local gateway against the existing remote AVEVA connection. Temporary historian:read key had a two-hour expiry and was revoked after the run. No historian writes/provisioning and no gateway deployment changes.
Nonexistent tag: `OtOpcUa.Issue653.Nonexistent.20261013`.
| Operation, five consecutive runs | Elapsed milliseconds | Result |
|---|---|---|
| Raw | 228, 0, 0, 0, 0 | empty |
| Count processed | 2, 0, 0, 0, 0 | six null/BadNoData buckets |
| AtTime | 1, 0, 0, 0, 0 | null/BadNoData at requested time |
These used one data source/cache: the first Raw call is cold, subsequent calls are cached. The prior direct-client measurement took ~30.6 seconds for missing-tag Raw/Aggregate and ~61.3 seconds for AtTime. Direct client RPCs are not modified; the fast-fail protection is in OtOpcUa.
**Control limitation:** SysTimeSec catalog presence succeeded, but its Raw RPC twice timed out upstream with DeadlineExceeded at ~30 seconds. The data source propagated IOException/communication failure rather than returning no data. A fresh control using the original package client (no data-source presence checks) also timed out with DeadlineExceeded after 30,656 ms, confirming this timeout is independent of the new checks. The earlier direct-client control had succeeded. This run therefore verifies missing-tag fast-fail and failure propagation, not healthy-tag live history behavior. No end-to-end deployed OPC UA claim is made.
#694 remains open for native aggregate rerun after historiangw#27. No sibling gateway code changed.
Full final-tree solution: 14,600 passed, 609 skipped, 0 failed across 62 suites. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --verbosity quiet`, exit 0. Includes 122 passing OPC UA integration tests and 268 passing host integration tests (12 host skips). Environment-gated suites are included in the skip count.
@@ -0,0 +1,28 @@
# Historian follow-up results — 2026-10-13
Scope: approved bounded fixes for #695–#698, plus live measurement of #653. Base: `66a2b0c3`.
- #695: spend the remaining learned-ceiling room on crowding reads; reduce crowded reverse-read windows using the observed pre-window count. Regression cases cover ceilings 1,709 and 1,369, and reverse paging through a crowded millisecond.
- #696 item 1: probe the future interval before starting the prior-sample search near now. Preserve stored future samples, requested timestamps and Uncertain status. Dense future probes still use the existing bounded walk. Items 2 and 3 remain open (stopped-tag strict-ceiling cost and aggregate ceiling re-probes).
- #697: actual Gateway, SQL Server and QuestDB transport failures become `BadNoCommunication` on every HistoryRead arm. Cancellation remains `BadTimeout`; unsupported operations retain `BadHistoryOperationUnsupported`. Tests include real refused database connections, repeated reads and nested gRPC failures.
- #698: normalize Raw, Modified and AtTime scalar values to the node DataType. Conversion failure gives per-sample null/`BadTypeMismatch`; arrays, audit metadata and timestamps survive. Processed results retain aggregate-specific types.
- #694 item 3 remains blocked on historiangw#27. No upstream code was changed.
## Live #653 measurement
Five sequential calls per operation and tag, using client 0.3.0 against the running local gateway (`localhost:5221`) with its existing remote AVEVA connection. Commissioned a two-hour `historian:read` key, then revoked it after measurement. No historian writes or provisioning performed.
| Operation | SysTimeSec, elapsed ms | Nonexistent tag, elapsed ms |
|---|---|---|
| Metadata | 299, 85, 81, 82, 48 (success) | 89, 53, 41, 77, 76 (gateway exception) |
| Raw | 143, 99, 145, 133, 159 (success) | 30260, 30634, 30675, 30754, 30628 (gateway exception) |
| Aggregate | 271, 275, 309, 249, 304 (success) | 30564, 30605, 30583, 30575, 30806 (gateway exception) |
| AtTime | 140, 88, 89, 127, 92 (success) | 61074, 61330, 61601, 61249, 61271 (gateway exception) |
Nonexistent tag: `OtOpcUa.Issue653.Nonexistent.20261013`. The delay is still present. Recommend a metadata pre-check with a short negative cache, caching only explicit missing-tag outcomes and never authentication, communication or arbitrary errors. This is follow-up implementation; #653 remains open. Direct gateway AtTime above differs from OtOpcUa's raw-based AtTime evaluator.
## Consumer and validation notes
ScadaBridge source and tests contain no HistoryRead consumer; no consumer change required. Independent review found a future-stored-sample edge case, which was fixed and covered before final verification. No deployment or live end-to-end OPC UA verification of these new fixes is claimed; the live run measures #653 through the gateway client.
Full solution: 14,581 passed, 609 skipped, 0 failed across 62 test suites. Command: `dotnet test ZB.MOM.WW.OtOpcUa.slnx --verbosity quiet`, exit 0. Skips include environment-gated integration suites.
@@ -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)
@@ -1,21 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve
/// the publisher at DI time, but the production <c>SdkServiceLevelPublisher</c> only exists
/// after <c>StandardServer.Start</c>. The Host's hosted service swaps the inner once the SDK
/// is up; until then writes route through <see cref="NullServiceLevelPublisher"/>.
/// Late-binding adapter that accepts and retains the latest requested ServiceLevel until
/// the SDK publisher is available. Binding synchronously replays that request, including zero.
/// </summary>
/// <remarks>
/// Publishing and binding are serialized through the synchronous inner call. Inner publishers
/// must not call back into this adapter. Acceptance while unbound is not an SDK write confirmation.
/// A failed call retains both the requested level and the installed publisher for later recovery.
/// </remarks>
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
{
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
private readonly object _gate = new();
private IServiceLevelPublisher? _inner;
private byte? _latest;
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
/// <param name="inner">The publisher implementation to use, or null to use the null publisher.</param>
public void SetInner(IServiceLevelPublisher? inner) =>
_inner = inner ?? NullServiceLevelPublisher.Instance;
/// <summary>Bind and replay the latest request, or unbind while retaining that request.</summary>
/// <param name="inner">The publisher implementation to use, or null to unbind.</param>
public void SetInner(IServiceLevelPublisher? inner)
{
lock (_gate)
{
_inner = inner;
if (_latest is { } latest) _inner?.Publish(latest);
}
}
/// <inheritdoc />
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
public void Publish(byte serviceLevel)
{
lock (_gate)
{
_latest = serviceLevel;
_inner?.Publish(serviceLevel);
}
}
}
@@ -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)
{
@@ -227,7 +227,27 @@ internal static class GatewayAtTimeReader
{
if (!beforeKnown)
{
before = await WalkBackAsync(ctx, qs, walkKnown, walkWidth).ConfigureAwait(false);
// #696: prove the future stretch empty before jumping to now. Historian clocks or imports
// can place real rows there; a full bounded probe seeds the original backward search.
var searchEnd = qs;
if (qs > now && walkKnown is null)
{
var future = await ReadTrustedAsync(ctx, MsFloor(now),
Min(MsFloor(qs) + OneMs, horizon), ReadRows).ConfigureAwait(false);
StoredSample? latest = null;
foreach (var row in future.Rows)
if (row.TimeUtc <= qs) latest = row;
if (future.Complete)
{
before = latest;
beforeKnown = latest is not null;
searchEnd = now;
}
else if (latest is { } found)
walkKnown = found.TimeUtc;
}
if (!beforeKnown)
before = await WalkBackAsync(ctx, searchEnd, walkKnown, walkWidth).ConfigureAwait(false);
beforeKnown = true;
}
prev = before;
@@ -68,7 +68,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
internal int MaxPreWindowRows => _maxPreWindowRows;
/// <summary>A strict gateway's row ceiling, learned from its first refusal (#691.3).</summary>
private readonly GatewayRowCeiling _ceiling = new();
private readonly GatewayRowCeiling _ceiling;
private readonly GatewayTagPresenceCache _tags;
private readonly object _healthLock = new();
private DateTime? _lastSuccessUtc;
@@ -107,6 +108,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
_logger = logger;
_maxRawRows = maxRawRows;
_time = timeProvider ?? TimeProvider.System;
_ceiling = new GatewayRowCeiling(_time);
_tags = new GatewayTagPresenceCache(_client, _time);
}
/// <inheritdoc />
@@ -126,6 +129,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
try
{
if (!await _tags.ExistsAsync(fullReference, cancellationToken).ConfigureAwait(false))
{
RecordOutcome(success: true, error: null);
return new HistoryReadResult([], ContinuationPoint: null);
}
var samples = await ReadRawWindowAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken)
.ConfigureAwait(false);
var snapshots = SampleMapper.ToSnapshots(samples);
@@ -152,6 +160,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
catch (Exception ex)
{
RecordReadFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("HistorianGateway read transport is unavailable.", ex);
throw;
}
}
@@ -239,7 +248,15 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
for (var attempt = 0; attempt < MaxCrowdedReReads && preWindow <= _maxPreWindowRows; attempt++)
{
var budget = (int)Math.Min(int.MaxValue, (long)cap + room + RawAggregateReader.PreWindowAllowance);
if (_ceiling.Learned is var known and > 0 && budget > known) break; // over a strict ceiling: refused anyway
// #695: doubling can leap over the ceiling while the cluster still fits. Spend the remaining room once.
if (_ceiling.Learned is var known and > 0 && budget > known)
{
var available = known - cap - RawAggregateReader.PreWindowAllowance;
if (available <= 0) break;
room = Math.Min(room, available);
budget = cap + room + RawAggregateReader.PreWindowAllowance;
}
if (budget <= pass.Budget) break; // no more room, never retry the same full read
pass = await PassAsync(budget).ConfigureAwait(false);
if (pass.Result.Complete) return pass.Result.Samples;
preWindow = Math.Max(preWindow, pass.Result.PreWindowRows);
@@ -342,6 +359,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
if (plan.BucketCount == 0) return new HistoryReadResult([], ContinuationPoint: null);
try
{
if (!await _tags.ExistsAsync(fullReference, cancellationToken).ConfigureAwait(false))
{
RecordOutcome(success: true, error: null);
return new HistoryReadResult(plan.Assemble(new Dictionary<long, DataValueSnapshot>(), t => AggregateEvaluator.NoData(t, _time.GetUtcNow().UtcDateTime)), ContinuationPoint: null);
}
var byBucket = new Dictionary<long, DataValueSnapshot>();
foreach (var request in NativeAggregatePlan.Requests(plan))
{
@@ -365,6 +387,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
catch (Exception ex)
{
RecordReadFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("HistorianGateway read transport is unavailable.", ex);
throw;
}
}
@@ -380,6 +403,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
try
{
var now = _time.GetUtcNow().UtcDateTime;
if (plan.BucketCount > 0 && !await _tags.ExistsAsync(fullReference, cancellationToken).ConfigureAwait(false))
{
RecordOutcome(success: true, error: null);
return new HistoryReadResult(plan.Assemble(new Dictionary<long, DataValueSnapshot>(), t => AggregateEvaluator.NoData(t, now)), ContinuationPoint: null);
}
var values = await RawAggregateReader
.ReadAsync(_client, _ceiling, fullReference, aggregate, plan, _maxRawRows, now, cancellationToken)
.ConfigureAwait(false);
@@ -408,6 +436,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
catch (Exception ex)
{
RecordReadFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("HistorianGateway read transport is unavailable.", ex);
throw;
}
}
@@ -449,6 +478,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
try
{
var now = _time.GetUtcNow().UtcDateTime;
if (timestampsUtc.Count > 0 && !await _tags.ExistsAsync(fullReference, cancellationToken).ConfigureAwait(false))
{
RecordOutcome(success: true, error: null);
return MissingAtTime(timestampsUtc, now);
}
var values = await GatewayAtTimeReader
.ReadAsync(_client, _ceiling, fullReference, timestampsUtc, now, cancellationToken, valueType)
.ConfigureAwait(false);
@@ -466,6 +500,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
catch (Exception ex)
{
RecordReadFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("HistorianGateway read transport is unavailable.", ex);
throw;
}
}
@@ -476,6 +511,12 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
{
try
{
if (timestampsUtc.Count > 0 && !await _tags.ExistsAsync(fullReference, cancellationToken).ConfigureAwait(false))
{
RecordOutcome(success: true, error: null);
return MissingAtTime(timestampsUtc, _time.GetUtcNow().UtcDateTime);
}
var samples = await _client
.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken)
.ConfigureAwait(false);
@@ -486,6 +527,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
catch (Exception ex)
{
RecordReadFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("HistorianGateway read transport is unavailable.", ex);
throw;
}
}
@@ -581,6 +623,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
catch (Exception ex)
{
RecordReadFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("HistorianGateway read transport is unavailable.", ex);
throw;
}
}
@@ -655,6 +698,19 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
return result;
}
/// <summary>No-data values for a missing tag, stamped with the client's requested times.</summary>
private static HistoryReadResult MissingAtTime(IReadOnlyList<DateTime> times, DateTime now) =>
new(times.Select(t => AtTimeEvaluator.Evaluate(t, null, null, now, now)).ToArray(), ContinuationPoint: null);
/// <summary>Classifies actual gRPC/package transport failures without treating refusals as outages (#697).</summary>
private static bool IsTransportFailure(Exception ex) => ex switch
{
ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayUnavailableException => true,
RpcException rpc => rpc.StatusCode is Grpc.Core.StatusCode.Unavailable or Grpc.Core.StatusCode.DeadlineExceeded,
TimeoutException => true,
_ => ex.InnerException is { } inner && IsTransportFailure(inner),
};
/// <summary>
/// Records a failed read: bumps the health counters and logs a generic, redaction-safe
/// debug line (no tag, host, or value). The exception itself is rethrown by the caller.
@@ -76,13 +76,15 @@ internal static class GatewayReadLimits
/// the read is re-issued at <c>N</c>; refused naming <c>C</c> again ⇒ the <c>C</c>-bounded result stands (no repeat).
/// </para>
/// <para>
/// <b>Why no TTL.</b> A raise only matters to a read that needs more than <c>C</c>, and such a read re-probes anyway
/// (rule 3), so a timer would only add refusals for reads that fit. The cost of the rule is one refused re-probe per
/// genuinely over-ceiling read — a read that fails <c>BadTooManyOperations</c> whatever is done.
/// <b>Re-probe timing (#696).</b> Raw and AtTime reads re-probe incomplete streams immediately. Per-sample
/// aggregate reads opt into a 30-second cooldown after a refusal; a full stream during it remains a request
/// limit, never a truncated answer. After the cooldown the next incomplete read detects a raised ceiling.
/// </para>
/// </remarks>
internal sealed class GatewayRowCeiling
internal sealed class GatewayRowCeiling(TimeProvider? timeProvider = null)
{
private readonly TimeProvider _time = timeProvider ?? TimeProvider.System;
private long _lastRefusalTicks;
private int _learned; // 0 = unknown / not strict
/// <summary>The learned ceiling (0 = none).</summary>
@@ -93,8 +95,9 @@ internal sealed class GatewayRowCeiling
/// <param name="budget">The <c>max_values</c> the read wants.</param>
/// <param name="pass">One drained read with the given budget.</param>
/// <param name="needsMore">True when a pass with the given budget delivered it all without proving completeness.</param>
/// <param name="deferRecentReprobe">Aggregate-only opt-in: reuse a refusal for 30 seconds before re-probing an incomplete read.</param>
/// <returns>The result and the budget it was read with.</returns>
public async Task<(T Result, int Budget)> ReadAsync<T>(int budget, Func<int, Task<T>> pass, Func<T, int, bool> needsMore)
public async Task<(T Result, int Budget)> ReadAsync<T>(int budget, Func<int, Task<T>> pass, Func<T, int, bool> needsMore, bool deferRecentReprobe = false)
{
var learned = Learned;
if (learned <= 0 || learned >= budget) return await AttemptAsync(budget, pass).ConfigureAwait(false);
@@ -102,6 +105,10 @@ internal sealed class GatewayRowCeiling
var bounded = await AttemptAsync(learned, pass).ConfigureAwait(false);
if (bounded.Budget < learned || !needsMore(bounded.Result, bounded.Budget)) return bounded;
// #696: aggregate reads may reuse a recent refusal for 30 seconds. Raw paging keeps immediate re-probes.
if (deferRecentReprobe && _time.GetUtcNow().UtcTicks - Volatile.Read(ref _lastRefusalTicks) < TimeSpan.FromSeconds(30).Ticks)
return bounded;
// Rule 3: full at a possibly stale ceiling — re-probe once at the full budget.
int fresh;
try
@@ -114,10 +121,16 @@ internal sealed class GatewayRowCeiling
{
// fall through (outside the catch)
}
Volatile.Write(ref _learned, fresh);
Learn(fresh);
return fresh > learned ? (await pass(fresh).ConfigureAwait(false), fresh) : bounded;
}
private void Learn(int ceiling)
{
Volatile.Write(ref _lastRefusalTicks, _time.GetUtcNow().UtcTicks);
Volatile.Write(ref _learned, ceiling);
}
private async Task<(T Result, int Budget)> AttemptAsync<T>(int budget, Func<int, Task<T>> pass)
{
int ceiling;
@@ -129,7 +142,7 @@ internal sealed class GatewayRowCeiling
{
// fall through to the retry (outside the catch, so a second refusal is not nested in this one)
}
Volatile.Write(ref _learned, ceiling);
Learn(ceiling);
return (await pass(ceiling).ConfigureAwait(false), ceiling);
}
}
@@ -0,0 +1,36 @@
using Grpc.Core;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// <summary>Bounded metadata cache (#653). Missing tags expire quickly; failures never become missing tags.</summary>
internal sealed class GatewayTagPresenceCache(IHistorianGatewayClient client, TimeProvider time)
{
internal const int Capacity = 4096;
private readonly object _sync = new();
private readonly Dictionary<string, (bool Exists, DateTimeOffset Expires)> _entries = new(StringComparer.Ordinal);
public async Task<bool> ExistsAsync(string tag, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
lock (_sync)
if (_entries.TryGetValue(tag, out var entry) && time.GetUtcNow() < entry.Expires) return entry.Exists;
bool exists;
try { exists = await client.TagExistsAsync(tag, ct).ConfigureAwait(false); }
catch (Exception ex) when (IsNotFound(ex)) { exists = false; }
ct.ThrowIfCancellationRequested();
lock (_sync)
{
if (!_entries.ContainsKey(tag) && _entries.Count >= Capacity) _entries.Remove(_entries.Keys.First());
_entries[tag] = (exists, time.GetUtcNow().AddSeconds(exists ? 60 : 5));
}
return exists;
}
private static bool IsNotFound(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
if (e is RpcException { StatusCode: StatusCode.NotFound }) return true;
return false;
}
}
@@ -81,6 +81,17 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi
return new HistorianGatewayClientAdapter(HistorianGatewayClient.Create(clientOptions));
}
/// <inheritdoc />
public async Task<bool> TagExistsAsync(string tag, CancellationToken ct)
{
// #653: older gateway SDKs misparse a missing GetTagInfo response as Internal. A catalog query proves
// absence without guessing from an error. Even a glob-like tag is checked by exact name, never by row count.
var found = false;
await foreach (var name in _inner.BrowseTagNamesAsync(tag, ct).ConfigureAwait(false))
found |= string.Equals(name, tag, StringComparison.OrdinalIgnoreCase);
return found; // drain first: a trailing stream fault must propagate and must not become a cache entry
}
/// <inheritdoc />
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) =>
@@ -11,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// </summary>
public interface IHistorianGatewayClient : IAsyncDisposable
{
/// <summary>Checks the tag catalog; false means explicitly absent, while failures propagate.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Whether the historian contains the tag.</returns>
Task<bool> TagExistsAsync(string tag, CancellationToken ct);
/// <summary>Streams raw historian samples for a tag over a time window.</summary>
/// <param name="tag">The historian tag name to read.</param>
/// <param name="startUtc">Inclusive start of the requested time window (UTC).</param>
@@ -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,
@@ -188,7 +188,7 @@ internal static class RawAggregateReader
var requested = MaxValuesFor(maxWindowRows);
var (pass, maxValues) = await ceiling.ReadAsync(requested,
budget => ReadWindowAsync(client, tag, aggregate, plan, requestStart, budget, maxWindowRows, ct),
(r, budget) => !r.OverCap && r.Received >= budget).ConfigureAwait(false);
(r, budget) => !r.OverCap && r.Received >= budget, deferRecentReprobe: true).ConfigureAwait(false);
var (buckets, earliest, latest, received, overCap) = pass;
if (overCap) throw LimitExceeded(tag, maxWindowRows);
if (received >= maxValues) throw BudgetExhausted(tag, maxWindowRows, requested, maxValues);
@@ -137,6 +137,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -232,6 +233,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -316,6 +318,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -443,6 +446,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -538,6 +542,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -701,6 +706,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
private void RecordSuccess() => _health.RecordSuccess();
/// <summary>Classifies provider transport/timeouts, keeping SQL/query errors distinct (#697).</summary>
private static bool IsTransportFailure(Exception ex) => ex is NpgsqlException { IsTransient: true } and not PostgresException
|| ex is PostgresException pg && (pg.SqlState.StartsWith("08", StringComparison.Ordinal)
|| pg.SqlState is "57P01" or "57P02" or "57P03");
private void RecordFailure(Exception ex)
{
// A cancelled read (deadline / client abort) is not a backend fault (the counters ignore it too).
@@ -132,6 +132,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -229,6 +230,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
catch (Exception ex) when (ex is not NotSupportedException)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -306,6 +308,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -401,6 +404,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -512,6 +516,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
catch (Exception ex)
{
RecordFailure(ex);
if (IsTransportFailure(ex)) throw new System.IO.IOException("Historian read transport is unavailable.", ex);
throw;
}
}
@@ -635,6 +640,9 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
private void RecordSuccess() => _health.RecordSuccess();
/// <summary>Classifies provider transport/timeouts, keeping SQL/query errors distinct (#697).</summary>
private static bool IsTransportFailure(Exception ex) => ex is SqlException sql && sql.Number is -2 or 0 or 53 or 64 or 233 or 258 or 10053 or 10054 or 10060 or 10061 or 10065 or 11001 or 40197 or 40613;
private void RecordFailure(Exception ex)
{
// A cancelled read (deadline / client abort) is not a backend fault (the counters ignore it too).
@@ -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,38 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>Coordinates endpoint cleanup after ServiceLevel binding fails during startup.</summary>
internal static class FailedServiceLevelBindingCleanup
{
/// <summary>Stops and disposes the endpoint, returning whether ownership can be released.</summary>
/// <param name="stop">The SDK stop operation.</param>
/// <param name="dispose">The application-host disposal operation.</param>
/// <param name="logger">The cleanup logger.</param>
/// <returns>Whether cleanup completed successfully.</returns>
internal static async Task<bool> StopAndDisposeAsync(
Func<Task> stop, Func<ValueTask> dispose, ILogger logger)
{
var completed = true;
try
{
await stop().ConfigureAwait(false);
}
catch (Exception ex)
{
completed = false;
logger.LogError(ex, "OtOpcUaServerHostedService: SDK stop failed after binding failure; retaining endpoint ownership for shutdown retry");
}
// Attempt application cleanup even when stopping faults; keep ownership on either failure.
try
{
await dispose().ConfigureAwait(false);
}
catch (Exception ex)
{
completed = false;
logger.LogError(ex, "OtOpcUaServerHostedService: endpoint disposal failed after binding failure; retaining endpoint ownership for shutdown retry");
}
return completed;
}
}
@@ -339,11 +339,36 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_server.NodeManager.Clock = historyUpdate.Clock;
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
try
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
if (_server.CurrentInstance is { } serverInternal)
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"OtOpcUaServerHostedService: ServiceLevel binding failed; stopping the OPC UA endpoint while the admin host continues");
try
{
await StopAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception cleanupError)
{
_logger.LogError(cleanupError, "OtOpcUaServerHostedService: failed to detach OPC UA bindings after startup failure");
}
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
async () => await _server.StopAsync(CancellationToken.None).ConfigureAwait(false),
() => _appHost.DisposeAsync(), _logger).ConfigureAwait(false);
if (released)
{
_appHost = null;
_server = null;
}
return;
}
// arch-review 03/S19 — LAST, after the node manager is known non-null and after the ACL gate is
@@ -69,8 +69,10 @@ public sealed class OpcUaApplicationHostOptions
/// Note this controls <i>admission</i>, not authority. An admitted anonymous session is a
/// normal principal (<c>anonymous</c>) whose grants come from <c>NodeAcl</c> rows naming it,
/// exactly like a key's principal — there is no anonymous bypass branch in the evaluator.
/// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole
/// address space, because reads carry no authorization check yet.
/// When a wired data-plane authorizer is enabled, per-node ACL checks apply to anonymous
/// sessions, including reads. Reads denied by the node manager return <c>BadUserAccessDenied</c>.
/// With the authorizer disabled or not wired, reads retain the legacy behaviour without
/// per-node ACL enforcement.
/// </para>
/// </remarks>
public bool AllowAnonymous { get; set; } = true;
@@ -558,10 +560,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
try
{
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
// An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
sessionManager.ImpersonateUser -= _impersonateHandler;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw");
}
_impersonateHandler = null;
@@ -5129,6 +5129,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
RunBounded(work, HistoryReadBatchParallelism);
});
NormalizeHistoryValues(nodesToProcess, results);
}
/// <inheritdoc />
@@ -5225,6 +5226,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
RunBounded(work, HistoryReadBatchParallelism);
});
NormalizeHistoryValues(nodesToProcess, results);
}
/// <inheritdoc />
@@ -5516,8 +5518,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
// only. Logged via the injected ILogger.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6233,7 +6235,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Bad status for THIS node only. Logged through the injected ILogger
// rather than swallowed silently.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6722,7 +6725,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadRaw (paged) failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6829,8 +6833,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead (modified) failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6879,6 +6883,49 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>Transport failures are retryable; unsupported operations retain their distinct status (#697).</summary>
private static uint HistoryReadFailureStatus(Exception error)
{
for (Exception? ex = error; ex is not null; ex = ex.InnerException)
if (ex is System.IO.IOException or System.Net.Sockets.SocketException or System.Net.Http.HttpRequestException or TimeoutException)
return StatusCodes.BadNoCommunication;
return StatusCodes.BadHistoryOperationUnsupported;
}
/// <summary>Normalize stored and AtTime history to each scalar node's declared DataType (#698).</summary>
private void NormalizeHistoryValues(List<NodeHandle> handles, IList<SdkHistoryReadResult> results)
{
foreach (var handle in handles)
{
if (AtTimeValueType(handle.NodeId) is not { } type || results[handle.Index]?.HistoryData is not { } body) continue;
var values = ExtensionObject.ToEncodeable(body) switch
{
HistoryData data => data.DataValues,
_ => null,
};
if (values is null) continue;
foreach (var value in values)
{
if (value.Value is null) continue;
try
{
// A gateway number cannot reconstruct a DateTime or a String which was never stored.
if ((type == TypeCode.DateTime && value.Value is not DateTime)
|| (type == TypeCode.String && value.Value is not string)) throw new InvalidCastException();
var converted = Convert.ChangeType(value.Value, type, System.Globalization.CultureInfo.InvariantCulture);
if (converted is float f && float.IsInfinity(f) && value.Value is double d && double.IsFinite(d))
throw new OverflowException();
value.WrappedValue = new Variant(converted);
}
catch (Exception ex) when (ex is InvalidCastException or OverflowException or FormatException)
{
value.WrappedValue = Variant.Null;
value.StatusCode = StatusCodes.BadTypeMismatch;
}
}
}
}
/// <summary>Project a plain sample list into an SDK <see cref="HistoryData"/> (the paged Raw path
/// works on a trimmed <see cref="IReadOnlyList{T}"/> rather than a whole <see cref="HistorianRead"/>).</summary>
/// <param name="samples">The samples to project (already trimmed of boundary duplicates).</param>
@@ -100,16 +100,22 @@ internal static class ReverseRawReader
{
var asked = limit > 0 && cap > limit ? limit : cap; // cap 0 (the whole window) is never cut
IReadOnlyList<DataValueSnapshot> samples;
try
for (var attempt = 0; ; attempt++)
{
samples = (await source.ReadRawAsync(tagname, start, end, (uint)asked, token).ConfigureAwait(false)).Samples;
}
catch (HistoryReadLimitExceededException ex) when (asked > 0 && ex.PreWindowRows > 0 && asked - ex.PreWindowRows >= 1)
{
// #694: rows just before the segment crowded the backend's budget — read it smaller; a full answer
// then counts as truncated (the walk narrows), never as complete.
asked -= ex.PreWindowRows;
samples = (await source.ReadRawAsync(tagname, start, end, (uint)asked, token).ConfigureAwait(false)).Samples;
try
{
samples = (await source.ReadRawAsync(tagname, start, end, (uint)asked, token).ConfigureAwait(false)).Samples;
break;
}
catch (HistoryReadLimitExceededException ex) when (asked > 0 && ex.PreWindowRows > 0 && attempt < 2)
{
// #695: the discovery request may be smaller than the pre-window cluster. Size against
// the ceiling, not that request; a second pass can discover more crowding rows.
var ceilingRoom = source.MaxRawValuesPerRead is var learned and > 0 ? learned : asked;
var smaller = Math.Min(asked - 1, ceilingRoom - ex.PreWindowRows - 1);
if (smaller < 1) throw;
asked = smaller;
}
}
return new ForwardSegmentRead<DataValueSnapshot>(samples, asked > 0 && samples.Count >= asked);
},
@@ -120,7 +126,7 @@ internal static class ReverseRawReader
(budget < Math.Max(1, maxTieCluster)
? "the backend's row ceiling (HistorianGateway Historian:ReadLimits:MaxValuesCeiling)."
: "ServerHistorian:MaxTieClusterOverfetch.")),
ct)
ct, useDiscoveryBudgetForGaps: limit > 0)
.ConfigureAwait(false);
return new ReverseRawPage(rows, next.WidthTicks) { Hint = next };
}
@@ -123,13 +123,14 @@ internal static class ReverseWalk
/// <param name="hint">What the previous page of the same read carried (default = none).</param>
/// <param name="tooManyAtOneTimestamp">Builds the exception for a timestamp holding more than M rows.</param>
/// <param name="ct">Cancellation.</param>
/// <param name="useDiscoveryBudgetForGaps">Use ceiling-sized discovery and retain truncated remainder rows (#696, strict raw backends only).</param>
/// <returns>At most <paramref name="want"/> rows, newest first, and the hint for the next page.</returns>
public static async Task<(List<T> Rows, ReverseWalkHint Next)> WalkAsync<T>(
Func<DateTime, DateTime, int, CancellationToken, Task<ForwardSegmentRead<T>>> read,
Func<T, long> ticksOf,
IComparer<T>? tieOrder,
DateTime hi, DateTime lo, uint want, int tieBudget, long resolutionTicks, DateTime nowUtc,
ReverseWalkHint hint, Func<DateTime, Exception> tooManyAtOneTimestamp, CancellationToken ct)
ReverseWalkHint hint, Func<DateTime, Exception> tooManyAtOneTimestamp, CancellationToken ct, bool useDiscoveryBudgetForGaps = false)
{
var resolution = Math.Max(1L, resolutionTicks);
var granularity = Math.Max(Alignment, resolution);
@@ -206,7 +207,8 @@ internal static class ReverseWalk
var segLo = segHi - stopTicks <= w ? stopTicks : segHi - w;
var start = Utc(AlignDown(segLo, granularity));
var end = Utc(AlignUp(segHi + 1, granularity));
var segBudget = discovering && !probe ? discoveryBudget : pageBudget;
var segBudget = (discovering && !probe) || (useDiscoveryBudgetForGaps && (probe || known > 0))
? discoveryBudget : pageBudget;
var cap = segBudget + 2;
IReadOnlyList<T> rows;
var narrowed = false;
@@ -220,10 +222,14 @@ internal static class ReverseWalk
if (probe)
{
// Deep history below: the probe saw only its oldest rows. Carry on walking from the same segHi.
// #696: strict-ceiling discovery retains the rows/density instead of discarding the remainder probe.
probed = true;
narrowed = true;
break;
if (!useDiscoveryBudgetForGaps)
{
narrowed = true;
break;
}
probe = false;
}
var leading = 0;
@@ -284,7 +290,7 @@ internal static class ReverseWalk
// Rows exist at lastInner; what lies above it is unknown. Aim at about half the page budget at the
// density the truncated rows showed (their span, not the segment's — it may open on an empty stretch).
var aimed = inner > 1 && lastInner > firstInner
? (long)Math.Min((double)(lastInner - firstInner) * (pageBudget / 2.0) / (inner - 1), maxWidth)
? (long)Math.Min((double)(lastInner - firstInner) * ((useDiscoveryBudgetForGaps ? segBudget : pageBudget) / 2.0) / (inner - 1), maxWidth)
: resolution;
known = lastInner;
knownWidth = Math.Max(resolution, aimed);
@@ -38,8 +38,8 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
var node = _serverInternal.ServerObject?.ServiceLevel;
if (node is null)
{
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write");
return;
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable");
throw new InvalidOperationException("Server.ServiceLevel is unavailable.");
}
try
@@ -55,6 +55,7 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
catch (Exception ex)
{
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
throw;
}
}
}
@@ -147,6 +147,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private readonly TimeSpan _staleWindow;
private readonly TimeSpan _probeFreshnessWindow;
private readonly TimeSpan _healthTickInterval;
private readonly Func<DateTime> _utcNow;
private readonly Akka.Cluster.Cluster _cluster = Akka.Cluster.Cluster.Get(Context.System);
private readonly ILoggingAdapter _log = Context.GetLogger();
@@ -200,9 +201,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="healthTickInterval">The period between freshness ticks; defaults to 5 seconds.
/// DB-backed nodes Ask the probe for cached status; DB-less nodes recompute snapshot freshness.
/// Legacy nodes without a probe do not start a timer.</param>
/// <param name="utcNow">The UTC clock used for freshness; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
@@ -219,7 +221,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
Func<DateTime>? utcNow = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -232,7 +235,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
probeFreshnessWindow,
healthTickInterval,
dbLess,
dataPlaneAuthority)).WithDispatcher(DispatcherId);
dataPlaneAuthority,
utcNow)).WithDispatcher(DispatcherId);
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
@@ -249,9 +253,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="healthTickInterval">The period between freshness ticks; defaults to 5 seconds.
/// DB-backed nodes Ask the probe for cached status; DB-less nodes recompute snapshot freshness.
/// Legacy nodes without a probe do not start a timer.</param>
/// <param name="utcNow">The UTC clock used for freshness; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
@@ -269,7 +274,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
Func<DateTime>? utcNow = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -282,7 +288,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
probeFreshnessWindow,
healthTickInterval,
dbLess,
dataPlaneAuthority));
dataPlaneAuthority,
utcNow));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
@@ -298,9 +305,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="probeFreshnessWindow">The window beyond which a peer's OPC UA probe verdict about
/// this node is considered stale (and thus given the benefit of the doubt rather than demoting);
/// defaults to 30 seconds.</param>
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="healthTickInterval">The period between freshness ticks; defaults to 5 seconds.
/// DB-backed nodes Ask the probe for cached status; DB-less nodes recompute snapshot freshness.
/// Legacy nodes without a probe do not start a timer.</param>
/// <param name="utcNow">The UTC clock used for freshness; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
@@ -316,7 +324,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null)
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
Func<DateTime>? utcNow = null)
{
_sink = sink;
_dataPlaneAuthority = dataPlaneAuthority;
@@ -330,6 +339,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
_staleWindow = staleWindow ?? TimeSpan.FromSeconds(30);
_probeFreshnessWindow = probeFreshnessWindow ?? TimeSpan.FromSeconds(30);
_healthTickInterval = healthTickInterval ?? TimeSpan.FromSeconds(5);
_utcNow = utcNow ?? (() => DateTime.UtcNow);
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AttributeValueBatchUpdate>(HandleAttributeBatchUpdate);
@@ -353,11 +363,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(RedundancyStateTopic, Self));
}
// Production delivery of DB-health: when a probe is wired, kick an immediate refresh + start the
// periodic tick so _lastDbHealth gets populated and kept fresh without any external pump. Gated
// ONLY on the probe being present (independent of the DPS subscribe) so the calculator path works
// in tests too. No probe → stay on the legacy role-only seam (no timer).
if (_dbHealthProbe is not null)
// DB-backed nodes refresh their cached probe status; DB-less nodes still need periodic
// snapshot/probe-verdict age checks even when no new messages arrive. Legacy nodes keep
// their role-only behavior without a timer.
if (_dbLess || _dbHealthProbe is not null)
{
Self.Tell(HealthTick.Instance); // immediate first refresh
Timers.StartPeriodicTimer("db-health", HealthTick.Instance, _healthTickInterval);
@@ -970,10 +979,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// node starting Detached/role-less (first level = 0) would be dedup'd away, leaving the SDK's
// built-in default (255 = full service) standing — a degraded node wrongly advertising 255.
if (_publishedAtLeastOnce && msg.ServiceLevel == _lastServiceLevel) return;
_lastServiceLevel = msg.ServiceLevel;
try
{
_serviceLevel.Publish(msg.ServiceLevel);
_lastServiceLevel = msg.ServiceLevel;
_publishedAtLeastOnce = true;
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
@@ -981,6 +990,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
catch (Exception ex)
{
// A failed write may be partially applied; even a previous successful value must retry.
_publishedAtLeastOnce = false;
_log.Warning(ex, "OpcUaPublish: ServiceLevel publisher threw at level {Level}", msg.ServiceLevel);
}
}
@@ -1004,7 +1015,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
RecomputeServiceLevel();
}
/// <summary>Periodic self-tick: Asks the local <see cref="DbHealthProbeActor"/> for its cached
/// <summary>Periodic self-tick: recomputes freshness directly when no DB probe exists; otherwise
/// Asks the local <see cref="DbHealthProbeActor"/> for its cached
/// status and pipes the reply back to <see cref="Self"/>, where <see cref="HandleDbHealthStatus"/>
/// caches it + recomputes. A hung/late probe (Ask timeout) pipes a <c>Reachable=false</c> status so
/// the node FAIL-SAFE-DEMOTES rather than freezing the last-known-good. The continuation only
@@ -1013,11 +1025,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// touched here.</summary>
private void OnHealthTick()
{
_dbHealthProbe!.Ask<DbHealthProbeActor.DbHealthStatus>(
if (_dbHealthProbe is null)
{
RecomputeServiceLevel();
return;
}
_dbHealthProbe.Ask<DbHealthProbeActor.DbHealthStatus>(
DbHealthProbeActor.GetStatus.Instance, TimeSpan.FromSeconds(1))
.ContinueWith(t => t.IsCompletedSuccessfully
? t.Result
: new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "db-health ask timeout"))
: new DbHealthProbeActor.DbHealthStatus(false, _utcNow(), "db-health ask timeout"))
.PipeTo(Self);
}
@@ -1030,7 +1048,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
// The result targets the probed node. If it isn't me, it's about a peer — ignore it.
if (_localNode is null || r.NodeId != _localNode.Value) return;
_probeAboutMe = (r.Ok, DateTime.UtcNow);
_probeAboutMe = (r.Ok, _utcNow());
RecomputeServiceLevel();
}
@@ -1042,7 +1060,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private bool OpcUaProbeOk()
{
if (_probeAboutMe is not { } verdict) return true;
if (DateTime.UtcNow - verdict.At > _probeFreshnessWindow) return true;
if (_utcNow() - verdict.At > _probeFreshnessWindow) return true;
return verdict.Ok;
}
@@ -1079,7 +1097,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// wins, so a DB-less Detached node stays 0.
if (_dbLess)
{
var utcNow = DateTime.UtcNow;
var utcNow = _utcNow();
var dbLessInputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: true,
@@ -1099,7 +1117,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
return;
}
var now = DateTime.UtcNow;
var now = _utcNow();
var inputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: _lastDbHealth.Reachable,
@@ -10,10 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
public class DeferredServiceLevelPublisherTests
{
[Fact]
public void Before_SetInner_Publish_is_a_noop()
public void Before_SetInner_Publish_accepts_a_cached_request()
{
var publisher = new DeferredServiceLevelPublisher();
// Must not throw; the NullServiceLevelPublisher absorbs it.
// Accept the request before an SDK publisher exists.
publisher.Publish(200);
}
@@ -30,7 +30,7 @@ public class DeferredServiceLevelPublisherTests
}
[Fact]
public void SetInner_null_reverts_to_null_publisher()
public void SetInner_null_detaches_the_previous_publisher()
{
var spy = new SpyPublisher();
var publisher = new DeferredServiceLevelPublisher();
@@ -39,8 +39,139 @@ public class DeferredServiceLevelPublisherTests
publisher.Publish(100);
// The spy should NOT have received the publish after revert.
spy.LastPublished.ShouldBe((byte)0, "spy should not have been updated after revert to null");
// The previous publisher must not receive an unbound request.
spy.LastPublished.ShouldBe((byte)0, "the previous publisher should not be updated after unbinding");
}
[Fact]
public void Binding_without_a_request_does_not_invent_a_level()
{
var received = new List<byte>();
new DeferredServiceLevelPublisher().SetInner(new CallbackPublisher(received.Add));
received.ShouldBeEmpty();
}
[Theory]
[InlineData((byte)100)]
[InlineData((byte)0)]
public void Binding_replays_the_latest_request_including_zero(byte latest)
{
var publisher = new DeferredServiceLevelPublisher();
publisher.Publish(240);
publisher.Publish(latest);
var received = new List<byte>();
publisher.SetInner(new CallbackPublisher(received.Add));
received.ShouldBe(new[] { latest });
}
[Fact]
public void Unbinding_retains_latest_for_rebinding()
{
var publisher = new DeferredServiceLevelPublisher();
var old = new List<byte>();
publisher.SetInner(new CallbackPublisher(old.Add));
publisher.Publish(240);
publisher.SetInner(null);
publisher.Publish(0);
var replacement = new List<byte>();
publisher.SetInner(new CallbackPublisher(replacement.Add));
old.ShouldBe(new byte[] { 240 });
replacement.ShouldBe(new byte[] { 0 });
}
[Fact]
public void Failed_publish_retains_the_requested_value_for_replay()
{
var publisher = new DeferredServiceLevelPublisher();
publisher.SetInner(new CallbackPublisher(_ => throw new InvalidOperationException()));
Should.Throw<InvalidOperationException>(() => publisher.Publish(0));
var received = new List<byte>();
publisher.SetInner(new CallbackPublisher(received.Add));
received.ShouldBe(new byte[] { 0 });
}
[Fact]
public void Failed_replay_keeps_candidate_bound_for_later_publish()
{
var publisher = new DeferredServiceLevelPublisher();
publisher.Publish(100);
var fail = true;
var received = new List<byte>();
var candidate = new CallbackPublisher(value =>
{
if (fail) throw new InvalidOperationException();
received.Add(value);
});
Should.Throw<InvalidOperationException>(() => publisher.SetInner(candidate));
fail = false;
publisher.Publish(0);
publisher.SetInner(null);
publisher.SetInner(candidate);
received.ShouldBe(new byte[] { 0, 0 });
}
[Fact]
public void Failed_replay_retains_latest_for_a_replacement_publisher()
{
var publisher = new DeferredServiceLevelPublisher();
publisher.Publish(100);
Should.Throw<InvalidOperationException>(() => publisher.SetInner(
new CallbackPublisher(_ => throw new InvalidOperationException())));
var received = new List<byte>();
publisher.SetInner(new CallbackPublisher(received.Add));
received.ShouldBe(new byte[] { 100 });
}
[Fact]
public async Task Binding_waits_for_inflight_publish_then_replays_latest()
{
var publisher = new DeferredServiceLevelPublisher();
using var entered = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
publisher.SetInner(new CallbackPublisher(_ =>
{
entered.Set();
release.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue();
}));
var publish = Task.Run(() => publisher.Publish(100), TestContext.Current.CancellationToken);
var received = new List<byte>();
using var bindingStarted = new ManualResetEventSlim();
Exception? bindingError = null;
var binding = new Thread(() =>
{
bindingStarted.Set();
try { publisher.SetInner(new CallbackPublisher(received.Add)); }
catch (Exception ex) { bindingError = ex; }
});
var bindingWasStarted = false;
try
{
entered.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue();
binding.Start();
bindingWasStarted = true;
bindingStarted.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue();
// Past the marker, SetInner is the thread's only potentially blocking operation.
// Observe actual contention rather than assuming Task.Run has entered the adapter.
SpinWait.SpinUntil(() => !binding.IsAlive ||
(binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0,
TimeSpan.FromSeconds(3)).ShouldBeTrue();
(binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin).ShouldNotBe(
(System.Threading.ThreadState)0, "binding must wait for the in-flight delivery");
}
finally
{
release.Set();
if (bindingWasStarted) binding.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue();
await publish.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
}
bindingError.ShouldBeNull();
publisher.Publish(0);
received.ShouldBe(new byte[] { 100, 0 });
}
private sealed class CallbackPublisher(Action<byte> publish) : IServiceLevelPublisher
{
public void Publish(byte serviceLevel) => publish(serviceLevel);
}
// ---- test double ----
@@ -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>
@@ -12,6 +12,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// </summary>
public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient
{
public bool TagExistsResult = true;
public int TagExistsCallCount;
public Exception? TagExistsThrows;
/// <summary>Optional asynchronous catalog response for overlapping presence-cache tests.</summary>
public Func<string, CancellationToken, Task<bool>>? TagExistsSource;
public Task<bool> TagExistsAsync(string tag, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref TagExistsCallCount);
if (TagExistsSource is { } source) return source(tag, ct);
if (TagExistsThrows is { } error) throw error;
return Task.FromResult(TagExistsResult);
}
// ---- ReadRaw -------------------------------------------------------------------------------
public IReadOnlyList<HistorianSample> RawSamples = Array.Empty<HistorianSample>();
public string? LastReadRawTag;
@@ -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()
{
@@ -128,6 +128,35 @@ public sealed class GatewayAtTimeTests
return r.Samples;
}
[Fact]
public async Task A_future_time_on_a_live_tag_finds_the_prior_sample_without_walking_the_empty_future()
{
var store = new AvevaStore(Enumerable.Range(0, 7201).Select(i => (Now.AddSeconds(i - 7200), (double)i, false)));
var fake = store.Client();
var result = (await AtTime(fake, Now.AddHours(1))).Single();
result.Value.ShouldBe(7200d);
result.StatusCode.ShouldBe(UncertainInterpolated);
fake.ReadRawCallCount.ShouldBeLessThanOrEqualTo(4);
}
[Fact]
public async Task A_future_time_still_uses_a_stored_future_prior_sample()
{
var fake = new AvevaStore([(Now.AddSeconds(-1), 1d, false), (Now.AddMinutes(30), 2d, false)]).Client();
var result = (await AtTime(fake, Now.AddHours(1))).Single();
result.Value.ShouldBe(2d);
result.StatusCode.ShouldBe(UncertainInterpolated);
}
[Fact]
public async Task A_full_future_probe_walks_to_the_latest_stored_future_sample()
{
var fake = new AvevaStore(Enumerable.Range(0, 7201).Select(i => (Now.AddSeconds(i), (double)i, false))).Client();
var result = (await AtTime(fake, Now.AddHours(3))).Single();
result.Value.ShouldBe(7200d);
result.StatusCode.ShouldBe(UncertainInterpolated);
}
// ---- the four live findings ------------------------------------------------------------------------------------
[Fact] // live: 19:00:03 (a stored NULL) ⇒ 107 Good
@@ -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)]
@@ -385,6 +383,21 @@ public sealed class GatewayCeilingPagingTests : IDisposable
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task A_stopped_dense_tag_under_a_strict_ceiling_is_found_with_bounded_discovery_cost()
{
var stored = PerSecond(7200);
var fake = Gateway(stored, Ceiling);
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadRawAsync("T", At(0), At(1), 5000, Ct);
fake.RawCalls.Clear();
var page = await ReverseRawReader.ReadAsync(ds, "T", At(7200).AddDays(1), DateTime.UnixEpoch,
3, 65536, At(7200).AddDays(1), 0, Ct);
page.Samples.Select(s => s.Value).ShouldBe(new object?[] { 7199d, 7198d, 7197d });
fake.RawCalls.Count.ShouldBeLessThanOrEqualTo(12);
EveryRequestFits(fake);
}
// ---- F3: rows crowding the look-back before a page start ---------------------------------------------------------------
[Theory]
@@ -420,6 +433,54 @@ public sealed class GatewayCeilingPagingTests : IDisposable
finally { await host.DisposeAsync(); }
}
[Theory]
[InlineData(1709, 1366)] // #695 reverse-walk trace
[InlineData(1369, 1292)] // #695 bounds trace
public async Task A_warm_read_uses_the_last_room_below_the_ceiling_for_pre_window_ties(int ceiling, int cluster)
{
var start = At(1000);
var stored = Enumerable.Range(0, cluster).Select(i => (start.AddMilliseconds(-0.5), (double)i))
.Append((start, 9000d)).Append((start.AddSeconds(1), 9001d)).ToList();
var fake = Gateway(stored, ceiling);
using var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadRawAsync("T", At(0), At(1), 5000, Ct); // learn without crowding
fake.RawCalls.Clear();
var result = await ds.ReadRawAsync("T", start, start.AddSeconds(2), 1, Ct);
result.Samples.Select(s => s.Value).ShouldBe([9000d]);
fake.RawCalls.ShouldAllBe(c => c.MaxValues <= ceiling);
}
[Fact]
public async Task A_warm_reverse_segment_can_shrink_again_when_the_pre_window_cluster_grows()
{
var start = At(1000);
var stored = Enumerable.Range(0, 1366).Select(i => (start.AddMilliseconds(-0.5), (double)i))
.Concat(Enumerable.Range(0, 500).Select(i => (start.AddMilliseconds(i), 9000d + i))).ToList();
var fake = Gateway(stored, 1709);
using var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadRawAsync("T", At(0), At(1), 5000, Ct);
var page = await ReverseRawReader.ReadAsync(ds, "T", start.AddMilliseconds(499), start.AddTicks(-1),
249, 65536, At(2000), new ReverseWalkHint(499 * TimeSpan.TicksPerMillisecond), Ct);
page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(9251, 249).Reverse().Select(i => (double)i));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Gateway_transport_faults_surface_as_BadNoCommunication(bool deadline)
{
var fake = new FakeHistorianGatewayClient { ThrowOnRead = deadline
? new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException("deadline", new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.DeadlineExceeded, "deadline")))
: new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayUnavailableException("unavailable") };
var (host, nm, nodeId) = await BootAsync(fake);
try
{
var r = ReadRaw(nm, nodeId, At(0), At(10), 10, null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
}
finally { await host.DisposeAsync(); }
}
// ---- helpers ---------------------------------------------------------------------------------------------------------
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId NodeId)> BootAsync(
@@ -0,0 +1,126 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
public sealed class GatewayMissingTagTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly DateTime Now = new(2026, 10, 13, 8, 0, 0, DateTimeKind.Utc);
private sealed class Clock : TimeProvider
{
public DateTime Current = Now;
public override DateTimeOffset GetUtcNow() => new(Current);
}
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake, Clock? clock = null, bool native = false) =>
new(fake, NullLogger<GatewayHistorianDataSource>.Instance, timeProvider: clock, atTimeFromRaw: !native);
[Theory]
[InlineData("Raw")]
[InlineData("AtTime")]
[InlineData("NativeAtTime")]
[InlineData("Count")]
[InlineData("TimeAverage")]
public async Task A_missing_tag_returns_no_data_without_a_history_RPC(string kind)
{
var fake = new FakeHistorianGatewayClient { TagExistsResult = false };
var ds = Source(fake, native: kind == "NativeAtTime");
var result = kind switch
{
"Raw" => await ds.ReadRawAsync("Missing", Now.AddMinutes(-1), Now, 10, Ct),
"AtTime" or "NativeAtTime" => await ds.ReadAtTimeAsync("Missing", [Now], Ct),
_ => await ds.ReadProcessedAsync("Missing", Now.AddMinutes(-1), Now, TimeSpan.FromMinutes(1),
kind == "Count" ? HistoryAggregateType.Count : HistoryAggregateType.TimeAverage, Ct),
};
if (kind == "Raw") result.Samples.ShouldBeEmpty();
else
{
result.Samples.Count.ShouldBe(1);
result.Samples[0].Value.ShouldBeNull();
(result.Samples[0].StatusCode & 0xFFFF0000u).ShouldBe(0x809B0000u);
result.Samples[0].SourceTimestampUtc.ShouldBe(kind.Contains("AtTime") ? Now : Now.AddMinutes(-1));
}
fake.TagExistsCallCount.ShouldBe(1);
(fake.ReadRawCallCount + fake.ReadAggregateCallCount + fake.ReadAtTimeCallCount).ShouldBe(0);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task Negative_cache_expires_so_a_newly_provisioned_tag_can_be_read()
{
var fake = new FakeHistorianGatewayClient { TagExistsResult = false };
var clock = new Clock(); var ds = Source(fake, clock);
await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct);
fake.TagExistsResult = true;
fake.RawSamples = [new HistorianSample { Timestamp = Timestamp.FromDateTime(Now.AddSeconds(-1)), NumericValue = 42, OpcQuality = 192 }];
(await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct)).Samples.ShouldBeEmpty();
fake.TagExistsCallCount.ShouldBe(1);
clock.Current = Now.AddSeconds(5);
(await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct)).Samples.Single().Value.ShouldBe(42d);
fake.TagExistsCallCount.ShouldBe(2);
}
[Fact]
public async Task Positive_cache_avoids_checks_per_page_and_expires_after_a_minute()
{
var fake = new FakeHistorianGatewayClient(); var clock = new Clock(); var ds = Source(fake, clock);
await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct);
await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct);
fake.TagExistsCallCount.ShouldBe(1);
clock.Current = Now.AddSeconds(60);
await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct);
fake.TagExistsCallCount.ShouldBe(2);
}
[Fact]
public async Task An_explicit_nested_NotFound_is_cached_as_missing()
{
var fake = new FakeHistorianGatewayClient { TagExistsThrows = new InvalidOperationException("wrapper",
new RpcException(new Status(StatusCode.NotFound, "absent"))) };
var ds = Source(fake);
(await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct)).Samples.ShouldBeEmpty();
(await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct)).Samples.ShouldBeEmpty();
fake.TagExistsCallCount.ShouldBe(1);
fake.ReadRawCallCount.ShouldBe(0);
}
[Fact]
public async Task The_presence_cache_is_bounded_and_a_displaced_entry_is_checked_again()
{
var fake = new FakeHistorianGatewayClient { TagExistsResult = false }; var ds = Source(fake);
for (var i = 0; i <= GatewayTagPresenceCache.Capacity; i++)
await ds.ReadRawAsync($"T{i}", Now.AddMinutes(-1), Now, 10, Ct);
await ds.ReadRawAsync("T0", Now.AddMinutes(-1), Now, 10, Ct);
fake.TagExistsCallCount.ShouldBe(GatewayTagPresenceCache.Capacity + 2);
}
[Fact]
public async Task Caller_cancellation_is_not_cached_as_missing()
{
var fake = new FakeHistorianGatewayClient { TagExistsThrows = new OperationCanceledException() }; var ds = Source(fake);
await Should.ThrowAsync<OperationCanceledException>(() => ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct));
fake.TagExistsThrows = null;
await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct);
fake.TagExistsCallCount.ShouldBe(2);
}
[Theory]
[InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.PermissionDenied)]
[InlineData(StatusCode.Internal)]
public async Task A_metadata_fault_is_not_cached_as_a_missing_tag(StatusCode code)
{
var fake = new FakeHistorianGatewayClient { TagExistsThrows = new RpcException(new Status(code, "failure")) };
var ds = Source(fake);
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct));
fake.TagExistsThrows = null;
await ds.ReadRawAsync("T", Now.AddMinutes(-1), Now, 10, Ct);
fake.TagExistsCallCount.ShouldBe(2);
fake.ReadRawCallCount.ShouldBe(1);
}
}
@@ -57,6 +57,30 @@ public sealed class GatewayStrictCeilingTests
public override DateTimeOffset GetUtcNow() => new(now);
}
private sealed class MovingClock : TimeProvider
{
public DateTime Current = Now;
public override DateTimeOffset GetUtcNow() => new(Current);
}
[Fact]
public async Task Aggregate_ceiling_re_probes_wait_thirty_seconds_then_notice_a_raised_limit()
{
var fake = Store(100, ceiling: 50);
var clock = new MovingClock();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, timeProvider: clock);
Task<HistoryReadResult> Read() => ds.ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(100), HistoryAggregateType.Count, Ct);
await Should.ThrowAsync<HistoryReadLimitExceededException>(Read);
await Should.ThrowAsync<HistoryReadLimitExceededException>(Read);
Refusals(fake).ShouldBe(1);
fake.MaxValuesCeiling = 200;
await Should.ThrowAsync<HistoryReadLimitExceededException>(Read);
Refusals(fake).ShouldBe(1);
clock.Current = Now.AddSeconds(30);
(await Read()).Samples.Single().Value.ShouldBe(100);
ds.MaxRawValuesPerRead.ShouldBe(200 - RawAggregateReader.PreWindowAllowance);
}
// ---- raw, uncapped ---------------------------------------------------------------------------------------------------
[Fact]
@@ -0,0 +1,90 @@
using System.Net;
using Grpc.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
public sealed class GatewayTagCatalogWireTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Theory]
[InlineData("Pump1", true)]
[InlineData("Missing", false)]
[InlineData("Pump*", false)]
public async Task Presence_is_proved_by_exact_names_without_using_the_broken_metadata_parser(string tag, bool expected)
{
var service = new TagsService();
await using var server = await StartAsync(service);
using var adapter = Create(server.Port);
(await adapter.TagExistsAsync(tag, Ct)).ShouldBe(expected);
service.Filter.ShouldBe(tag);
service.MetadataCalls.ShouldBe(0);
service.Completed.ShouldBe(1);
}
[Fact]
public async Task A_catalog_stream_fault_after_a_matching_name_is_not_cached_as_presence()
{
var service = new TagsService { FailAfterRow = true };
await using var server = await StartAsync(service);
using var adapter = Create(server.Port);
var source = new GatewayHistorianDataSource(adapter, NullLogger<GatewayHistorianDataSource>.Instance);
var now = DateTime.UtcNow;
await Should.ThrowAsync<Exception>(() => source.ReadRawAsync("Pump1", now.AddMinutes(-1), now, 10, Ct));
service.FailAfterRow = false; service.Names = [];
(await source.ReadRawAsync("Pump1", now.AddMinutes(-1), now, 10, Ct)).Samples.ShouldBeEmpty();
service.BrowseCalls.ShouldBe(2);
}
private static HistorianGatewayClientAdapter Create(int port) => HistorianGatewayClientAdapter.Create(
new ServerHistorianOptions { Endpoint = $"http://127.0.0.1:{port}", ApiKey = "histgw_test_key", UseTls = false },
NullLoggerFactory.Instance);
private sealed class TagsService : HistorianTags.HistorianTagsBase
{
public int MetadataCalls, BrowseCalls, Completed;
public string? Filter;
public bool FailAfterRow;
public string[] Names = ["Pump1", "Pump2"];
public override Task<GetTagMetadataReply> GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context)
{
MetadataCalls++;
throw new RpcException(new Status(StatusCode.Internal, "GetTagInfo response ended unexpectedly."));
}
public override async Task BrowseTagNames(BrowseTagNamesRequest request, IServerStreamWriter<TagName> stream, ServerCallContext context)
{
BrowseCalls++; Filter = request.Filter;
foreach (var name in Names)
{
await stream.WriteAsync(new TagName { Name = name }, context.CancellationToken);
if (FailAfterRow) throw new RpcException(new Status(StatusCode.Internal, "catalog failure"));
}
Completed++;
}
}
private sealed class Server(WebApplication app, int port) : IAsyncDisposable
{
public int Port => port;
public async ValueTask DisposeAsync() { await app.StopAsync(); await app.DisposeAsync(); }
}
private static async Task<Server> StartAsync(TagsService service)
{
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0, o => o.Protocols = HttpProtocols.Http2));
builder.Services.AddGrpc(); builder.Services.AddSingleton(service);
var app = builder.Build(); app.MapGrpcService<TagsService>(); await app.StartAsync();
return new Server(app, new Uri(app.Urls.First()).Port);
}
}
@@ -0,0 +1,209 @@
using Grpc.Core;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
public sealed class GatewayTagPresenceCacheConcurrencyTests(ITestOutputHelper output)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const int BurstSize = 16;
private sealed class Clock : TimeProvider
{
public DateTimeOffset Current = new(2026, 10, 13, 8, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => Current;
}
private static TaskCompletionSource<bool> Gate() => new(TaskCreationOptions.RunContinuationsAsynchronously);
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Overlapping_cold_lookups_return_the_catalog_result_and_populate_the_cache(bool exists)
{
// Catches dropped/corrupted concurrent writes or a result cached with the wrong presence value.
var gate = Gate();
var fake = new FakeHistorianGatewayClient { TagExistsSource = (_, _) => gate.Task };
var cache = new GatewayTagPresenceCache(fake, new Clock());
var reads = Enumerable.Range(0, BurstSize).Select(_ => cache.ExistsAsync("T", Ct)).ToArray();
reads.ShouldAllBe(read => !read.IsCompleted);
var rpcCount = fake.TagExistsCallCount;
// Measure amplification without requiring either coalescing or one RPC per caller.
rpcCount.ShouldBeInRange(1, BurstSize);
output.WriteLine($"Cold {exists}: {BurstSize} overlapping callers, {rpcCount} catalog RPCs.");
gate.SetResult(exists);
(await Task.WhenAll(reads).WaitAsync(Ct)).ShouldAllBe(value => value == exists);
fake.TagExistsSource = null;
fake.TagExistsThrows = new InvalidOperationException("A warm lookup must not contact the catalog.");
(await cache.ExistsAsync("T", Ct)).ShouldBe(exists);
}
[Theory]
[InlineData(false, 5)]
[InlineData(true, 60)]
public async Task Overlapping_expired_lookups_refresh_presence_at_the_TTL_boundary(bool initial, int ttlSeconds)
{
// Catches an inclusive expiry comparison or failure to replace an expired positive/negative entry.
var clock = new Clock();
var fake = new FakeHistorianGatewayClient { TagExistsResult = initial };
var cache = new GatewayTagPresenceCache(fake, clock);
(await cache.ExistsAsync("T", Ct)).ShouldBe(initial);
var expires = clock.Current.AddSeconds(ttlSeconds);
clock.Current = expires.AddTicks(-1);
fake.TagExistsThrows = new InvalidOperationException("The entry must still be warm before expiry.");
(await cache.ExistsAsync("T", Ct)).ShouldBe(initial);
clock.Current = expires;
var gate = Gate();
fake.TagExistsThrows = null;
fake.TagExistsSource = (_, _) => gate.Task;
var reads = Enumerable.Range(0, BurstSize).Select(_ => cache.ExistsAsync("T", Ct)).ToArray();
reads.ShouldAllBe(read => !read.IsCompleted);
var rpcCount = fake.TagExistsCallCount - 1;
rpcCount.ShouldBeInRange(1, BurstSize);
output.WriteLine($"Expired {initial}: {BurstSize} overlapping callers, {rpcCount} catalog RPCs.");
gate.SetResult(!initial);
(await Task.WhenAll(reads).WaitAsync(Ct)).ShouldAllBe(value => value == !initial);
fake.TagExistsSource = null;
fake.TagExistsThrows = new InvalidOperationException("A refreshed lookup must stay cached.");
(await cache.ExistsAsync("T", Ct)).ShouldBe(!initial);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Overlapping_distinct_tag_completions_keep_the_cache_bounded(bool exists)
{
// Catches racing capacity checks that allow more than Capacity resident entries.
var fake = new FakeHistorianGatewayClient { TagExistsResult = exists };
var cache = new GatewayTagPresenceCache(fake, new Clock());
var tags = Enumerable.Range(0, GatewayTagPresenceCache.Capacity + 31).Select(i => $"T{i}").ToArray();
foreach (var tag in tags.Take(GatewayTagPresenceCache.Capacity - 1))
(await cache.ExistsAsync(tag, Ct)).ShouldBe(exists);
var gate = Gate();
fake.TagExistsSource = (_, _) => gate.Task;
var reads = tags.Skip(GatewayTagPresenceCache.Capacity - 1).Select(tag => cache.ExistsAsync(tag, Ct)).ToArray();
reads.ShouldAllBe(read => !read.IsCompleted);
gate.SetResult(exists);
(await Task.WhenAll(reads).WaitAsync(Ct)).ShouldAllBe(value => value == exists);
// Failed probes cannot insert or evict entries, so count resident hits through the public behavior.
fake.TagExistsSource = null;
var uncached = new InvalidOperationException("Uncached catalog probe.");
fake.TagExistsThrows = uncached;
var hits = 0;
foreach (var tag in tags)
{
try { (await cache.ExistsAsync(tag, Ct)).ShouldBe(exists); hits++; }
catch (InvalidOperationException error) when (ReferenceEquals(error, uncached)) { }
}
hits.ShouldBe(GatewayTagPresenceCache.Capacity);
}
[Theory]
[InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.PermissionDenied)]
[InlineData(StatusCode.Internal)]
[InlineData(StatusCode.Cancelled)]
public async Task A_faulting_lookup_cannot_overwrite_an_overlapping_positive_result(StatusCode code)
{
// Catches converting metadata faults to false, including a fault finishing after a successful lookup.
var success = Gate();
var failure = Gate();
var calls = 0;
var fake = new FakeHistorianGatewayClient
{
TagExistsSource = (_, _) => Interlocked.Increment(ref calls) == 1 ? success.Task : failure.Task,
};
var cache = new GatewayTagPresenceCache(fake, new Clock());
var goodRead = cache.ExistsAsync("T", Ct);
var badRead = cache.ExistsAsync("T", Ct);
goodRead.IsCompleted.ShouldBeFalse();
badRead.IsCompleted.ShouldBeFalse();
success.SetResult(true);
(await goodRead.WaitAsync(Ct)).ShouldBeTrue();
failure.SetException(new RpcException(new Status(code, "Catalog fault.")));
(await Should.ThrowAsync<RpcException>(() => badRead.WaitAsync(Ct))).StatusCode.ShouldBe(code);
fake.TagExistsSource = null;
fake.TagExistsThrows = new InvalidOperationException("The positive entry must survive the failed lookup.");
(await cache.ExistsAsync("T", Ct)).ShouldBeTrue();
}
[Fact]
public async Task Cancellation_after_a_catalog_result_cannot_overwrite_an_overlapping_positive_result()
{
// Catches removal of the post-await token check when the client ignores cancellation and returns false.
var success = Gate();
var cancelled = Gate();
using var caller = CancellationTokenSource.CreateLinkedTokenSource(Ct);
var fake = new FakeHistorianGatewayClient
{
TagExistsSource = (_, token) => token == caller.Token ? cancelled.Task : success.Task,
};
var cache = new GatewayTagPresenceCache(fake, new Clock());
var goodRead = cache.ExistsAsync("T", Ct);
var cancelledRead = cache.ExistsAsync("T", caller.Token);
success.SetResult(true);
(await goodRead.WaitAsync(Ct)).ShouldBeTrue();
caller.Cancel();
cancelled.SetResult(false);
await Should.ThrowAsync<OperationCanceledException>(() => cancelledRead.WaitAsync(Ct));
fake.TagExistsSource = null;
fake.TagExistsThrows = new InvalidOperationException("The positive entry must survive cancellation.");
(await cache.ExistsAsync("T", Ct)).ShouldBeTrue();
// Cached results still respect an already-cancelled caller.
await Should.ThrowAsync<OperationCanceledException>(() => cache.ExistsAsync("T", caller.Token));
}
[Theory]
[InlineData(false, 5)]
[InlineData(true, 60)]
public async Task An_expired_entry_is_rechecked_after_a_faulting_refresh(bool initial, int ttlSeconds)
{
// Catches faults renewing an expired negative or replacing an expired positive with a negative.
var clock = new Clock();
var fake = new FakeHistorianGatewayClient { TagExistsResult = initial };
var cache = new GatewayTagPresenceCache(fake, clock);
(await cache.ExistsAsync("T", Ct)).ShouldBe(initial);
clock.Current = clock.Current.AddSeconds(ttlSeconds);
var gate = Gate();
fake.TagExistsSource = (_, _) => gate.Task;
var read = cache.ExistsAsync("T", Ct);
read.IsCompleted.ShouldBeFalse();
gate.SetException(new RpcException(new Status(StatusCode.Unavailable, "Catalog unavailable.")));
await Should.ThrowAsync<RpcException>(() => read.WaitAsync(Ct));
fake.TagExistsSource = null;
fake.TagExistsResult = true;
(await cache.ExistsAsync("T", Ct)).ShouldBeTrue();
fake.TagExistsCallCount.ShouldBe(3);
}
[Theory]
[InlineData(false, 5)]
[InlineData(true, 60)]
public async Task An_expired_entry_is_rechecked_after_a_cancelled_refresh(bool initial, int ttlSeconds)
{
// Catches cancellation renewing expired entries when the catalog returns an absence result anyway.
var clock = new Clock();
var fake = new FakeHistorianGatewayClient { TagExistsResult = initial };
var cache = new GatewayTagPresenceCache(fake, clock);
(await cache.ExistsAsync("T", Ct)).ShouldBe(initial);
clock.Current = clock.Current.AddSeconds(ttlSeconds);
var gate = Gate();
fake.TagExistsSource = (_, _) => gate.Task;
using var caller = CancellationTokenSource.CreateLinkedTokenSource(Ct);
var read = cache.ExistsAsync("T", caller.Token);
read.IsCompleted.ShouldBeFalse();
caller.Cancel();
gate.SetResult(false);
await Should.ThrowAsync<OperationCanceledException>(() => read.WaitAsync(Ct));
fake.TagExistsSource = null;
fake.TagExistsResult = true;
(await cache.ExistsAsync("T", Ct)).ShouldBeTrue();
fake.TagExistsCallCount.ShouldBe(3);
}
}
@@ -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));
}
}
@@ -11,6 +11,33 @@ public sealed class QuestDbHistorianProviderTests
ConnectionString = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading",
});
[Theory]
[InlineData("Raw")]
[InlineData("AtTime")]
[InlineData("Processed")]
[InlineData("Events")]
[InlineData("Modified")]
public async Task A_connection_failure_is_exposed_as_a_transport_failure(string kind)
{
using var provider = new QuestDbHistorianProvider(new QuestDbHistorianOptions {
ConnectionString = "Host=127.0.0.1;Port=1;Username=admin;Password=test;Database=qdb;Timeout=1;Pooling=False;ServerCompatibilityMode=NoTypeLoading", AutoCreateSchema = false
});
using var source = provider.CreateDataSource(new NullSp());
var t = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
Func<Task> read = kind switch
{
"Raw" => async () => await source.ReadRawAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
"AtTime" => async () => await source.ReadAtTimeAsync("T", [t], TestContext.Current.CancellationToken),
"Processed" => async () => await source.ReadProcessedAsync("T", t, t.AddHours(1), TimeSpan.FromSeconds(10), HistoryAggregateType.Average, TestContext.Current.CancellationToken),
"Events" => async () => await source.ReadEventsAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
_ => async () => await source.ReadModifiedAsync("T", t, t.AddHours(1), 10, null, TestContext.Current.CancellationToken)
};
var error = await Should.ThrowAsync<System.IO.IOException>(read);
error.InnerException.ShouldNotBeNull();
// A cached bootstrap outage must remain retryable on subsequent reads as well.
(await Should.ThrowAsync<System.IO.IOException>(read)).InnerException.ShouldNotBeNull();
}
[Fact]
public void Name_and_all_six_kinds()
{
@@ -11,6 +11,33 @@ public sealed class SqlServerHistorianProviderTests
ConnectionString = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
});
[Theory]
[InlineData("Raw")]
[InlineData("AtTime")]
[InlineData("Processed")]
[InlineData("Events")]
[InlineData("Modified")]
public async Task A_connection_failure_is_exposed_as_a_transport_failure(string kind)
{
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions {
ConnectionString = "Server=127.0.0.1,1;User Id=sa;Password=test;TrustServerCertificate=True;Connect Timeout=1;ConnectRetryCount=0;Pooling=False", AutoCreateSchema = false
});
using var source = provider.CreateDataSource(new NullSp());
var t = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
Func<Task> read = kind switch
{
"Raw" => async () => await source.ReadRawAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
"AtTime" => async () => await source.ReadAtTimeAsync("T", [t], TestContext.Current.CancellationToken),
"Processed" => async () => await source.ReadProcessedAsync("T", t, t.AddHours(1), TimeSpan.FromSeconds(10), HistoryAggregateType.Average, TestContext.Current.CancellationToken),
"Events" => async () => await source.ReadEventsAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
_ => async () => await source.ReadModifiedAsync("T", t, t.AddHours(1), 10, null, TestContext.Current.CancellationToken)
};
var error = await Should.ThrowAsync<System.IO.IOException>(read);
error.InnerException.ShouldNotBeNull();
// A cached bootstrap outage must remain retryable on subsequent reads as well.
(await Should.ThrowAsync<System.IO.IOException>(read)).InnerException.ShouldNotBeNull();
}
[Fact]
public void Name_and_all_six_kinds()
{
@@ -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]
@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
public sealed class FailedServiceLevelBindingCleanupTests
{
[Fact]
public async Task Successful_cleanup_observes_stop_before_disposal_and_releases_ownership()
{
var events = new List<string>();
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
() => { events.Add("stop"); return Task.CompletedTask; },
() => { events.Add("dispose"); return ValueTask.CompletedTask; },
NullLogger.Instance);
events.ShouldBe(new[] { "stop", "dispose" });
released.ShouldBeTrue();
}
[Fact]
public async Task Failed_stop_still_disposes_and_retains_ownership_for_retry()
{
var events = new List<string>();
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
() => { events.Add("stop"); throw new InvalidOperationException("controlled stop failure"); },
() => { events.Add("dispose"); return ValueTask.CompletedTask; },
NullLogger.Instance);
events.ShouldBe(new[] { "stop", "dispose" });
released.ShouldBeFalse();
}
[Fact]
public async Task Failed_disposal_retains_ownership_without_escaping_the_binding_failure_handler()
{
var events = new List<string>();
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
() => { events.Add("stop"); return Task.CompletedTask; },
() => { events.Add("dispose"); throw new InvalidOperationException("controlled disposal failure"); },
NullLogger.Instance);
events.ShouldBe(new[] { "stop", "dispose" });
released.ShouldBeFalse();
}
}
@@ -0,0 +1,103 @@
using System.Net;
using System.Net.Sockets;
using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
public sealed class OtOpcUaServerHostedServiceBindingFailureTests
{
[Fact]
public async Task Binding_failure_closes_started_endpoint_and_allows_other_host_services_to_start()
{
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-binding-{Guid.NewGuid():N}");
var deferred = new DeferredServiceLevelPublisher();
deferred.Publish(0);
var bindingReached = false;
var listenerObserved = false;
OtOpcUaServerHostedService? service = null;
var factory = new FaultInjectingLoggerFactory(() =>
{
using var connected = new TcpClient();
connected.Connect(IPAddress.Loopback, port);
listenerObserved = connected.Connected;
bindingReached = true;
throw new InvalidOperationException("controlled binding factory failure");
});
service = new OtOpcUaServerHostedService(
Options.Create(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.BindingFailure",
ApplicationUri = $"urn:OtOpcUa.BindingFailure:{Guid.NewGuid():N}",
OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = pki,
}), new DeferredAddressSpaceSink(), deferred,
NullOpcUaUserAuthenticator.Instance, NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance, new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no actor system required"), new ActorRegistry(),
null, Options.Create(new AclOptions()), TimeProvider.System, factory);
var admin = new AdminBootSentinel();
using var host = new HostBuilder().ConfigureServices(services =>
{
services.AddSingleton<IHostedService>(service);
services.AddSingleton<IHostedService>(admin);
}).Build();
try
{
await host.StartAsync(deadline.Token);
listenerObserved.ShouldBeTrue("the SDK endpoint was actually listening before replay");
bindingReached.ShouldBeTrue();
admin.Started.ShouldBeTrue();
using var client = new TcpClient();
await Should.ThrowAsync<SocketException>(async () =>
await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token));
// After construction failure, subsequent requests are accepted into the unbound cache.
deferred.Publish(100);
await host.StopAsync(deadline.Token);
}
finally
{
await service.DisposeAsync();
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
}
}
private sealed class FaultInjectingLoggerFactory(Action installFault) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName)
{
if (categoryName == typeof(SdkServiceLevelPublisher).FullName) installFault();
return NullLogger.Instance;
}
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
private sealed class AdminBootSentinel : IHostedService
{
public bool Started { get; private set; }
public Task StartAsync(CancellationToken cancellationToken)
{
Started = true;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -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 …
@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>Local loopback evidence that late binding replaces the SDK default on the wire.</summary>
public sealed class ServiceLevelBindingReplayTests
{
[Theory]
[InlineData((byte)100)]
[InlineData((byte)0)]
public async Task Late_binding_replays_latest_request_to_standard_ServiceLevel_read(byte latest)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(15));
var ct = deadline.Token;
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-replay-{Guid.NewGuid():N}");
var clientPki = TestClientSecurity.AllocatePkiRoot();
var port = 0;
try
{
var deferred = new DeferredServiceLevelPublisher();
deferred.Publish(240);
deferred.Publish(latest);
var started = await OpcUaHostStart.StartAsync(p =>
{
port = p;
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Replay",
ApplicationUri = $"urn:OtOpcUa.Replay:{Path.GetFileName(pki)}",
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
}, NullLogger<OpcUaApplicationHost>.Instance);
}, () => new StandardServer(), ct);
await using var host = started.Host;
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance));
var config = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.ReplayClient",
ApplicationUri = $"urn:OtOpcUa.ReplayClient:{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(clientPki),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
};
await config.ValidateAsync(ApplicationType.Client, ct);
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(config,
$"opc.tcp://localhost:{port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
config, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)),
updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelReplay",
sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null, ct: ct);
var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
read.StatusCode.ShouldBe(StatusCodes.Good);
read.Value.ShouldBe(latest);
}
finally
{
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
}
}
}
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class DeferredServiceLevelPublisherTests
{
/// <summary>Verifies that publish before SetInner is a safe no-op.</summary>
/// <summary>Verifies that publish before SetInner safely accepts a cached request.</summary>
[Fact]
public void Publish_before_SetInner_is_a_safe_noop()
public void Publish_before_SetInner_accepts_a_cached_request()
{
var deferred = new DeferredServiceLevelPublisher();
@@ -28,9 +28,9 @@ public sealed class DeferredServiceLevelPublisherTests
recording.LastValue.ShouldBe((byte)200);
}
/// <summary>Verifies that SetInner with null reverts to the null publisher.</summary>
/// <summary>Verifies that SetInner with null detaches the previous publisher.</summary>
[Fact]
public void SetInner_null_reverts_to_Null_publisher()
public void SetInner_null_detaches_the_previous_publisher()
{
var recording = new RecordingPublisher();
var deferred = new DeferredServiceLevelPublisher();
@@ -295,12 +295,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
/// HistoryRead call. The fake source MUST be invoked (proving we reached the bridge) and threw;
/// the production catch now sets BOTH errors and results explicitly, so both are asserted here
/// rather than relying on the SDK base pre-seeding results[i].</summary>
[Fact]
public async Task Events_backend_throw_yields_bad_status_and_does_not_escape()
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Events_backend_throw_yields_bad_status_and_does_not_escape(bool communication)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ThrowOnRead = true };
var fake = new RecordingHistorianDataSource { ThrowOnRead = !communication, ReadException = communication ? new System.IO.IOException("lost connection") : null };
nm.HistorianDataSource = fake;
const string equipmentId = "eq-boom";
@@ -321,10 +323,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
// Authoritative per-node signal: errors[0] must be Bad.
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
// The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding).
results[0].ShouldNotBeNull();
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
// The source WAS entered (proving the override reached the bridge and the throw was swallowed).
fake.EventsEntered.ShouldBeTrue();
// ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path.
@@ -380,6 +382,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
private sealed class RecordingHistorianDataSource : IHistorianDataSource
{
public bool ThrowOnRead { get; init; }
public Exception? ReadException { get; init; }
public HistoricalEventsResult EventsResult { get; set; } =
new(Array.Empty<HistoricalEvent>(), null);
@@ -411,6 +414,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
CancellationToken cancellationToken)
{
EventsEntered = true;
if (ReadException is not null) throw ReadException;
if (ThrowOnRead) throw new InvalidOperationException("backend boom for events");
EventsCalled = true;
LastSourceName = sourceName;
@@ -25,6 +25,34 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-modified-{Guid.NewGuid():N}");
[Fact]
public async Task Modified_values_use_the_scalar_node_type_and_preserve_the_audit_information()
{
var (host, nm, node, _) = await SetupAsync([Mod(1, 1, 2d, HistoryModificationType.Replace, "alice", Epoch)], "Float");
try
{
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
r.Data!.DataValues.Single().Value.ShouldBeOfType<float>().ShouldBe(2f);
r.Data.ModificationInfos.Single().UserName.ShouldBe("alice");
r.Data.ModificationInfos.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace);
}
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task A_modified_read_of_an_unreachable_backend_answers_BadNoCommunication()
{
var (host, nm, node, src) = await SetupAsync([]);
try
{
src.Throw = new System.IO.IOException("connection lost");
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
}
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task A_modified_read_dispatches_to_the_trail_and_projects_modification_infos()
{
@@ -289,7 +317,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync(
List<HistoryModifiedValue> trail)
List<HistoryModifiedValue> trail, string dataType = "Double")
{
var started = await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
@@ -307,7 +335,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
var src = new SqlLikeTrail(trail);
nm.HistorianDataSource = src;
nm.EnsureVariable("eq-1/mod", null, "Mod", "Double", false, historianTagname: Tag, realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/mod", null, "Mod", dataType, false, historianTagname: Tag, realm: AddressSpaceRealm.Uns);
return (started.Host, nm, nm.TryGetVariable("eq-1/mod")!.NodeId, src);
}
@@ -786,6 +786,132 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
return (results, errors);
}
[Theory]
[InlineData("Raw")]
[InlineData("AtTime")]
[InlineData("Processed")]
public async Task An_unreachable_backend_answers_a_communication_status(string kind)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = new RecordingHistorianDataSource { ReadException = new System.IO.IOException("connection lost") };
nm.EnsureVariable("eq/comm", null, "Comm", "Double", false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
var t = DateTime.UtcNow.AddMinutes(-1);
HistoryReadDetails details = kind switch
{
"Raw" => new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) },
"AtTime" => new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t } },
_ => new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), ProcessingInterval = 1000,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average } }
};
var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/comm")!.NodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
}
finally { await host.DisposeAsync(); }
}
[Theory]
[InlineData("Int32", "Raw")]
[InlineData("Boolean", "Raw")]
[InlineData("Float", "Raw")]
[InlineData("Int32", "AtTime")]
[InlineData("Boolean", "AtTime")]
[InlineData("Float", "AtTime")]
public async Task History_values_use_the_nodes_scalar_type(string dataType, string kind)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
var value = new HistorianRead([new DataValueSnapshot(1d, StatusCodes.Good, t, t)], null);
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = value, AtTimeResult = value };
nm.EnsureVariable("eq/typed", null, "Typed", dataType, false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
HistoryReadDetails details = kind == "Raw"
? new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) }
: new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t } };
var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/typed")!.NodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
if (dataType == "Int32") actual.Value.ShouldBeOfType<int>().ShouldBe(1);
if (dataType == "Boolean") actual.Value.ShouldBeOfType<bool>().ShouldBeTrue();
if (dataType == "Float") actual.Value.ShouldBeOfType<float>().ShouldBe(1f);
actual.SourceTimestamp.ShouldBe(t);
actual.StatusCode.Code.ShouldBe(StatusCodes.Good);
}
finally { await host.DisposeAsync(); }
}
[Theory]
[InlineData("Int16", 40000d)]
[InlineData("DateTime", 1d)]
public async Task An_unrepresentable_historical_value_is_null_with_BadTypeMismatch(string type, double input)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
nm.EnsureVariable("eq/mismatch", null, "Mismatch", type, false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([new(input, StatusCodes.Good, t, t)], null) };
var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false,
StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/mismatch")!.NodeId);
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
value.Value.ShouldBeNull();
value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch);
value.SourceTimestamp.ShouldBe(t);
value.ServerTimestamp.ShouldBe(t);
}
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task Array_and_null_history_values_preserve_quality_and_timestamps()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
var serverTime = t.AddSeconds(1);
nm.EnsureVariable("eq/array", null, "Array", "Int32", false, historianTagname: "T", realm: AddressSpaceRealm.Raw, isArray: true, arrayLength: 2);
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([
new(new int[] { 1, 2 }, StatusCodes.Uncertain, t, serverTime),
new(null, StatusCodes.BadNoCommunication, t.AddSeconds(1), serverTime)], null) };
var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false,
StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/array", AddressSpaceRealm.Raw)!.NodeId);
var values = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues;
values[0].Value.ShouldBeOfType<int[]>().ShouldBe([1, 2]);
values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain);
values[0].SourceTimestamp.ShouldBe(t);
values[0].ServerTimestamp.ShouldBe(serverTime);
values[1].Value.ShouldBeNull();
values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
}
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task Processed_Count_keeps_its_aggregate_type_on_a_Boolean_node()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
nm.EnsureVariable("eq/count", null, "Count", "Boolean", false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
nm.HistorianDataSource = new RecordingHistorianDataSource { ProcessedResult = new HistorianRead([new(3d, StatusCodes.Good, t, t)], null) };
var (results, _) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10),
ProcessingInterval = 1000, AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId);
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
value.Value.ShouldBeOfType<double>().ShouldBe(3d);
}
finally { await host.DisposeAsync(); }
}
private static HistorianRead Empty() => new(Array.Empty<DataValueSnapshot>(), null);
/// <summary>A recording fake historian source — captures the last call's kind + arguments and returns
@@ -793,6 +919,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
private sealed class RecordingHistorianDataSource : IHistorianDataSource
{
public bool ThrowOnRead { get; init; }
public Exception? ReadException { get; init; }
public bool AggregateNotSupported { get; init; }
public bool LimitExceeded { get; init; }
public IReadOnlySet<HistoryAggregateType>? Supported { get; init; } = HistoryAggregateTypes.All;
@@ -814,6 +941,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
if (ReadException is not null) throw ReadException;
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
LastCall = "Raw";
LastTagname = fullReference;
@@ -827,6 +955,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
if (ReadException is not null) throw ReadException;
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
if (AggregateNotSupported) throw new HistoryAggregateNotSupportedException(aggregate);
if (LimitExceeded) throw new HistoryReadLimitExceededException(4, "too many raw rows");
@@ -842,6 +971,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
public Task<HistorianRead> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
if (ReadException is not null) throw ReadException;
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
LastCall = "AtTime";
LastTagname = fullReference;
@@ -19,6 +19,40 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
[Fact]
public void Missing_ServiceLevel_is_reported_as_failure()
{
var server = System.Reflection.DispatchProxy.Create<IServerInternal, FaultingServer>();
var publisher = new SdkServiceLevelPublisher(server, NullLogger<SdkServiceLevelPublisher>.Instance);
Should.Throw<InvalidOperationException>(() => publisher.Publish(0));
}
[Fact]
public void Sdk_write_failure_propagates_the_original_exception()
{
var server = System.Reflection.DispatchProxy.Create<IServerInternal, FaultingServer>();
var proxy = (FaultingServer)(object)server;
proxy.Node = new Opc.Ua.ServerObjectState(null)
{
ServiceLevel = new Opc.Ua.PropertyState<byte>(null),
};
proxy.Fault = new InvalidOperationException("controlled diagnostics failure");
var publisher = new SdkServiceLevelPublisher(server, NullLogger<SdkServiceLevelPublisher>.Instance);
Should.Throw<InvalidOperationException>(() => publisher.Publish(0)).ShouldBeSameAs(proxy.Fault);
}
public class FaultingServer : System.Reflection.DispatchProxy
{
public Opc.Ua.ServerObjectState? Node { get; set; }
public Exception? Fault { get; set; }
protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args)
{
if (method?.Name == "get_ServerObject") return Node;
if (method?.Name == "get_DiagnosticsLock") throw Fault!;
throw new NotSupportedException(method?.Name);
}
}
/// <summary>Verifies that the publisher writes values to the standard Server.ServiceLevel variable.</summary>
[Fact]
public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
@@ -0,0 +1,240 @@
using System.Collections.Concurrent;
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorFreshnessTests : RuntimeActorTestBase
{
[Theory]
[InlineData(false, 240, 200)]
[InlineData(true, 250, 210)]
public void DbLess_timer_demotes_without_incoming_messages(bool primary, byte fresh, byte stale)
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("freshness-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(1), healthTickInterval: TimeSpan.FromMilliseconds(25),
utcNow: clock.Read));
actor.Tell(Snapshot(local, primary, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh }), PresenceBudget);
// Equality remains fresh. Observe several real ticks with a fixed clock; no actor Tell.
clock.Advance(TimeSpan.FromSeconds(1));
var reads = clock.Reads;
AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget);
// Drain earlier self-enqueued publications without triggering a health recomputation.
actor.Tell(new Identify("freshness-barrier"), TestActor);
ExpectMsg<ActorIdentity>().Subject.ShouldBe(actor);
publisher.Levels.ShouldBe(new[] { fresh });
clock.Advance(TimeSpan.FromTicks(1));
// No further Tell: only the production timer can notice age crossing the window.
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale }), PresenceBudget);
// A new fresh snapshot restores service, after which ticks continue to age that snapshot.
actor.Tell(Snapshot(local, primary, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale, fresh }), PresenceBudget);
clock.Advance(TimeSpan.FromSeconds(1) + TimeSpan.FromTicks(1));
AwaitAssert(() => publisher.Levels.ShouldBe(new[] { fresh, stale, fresh, stale }), PresenceBudget);
}
[Fact]
public void DbLess_timer_expires_negative_peer_verdict_only_after_exact_boundary()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("peer-verdict-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(10), probeFreshnessWindow: TimeSpan.FromSeconds(2),
healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget);
actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0 }), PresenceBudget);
clock.Advance(TimeSpan.FromSeconds(2));
var reads = clock.Reads;
AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget);
// Drain earlier self-enqueued publications without triggering a health recomputation.
actor.Tell(new Identify("freshness-barrier"), TestActor);
ExpectMsg<ActorIdentity>().Subject.ShouldBe(actor);
publisher.Levels.ShouldBe(new byte[] { 240, 0 });
clock.Advance(TimeSpan.FromTicks(1));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240 }), PresenceBudget);
}
[Fact]
public void Stopping_DbLess_actor_cancels_freshness_timer_and_publications()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("stopped-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(1), healthTickInterval: TimeSpan.FromMilliseconds(25),
utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget);
var reads = clock.Reads;
AwaitCondition(() => clock.Reads >= reads + 4, PresenceBudget);
Watch(actor);
Sys.Stop(actor);
ExpectTerminated(actor);
reads = clock.Reads;
clock.Advance(TimeSpan.FromSeconds(2));
ExpectNoMsg(TimeSpan.FromMilliseconds(150)); // Six timer periods after confirmed termination.
clock.Reads.ShouldBe(reads);
publisher.Levels.ShouldBe(new byte[] { 240 });
}
[Fact]
public void DbBacked_timer_refreshes_probe_status_periodically()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("db-backed-periodic-node");
var db = CreateTestProbe();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: db.Ref,
healthTickInterval: TimeSpan.FromMilliseconds(100), utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
db.Reply(new DbHealthProbeActor.DbHealthStatus(true, clock.Now, null));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), PresenceBudget);
// A second request comes from the periodic timer, not the immediate startup tick.
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
db.Reply(new DbHealthProbeActor.DbHealthStatus(false, clock.Now, "unreachable"));
AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)100), PresenceBudget);
}
[Fact]
public void DbBacked_timer_ask_timeout_demotes_last_known_healthy_node()
{
var publisher = new RecordingPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("db-backed-timeout-node");
var db = CreateTestProbe();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: db.Ref,
healthTickInterval: TimeSpan.FromMilliseconds(100), utcNow: clock.Read));
actor.Tell(Snapshot(local, true, clock.Now));
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
db.Reply(new DbHealthProbeActor.DbHealthStatus(true, clock.Now, null));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), PresenceBudget);
// Leave subsequent real Ask requests unanswered: the timeout status must reach the actor.
db.ExpectMsg<DbHealthProbeActor.GetStatus>();
AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)110), PresenceBudget);
}
[Fact]
public void Missing_local_snapshot_entry_retries_zero_on_timer_and_recovers()
{
var publisher = new RetryZeroPublisher();
var clock = new ControlledClock();
var local = NodeId.Parse("missing-entry-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read));
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget);
// Only this snapshot Tell requests the first zero. Fail it and the next attempt so
// subsequent zero attempts must come from the periodic timer, exercising the null-entry
// branch repeatedly rather than mistaking the Detached branch for missing-node coverage.
actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0 }), PresenceBudget);
publisher.ZeroAttempts.ShouldBeGreaterThanOrEqualTo(3);
actor.Tell(Snapshot(local, false, clock.Now));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240 }), PresenceBudget);
clock.Advance(TimeSpan.FromSeconds(30) + TimeSpan.FromTicks(1));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240, 200 }), PresenceBudget);
}
[Fact]
public void No_snapshot_startup_does_not_publish_across_virtual_timer_periods()
{
// This actor requires the Cluster extension, but the absent/missing-entry guards do
// not inspect membership. Do not self-join: the ordinary harness waits for Up in its
// constructor, which cannot progress with a frozen scheduler.
using var local = new TestKit(AkkaTestHocon + @"
akka.scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit""");
var scheduler = (TestScheduler)local.Sys.Scheduler;
var publisher = new RetryZeroPublisher();
var clock = new ControlledClock();
var actor = local.ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: NodeId.Parse("no-snapshot-node"), dbLess: true,
healthTickInterval: TimeSpan.FromSeconds(1), utcNow: clock.Read));
// TestActorRef uses the calling-thread dispatcher: Advance completes delivery before
// these assertions, including the immediate startup tick and three periodic ticks.
for (var period = 0; period < 3; period++)
scheduler.Advance(TimeSpan.FromSeconds(1));
publisher.Levels.ShouldBeEmpty();
publisher.ZeroAttempts.ShouldBe(0);
clock.Reads.ShouldBe(0);
// Positive control: the same registered timer must subsequently retry a failed zero.
// No whole snapshot at startup means no publication; a received snapshot missing the
// local entry means zero. Keep these two policies explicitly distinct.
actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now));
publisher.ZeroAttempts.ShouldBe(1);
scheduler.Advance(TimeSpan.FromSeconds(1));
publisher.ZeroAttempts.ShouldBe(2);
publisher.Levels.ShouldBeEmpty();
scheduler.Advance(TimeSpan.FromSeconds(1));
publisher.ZeroAttempts.ShouldBe(3);
publisher.Levels.ShouldBe(new byte[] { 0 });
clock.Reads.ShouldBe(0);
}
private sealed class RetryZeroPublisher : IServiceLevelPublisher
{
private readonly ConcurrentQueue<byte> _levels = new();
private int _zeroAttempts;
public byte[] Levels => _levels.ToArray();
public int ZeroAttempts => Volatile.Read(ref _zeroAttempts);
public void Publish(byte serviceLevel)
{
if (serviceLevel == 0 && Interlocked.Increment(ref _zeroAttempts) <= 2)
throw new InvalidOperationException("test zero-publication failure");
_levels.Enqueue(serviceLevel);
}
}
private sealed class ControlledClock
{
private long _ticks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
private int _reads;
public DateTime Now => new(Interlocked.Read(ref _ticks), DateTimeKind.Utc);
public int Reads => Volatile.Read(ref _reads);
public DateTime Read()
{
Interlocked.Increment(ref _reads);
return Now;
}
public void Advance(TimeSpan age) => Interlocked.Add(ref _ticks, age.Ticks);
}
private static RedundancyStateChanged Snapshot(NodeId local, bool primary, DateTime asOf) =>
new(new[] { new NodeRedundancyState(local,
primary ? RedundancyRole.Primary : RedundancyRole.Secondary,
IsClusterLeader: primary, IsDriverPrimary: primary, asOf) }, CorrelationId.NewId());
private sealed class RecordingPublisher : IServiceLevelPublisher
{
private readonly ConcurrentQueue<byte> _levels = new();
public byte[] Levels => _levels.ToArray();
public void Publish(byte serviceLevel) => _levels.Enqueue(serviceLevel);
}
}
@@ -15,6 +15,49 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
{
[Fact]
public void Failed_value_is_retried_and_last_level_only_tracks_success()
{
var sink = new RecoveringPublisher { Fail = false };
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(serviceLevel: sink));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
sink.Fail = true;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0));
actor.UnderlyingActor.LastServiceLevel.ShouldBe((byte)100);
sink.Fail = false;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0));
sink.Delivered.ShouldBe(new byte[] { 100, 0 });
}
[Fact]
public void Requesting_prior_success_after_failure_updates_delivery_and_replay()
{
var sink = new RecoveringPublisher();
var deferred = new DeferredServiceLevelPublisher();
deferred.SetInner(sink);
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(serviceLevel: deferred));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
sink.Fail = true;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(0));
sink.Fail = false;
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
sink.Delivered.ShouldBe(new byte[] { 100, 100 });
var rebound = new RecoveringPublisher();
deferred.SetInner(rebound);
rebound.Delivered.ShouldBe(new byte[] { 100 });
}
private sealed class RecoveringPublisher : IServiceLevelPublisher
{
public bool Fail { get; set; }
public List<byte> Delivered { get; } = new();
public void Publish(byte level)
{
if (Fail) throw new InvalidOperationException("controlled failure");
Delivered.Add(level);
}
}
/// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
[Fact]
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()