Compare commits
30 Commits
bc03adb1fd
...
6d5c1c768b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d5c1c768b | |||
| beb1c583b1 | |||
| 945ec2c069 | |||
| 7349260231 | |||
| 50c09e21d5 | |||
| 27bf4826d6 | |||
| 029a916288 | |||
| 232c03ed1b | |||
| 7594120a55 | |||
| 93d6c1da67 | |||
| 30a9d5f094 | |||
| fccffd0a0f | |||
| 9120182314 | |||
| 27ca6cd791 | |||
| 8e6ac4b344 | |||
| e59ab69398 | |||
| 8d154c6a81 | |||
| 0658d244d0 | |||
| 69190b14f1 | |||
| 1375f9349e | |||
| 045a21da14 | |||
| f292cf3beb | |||
| 3067146be3 | |||
| fe919f8b08 | |||
| f0b409f999 | |||
| f9ab2f7293 | |||
| 1e343b0c52 | |||
| 42eb96af9e | |||
| f41df6710c | |||
| 825393299e |
@@ -321,6 +321,13 @@ non-`Healthy` state **and** its `LastSuccessfulRead` has been frozen for 90 s. T
|
||||
`DefaultReconnectInterval`. Unlike the #523 drift detector this generates **no** traffic on a healthy
|
||||
system — it is a local `GetHealth()` read, and only reaches the network once a driver has already stopped
|
||||
reading.
|
||||
- **Fast path for DEFINITIVE losses (#676):** a driver implementing the opt-in `IDriverConnectionLossSource` raises
|
||||
`ConnectionLost` (once per connection) when its protocol proves the connection can never recover in place — OpcUaClient:
|
||||
keep-alive `BadSessionIdInvalid`/`BadSessionClosed`/`BadSessionNotActivated`/`BadServerHalted`, a SUSTAINED closed
|
||||
connection (2nd `BadSecureChannelClosed`/`BadConnectionClosed` in one outage — what a real process stop produces; the
|
||||
SDK never reopens it) or a subscription the SERVER reported dropped (its publishing-stall heuristic does not raise) — and `DriverInstanceActor` (attach PreStart, detach PostStop) turns it into the same disconnect only
|
||||
while Connected; ambiguous failures (timeouts, `BadNoCommunication`, a stalled socket) must never raise and stay with
|
||||
the watchdog's conjunction. `docs/DriverLifecycle.md`.
|
||||
|
||||
- **What comms loss now does downstream — one contract, six seams (archreview R3-02).** "A value consumer
|
||||
never reports a quality better than its source's" is stated once in `docs/DriverLifecycle.md` §"The
|
||||
@@ -1345,9 +1352,10 @@ historian tagname, which is the tag's driver `FullName`.
|
||||
- **Selection:** `ServerHistorian:Backend` (`Gateway` default | `SqlServer` | `QuestDb`; unknown fails host start). `HistorianBackendRegistry` → `IHistorianBackendProvider` builds all five seams; the gateway keys are only required for `Gateway`. `SqlServer` uses `ServerHistorian:SqlServer:{ConnectionString (required), Schema=hist, CommandTimeout=30s, AutoCreateSchema=true}`.
|
||||
- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (12, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted five (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM`) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; client `AggregateConfiguration` ignored; status = the SDK `AggregateCalculator` time-based rule, Uncertain time counts as Good; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`). Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention.
|
||||
- **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend".
|
||||
- ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates.
|
||||
- **Real status fix:** the recorder used to stamp every sample Good (`192`); it now carries the driver's status. ⚠️ On the **Gateway** backend this changes nothing stored: the gateway discards quality (C-002), so AVEVA still stores Good.
|
||||
- **Comms loss is historized as an outage marker** (`Empty` + `BadNoCommunication`, one per tag per outage) on SqlServer; dropped (`DroppedUnsupportedKind`) on Gateway. Outbox records are **format v2** — drain or delete `ContinuousHistorization:OutboxPath` before downgrading a node.
|
||||
- **HistoryUpdate** (SqlServer only; Gateway ⇒ `BadHistoryOperationUnsupported`): Insert/Replace/Update/DeleteRaw/DeleteAtTime, gated in both ACL modes — flag off needs the `HistoryUpdate` role from a **Cluster-scoped** grant (+ key re-check), flag on uses `DataPlaneAuthorizer`'s HistoryUpdate operation; `WriteOperate` never implies it. The `HistoryWrite` AccessLevel bit is set only when an updater is wired at materialise time. `docs/Historian.md` §HistoryUpdate, `docs/security.md`.
|
||||
- **HistoryUpdate** (SqlServer + QuestDb; Gateway ⇒ `BadHistoryOperationUnsupported`): Insert/Replace/Update/DeleteRaw/DeleteAtTime, **Primary-only** since #674 (off-Primary ⇒ `BadInvalidState`, after authorization AND after planning — an unservable request (Remove / `IsDeleteModified` / not historized) stays `BadHistoryOperationUnsupported` off-Primary; the verdict is `IHistoryUpdateRoleView` = the data-plane verdict but **closed until this node's first redundancy snapshot**, so a pair cannot both accept in the boot window; admin-only nodes register no view ⇒ no gate; reads ungated), items with a `SourceTimestamp` past now + `ServerHistorian:HistoryUpdateMaxFutureSkew` (default 1 day, ≤0 disables) ⇒ `BadOutOfRange` in the node manager (every backend), gated in both ACL modes — flag off needs the `HistoryUpdate` role from a **Cluster-scoped** grant (+ key re-check), flag on uses `DataPlaneAuthorizer`'s HistoryUpdate operation; `WriteOperate` never implies it. The `HistoryWrite` AccessLevel bit is set only when an updater is wired at materialise time. `docs/Historian.md` §HistoryUpdate, `docs/security.md`. ⚠️ **SqlServer schema v1 is accepted (#674)** with `AutoCreateSchema=false`: writes/reads/events/retention work; modified reads + HistoryUpdate ⇒ node-level `BadHistoryOperationUnsupported` on every arm (the updater throws `NotSupportedException`, nothing sent; the `HistoryWrite` bit is still advertised — not knowable at materialise time), retention skips `SampleModified`, one Warning per boot (`SqlServerSchemaBootstrapper.SupportsModifiedTrail`; `RequiredVersion` = 1, `ModifiedTrailVersion` = 2). QuestDB: keep `cairo.ttl.use.wall.clock=true`; a QuestDB trail INSERT retried after a client timeout duplicates (no DEDUP key).
|
||||
- **Modified-value history (#668, Part 11):** every applied HistoryUpdate item leaves a trail row (prior value, principal from the session's API key / `anonymous`, `HistoryModificationType`, time) — SqlServer `hist.SampleModified` (**schema v2**, written by `OUTPUT … INTO` in the same statement ⇒ atomic; ⚠️ no FK/CHECK/trigger on it, and `AutoCreateSchema=false` installs must run `002_sample_modified.sql` before upgrading), QuestDB `{p}sample_modified` (append-only, written **before** the change ⇒ a crash leaves an orphan trail row, never an untracked change). `IsReadModified=true` HistoryRead serves it as `HistoryModifiedData` via `IHistorianDataSource.ReadModifiedAsync` (default ⇒ `NotSupported` ⇒ `BadHistoryOperationUnsupported` on Gateway/Null), paged on `(SourceTime, ModifiedId)`, gated by plain HistoryRead. Live writes and retention never write it; retention/TTL purge it on source time. `IHistorianDataUpdater` methods take a `HistoryUpdateContext`. CLI `historyread --modified`. `docs/Historian.md` §Modified-value history.
|
||||
|
||||
### Read path (`ServerHistorian` section)
|
||||
|
||||
@@ -207,7 +207,9 @@ The `-v` drops the SQL volume; remove it to keep ConfigDb state across restarts.
|
||||
|
||||
The full single-mesh stack (`central-1`/`central-2` + the four site nodes) can OOM-kill `central-1` on a loaded host. Two settings in the compose file guard against that:
|
||||
|
||||
- **EF Core + ASP.NET Core logs are pinned to `Warning`** on every host node (`Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore` / `…Microsoft.AspNetCore` = `Warning`). The host logs via Serilog (`AddZbSerilog` → `ReadFrom.Configuration`), and in `Development` the default level is `Debug` — without these overrides every Deployment-poll emits an `Executed DbCommand` / `SELECT … FROM [Deployment]` line, flooding the Serilog pipeline and starving the Akka cluster heartbeat thread. Application + Akka log levels are left untouched, so this only silences the per-poll SQL chatter. To temporarily restore the SQL log flood for debugging, drop those two env vars (or set them back to `Information`) on the node you're inspecting.
|
||||
- **EF Core + ASP.NET Core logs are pinned to `Warning`** on every host node (`Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore` / `…Microsoft.AspNetCore` = `Warning`). The host logs via Serilog (`AddZbSerilog` → `ReadFrom.Configuration`), and a `Development` environment would default to `Debug` (`appsettings.Development.json`) — the rig does NOT set `ASPNETCORE_ENVIRONMENT`, so nodes actually run at the base `Information` default, but these pins keep the SQL chatter out should the rig ever run as Development; without them a Debug/Information-level EF pipeline emits an `Executed DbCommand` / `SELECT … FROM [Deployment]` line, flooding the Serilog pipeline and starving the Akka cluster heartbeat thread. Application + Akka log levels are left untouched, so this only silences the per-poll SQL chatter. To temporarily restore the SQL log flood for debugging, drop those two env vars (or set them back to `Information`) on the node you're inspecting.
|
||||
|
||||
To raise Debug logging for chosen categories on `central-1`/`central-2` (e.g. `ZB.MOM.WW.OtOpcUa.Runtime`), use the overlay: `docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.debug.yml up -d --no-deps central-1 central-2` — it sets `Serilog__MinimumLevel__Override__<Namespace>=Debug`; edit its category list to taste.
|
||||
- **Each host node has `mem_limit: 1g`** (`mem_reservation: 512m`). A quiet solo `central-1` measures ~357 MiB; the limit leaves headroom for the deploy/UI load and per-cluster driver subscriptions that push a fully-loaded node higher. The limit/reservation live on the `&otopcua-host` anchor, so all six host services inherit them; `sql`, `traefik`, and the one-shot `migrator`/`cluster-seed` are left unbounded.
|
||||
|
||||
The full six-node host stack therefore needs roughly **6 GiB** of Docker Desktop VM memory just for the host nodes (plus SQL Server's own footprint on top). On a constrained host, either raise the Docker Desktop VM memory or run fewer host services (e.g. just `central-1` + `central-2`, or a single central node) rather than the full mesh.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# docker-dev/docker-compose.debug.yml — optional overlay: raise Serilog levels per category on the central pair.
|
||||
#
|
||||
# The rig nodes run with ASPNETCORE_ENVIRONMENT unset (Production), so there is NO appsettings.Development.json and the
|
||||
# Serilog default level is Information (the base appsettings.json sets none). Debug is therefore OFF by default; this
|
||||
# overlay turns it on for chosen namespaces only, via Serilog:MinimumLevel:Override:<Namespace> (env: double underscore
|
||||
# for ':', and the namespace's own dots stay dots).
|
||||
#
|
||||
# Up (recreates only the two central nodes with the extra env):
|
||||
# docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.debug.yml up -d --no-deps central-1 central-2
|
||||
# Back to normal: re-run `up -d --no-deps central-1 central-2` with the base file alone.
|
||||
#
|
||||
# Edit the list below to the categories you care about. Useful ones:
|
||||
# ZB.MOM.WW.OtOpcUa.Runtime actors: DriverHostActor, OpcUaPublishActor, redundancy, deploys
|
||||
# ZB.MOM.WW.OtOpcUa.Runtime.Drivers.DriverHostActor one actor only (e.g. the #672 historization verdict logs)
|
||||
# ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient a single driver
|
||||
# Opc.Ua OPC Foundation stack (very chatty)
|
||||
# Akka cluster/remoting internals (chatty; base is Information)
|
||||
# Leave Microsoft.EntityFrameworkCore / Microsoft.AspNetCore at Warning (see README "Resource limits & dev logging"):
|
||||
# a Debug SQL flood starves the Akka heartbeat thread.
|
||||
services:
|
||||
central-1:
|
||||
environment:
|
||||
Serilog__MinimumLevel__Override__ZB.MOM.WW.OtOpcUa.Runtime: "Debug"
|
||||
central-2:
|
||||
environment:
|
||||
Serilog__MinimumLevel__Override__ZB.MOM.WW.OtOpcUa.Runtime: "Debug"
|
||||
+14
-3
@@ -57,6 +57,11 @@ otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operat
|
||||
|
||||
### Failover
|
||||
|
||||
**Mapped ports and internal hostnames.** Endpoint discovery rewrites the host *and port* of the endpoint the server
|
||||
advertises to the URL you passed (when the scheme matches), so connecting through a port mapping works even though the
|
||||
server advertises its internal address (for example the docker-dev rig: `-u opc.tcp://localhost:4841` reaches the
|
||||
node mapped to 4841, although every container advertises `:4840`).
|
||||
|
||||
When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure.
|
||||
|
||||
```bash
|
||||
@@ -229,7 +234,7 @@ continuation points up to `--max`. A server on the `Gateway` backend (no modific
|
||||
| `--start` | Start time in ISO 8601 UTC format, e.g. `2026-01-15T08:00:00Z` (default: 24 hours ago) |
|
||||
| `--end` | End time in ISO 8601 UTC format, e.g. `2026-01-15T09:00:00Z` (default: now) |
|
||||
| `--max` | Maximum number of values (default: 1000) |
|
||||
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation |
|
||||
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative |
|
||||
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
|
||||
| `--modified` | Read modified-value history (Part 11 `IsReadModified`) instead of raw samples |
|
||||
|
||||
@@ -270,8 +275,9 @@ node-level code (`Good`, or `BadNoData` if the range held nothing). A node that
|
||||
updater, so no `HistoryWrite` access bit), answers `BadHistoryOperationUnsupported`; a `NaN`/`Infinity` double is `BadInvalidArgument`. The session's API key
|
||||
(`-U apikey -P otua_<keyId>_<secret>`) needs the `HistoryUpdate` permission — via a `Cluster`-scoped grant when `Security:Acl:Enabled` is off, or a scoped
|
||||
grant on the node when it is on; a key with only `WriteOperate` gets `BadUserAccessDenied`. See [Historian.md §HistoryUpdate](Historian.md#historyupdate) and
|
||||
[security.md](security.md#historyupdate-historian-writes). `historyread --aggregate` only knows the seven names in the table below; the SQL backend's other
|
||||
aggregates (Total, Range, Delta, PercentGood, …) need a different client.
|
||||
[security.md](security.md#historyupdate-historian-writes). `historyread --aggregate` only knows the twelve names in the table below; the SQL backend's other
|
||||
aggregates (Range, Delta, PercentGood, …) need a different client. A refused history read (for example
|
||||
`BadHistoryOperationUnsupported`) prints one error line and exits non-zero; it does not dump a stack trace.
|
||||
|
||||
`delete-raw` is sent with `IsDeleteModified=false`. Invalid or inconsistent options (unknown mode, unpaired
|
||||
value/time, unparsable value, options that do not apply to the mode) fail before any connection is made.
|
||||
@@ -287,6 +293,11 @@ value/time, unparsable value, options that do not apply to the mode) fail before
|
||||
| `Start` | `first` | `AggregateFunction_Start` |
|
||||
| `End` | `last` | `AggregateFunction_End` |
|
||||
| `StandardDeviation` | `stddev`, `stdev` | `AggregateFunction_StandardDeviationPopulation` |
|
||||
| `TimeAverage` | `timeavg` | `AggregateFunction_TimeAverage` |
|
||||
| `TimeAverage2` | `timeavg2` | `AggregateFunction_TimeAverage2` |
|
||||
| `Total` | | `AggregateFunction_Total` |
|
||||
| `Total2` | | `AggregateFunction_Total2` |
|
||||
| `Interpolative` | `interp` | `AggregateFunction_Interpolative` |
|
||||
|
||||
### alarms
|
||||
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ All times are in UTC. Invalid input turns red on blur.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Aggregate | Raw (default), Average, Minimum, Maximum, Count, Start, End, Standard Deviation |
|
||||
| Aggregate | Raw (default), Average, Minimum, Maximum, Count, Start, End, Standard Deviation, Time Average, Time Average 2, Total, Total 2, Interpolative |
|
||||
| Interval (ms) | Processing interval for aggregate queries (shown only for aggregates) |
|
||||
| Max Values | Maximum number of raw values to return (default 1000) |
|
||||
|
||||
|
||||
+26
-5
@@ -22,14 +22,30 @@ Two related concerns get their own dedicated pages and are **only summarised + l
|
||||
|
||||
The Host (`Program.cs`) loads `appsettings.json`, then overlays a **per-role** file chosen from the cluster roles:
|
||||
|
||||
- A single role → `appsettings.{role}.json` (e.g. `appsettings.driver.json`, `appsettings.admin.json`).
|
||||
- Both roles → `appsettings.admin-driver.json` (roles joined with `-`, ordinal-sorted).
|
||||
- A single plain role → `appsettings.{role}.json` (e.g. `appsettings.driver.json`, `appsettings.admin.json`).
|
||||
- Both roles → `appsettings.admin-driver.json` (plain roles joined with `-`, ordinal-sorted).
|
||||
- `cluster-{ClusterId}` roles are **ignored** when naming the overlay (#678.6) — `[driver, cluster-SITE-A]` loads `appsettings.driver.json`.
|
||||
- `appsettings.{ASPNETCORE_ENVIRONMENT}.json` (e.g. `appsettings.Development.json`) is layered on by the host builder.
|
||||
|
||||
All role overlays are **optional** — the base `appsettings.json` plus the Options-class C# defaults are enough to boot. The roles themselves come from `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`) — `Cluster:Roles`, falling back to `OTOPCUA_ROLES` when that is empty (see [`ServiceHosting.md`](ServiceHosting.md) and the table below).
|
||||
|
||||
The checked-in `appsettings*.json` files are deliberately thin: they carry only `Serilog` and the `Security:Ldap` overlay. Everything else (`OpcUa`, `Cluster`, `ConnectionStrings`/`ConfigDb`) binds from the Options-class defaults documented below unless an operator adds the section explicitly or supplies the corresponding environment variable.
|
||||
|
||||
<a id="role-overlay-audit"></a>
|
||||
#### Role overlay audit (#678.6)
|
||||
|
||||
Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the cluster role leaked into the filename). Fixing that **activates** the shipped overlays on those nodes (the docker-dev rig and any per-cluster-mesh production node), so each key was audited against base `appsettings.json` / the Options defaults. Env vars and command-line args are re-appended after the overlay, so any value a deployment sets explicitly (the rig sets every `Security__Ldap__*` key) still wins.
|
||||
|
||||
| Key | Overlay(s) | Base / default | Effect on activation |
|
||||
|---|---|---|---|
|
||||
| `Serilog:MinimumLevel:Default` = `Information` | all three | Serilog default `Information` | no-op (in `Development` it outranks `appsettings.Development.json`'s `Debug`, by design) |
|
||||
| `…Override:Akka` = `Information` | all three | inherits `Default` (`Information`) | no-op |
|
||||
| `…Override:Microsoft.AspNetCore` = `Warning` | `admin`, `admin-driver` | unset (`Information`) | quieter ASP.NET Core logs; log-level only, matches what the rig already pins |
|
||||
| `…Override:Opc.Ua` = `Information` | `admin-driver`, `driver` | inherits `Default` | no-op. **`driver` shipped `Debug` — changed to `Information` in #678.6**: activating a Debug OPC UA stack trace on every production driver node would flood Serilog (the same pipeline starvation the rig's EF/AspNetCore pins guard against). Raise it per node with `Serilog__MinimumLevel__Override__Opc.Ua=Debug`. |
|
||||
| `Security:Ldap:DevStubMode` = `false` | all three | `false` | no-op; the overlay's point is that it outranks a developer's `appsettings.Development.json` (`true`) |
|
||||
| `Security:Ldap:Transport` = `Ldaps` | all three | `LdapOptions.Transport` default `None` | **behaviour-changing, kept deliberately** (documented secure production default). A cluster-role node that configures `Security:Ldap:Server`/`Port` for a plaintext LDAP but never sets `Transport` would now dial LDAPS and fail AdminUI sign-in; set `Security__Ldap__Transport` (the rig already sets `None` explicitly). Data-plane auth is API-key based and unaffected; driver-only nodes have no AdminUI. |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## `appsettings` sections
|
||||
@@ -38,7 +54,7 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
|
||||
|
||||
- **Purpose:** logging. Console + rolling daily file sink, layered with the shared `ZB.MOM.WW.Telemetry` enrichers (`AddZbSerilog` in `Program.cs`).
|
||||
- **Where bound:** `builder.AddZbSerilog(...)` reads `Serilog` from configuration (`ReadFrom.Configuration`).
|
||||
- **Checked-in shape** (`appsettings.json`): `Using` = `[ "Serilog.Sinks.Console", "Serilog.Sinks.File" ]`, `WriteTo` = a `Console` sink and a `File` sink (`path: logs/otopcua-.log`, `rollingInterval: Day`). Role overlays add `MinimumLevel` / `Override` blocks (e.g. `Opc.Ua: Debug`, `Akka: Information`).
|
||||
- **Checked-in shape** (`appsettings.json`): `Using` = `[ "Serilog.Sinks.Console", "Serilog.Sinks.File" ]`, `WriteTo` = a `Console` sink and a `File` sink (`path: logs/otopcua-.log`, `rollingInterval: Day`). Role overlays add `MinimumLevel` / `Override` blocks (e.g. `Opc.Ua: Information`, `Akka: Information`).
|
||||
|
||||
### `OpcUa`
|
||||
|
||||
@@ -410,7 +426,7 @@ Validated at host start (driver-role nodes) when `ServerHistorian:Enabled` **or*
|
||||
| `ServerHistorian:SqlServer:ConnectionString` | `""` | **Required.** Historian database connection string (not the ConfigDb). Supply via `${secret:OT-HISTORIAN-SQL}` or env `ServerHistorian__SqlServer__ConnectionString`; the database must already exist. |
|
||||
| `ServerHistorian:SqlServer:Schema` | `hist` | Must match `^[A-Za-z_][A-Za-z0-9_]{0,127}$` (spliced into DDL/DML). |
|
||||
| `ServerHistorian:SqlServer:CommandTimeout` | `00:00:30` | Must be `> 0`; rounded up to whole seconds. |
|
||||
| `ServerHistorian:SqlServer:AutoCreateSchema` | `true` | `true` applies the embedded schema script under an app-lock on first use; `false` only verifies `SchemaVersion >= 1` and fails every historian call (health `LastError`, no crash) if behind. |
|
||||
| `ServerHistorian:SqlServer:AutoCreateSchema` | `true` | `true` applies the embedded schema scripts under an app-lock on first use (upgrading a v1 schema to v2); `false` only verifies `SchemaVersion >= 1` and fails every historian call (health `LastError`, no crash) if behind. A verified **v1** schema (no `002_sample_modified.sql`) is served with modified-value reads and HistoryUpdate refused `BadHistoryOperationUnsupported`, retention skipping `SampleModified`, and one Warning per boot (#674). |
|
||||
| `ServerHistorian:SqlServer:RetentionDays` | unset | Days of history kept (source time), `>= 1` when set; unset keeps everything. Enables the Primary-gated purge of `hist.Sample`/`hist.Event` (see [Historian.md](Historian.md#retention-retentiondays)). |
|
||||
| `ServerHistorian:SqlServer:PurgeInterval` | `01:00:00` | Purge cadence; must be `> 0`. |
|
||||
| `ServerHistorian:SqlServer:PurgeBatchSize` | `4000` | Rows per `DELETE TOP (n)` batch; `>= 1`. Keep under ~5000 to avoid lock escalation. |
|
||||
@@ -430,7 +446,12 @@ when `ServerHistorian:Enabled` **or** `AlarmHistorian:Enabled` is true (`QuestDb
|
||||
| `ServerHistorian:QuestDb:VisibilityTimeout` | `00:00:05` | Must be `>= 0`. How long a write waits for its rows to become visible to queries (WAL apply); `0` disables the wait. A timeout only logs a Warning. |
|
||||
|
||||
HistoryUpdate (SqlServer and QuestDb only; QuestDb deletes are soft — tombstones) additionally needs `ServerHistorian:Enabled=true` and a key
|
||||
whose principal holds the `HistoryUpdate` permission — see [`docs/security.md`](security.md#historyupdate-historian-writes). Full semantics,
|
||||
whose principal holds the `HistoryUpdate` permission — see [`docs/security.md`](security.md#historyupdate-historian-writes). It is served
|
||||
only on the redundancy **Primary** (`BadInvalidState` elsewhere, #674) and is bounded by one backend-independent key:
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `ServerHistorian:HistoryUpdateMaxFutureSkew` | `1.00:00:00` (1 day) | TimeSpan. A HistoryUpdate (Insert / Replace / Update) item whose `SourceTimestamp` is later than server-clock now + this is refused `BadOutOfRange` before any backend sees it (#674 — a far-future typo could otherwise age out a QuestDB TTL table or poison the history). Applies to every backend; the live recorder is not bounded. Non-positive disables the bound. | Full semantics,
|
||||
schema, aggregates and the docker-dev recipe: [`docs/Historian.md`](Historian.md#sql-server-backend) (SqlServer) and
|
||||
[`docs/Historian.md`](Historian.md#questdb-backend) (QuestDb: the µs timestamp rules, pre-1970 limits, TTL and the docker-dev overlay).
|
||||
|
||||
|
||||
@@ -441,6 +441,40 @@ Two consequences worth knowing:
|
||||
**notices**, so a silent-success termination path — a stream that ends cleanly, a poll loop that exits
|
||||
without throwing — bypasses it entirely.
|
||||
|
||||
**A driver that KNOWS its connection is gone can skip the wait: `IDriverConnectionLossSource` (Gitea #676).** The
|
||||
watchdog has to prove a loss from silence, so it costs ~90 s even when the protocol already said "this connection
|
||||
will never come back". An opt-in `Core.Abstractions` interface lets a driver say so:
|
||||
`event EventHandler<DriverConnectionLossEventArgs> ConnectionLost` (a reason string). `DriverInstanceActor` attaches
|
||||
in `PreStart` and detaches in `PostStop` — the same lifetime as `IRediscoverable`, for the same reason (the `IDriver`
|
||||
outlives the actor on a respawn) — marshals each raise to the actor thread (`Self.Tell`; actor state is never touched
|
||||
on the driver's thread), and acts on it **only while `Connected`**, through the very handler the watchdog's
|
||||
`DisconnectObserved` uses (detach, record a fault, `Reconnecting`, teardown-then-init on the retry). In
|
||||
`Connecting`/`Reconnecting`/`Stubbed` it is ignored, not deferred: a re-initialise is already pending, and acting on a
|
||||
stale raise later could tear down the connection that attempt is building. The two are complementary, not
|
||||
alternatives:
|
||||
|
||||
- **The signal is for DEFINITIVE losses only.** A driver must raise only when no in-place recovery is possible, at most
|
||||
once per connection, re-armed by a re-initialise. An ambiguous failure — a timeout, a stalled socket, "no
|
||||
communication" — must not raise: a stalled link that resumes keeps its session, subscriptions and in-place recovery,
|
||||
and tearing it down would cost all three. Those stay with the watchdog, whose conjunction is the honest test for them.
|
||||
- **Today only `OpcUaClientDriver` implements it:** keep-alive `BadSessionIdInvalid` / `BadSessionClosed` /
|
||||
`BadSessionNotActivated` / `BadServerHalted`; a CLOSED connection (`BadSecureChannelClosed` /
|
||||
`BadConnectionClosed`) once sustained — the second closed-channel keep-alive in one outage, which is what a real
|
||||
process stop actually produces (live gate A1), and the SDK never reopens a closed connection by itself; or a
|
||||
subscription the server REPORTED dropped (not the driver's
|
||||
publishing-stall heuristic, which is an inference from silence and stays with the watchdog; see
|
||||
[OpcUaClient.md](drivers/OpcUaClient.md) §"Upstream outage and recovery" for why `BadServerHalted` is on the list —
|
||||
it was measured, the SDK never re-opens a halted channel). A restarted, stopped or killed upstream therefore
|
||||
recovers in about one keep-alive plus one reconnect interval instead of ~100 s; a stall/pause
|
||||
(`BadRequestTimeout`/`BadNoCommunication`) still goes through the watchdog and keeps its in-place recovery.
|
||||
- ⚠️ **Known gap:** "only while `Connected`" plus "once per connection" means a raise the host receives while not yet
|
||||
Connected (e.g. the protocol proves the fresh connection dead before `InitializeSucceeded` is processed) is swallowed,
|
||||
and that connection falls back to the watchdog — the pre-#676 recovery time, in a narrow window.
|
||||
- Pinned by `DriverInstanceActorConnectionLossSignalTests` (Reconnecting within seconds while the watchdog is set to an
|
||||
hour; a falsification control with no raise that stays Connected; a raise mid-connect ignored; a stray watchdog
|
||||
`DisconnectObserved` in `Reconnecting` is a no-op; detach on stop) and `OpcUaClientConnectionLossSignalTests`
|
||||
(definitive raises once, ambiguous and the stall heuristic never, re-armed after re-init).
|
||||
|
||||
**The reconnect retry now tears down before re-initialising.** `Reconnecting`'s `RetryConnect` calls
|
||||
`ShutdownAsync` before `InitializeAsync` — the sequence every driver's own `ReinitializeAsync` already
|
||||
uses — because several drivers **overwrite** an `IAsyncDisposable` transport field inside `InitializeAsync`
|
||||
|
||||
+179
-38
@@ -85,7 +85,8 @@ consumes the historian. The existing enable gates (`ServerHistorian:Enabled`, `A
|
||||
| Connection | `Endpoint` + `ApiKey` + TLS keys (top-level `ServerHistorian:*`) | `ServerHistorian:SqlServer:*` | `ServerHistorian:QuestDb:*` |
|
||||
|
||||
Recording stays **Primary-only** (#537) and **at-least-once** with the #538 per-tag quarantine on every backend;
|
||||
HistoryRead and HistoryUpdate are served from any node (the database is shared). The historian connection string is
|
||||
HistoryRead is served from any node (the database is shared). HistoryUpdate is **Primary-only** since #674
|
||||
(`BadInvalidState` on any other node — see [HistoryUpdate](#primary-only-674)). The historian connection string is
|
||||
**not** a ConfigDb string, and a driver-only node may carry it without breaking the Phase 4 "no ConfigDb" posture.
|
||||
|
||||
---
|
||||
@@ -145,6 +146,7 @@ and all HistoryRead calls on historized nodes return `GoodNoData` (empty, not an
|
||||
| `MaxConcurrentHistoryReadBatches` | int | `16` | Process-wide limit on HistoryRead (and HistoryUpdate) batches served **concurrently** (arch-review 03/S3). A flood of history clients degrades to `BadTooManyOperations` rather than exhausting the SDK request-thread pool. With `HistoryReadBatchParallelism` this caps total in-flight gateway reads at `MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism` (default 16 × 4 = 64). A batch that cannot get a slot within `min(HistoryReadDeadline, 5 s)` fails every handle `BadTooManyOperations`. |
|
||||
| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` only (#671). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped. 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). |
|
||||
| `HistoryReadDeadline` | TimeSpan | `00:01:00` | Per-request wall-clock deadline for a HistoryRead (and HistoryUpdate) batch (arch-review 03/S3). A node still in flight at the deadline surfaces `BadTimeout`, so one request can never hold an SDK request thread longer than this regardless of node count. Non-positive = unbounded (a `Validate()` warning). |
|
||||
| `HistoryUpdateMaxFutureSkew` | TimeSpan | `1.00:00:00` | #674. How far past the server clock a HistoryUpdate (Insert / Replace / Update) item's `SourceTimestamp` may lie; an item beyond `now + skew` is refused **`BadOutOfRange`** by the node manager before any backend sees it (every backend). Guards against a far-future typo (a 2062 timestamp). The live recorder is not bounded. Non-positive disables the bound (no warning — a deliberate choice). See [HistoryUpdate](#historyupdate). |
|
||||
|
||||
**`ServerHistorian:SqlServer`** (read only when `Backend=SqlServer`; bound by `HistorianBackendRegistry.BindSqlServer`):
|
||||
|
||||
@@ -630,13 +632,37 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un
|
||||
|
||||
- The cursor is `(source, next start, end, boundary skip, cap)` in the same session-bound store as Raw (flagged as an events
|
||||
cursor — a Raw point presented to an events read, or the reverse, is `BadContinuationPointInvalid`). The next page reads from
|
||||
the last event's `EventTimeUtc` **inclusive** and drops the events at that timestamp already emitted.
|
||||
the last event's `EventTimeUtc` **inclusive** and drops the events at that timestamp already emitted — and asks the source for
|
||||
that many more rows than the cap, so every page short of the last is a FULL `NumValuesPerNode` page (#675; resumed pages used
|
||||
to come back short by the skip).
|
||||
- A tie cluster larger than the cap is paged within by over-fetching the half-open window `[T, T + resolution)` up to
|
||||
`MaxTieClusterOverfetch`; beyond that bound the node answers `BadHistoryOperationUnsupported` (raise the bound).
|
||||
- **Tie exactness depends on the backend.** SqlServer and QuestDB order by `(EventTimeUtc, EventId)`, so the skip count is exact.
|
||||
The Gateway backend has no stable intra-timestamp order (and only a truncation flag), so ties at a page boundary are
|
||||
best-effort there.
|
||||
- Reverse event reads (`start > end`) are not paged server-side; no continuation point is issued for them.
|
||||
- **Reverse event reads page newest-first (#675).** `StartTime > EndTime` reads the Part 11 reverse window `(EndTime, StartTime]`
|
||||
with events in DESCENDING `(EventTimeUtc, EventId)` order, so a cap keeps the **latest** N and a continuation point is issued
|
||||
when the source flags more. The direction is stored **in the cursor**: a reverse point resumes reverse (and a forward one
|
||||
forward) whatever the resume request's details say. Reverse tie clusters are over-fetched forward and sliced from the newest
|
||||
end, exactly as Raw does. Before #675 a capped reverse read silently returned the EARLIEST N, ascending, Good, with no
|
||||
continuation point.
|
||||
- **End-only events reads: backward on SqlServer/QuestDB, forward on Gateway (#675).** `StartTime` unset
|
||||
(`DateTime.MinValue`) with an `EndTime` and `NumValuesPerNode > 0` pages backward from `EndTime` over `(MinValue, EndTime]` —
|
||||
the same rule as Raw — **only on a backend that reports `SupportsReverseEvents`** (SqlServer, QuestDB). On Gateway (the
|
||||
default backend) and Null it keeps its pre-#675 forward `[MinValue, EndTime)` meaning, paged oldest-first, rather than
|
||||
becoming a refused reverse read. Start-only reads page forward to an open upper bound (`DateTime.MaxValue`, UTC) everywhere.
|
||||
Without a cap an end-only read is forward `[MinValue, EndTime)` everywhere.
|
||||
- **Backends.** The server checks `IHistorianDataSource.SupportsReverseEvents` and then calls
|
||||
`ReadEventsAsync(…, bool reverse, …)`. SqlServer and QuestDB serve reverse natively (`… > lo AND … <= hi ORDER BY time DESC,
|
||||
id DESC`) and report `true`. **Gateway and Null report `false`**: an explicit reverse read (`StartTime > EndTime`) is answered
|
||||
`BadHistoryOperationUnsupported` **without calling the backend** — the gateway streams ascending only and rejects `end < start`,
|
||||
and draining the whole window to keep its tail would be exactly the unbounded read the cap prevents. Because the refusal is a
|
||||
capability check, not a caught exception, a `NotSupportedException` thrown from inside a backend (a driver library) is logged
|
||||
as a backend fault like any other. Every implementation (and any future wrapper) must declare both members itself — the
|
||||
interface defaults (`false` / throw) would silently disable reverse reads; `HistorianDataSourceReverseEventsGuardTests` pins it.
|
||||
- **Gateway open upper bound.** The `DateTime.MaxValue` (UTC) bound of a start-only forward read is pinned offline through the
|
||||
published client's proto `Timestamp` conversion (captured by an in-process gRPC server) and the FILETIME encoding
|
||||
(`GatewayEventBoundsTests`). What a live AVEVA Historian does with a year-9999 bound is still unverified (needs the VPN).
|
||||
- No session on the request (in-process callers) degrades to single-shot, as for Raw.
|
||||
|
||||
### Modified-value history (Part 11)
|
||||
@@ -686,6 +712,28 @@ modification time and the Part 11 `HistoryUpdateType`. A HistoryReadRaw with `Is
|
||||
within one node the order is strict.
|
||||
- **Retention.** The trail ages out on the same **source-time** cutoff as the samples it describes: `SqlServer` purges `hist.SampleModified`
|
||||
per tag alongside `hist.Sample`; QuestDB puts the same TTL on `{p}sample_modified`.
|
||||
- **SqlServer schema v1 (#674).** With `AutoCreateSchema=false` a database still at schema **version 1** (script `002_sample_modified.sql` not
|
||||
applied) keeps historizing — live writes, raw/processed/at-time reads, alarm events and retention all work — but has no trail: modified reads
|
||||
and every HistoryUpdate request answer a **node-level `BadHistoryOperationUnsupported`** (all three arms alike — the updater throws
|
||||
`NotSupportedException`, as the read side does) with nothing sent to SQL, retention skips `SampleModified`, and the
|
||||
node logs **one Warning per boot**. ⚠️ Historized nodes still advertise the `HistoryWrite` AccessLevel bit on a v1 database: the bit is
|
||||
decided at materialise time from "an updater is wired", and whether the schema has the trail is only known after the backend's lazy
|
||||
first-use bootstrap (I/O the node manager does not do while materialising), so a v1 node is admitted to HistoryUpdate and then refused. Apply `002_sample_modified.sql` and restart the node to enable them (the verdict is taken once per
|
||||
process). `AutoCreateSchema=true` (the default) still upgrades v1 to v2 in place. See [Schema](#schema).
|
||||
- **QuestDB retry duplicates (#674).** `{p}sample_modified` is append-only with **no DEDUP key** (a key would collapse genuinely distinct
|
||||
modifications of one sample), and each trail row gets a fresh `modified_id`. So a HistoryUpdate a client **retries** after a timeout — when the
|
||||
first attempt had in fact landed — records a second trail row for the same change (and, for Insert, the retry is `BadEntryExists`). The
|
||||
sample itself is idempotent (dedup upsert); only the audit trail can show the duplicate. `SqlServer` has the same exposure only if the client
|
||||
retries a committed request; there is no request id to dedupe on in either backend.
|
||||
- **Cross-node ordering (QuestDB).** The per-tag lock is per process; the [Primary-only](#primary-only-674) HistoryUpdate gate (#674) is what keeps
|
||||
two nodes of a pair from recording concurrent updates of one tag (and so a wrong prior value). Two **separate** pairs sharing one QuestDB can
|
||||
still race; that is unsupported.
|
||||
- ⚠️ **Who can read the trail (#674.4).** Modified reads are ordinary HistoryReads: with `Security:Acl:Enabled=false` (the default) **any admitted
|
||||
session, including Anonymous, can read who rewrote history** (`UserName` is the principal). Turn the ACL flag on, or set
|
||||
`OpcUa:AllowAnonymous=false`, if the principal names are sensitive (see [security.md](security.md)).
|
||||
- **Not implemented (#674.5):** `IsDeleteModified` (deleting trail entries — `BadHistoryOperationUnsupported`); recording an **Update** (upsert) as
|
||||
a split `Insert` / `Replace` — it is recorded as `Update` either way; and **`AuditHistoryUpdateEvent`** (no audit event is emitted for a history
|
||||
change — the trail is the only record).
|
||||
|
||||
```bash
|
||||
otopcua-cli historyread -u opc.tcp://localhost:4840 -U apikey -P otua_<keyId>_<secret> \
|
||||
@@ -701,6 +749,12 @@ RetrievalMode.Integral`) — no client-side scaling. (This replaces the retired
|
||||
likewise a native gateway mode. Bucket status codes and timestamps are preserved unchanged; empty / null
|
||||
buckets surface as `BadNoData`.
|
||||
|
||||
**Integral unit (#677).** AVEVA's integral is `∫ value dt` (seconds) ÷ the tag's `IntegralDivisor`. The proto3 `double` default for
|
||||
`HistorianTagDefinition.IntegralDivisor` is `0`, so `GatewayTagProvisioner` now sends **`IntegralDivisor = 1`** explicitly on every
|
||||
`EnsureTags` — the value·seconds unit the SQL backends' `Total` uses. A tag provisioned earlier keeps its divisor until its next
|
||||
`EnsureTags` (an upsert). The live magnitude check against AVEVA (a constant 1 integrated over 1 s reads 1) is still open — it needs the
|
||||
VPN. Reverse processed reads on the `Gateway` backend are answered by AVEVA and are not re-anchored by #677.
|
||||
|
||||
### Aggregate set and `BadAggregateNotSupported` (#285)
|
||||
|
||||
`HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range,
|
||||
@@ -752,7 +806,7 @@ The bootstrapper applies every script above the recorded version, in order, unde
|
||||
|
||||
| Object | Purpose |
|
||||
|---|---|
|
||||
| `SchemaVersion(Version, AppliedUtc)` | Applied-script ledger. Required version: **2** (since #668). |
|
||||
| `SchemaVersion(Version, AppliedUtc)` | Applied-script ledger. Required version: **1**; version **2** adds the modification trail (#668). A v1 database is served with the trail features off (#674 — see below). |
|
||||
| `Tag(TagId int identity, Name nvarchar(400) unique, DataType tinyint, EngineeringUnit, Description, CreatedUtc, UpdatedUtc)` | One row per historian tagname. `DataType` is a `DriverDataType`. |
|
||||
| `Sample(TagId, SourceTimeUtc datetime2(7), ValueKind tinyint, NumValue float, IntValue bigint, StrValue nvarchar(max), StatusCode int, ServerTimeUtc)` | **PK `(TagId, SourceTimeUtc)`** — one value per timestamp (Part 11), which makes redelivery idempotent and rules out tie clusters. `StatusCode` is the uint status stored bit-for-bit. |
|
||||
| `Event(EventId nvarchar(64) PK nonclustered, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, UserName, Comment, EventTimeUtc, ReceivedTimeUtc)` | Alarm history. Clustered on `(EventTimeUtc, EventId)`; extra index `(SourceName, EventTimeUtc)`. |
|
||||
@@ -768,11 +822,14 @@ everywhere (full 100 ns precision — never a `DataTable`, which would round to
|
||||
`sqlcmd -S <server> -U <user> -P <pw> -d <db> -I -v Schema=hist -i docs/historian/sqlserver/001_initial.sql`, then the same with
|
||||
`002_sample_modified.sql` (`-v Schema=` supplies the `$(Schema)` placeholder; verified against the docker-dev SQL container). Re-running is a no-op.
|
||||
|
||||
⚠️ **Upgrading an existing v1 historian (#668).** A build with this change requires schema **version 2**. With `AutoCreateSchema=true`
|
||||
(the default) the first historian call after the upgrade applies `002_sample_modified.sql` in place (it only creates `SampleModified`; existing
|
||||
samples are untouched). With `AutoCreateSchema=false` **every historian call fails** (`missing version 2 (found 1)`, in health `LastError`)
|
||||
until an operator runs `002_sample_modified.sql` by hand as above — do that before rolling the new build out. Modifications made before the
|
||||
upgrade have no trail; the trail starts with the first HistoryUpdate after it.
|
||||
⚠️ **Upgrading an existing v1 historian (#668, #674).** With `AutoCreateSchema=true` (the default) the first historian call after the
|
||||
upgrade applies `002_sample_modified.sql` in place (it only creates `SampleModified`; existing samples are untouched). With
|
||||
`AutoCreateSchema=false` a **v1 database is accepted** (#674 — between #668 and #674 it refused every historian call, continuous-historization
|
||||
drains included): writes, reads, events and retention work, while modified-value reads and HistoryUpdate report
|
||||
`BadHistoryOperationUnsupported` without touching SQL, retention skips `SampleModified`, and one Warning per boot names the missing script.
|
||||
Run `002_sample_modified.sql` by hand as above and **restart the node** to enable them (the version is read once per process). Only a schema
|
||||
below version 1 (`missing version 1 (found 0)`) still fails every call. Modifications made before the upgrade have no trail; the trail starts
|
||||
with the first HistoryUpdate after it.
|
||||
|
||||
### Writes
|
||||
|
||||
@@ -837,8 +894,10 @@ result never has both.
|
||||
|
||||
### Events
|
||||
|
||||
`[start, end)` by `EventTimeUtc`; **a reversed window (`end < start`) is swapped and returned ascending**. A null `sourceName` means all
|
||||
sources, otherwise `SourceName = @source`. Ordered `(EventTimeUtc, EventId)` ascending. A bounded read fetches `maxEvents + 1` rows and
|
||||
`[start, end)` by `EventTimeUtc`; through the five-argument read **a reversed window (`end < start`) is swapped and returned
|
||||
ascending**. The direction-aware overload with `reverse: true` (what the node manager calls for a reverse read, #675) reads
|
||||
`(lo, hi]` ordered `(EventTimeUtc, EventId)` **descending** — the clustered index scanned backwards. A null `sourceName` means all
|
||||
sources, otherwise `SourceName = @source`. Forward is ordered `(EventTimeUtc, EventId)` ascending. A bounded read fetches `maxEvents + 1` rows and
|
||||
returns a non-null continuation point when the extra row exists; the node manager turns that flag into an OPC UA continuation
|
||||
point (see [Continuation-point paging (Events)](#continuation-point-paging-events-669)).
|
||||
|
||||
@@ -860,21 +919,44 @@ point (see [Continuation-point paging (Events)](#continuation-point-paging-event
|
||||
> that relied on the old sum gets a different number for the same data (e.g. two samples 10 and 20 ten seconds apart no longer total
|
||||
> 30). There is no switch back; `Count` × `Average` reproduces the old sum.
|
||||
|
||||
**Bucketing.** One result per interval of `[start, end)`; bucket *i* = `[start + i·interval, start + (i+1)·interval)`, start-inclusive /
|
||||
end-exclusive to the tick, the last bucket truncated at `end`. `interval <= 0` ⇒ a single interval `[start, end)`; `start == end` ⇒ no
|
||||
> ⚠️ **Release note (#677): reverse processed reads changed — client-visible.** A reverse read (`end < start`) is now anchored at the
|
||||
> request's start (its *later* time) for **every** aggregate, as OPC UA Part 13 and the SDK's own `AggregateCalculator` do: intervals
|
||||
> step back from `start`, the window is `(end, start]`, the truncated interval is the **earliest** one, and each result is stamped with its
|
||||
> interval's **later** edge. Before #677 only `Interpolative` did this; every other aggregate swapped the window, anchored it at the
|
||||
> earlier time (`[end, start)`, the partial interval at the latest end) and stamped each interval with its earlier edge. So a reverse
|
||||
> 80 s → 0 s read at 20 s used to answer `[60,80) [40,60) [20,40) [0,20)` stamped 60/40/20/0; it now answers
|
||||
> `(60,80] (40,60] (20,40] (0,20]` stamped 80/60/40/20 — a sample exactly on an interval edge now belongs to the interval it *ends*, a
|
||||
> sample at `start` is included and one at `end` is not (the same window a reverse raw read uses). Forward reads are unchanged apart
|
||||
> from the new (forward-only) start-of-data Partial bit below. A client that paired reverse results with forward ones by timestamp must shift by one
|
||||
> interval.
|
||||
|
||||
**Bucketing.** Forward: one result per interval of `[start, end)`; bucket *i* = `[start + i·interval, start + (i+1)·interval)`,
|
||||
start-inclusive / end-exclusive to the tick, the last bucket truncated at `end`, each stamped with its start. Reverse (`end < start`,
|
||||
#677): the window is `(end, start]`; result *k* = `(start − (k+1)·interval, start − k·interval]`, clipped at `end` (the **earliest**
|
||||
interval is the truncated one), stamped with its **later** edge `start − k·interval`, returned latest first. `interval <= 0` ⇒ a single interval `[start, end)`; `start == end` ⇒ no
|
||||
intervals. The bucket index is computed in **100 ns ticks** (not milliseconds): `DATEDIFF_BIG(microsecond, @start, t) × 10` plus a
|
||||
sub-microsecond correction from the two tick digits, divided by the interval in ticks. (`DATEDIFF` counts unit *boundaries crossed*, so a
|
||||
millisecond formulation misplaces samples near a bucket edge, and a nanosecond one overflows past ≈292 years.) A window of more than
|
||||
**100 000 intervals** is refused before any I/O and reported `BadHistoryOperationUnsupported` for that node. **Reverse windows**
|
||||
(`end < start`) are swapped, computed forward **anchored at the earlier time**, and returned in **descending** bucket order. Every returned
|
||||
status carries the historian *Calculated* info bit, `BadNoData` included.
|
||||
millisecond formulation misplaces samples near a bucket edge, and a nanosecond one overflows past ≈292 years.) A reverse read adds one
|
||||
constant to that elapsed-ticks dividend (`BucketPlan.BucketIndexAdjustTicks`; QuestDB folds it into its base) that re-anchors the grid at
|
||||
the later time and makes the buckets later-edge-inclusive — `BucketPlan.IndexOf` is the C# statement of the math both dialects mirror. A
|
||||
window of more than **100 000 intervals** is refused before any I/O and reported `BadHistoryOperationUnsupported` for that node. Every
|
||||
returned status carries the historian *Calculated* info bit, `BadNoData` included.
|
||||
|
||||
**Partial bit (#677).** On a **forward** read, every aggregate except plain `Average` sets the **Partial** bit (`0x4`) on a non-Bad
|
||||
result for the interval the tag's data **starts inside** — its earliest stored sample (any quality) lies strictly between the interval's
|
||||
edges, so there is no bounding value before it and the data does not cover the whole interval. This follows the SDK's `m_startOfData`
|
||||
rule, which the SDK turns off for `Average` and in effect applies to forward reads only (a reverse read is fed latest-first, so its
|
||||
"start of data" is the latest sample, past the window). A reverse read therefore sets no start-of-data Partial — matching the SDK. Only a
|
||||
forward per-sample read of an aggregate other than `Average` fetches the tag's earliest sample (one seek, bounded by the window end);
|
||||
every other read skips that query. An earliest sample exactly on an edge, or before the interval, sets nothing. The time-weighted
|
||||
aggregates also keep setting Partial on the truncated interval (below).
|
||||
|
||||
**Sample classes.** *Usable* = severity Good or Uncertain **and** a stored value (not `Empty`). *Good* = usable with severity Good.
|
||||
*Bad* = not usable.
|
||||
|
||||
| Aggregate | Computed over | Value timestamp |
|
||||
|---|---|---|
|
||||
| `Average`, `Minimum`, `Maximum` | `AVG` / `MIN` / `MAX` of `NumValue` over usable samples | bucket start |
|
||||
| `Average`, `Minimum`, `Maximum` | `AVG` / `MIN` / `MAX` of `NumValue` over usable samples | bucket start (reverse read: its later edge, as every row marked "bucket start" here) |
|
||||
| `Count` | number of usable samples (`Int32`) | bucket start |
|
||||
| `Range` | `Maximum − Minimum` | bucket start |
|
||||
| `Start`, `End` | first / last usable sample by time (keeps the sample's type) | bucket start |
|
||||
@@ -896,11 +978,16 @@ Status and edge rules:
|
||||
`TimeAverage`, `TimeAverage2`, `Total`, `Total2` and `Interpolative`, evaluated by the shared
|
||||
`Driver.Historian.Sql.Common/TimeWeightedAggregator.cs` for **both** SQL backends (so `SqlServer` and `QuestDb` agree exactly).
|
||||
**This whole family — `Total` included — is evaluated in C#, not pushed down to SQL** (see *Pushdown trade-off* and the raw-row cap
|
||||
below). Bucketing (edges, truncated last bucket, the 100 000-interval cap) is the same as above; reverse windows are covered below.
|
||||
below). Bucketing (edges, truncated interval, reverse anchoring, the 100 000-interval cap) is the same as above.
|
||||
|
||||
**Server defaults, fixed:** `TreatUncertainAsBad = false`, `PercentDataBad = PercentDataGood = 100`, `UseSlopedExtrapolation = false`.
|
||||
**A client's `AggregateConfiguration` in the HistoryRead request is ignored** (the node manager does not forward it); every read uses
|
||||
these defaults.
|
||||
**Server defaults, fixed:** `TreatUncertainAsBad = false`, `PercentDataBad = PercentDataGood = 100`, `UseSlopedExtrapolation = false`
|
||||
(`ServerAggregateConfiguration` in the OPC UA server project — the SDK's own `AggregateManager` defaults, which a test pins). **A
|
||||
client's `AggregateConfiguration` (#677):** `UseServerCapabilitiesDefaults = true`, or the four values spelled out equal to these defaults
|
||||
⇒ served; anything else ⇒ **`BadAggregateConfigurationRejected`** for each node of the request (the source is never called). It used to
|
||||
be silently ignored, so a client asking for, say, `TreatUncertainAsBad = true` got answers computed without it. The check is server-wide
|
||||
(it applies on the `Gateway` backend too). A client sending an all-zero structure without `UseServerCapabilitiesDefaults` is asking for
|
||||
`PercentDataBad = PercentDataGood = 0` and is refused; the SDK's default-constructed `AggregateConfiguration` sets
|
||||
`UseServerCapabilitiesDefaults = true` and is served.
|
||||
|
||||
**Bounding values.** Besides the window `[start, end)`, each read fetches four single-row seeks: the latest *usable* sample before
|
||||
`start`, the latest sample of any quality before `start` (they differ when an outage straddles the start), the first sample of any
|
||||
@@ -934,9 +1021,9 @@ PercentDataGood.
|
||||
- No data at all ⇒ `BadNoData`.
|
||||
- A usable `String`/`DateTime` sample feeding the interval ⇒ `BadAggregateInvalidInputs`.
|
||||
|
||||
All carry the Calculated bit; a non-Bad result for the **truncated last interval** (shorter than the processing interval) also carries
|
||||
the **Partial** bit (`0x4`, `AggregateBits.Partial`). Timestamp = interval start; value `double` (Boolean 0/1). (The per-sample
|
||||
aggregates do not set Partial — a follow-up.)
|
||||
All carry the Calculated bit; a non-Bad result for the **truncated interval** (shorter than the processing interval — the latest one
|
||||
forward, the earliest one reverse) or for the interval the tag's **data starts inside** (#677, see *Partial bit* above) also carries the
|
||||
**Partial** bit (`0x4`, `AggregateBits.Partial`). Timestamp = interval start (reverse: its later edge); value `double` (Boolean 0/1).
|
||||
|
||||
> **A live tag's latest interval.** Past the tag's last stored sample the value is held (stepped extrapolation, Uncertain). Under
|
||||
> the SDK rule that Uncertain time is Good time, so the latest interval of a live tag normally reads **`Good`** — not Uncertain —
|
||||
@@ -951,11 +1038,27 @@ aggregates do not set Partial — a follow-up.)
|
||||
- `Good` on a segment whose defining samples are Good;
|
||||
- `UncertainDataSubNormal` inside or up to an outage (bridged) and past the last sample (extrapolated).
|
||||
|
||||
**Reverse reads (`end < start`).** Part 13 anchors a reverse read at the request's start, which is the later time. `Interpolative`
|
||||
follows that: its points are `start, start − interval, …` (in the forward window's terms `End − i·interval`), latest first. A
|
||||
reverse read 10:00 → 09:00 at 1 h returns the value at **10:00**, not 09:00, and no point ever falls before the window. The interval
|
||||
aggregates (TimeAverage, Total, …) keep the existing backend-wide reverse rule: computed forward with intervals anchored at the
|
||||
earlier time, returned in descending order. Re-anchoring those at the later time for every aggregate is a follow-up.
|
||||
**Reverse reads (`end < start`).** Part 13 anchors a reverse read at the request's start, which is the later time — for every aggregate
|
||||
since #677. `Interpolative`'s points are `start, start − interval, …`, latest first: a reverse read 10:00 → 09:00 at 1 h returns the
|
||||
value at **10:00**, not 09:00, and no point ever falls before the window. The interval aggregates use the intervals
|
||||
`(start − (k+1)·interval, start − k·interval]`, the earliest one truncated, each stamped with its later edge.
|
||||
|
||||
**SDK parity (#677).** `SdkAggregateParityTests` (unit, `Sql.Common.Tests`) and
|
||||
`ProcessedConformance.Processed_reads_match_the_SDK_aggregate_calculators` (both SQL backends, through the real SQL) run the pinned SDK's
|
||||
own calculators (`AverageAggregateCalculator`, `MinMaxAggregateCalculator`, `CountAggregateCalculator`, the base `AggregateCalculator`
|
||||
for Interpolative) over the same samples, forward and reverse, and compare every interval's timestamp, value, status code and Partial
|
||||
bit. The per-sample comparison covers Average, Minimum, Maximum, Count, Range, Start, End, Delta, Minimum/MaximumActualTime and
|
||||
PercentGood/Bad; reverse reads over data that starts inside the window are compared too. Known differences that remain (tracked as
|
||||
#680) — documented and masked in the tests, deliberately not changed here because forward results were to stay as they are — are:
|
||||
`Start`/`End` (the SDK returns the raw first/last sample, of any quality, with its own timestamp and stored status; ours returns the
|
||||
first/last usable value stamped with the interval, under the per-sample status rule — the values agree wherever the SDK's is not Bad);
|
||||
`Delta` status (the SDK's stays Good with a Bad sample in the interval, ours is Uncertain); `PercentGood`/`PercentBad` (ours count
|
||||
samples, the SDK measures duration — different definitions, so only timestamp and status are compared); the SDK reports `TimeAverage`/`Total` Uncertain whenever any region of the interval is non-Good, where ours applies the
|
||||
time-based rule (a fully-covered interval bridged across a Bad sample is Good here); `Total` over an interval the data starts inside is
|
||||
`TimeAverage × interval` here but the integral of the covered part in the SDK; ours flags a truncated interval Partial, the SDK does
|
||||
not; the SDK's forward end-of-data Partial rule is not implemented; the SDK's per-sample Count answers 0/Good for an empty interval
|
||||
inside the data and its per-sample aggregates use Good samples only; and on a reverse `Interpolative` read the SDK marks a sample exactly
|
||||
at a point Interpolated (Uncertain if a Bad sample follows) where ours — like the SDK forward — returns the raw sample.
|
||||
|
||||
**Pushdown trade-off.** Unlike the per-sample aggregates, the time-weighted family is **not** pushed down to SQL: the backend streams
|
||||
the window's rows plus the four bounds (one batch of five result sets on SQL Server; a bounds `UNION ALL` + a window select on QuestDB)
|
||||
@@ -999,6 +1102,8 @@ equivalent TTL (`QuestDb:RetentionDays`); the Gateway backend's retention is the
|
||||
timeout 0) lets one purger run and the others skip that tick (Debug log).
|
||||
- **Observability.** Counter `otopcua.historian.retention.purged{table=Sample|SampleModified|Event}`; a failed pass logs a Warning and retries next
|
||||
interval — it never affects capture or reads.
|
||||
- **Schema v1 (#674).** On a v1 schema (no `SampleModified`, `AutoCreateSchema=false`) the trail purge is skipped and omitted from the
|
||||
pass results; samples and events purge normally.
|
||||
- **Not done: partition switching.** For very large installs (billions of rows) a partitioned `hist.Sample` with sliding-window
|
||||
`SWITCH`/drop is cheaper than row deletes; the purger is deliberately simple batched deletes. Another table is added by one more
|
||||
`DeleteLoopAsync` call in `SqlServerHistorianRetention.PurgeAsync` (as `hist.SampleModified` was, #668).
|
||||
@@ -1179,9 +1284,12 @@ Every query filters `NOT deleted`; every value goes through `StoredSample`, so t
|
||||
from the last timestamp ± `TimestampResolutionTicks`.
|
||||
- **AtTime:** previous-or-equal and next neighbour of each time by `LIMIT 1` seeks, `UNION ALL`-ed, **50 times per statement** (QuestDB 10.0.1 refuses
|
||||
more than 126 branches). `AtTimeEvaluator` decides each value, as on `SqlServer`.
|
||||
- **Events:** `[start, end)` by `ts`, ordered `(ts, event_id)`, `maxEvents + 1` fetched to produce the continuation point.
|
||||
- **Processed:** one query per node; bucket index `(cast(ts AS LONG) * 10 - startUnixTicks) / intervalTicks` (never `SAMPLE BY`, whose alignment
|
||||
differs), so bucket edges are identical to `SqlServer` (start-inclusive, end-exclusive, partial last bucket, interval 0 ⇒ one bucket, 100,000-bucket cap).
|
||||
- **Events:** `[start, end)` by `ts`, ordered `(ts, event_id)`, `maxEvents + 1` fetched to produce the continuation point; a
|
||||
reverse read (#675) is `(lo, hi]` ordered `(ts, event_id)` descending.
|
||||
- **Processed:** one query per node; bucket index `(cast(ts AS LONG) * 10 - bucketBaseUnixTicks) / intervalTicks` (never `SAMPLE BY`, whose alignment
|
||||
differs), so bucket edges are identical to `SqlServer` (forward start-inclusive / end-exclusive with the partial bucket last; reverse anchored at the
|
||||
later time, later-edge-inclusive, partial bucket earliest — #677; interval 0 ⇒ one bucket, 100,000-bucket cap). On a forward read of any aggregate
|
||||
but Average, a second `LIMIT 1` statement (capped at the window end) fetches the tag's earliest live sample for the start-of-data Partial bit.
|
||||
Min/max times use `row_number()` windows with the earliest-timestamp tie-break.
|
||||
|
||||
### Retention (TTL)
|
||||
@@ -1195,7 +1303,14 @@ expired day partitions itself, tombstones included.
|
||||
re-read to verify; a very large `RetentionDays` that QuestDB normalises into MONTH/YEAR is re-`ALTER`ed on every boot (harmless).
|
||||
- ⚠️ **TTL is enforced only on write commits.** QuestDB checks for expired partitions when a write commits to the table, so **a quiet table never
|
||||
drops anything**: partitions older than the TTL linger until the next write lands. Do not read a stale-looking partition as a broken TTL.
|
||||
- Expiry is judged against the wall clock, so a future-dated sample does not expire current data.
|
||||
- **Expiry is judged against the wall clock** (`min(max timestamp, now)`), so a future-dated sample does not expire current data.
|
||||
⚠️ **Keep `cairo.ttl.use.wall.clock` at its default, `true`** (#674). It is what QuestDB documents as protecting a TTL table from a
|
||||
future-dated row; with it `false` the TTL would be measured from the table's newest timestamp and one far-future row (a 2062 typo) could
|
||||
drop every older partition — including `{p}sample_modified`, the record of who changed history. Evidence (QuestDB 10.0.1, 2026-10-11): the
|
||||
default is `true`, and a 2066 row inserted into a 30-day-TTL table evicted nothing inside the TTL, while a partition genuinely older than
|
||||
30 days was evicted by that same commit — pinned by `TtlWallClockTests`. (The same run with the flag `false` also kept the in-TTL
|
||||
partitions, so the test pins the outcome rather than isolating the flag; do not rely on that.) The server-side
|
||||
`ServerHistorian:HistoryUpdateMaxFutureSkew` bound (default 1 day) is the primary guard: a HistoryUpdate cannot write a far-future row.
|
||||
- The TTL is visible in table metadata: `SELECT table_name, ttlValue, ttlUnit FROM tables() WHERE starts_with(table_name, 'hist_')`.
|
||||
|
||||
### Docker-dev recipe
|
||||
@@ -1236,8 +1351,8 @@ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Integration
|
||||
|
||||
OPC UA **HistoryUpdate** lets a client insert, replace, update and delete stored raw history. It is available only when the selected
|
||||
backend provides an updater — **`SqlServer` and `QuestDb` do; `Gateway` does not**, and an updater is registered only when `ServerHistorian:Enabled=true`.
|
||||
Updates go straight to the backend (not through the outbox), are **not Primary-gated** (the database is shared), and share the HistoryRead
|
||||
batch limiter and `HistoryReadDeadline`.
|
||||
Updates go straight to the backend (not through the outbox), are **Primary-only** (#674, [below](#primary-only-674)), and share the
|
||||
HistoryRead batch limiter and `HistoryReadDeadline`.
|
||||
|
||||
### Operations
|
||||
|
||||
@@ -1255,7 +1370,8 @@ caller's principal, in the [modification trail](#modified-value-history-part-11)
|
||||
mixed (Part 11 reports item outcomes in `OperationResults`); it is Bad only for whole-node failures.
|
||||
|
||||
**Per-item rejections** (answered without touching SQL; other items still proceed): a null item ⇒ `BadInvalidArgument`;
|
||||
`SourceTimestamp == DateTime.MinValue` ⇒ `BadInvalidTimestamp`; a value the historian cannot type (array, unsupported CLR type) ⇒
|
||||
`SourceTimestamp == DateTime.MinValue` ⇒ `BadInvalidTimestamp`; a **`SourceTimestamp` later than `now + ServerHistorian:HistoryUpdateMaxFutureSkew`**
|
||||
(default 1 day, server clock; #674 — a `Local`-kind timestamp is converted to UTC first, and is sent as UTC) ⇒ **`BadOutOfRange`** — on every backend, never sent; a value the historian cannot type (array, unsupported CLR type) ⇒
|
||||
`BadTypeMismatch`; a **`NaN` / `±Infinity` `Double` ⇒ `BadInvalidArgument`** — it cannot live in a SQL `float`, and unlike the live-value
|
||||
writer (which stores it as `Empty`) an operator's explicit update is rejected rather than silently rewritten; and a **null value with a
|
||||
Good or Uncertain status ⇒ `BadInvalidArgument`** — an `Empty` sample is always Bad, so a null value is accepted only with a Bad status
|
||||
@@ -1278,7 +1394,32 @@ outcome, later ones `BadNoEntryExists`. **Unknown tag:** Insert / Update create
|
||||
`ServerHistorian:Enabled=false`); `PerformUpdateType.Remove`; `DeleteRawModifiedDetails.IsDeleteModified=true` (the
|
||||
[modification trail](#modified-value-history-part-11) is an audit record, aged out only by retention); and the out-of-scope detail types — **event** HistoryUpdate (`UpdateEventDetails` / `DeleteEventDetails`) and `UpdateStructureData` —
|
||||
which are not overridden, so the SDK base rejects them. A backend exception is `BadInternalError` for that node and a deadline is `BadTimeout`.
|
||||
No `AuditHistoryUpdateEvent` is emitted.
|
||||
On a `SqlServer` database still at schema v1 the whole node is `BadHistoryOperationUnsupported` on every arm ([#674](#modified-value-history-part-11)).
|
||||
No `AuditHistoryUpdateEvent` is emitted (#674.5, not implemented).
|
||||
|
||||
### Primary-only (#674)
|
||||
|
||||
HistoryUpdate is served only by the node holding the redundancy **Primary** data-plane role — the same verdict
|
||||
(`IDriverPrimaryRoleSource`, published by `DriverHostActor`) that gates native-alarm acknowledges (#597). On any other node every node of
|
||||
the request that **passed authorization and is servable** is refused **`BadInvalidState`** (node-level code, with a message pointing at the
|
||||
Primary) and nothing reaches the backend. Order: authorization first (an unauthorized caller learns `BadUserAccessDenied`, not the node's
|
||||
role), then the request is **planned** (a request no server could serve — no updater, a node that is not historized, `Remove`,
|
||||
`IsDeleteModified` — is the honest `BadHistoryOperationUnsupported` on every node, so a Secondary does not send the client to a Primary
|
||||
that would refuse it too), then the gate.
|
||||
|
||||
**Closed until the first redundancy snapshot (boot window).** The gate reads its own verdict, `IHistoryUpdateRoleView.ShouldServeHistoryUpdate`
|
||||
(`PrimaryGatePolicy.ShouldServeHistoryUpdate`): the data-plane Primary verdict, but **closed until this node has received its first redundancy
|
||||
snapshot**. The data-plane verdict itself allows during the pre-join boot window (a node not yet joined sees no driver peer, which reads as
|
||||
"single node") — right for live writes and acks, which must not stall on a genuine single node, but it would let both nodes of a pair accept a
|
||||
HistoryUpdate for a few seconds (up to ~25 s under the bootstrap guard). "No verdict yet" is told apart from "single node" by the snapshot
|
||||
itself: the redundancy singleton runs on every driver node — a lone node included — and publishes on every change and every 10 s heartbeat,
|
||||
so a single node is named Primary within seconds of joining and opens then. Until that first snapshot every node refuses `BadInvalidState`;
|
||||
after it the gate is exactly the data-plane verdict (including the unknown-role / member-count rule for a snapshot that omits this node). A
|
||||
node with no redundancy manager (admin-only — it registers no `IHistoryUpdateRoleView`) wires no gate and allows.
|
||||
HistoryRead is **not** gated. Why: rewriting history is a single-writer operation — both backends capture the prior value for the
|
||||
[modification trail](#modified-value-history-part-11) under a per-tag, **per-process** lock (QuestDB) or statement (SqlServer), so two pair
|
||||
nodes updating one tag concurrently could record a wrong prior value. A single-node deployment (no redundancy manager) is unaffected. Clients
|
||||
should send HistoryUpdate to the server advertising the higher `ServiceLevel`.
|
||||
|
||||
### The `HistoryWrite` AccessLevel bit
|
||||
|
||||
|
||||
@@ -32,13 +32,13 @@ Single-node dev: `OTOPCUA_ROLES=admin,driver` (or the `Cluster:Roles` equivalent
|
||||
|
||||
### Per-role configuration overlays
|
||||
|
||||
`Program.cs` builds a role suffix by joining the resolved `EffectiveRoles.Gossiped` roles **alphabetically** with `-` and loads `appsettings.{roleSuffix}.json` as an optional overlay on top of base `appsettings.json`. A plain role (`admin`/`driver`/`dev`) is always lower-case in `Gossiped` regardless of how the operator spelled it, so the overlay filename for those is stable (`appsettings.driver.json`); if a `cluster-{ClusterId}` role is also configured, its suffix carries into the filename **verbatim** (e.g. `Cluster:Roles: ["driver", "cluster-SITE-A"]` loads `appsettings.cluster-SITE-A-driver.json`), since `Gossiped` preserves that spelling by design (see [Configuration.md § `Roles`](Configuration.md#appsettings-sections)). Three overlays ship in `src/Server/ZB.MOM.WW.OtOpcUa.Host/`:
|
||||
`Program.cs` builds a role suffix (`RoleOverlay.Suffix`) by joining the resolved `EffectiveRoles.Gossiped` **plain** roles (`admin`/`driver`/`dev`) alphabetically with `-` and loads `appsettings.{roleSuffix}.json` as an optional overlay on top of base `appsettings.json`. A plain role is always lower-case in `Gossiped` regardless of how the operator spelled it, so the overlay filename is stable (`appsettings.driver.json`). **`cluster-{ClusterId}` roles are NOT part of the filename** (#678.6): they are mesh-membership tags, so `Cluster:Roles: ["driver", "cluster-SITE-A"]` loads `appsettings.driver.json` and `["admin", "driver", "cluster-MAIN"]` loads `appsettings.admin-driver.json`. (Before #678.6 the cluster role was folded into the name, producing e.g. `appsettings.admin-cluster-MAIN-driver.json`, which never exists — so on every node with a cluster role no shipped overlay ever loaded.) Three overlays ship in `src/Server/ZB.MOM.WW.OtOpcUa.Host/`:
|
||||
|
||||
- `appsettings.admin.json` — admin-only nodes
|
||||
- `appsettings.driver.json` — driver-only nodes
|
||||
- `appsettings.admin-driver.json` — fused single-node dev / small deployments
|
||||
|
||||
All three carry Serilog log-level overrides + `Security:Ldap:DevStubMode = false`. Configuration loading order (lowest to highest precedence) is:
|
||||
All three carry Serilog log-level overrides + `Security:Ldap:DevStubMode = false` + `Security:Ldap:Transport = Ldaps` (audit table: [Configuration.md § Role overlays](Configuration.md#role-overlay-audit)). Configuration loading order (lowest to highest precedence) is:
|
||||
|
||||
`appsettings.json` < `appsettings.{Environment}.json` < `appsettings.{role}.json` < environment variables < command-line args
|
||||
|
||||
|
||||
@@ -50,7 +50,19 @@ provides ordered **failover** — the driver tries each candidate in turn at eve
|
||||
4840 / 4841). See
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs`
|
||||
for every field (security policy/mode, auth type, session timeout, keep-alive,
|
||||
browse root, node/depth caps). `ReconnectPeriod` is still parsed but no longer used (#670).
|
||||
browse root, node/depth caps). `ReconnectPeriod` was **deleted** (#676 — unused since #670 removed the SDK reconnect
|
||||
handler, and the AdminUI no longer shows it); stored configs that still carry `reconnectPeriod` keep parsing in the
|
||||
factory, the Test-Connect probe and the browse picker (unknown keys are skipped), and the form drops it on the next save.
|
||||
|
||||
### Endpoint address: the configured host and port win (#676)
|
||||
|
||||
GetEndpoints returns each endpoint with the URL the **server** believes it lives at. Behind NAT, a port map, a
|
||||
container port publish or a proxy that address is unreachable from the client — or reachable but bypassing the proxy.
|
||||
The driver therefore rewrites the selected endpoint's **host and port** to the configured URL's whenever the schemes
|
||||
match, keeping the advertised path (the SDK's own `CoreClientUtils.SelectEndpoint` rule, which the driver's
|
||||
policy-aware endpoint selection used to bypass). A configured URL with **no port** keeps the advertised port (copying
|
||||
the port-less `-1` would have sent the session to 4840). A different scheme is left alone; an unparseable advertised
|
||||
URL falls back to the configured one. `OpcUaClientDriver.RewriteEndpointUrl`.
|
||||
|
||||
### Session lifetime
|
||||
|
||||
@@ -83,12 +95,67 @@ The session keep-alive (`KeepAliveInterval`, default 5 s) is the liveness signal
|
||||
without moving the mark. Outside that window every notification is
|
||||
accepted whatever its timestamp, so an upstream clock that steps backwards (NTP, a PLC clock reset) never
|
||||
freezes a node. The outage Bad never moves the comparison; a re-initialise resets it.
|
||||
⚠️ **By design, inside the window a notification whose source time EQUALS the last accepted one is dropped** — so a
|
||||
status-only change that keeps the old source timestamp (not what OPC UA servers are supposed to do) is lost during
|
||||
the window only (the trailing re-read below repairs it). When the window closes — or a second recovery re-arms it —
|
||||
the driver logs ONE line, `post-recovery replay window closed — dropped N late notifications across M refs after
|
||||
recovery`, at **Information when N > 0** and Debug otherwise, because the per-drop line is Debug and the
|
||||
`stale_notification_dropped` counter is OTel-only, which made the guard unobservable on the docker-dev rig (#676).
|
||||
A re-arm starts the count afresh.
|
||||
- **Trailing re-read after an in-place recovery (#676).** Found by the TCP-stall wire test: a change the server
|
||||
published just after the stall ended rode a Publish response that had been in flight across the stall; the SDK
|
||||
timed that request out (`BadTimeout`), discarded the response and never republished the message (the following
|
||||
keep-alives moved the sequence number past it), so the node stayed **Good on the pre-change value indefinitely**.
|
||||
`Timeout + KeepAliveInterval` after the recovery — when every request in flight across the stall has completed or
|
||||
timed out — every subscribed ref is read once more and published when its **(value, status) differs** from the last
|
||||
value delivered for that ref **and is not older** than it. Any status counts: a Good→Bad/Uncertain change lost in the
|
||||
pipeline is as stale as a lost value. "Older" compares source timestamps only when both carry one; a read with no
|
||||
source timestamp (a server that does not send them) is the server's current state and counts as newer. An **equal**
|
||||
source timestamp with different content IS published — it is the server's current state, not a replay, and it repairs
|
||||
the equal-timestamp status-only change the replay guard drops by design. An identical (value, status) is never
|
||||
republished. A ref that received ANY delivery while the read was in flight is skipped (a per-ref delivery counter is
|
||||
captured before the read is issued): without source timestamps the read's sample cannot be ordered against that
|
||||
notification, and publishing it would regress the node. An Information line names how many refs it had to repair. The trailing read and the replay-guard disarm
|
||||
timer run on the driver clock and are cancelled when the session is released (re-initialise / shutdown / dispose).
|
||||
- **No older value is ever delivered after a newer one.** The paths that publish upstream data — live notifications,
|
||||
the recovery read, the trailing read and the comms-loss Bad fan — make their "may I publish this?" decision and the
|
||||
publish inside one driver-wide critical section. A single lock rather than one per ref: it also serialises delivery
|
||||
across subscriptions (the SDK dispatches each subscription's messages on its own worker), which is cheap because each
|
||||
section is a dictionary update plus a subscriber `Tell` and nothing awaits under it. Subscribers run inside it — the
|
||||
host's handler only `Tell`s an actor — so a subscriber must never block on, or call back into, the driver.
|
||||
- **The session does not come back** (an upstream restart: the server forgot the session) ⇒ the driver stays
|
||||
`Degraded` with its read stamp frozen, which is exactly the #527 comms-loss watchdog's trip condition. After 90 s
|
||||
`DriverInstanceActor` runs `ShutdownAsync` + `InitializeAsync` (retried every 10 s while the server is down) and
|
||||
re-subscribes data + alarms; the new subscription's initial values come back Good. Alarm last-told state survives
|
||||
the re-initialise (#619), so the post-subscribe `ConditionRefresh` raises no un-ack flicker; the host's reconnect
|
||||
annotation restores condition quality.
|
||||
`Degraded` with its read stamp frozen, which is the #527 comms-loss watchdog's trip condition; `DriverInstanceActor`
|
||||
runs `ShutdownAsync` + `InitializeAsync` (retried every 10 s while the server is down) and re-subscribes data +
|
||||
alarms; the new subscription's initial values come back Good. Alarm last-told state survives the re-initialise
|
||||
(#619), so the post-subscribe `ConditionRefresh` raises no un-ack flicker; the host's reconnect annotation restores
|
||||
condition quality.
|
||||
- **Fast recovery for a DEFINITIVELY lost session (#676).** The driver implements `IDriverConnectionLossSource` and
|
||||
raises `ConnectionLost` — **once per session**, re-armed by a re-initialise — when the loss is certain, so the host
|
||||
re-initialises at once instead of after the watchdog's 90 s. Definitive means: a keep-alive status of
|
||||
`BadSessionIdInvalid` / `BadSessionClosed` / `BadSessionNotActivated` (the server forgot, closed or never activated
|
||||
the session) or `BadServerHalted`, or a subscription the **server** reported gone (below). `BadServerHalted` is on the list
|
||||
because it was **measured**: on the restart wire test a gracefully stopped server reports `BadServerHalted` (and the
|
||||
keep-alives alternate it with `BadNoCommunication`), and the SDK never re-opens a channel the server halted — 15 s
|
||||
after the server was back, the same session still failed every request with `BadServerHalted`; `BadSessionIdInvalid`
|
||||
never arrived.
|
||||
**A CLOSED connection is definitive too, once sustained (live gate A1).** Against a real opc-plc on docker-dev, none of
|
||||
`docker restart`, a graceful SIGINT stop (opc-plc itself logged `BadServerHalted`) or `docker kill` delivered a
|
||||
session status to the client: the keep-alives reported `BadSecureChannelClosed` (0x80860000) once, then
|
||||
`BadConnectionClosed` (0x80AE0000) interleaved with `BadNoCommunication` for the whole outage, so the first cut of the
|
||||
fast path never fired and recovery took 91–127 s. Reproduced in-process with `TcpStallProxy.HardClose` (every
|
||||
connection reset, new ones refused, the server AND its session still alive behind it): the same sequence, and the SDK
|
||||
1.5.378 session **never opens a new connection by itself** — 48 s after the proxy accepted connections again, not one
|
||||
attempt. So a closed channel can never recover in place; on the **second** closed-channel keep-alive failure in one
|
||||
outage (`IsClosedChannel`; a good keep-alive resets the count, so a single transient does not count) the driver raises
|
||||
`ConnectionLost` — about one keep-alive interval after the connection dropped. Everything else —
|
||||
`BadNoCommunication`, `BadNotConnected`, `BadRequestTimeout`/`BadTimeout` (what a stalled or paused link produces) —
|
||||
is **ambiguous** (the link may resume and keep its session, see the stall test) and stays with the watchdog.
|
||||
Consequence: a restarted, stopped or killed upstream is re-initialised within roughly one keep-alive plus one
|
||||
reconnect interval instead of ~100 s; a pause (`docker pause`, a congested link) still recovers in place. ⚠️ **Known gap:** the host acts on the signal only while the driver is
|
||||
`Connected`, and the driver raises it once per session — so a definitive loss raised while the host is not
|
||||
Connected (e.g. `BadServerHalted` on the fresh session before the host has processed the successful initialise) is
|
||||
swallowed and that session falls back to the watchdog. Re-raising on later keep-alives would close it but was left out
|
||||
to keep the once-per-session contract simple; the cost is the pre-#676 recovery time, in a narrow window.
|
||||
- **The subscription dies but the session lives** (an outage longer than the subscription lifetime —
|
||||
`LifetimeCount` x publishing interval, often well under the session timeout — or a server that drops it) ⇒ keep-alives
|
||||
stay Good, so they cannot be the signal. A `PublishError` of `BadNoSubscription` (while subscriptions are tracked) or
|
||||
@@ -96,7 +163,10 @@ The session keep-alive (`KeepAliveInterval`, default 5 s) is the liveness signal
|
||||
persists for one more subscription keep-alive window + 5 s across good keep-alives, declares it lost: `Degraded`
|
||||
(`"subscription lost: …"`), the outage published once, and — sticky until the session is released — **nothing**
|
||||
advances `LastSuccessfulRead` any more (not good keep-alives, not a sibling subscription's notifications), so the
|
||||
watchdog's re-initialise recreates it. Both detectors are wire-tested independently.
|
||||
watchdog's re-initialise recreates it. Both detectors are wire-tested independently. A subscription the **server**
|
||||
reported gone (`BadNoSubscription` / `BadSubscriptionIdInvalid`) never comes back on its session, so that also raises
|
||||
`ConnectionLost` (#676) and the re-initialise happens at once; the `PublishingStopped` **heuristic** is an inference
|
||||
from silence and keeps going through the watchdog.
|
||||
- An in-place recovery is bound to the session whose keep-alive recovered: one overtaken by a re-initialise neither
|
||||
publishes, nor degrades, nor re-arms the outage latch of the new session.
|
||||
|
||||
@@ -126,7 +196,7 @@ initialise (the AdminUI form drops them on the next save). Pinned by
|
||||
|
||||
## Capability surface
|
||||
|
||||
`OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource`
|
||||
`OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IDriverConnectionLossSource`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs:31`).
|
||||
|
||||
| Capability | Path | Notes |
|
||||
@@ -253,6 +323,17 @@ the reconnect handler, #670). They are not part of `DriverHealth` or the
|
||||
- **Integration fixture** — exercises the driver against a reference OPC UA
|
||||
server (opc-plc) on the shared docker host; see
|
||||
[OpcUaClient-Test-Fixture.md](OpcUaClient-Test-Fixture.md) for the coverage map.
|
||||
- **Wire tests against the in-process server** — `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/`
|
||||
`OpcUaClientDriver*WireTests` (no Docker). `OpcUaClientDriverStallWireTests` (#676) covers the in-place blip that
|
||||
used to be live-gate-only: the driver connects through `TcpStallProxy`, an in-test TCP forwarder whose pause holds
|
||||
every byte without closing anything (which only works because of the endpoint rewrite above); it asserts one Bad,
|
||||
a frozen read stamp, recovery on the SAME session, the identical value republished Good, a change published right
|
||||
after recovery still arriving, and no source-time regression once the SDK's late backlog flush has arrived. Its
|
||||
second test uses `HardClose` (reset every connection, refuse new ones) — the shape of a real process stop — and
|
||||
asserts the live-gate status sequence, that the SDK never reconnects on its own once the proxy accepts again,
|
||||
`ConnectionLost` exactly once, and the re-initialise bringing the identical value back Good.
|
||||
`OpcUaClientDriverReconnectWireTests` covers the server restart (incl. the `BadServerHalted` → `ConnectionLost` signal)
|
||||
and the subscription-lost case.
|
||||
|
||||
## Further reading
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Follow-up batch #674–#678 — design (2026-10-11)
|
||||
|
||||
Follow-ups filed after the #667–#673 batch. Base: master `bc03adb1`. User-approved; recommended choices throughout.
|
||||
Investigation found three new defects (client endpoint port not rewritten; role overlays never load on
|
||||
cluster-role nodes; Gateway Count/Average mode mapping) — the first two are fixed here, the third is filed.
|
||||
|
||||
| Lane | Scope | Model |
|
||||
|---|---|---|
|
||||
| L1 | #674 HistoryUpdate posture | opus |
|
||||
| L2 | #675 reverse event paging | opus |
|
||||
| L3 | #677 aggregates | opus |
|
||||
| L4 | client (#677.5, #678.5/.6, #674.6) | sonnet |
|
||||
| L5 | #676 OpcUaClient | opus |
|
||||
| L6 | #678.1–.4 + role-overlay bug | sonnet |
|
||||
|
||||
## L1 — #674
|
||||
- **674.1:** spike on QuestDB 10.0.1 showed TTL is judged against `min(maxTs, wall clock)` (`cairo.ttl.use.wall.clock=true`
|
||||
default) — a 2066 row evicted nothing. Add a QuestDB integration test pinning that + a docs note. HistoryUpdate refuses a
|
||||
SourceTimestamp beyond `now + ServerHistorian:HistoryUpdateMaxFutureSkew` (default 1 day) with `BadOutOfRange`; the
|
||||
recorder is not bounded.
|
||||
- **674.2:** SqlServer base schema requirement back to v1; `SupportsModifiedTrail` (v ≥ 2). On v1: modified reads and
|
||||
HistoryUpdate ⇒ `BadHistoryOperationUnsupported` (nothing sent), retention skips `SampleModified`, one Warning per boot.
|
||||
- **674.3:** HistoryUpdate Primary-gated (off-Primary ⇒ `BadInvalidState`, same shape as `NativeAlarmAckPrimaryGate`).
|
||||
Retry duplicates on QuestDB documented; no DEDUP key.
|
||||
- 674.4/674.5 documented only.
|
||||
|
||||
## L2 — #675
|
||||
- Reverse event reads: `reverse` flag on `IHistorianDataSource.ReadEventsAsync`; SqlServer/QuestDB order DESC and take
|
||||
the top N; direction-aware cursor (reuse raw `SliceTieCluster(reverse:)`); Gateway ⇒ `BadHistoryOperationUnsupported`
|
||||
for reverse.
|
||||
- End-only event read ⇒ backward (mirrors raw).
|
||||
- Offline test pinning `DateTime.MaxValue` through the Gateway proto/FILETIME encoding; live AVEVA leg stays open (VPN).
|
||||
|
||||
## L3 — #677
|
||||
- Reverse processed reads per SDK/Part 13: buckets anchored at the later (start) time stepping back, partial bucket
|
||||
earliest, bucket timestamp = later edge. Parity test vs SDK `AggregateCalculator`. Client-visible, release-noted.
|
||||
- Partial bit also when the data starts inside a bucket (no bounding prior).
|
||||
- `AggregateConfiguration`: `UseServerCapabilitiesDefaults` or values equal to the server defaults ⇒ served; otherwise
|
||||
per-node `BadAggregateConfigurationRejected`.
|
||||
- Gateway provisioner sends `IntegralDivisor = 1` (proto3 default 0). Live magnitude check stays open (VPN).
|
||||
|
||||
## L4 — client
|
||||
- `EndpointSelector.SelectBest` rewrites host **and** port of the advertised endpoint (fixes rig `:4841` cert error).
|
||||
- Client aggregate enum/mapper/CLI/UI gain TimeAverage, TimeAverage2, Total, Total2, Interpolative.
|
||||
- History read refusal ⇒ one-line `CommandException`, no stack trace.
|
||||
- `NodeManagerHistoryReadTests` non-historized case sets `IsReadModified=false`.
|
||||
|
||||
## L5 — #676
|
||||
- Driver endpoint host+port rewrite; in-process TCP-stall proxy wire test (in-place blip, same session, Good republish,
|
||||
no regression).
|
||||
- Remove dead `ReconnectPeriod` (options, AdminUI form, docs, tests); stored JSON with it still parses.
|
||||
- New opt-in `IDriverConnectionLossSource` (Core.Abstractions): raised once per session by OpcUaClient only on a
|
||||
definitive loss (`BadSessionIdInvalid`/`BadSessionClosed`/`BadSessionNotActivated`/subscription lost);
|
||||
`DriverInstanceActor` (attach PreStart, detach PostStop) turns it into `DisconnectObserved` only while Connected.
|
||||
Ambiguous codes keep going through the #527 watchdog.
|
||||
- Information-level per-recovery replay-drop summary. Equal-timestamp drop documented.
|
||||
|
||||
## L6 — small
|
||||
- Retention: mid-command cancel test + `ClearPool` fallback test with falsification control.
|
||||
- #672 tests: initial-state unreachable path; strengthen `Self_unreachable_is_ignored`; edge-triggered Debug log.
|
||||
- FOCAS `WireBackendTests`: wait for motor speeds too; `WaitFor` fails on timeout.
|
||||
- QuestDB TTL-cleared log wording; `docker-dev/docker-compose.debug.yml` Debug recipe.
|
||||
- Role overlay suffix built from plain roles only — after auditing each `appsettings.<role>.json` and stripping anything
|
||||
that would change production behaviour on activation.
|
||||
|
||||
## Live gates (docker-dev)
|
||||
1. opc-plc `docker restart` recovers in seconds (was ~100 s); pause-blip re-check.
|
||||
2. Client.CLI connects to `:4841`–`:4845`.
|
||||
3. HistoryUpdate refused on the Secondary, accepted on the Primary; v1-schema DB keeps historizing.
|
||||
4. Reverse event paging and reverse processed reads over the wire, SqlServer + QuestDB.
|
||||
@@ -884,6 +884,7 @@ HistoryUpdate rewrites recorded history, so unlike HistoryRead it is gated **wha
|
||||
- **Flag off (default):** the session's API key is re-checked on the call (`DataPlaneAuthorizer.AuthorizeKeyUsable`, Gitea #563 — an expired or disabled key loses its activation-time roles), then the session must carry the **`HistoryUpdate` role** (`OpcUaDataPlaneRoles.HistoryUpdate`). `ApiKeyInterimRoleMapper` confers it **only from a `Cluster`-scoped grant** carrying `NodePermissions.HistoryUpdate` — narrower-scoped grants confer nothing, for the same reason as `WriteOperate` (honouring a flag without honouring its scope would widen it). A key holding only `WriteOperate` is denied `BadUserAccessDenied`.
|
||||
- **Flag on:** `DataPlaneAuthorizer.Authorize(identity, OpcUaOperation.HistoryUpdate, nodeId, realm)` decides per node and **replaces** the role check (never chained) — a session with no roles succeeds on the strength of a scoped `HistoryUpdate` grant, and a `WriteOperate` grant does not help.
|
||||
- **Availability is separate from authorization.** A node accepts HistoryUpdate only if it is historized **and** the selected historian backend has an updater when the node materialises (the `HistoryWrite` AccessLevel bit). `Backend=Gateway` has none, so it answers `BadHistoryOperationUnsupported` for every node whatever the grant. Operations, result codes and unsupported cases: [`docs/Historian.md` §HistoryUpdate](Historian.md#historyupdate).
|
||||
- **Primary-only, after authorization (Gitea #674).** An authorized HistoryUpdate on a node that is not the redundancy Primary is refused `BadInvalidState` (the native-alarm-ack verdict, #597); an unauthorized one is told `BadUserAccessDenied` first, so the refusal never reveals the node's role to a caller who may not update.
|
||||
- **Every applied change is attributed (Part 11 modified-value history, Gitea #668).** The session's principal (the API key's
|
||||
`NodeAcl` principal, or the reserved `anonymous`) and key id are passed to the backend with every HistoryUpdate and recorded in the
|
||||
modification trail next to the prior value; a modified-value HistoryRead (`IsReadModified = true`) returns it as
|
||||
|
||||
@@ -815,7 +815,6 @@ Already used by Client.Shared in the existing codebase.
|
||||
| `Password` | string? | — | For Username auth |
|
||||
| `SessionTimeoutMs` | int | 120000 | Server-negotiated session timeout |
|
||||
| `KeepAliveIntervalMs` | int | 5000 | Session keep-alive interval |
|
||||
| `ReconnectPeriodMs` | int | 5000 | Initial reconnect delay |
|
||||
| `BrowseStrategy` | enum | Full | `Full` (browse all at startup), `Lazy`, `Hybrid` |
|
||||
| `BrowseRoot` | string? | — | Optional root NodeId to mirror (default: ObjectsFolder) |
|
||||
| `AutoAcceptCertificates` | bool | false | Accept untrusted server certificates |
|
||||
|
||||
@@ -47,7 +47,7 @@ public class HistoryReadCommand : CommandBase
|
||||
/// <summary>
|
||||
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
|
||||
/// </summary>
|
||||
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation")]
|
||||
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")]
|
||||
public string? Aggregate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
@@ -183,6 +183,12 @@ public class HistoryReadCommand : CommandBase
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync($"{values.Count} values returned.");
|
||||
}
|
||||
catch (ServiceResultException ex) when (ex.InnerException is null)
|
||||
{
|
||||
// e.g. HistoryReadStatus.ThrowIfRefused: a refusal is an operator-facing outcome, not a crash.
|
||||
// A wrapped failure (certificate validation, identity rejection on connect) keeps its full detail.
|
||||
throw new CommandException(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (service != null)
|
||||
@@ -204,8 +210,13 @@ public class HistoryReadCommand : CommandBase
|
||||
"start" or "first" => AggregateType.Start,
|
||||
"end" or "last" => AggregateType.End,
|
||||
"standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation,
|
||||
"timeaverage" or "timeavg" => AggregateType.TimeAverage,
|
||||
"timeaverage2" or "timeavg2" => AggregateType.TimeAverage2,
|
||||
"total" => AggregateType.Total,
|
||||
"total2" => AggregateType.Total2,
|
||||
"interpolative" or "interp" => AggregateType.Interpolative,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation")
|
||||
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,17 +85,22 @@ internal static class EndpointSelector
|
||||
$"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}");
|
||||
}
|
||||
|
||||
// Rewrite endpoint URL hostname to match user-supplied hostname. Necessary
|
||||
// when the OPC UA server returns a discovery URL using a different hostname
|
||||
// (e.g. internal DNS name) than the one the operator routed to.
|
||||
// Rewrite the endpoint URL host AND port to the user-supplied ones (as the SDK's
|
||||
// CoreClientUtils.SelectEndpoint does). A server behind a port mapping or internal DNS
|
||||
// advertises its own host:port (docker-dev containers all say :4840); following that would
|
||||
// open the session on a different server and fail the secure-channel certificate check.
|
||||
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
|
||||
var serverUri = new Uri(best.EndpointUrl);
|
||||
var requestedUri = new Uri(endpointUrl);
|
||||
if (serverUri.Host != requestedUri.Host)
|
||||
// A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
|
||||
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
|
||||
if (string.Equals(serverUri.Scheme, requestedUri.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||
&& (serverUri.Host != requestedUri.Host || serverUri.Port != port))
|
||||
{
|
||||
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host };
|
||||
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host, Port = port };
|
||||
best.EndpointUrl = builder.ToString();
|
||||
Logger.Debug("Rewrote endpoint host from {ServerHost} to {RequestedHost}", serverUri.Host,
|
||||
requestedUri.Host);
|
||||
Logger.Debug("Rewrote endpoint from {ServerHost}:{ServerPort} to {RequestedHost}:{RequestedPort}",
|
||||
serverUri.Host, serverUri.Port, requestedUri.Host, port);
|
||||
}
|
||||
|
||||
return best;
|
||||
|
||||
@@ -24,6 +24,11 @@ public static class AggregateTypeMapper
|
||||
AggregateType.Start => ObjectIds.AggregateFunction_Start,
|
||||
AggregateType.End => ObjectIds.AggregateFunction_End,
|
||||
AggregateType.StandardDeviation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
|
||||
AggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
|
||||
AggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
|
||||
AggregateType.Total => ObjectIds.AggregateFunction_Total,
|
||||
AggregateType.Total2 => ObjectIds.AggregateFunction_Total2,
|
||||
AggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,5 +24,20 @@ public enum AggregateType
|
||||
End,
|
||||
|
||||
/// <summary>Population standard deviation of values in the interval.</summary>
|
||||
StandardDeviation
|
||||
StandardDeviation,
|
||||
|
||||
/// <summary>Time-weighted average, interpolated bounds (Part 13 TimeAverage).</summary>
|
||||
TimeAverage,
|
||||
|
||||
/// <summary>Time-weighted average, stepped bounds (Part 13 TimeAverage2).</summary>
|
||||
TimeAverage2,
|
||||
|
||||
/// <summary>Time-weighted integral over the interval (Part 13 Total).</summary>
|
||||
Total,
|
||||
|
||||
/// <summary>Time-weighted integral, stepped bounds (Part 13 Total2).</summary>
|
||||
Total2,
|
||||
|
||||
/// <summary>Interpolated value at the interval start (Part 13 Interpolative).</summary>
|
||||
Interpolative
|
||||
}
|
||||
@@ -53,7 +53,12 @@ public partial class HistoryViewModel : ObservableObject
|
||||
AggregateType.Count,
|
||||
AggregateType.Start,
|
||||
AggregateType.End,
|
||||
AggregateType.StandardDeviation
|
||||
AggregateType.StandardDeviation,
|
||||
AggregateType.TimeAverage,
|
||||
AggregateType.TimeAverage2,
|
||||
AggregateType.Total,
|
||||
AggregateType.Total2,
|
||||
AggregateType.Interpolative
|
||||
];
|
||||
|
||||
/// <summary>Gets a value indicating whether an aggregate read is selected.</summary>
|
||||
|
||||
@@ -88,7 +88,7 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by <see cref="IHistorianDataSource.ReadEventsAsync"/> — a historical
|
||||
/// One row returned by <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> — a historical
|
||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
||||
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
|
||||
/// </summary>
|
||||
@@ -106,8 +106,8 @@ public sealed record HistoricalEvent(
|
||||
string? Message,
|
||||
ushort Severity);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync"/> call.</summary>
|
||||
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
||||
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
|
||||
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read (#675).</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||
public sealed record HistoricalEventsResult(
|
||||
IReadOnlyList<HistoricalEvent> Events,
|
||||
|
||||
@@ -79,7 +79,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <c>uint maxValuesPerNode</c> because their OPC UA HistoryRead surface has no
|
||||
/// equivalent "use default" sentinel.
|
||||
///
|
||||
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/>
|
||||
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
|
||||
/// as required members — a server-side historian owns the full read surface.
|
||||
/// </remarks>
|
||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||
@@ -101,6 +101,57 @@ public interface IHistorianDataSource : IDisposable
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
|
||||
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/> (Gitea #675). The server
|
||||
/// checks it BEFORE calling the source: an explicit reverse read (<c>StartTime > EndTime</c>) on a backend that
|
||||
/// cannot is answered <c>BadHistoryOperationUnsupported</c> without a call, and an end-only capped read, which Part 11
|
||||
/// reads backward, keeps its forward <c>[MinValue, EndTime)</c> meaning there. Default <c>false</c>; an
|
||||
/// implementation (or a wrapper) declares it itself — a guard test pins that.
|
||||
/// </summary>
|
||||
bool SupportsReverseEvents => false;
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm / event records in a chosen direction — the overload the server's HistoryReadEvents
|
||||
/// paging calls (Gitea #675).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The bounds may be given in either order; let <c>lo</c>/<c>hi</c> be the earlier/later of them. Forward
|
||||
/// (<paramref name="reverse"/> <c>false</c>) is exactly <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>:
|
||||
/// <c>[lo, hi)</c> ascending by <c>(EventTimeUtc, EventId)</c>. Reverse is the Part 11 reverse window
|
||||
/// <c>(lo, hi]</c> in DESCENDING <c>(EventTimeUtc, EventId)</c> order, so a cap keeps the LATEST
|
||||
/// <paramref name="maxEvents"/> events — the mirror of forward, with the same tie-breaker reversed, so a
|
||||
/// resumed reverse page that re-reads its boundary timestamp sees the boundary ties in the same order.
|
||||
/// <paramref name="maxEvents"/> and the continuation-point contract are as for the forward read.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default forwards a forward read to the five-argument overload and throws
|
||||
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (Gateway,
|
||||
/// Null). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and
|
||||
/// answers <c>BadHistoryOperationUnsupported</c> without calling, rather than returning the earliest events in
|
||||
/// ascending order as if they were the answer. A backend that serves reverse must override BOTH members.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||
/// <param name="startUtc">One bound of the window (UTC).</param>
|
||||
/// <param name="endUtc">The other bound of the window (UTC).</param>
|
||||
/// <param name="maxEvents">The cap; non-positive uses the backend default (see the five-argument overload).</param>
|
||||
/// <param name="reverse"><c>true</c> to read newest-first over <c>(lo, hi]</c>.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>The matching events in the read's direction, with a continuation point if truncated.</returns>
|
||||
/// <exception cref="NotSupportedException">A reverse read on a backend that cannot read newest-first.</exception>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
bool reverse,
|
||||
CancellationToken cancellationToken) =>
|
||||
reverse
|
||||
? throw new NotSupportedException("This historian backend cannot read events newest-first (reverse).")
|
||||
: ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
|
||||
/// observation; never blocks on backend I/O.
|
||||
|
||||
@@ -23,6 +23,12 @@ public enum HistoryUpdateMode
|
||||
/// <see cref="HistoryUpdateContext.Principal"/>, the modification time and the <see cref="HistoryModificationType"/>,
|
||||
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. A refused item (any non-Good code) leaves
|
||||
/// no record. Live recorder writes and retention purges never leave one.
|
||||
/// <para>
|
||||
/// <b>Cannot update at all</b> (Gitea #674): an implementation that is wired but cannot perform any update right
|
||||
/// now — the SqlServer backend on a schema without the modification trail — throws
|
||||
/// <see cref="NotSupportedException"/> from every method without touching storage. The server reports it as a
|
||||
/// node-level <c>BadHistoryOperationUnsupported</c>, the same as <see cref="IHistorianDataSource.ReadModifiedAsync"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IHistorianDataUpdater
|
||||
{
|
||||
|
||||
@@ -55,6 +55,25 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks><c>false</c> (Gitea #675): there is no history to read in either direction, and an explicit reverse read
|
||||
/// is answered <c>BadHistoryOperationUnsupported</c> like any backend that cannot read newest-first.</remarks>
|
||||
public bool SupportsReverseEvents => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Forward ⇒ the empty result; reverse ⇒ <see cref="NotSupportedException"/>, the interface contract for a
|
||||
/// backend without <see cref="SupportsReverseEvents"/>.</remarks>
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
bool reverse,
|
||||
CancellationToken cancellationToken) =>
|
||||
reverse
|
||||
? throw new NotSupportedException("The null historian cannot read events newest-first (reverse).")
|
||||
: Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new(
|
||||
TotalQueries: 0,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability (Gitea #676) — a driver that can tell, from its protocol, that its connection is
|
||||
/// DEFINITIVELY gone (it can never recover in place, only a re-initialise brings it back) raises
|
||||
/// <see cref="ConnectionLost"/> so the host re-initialises it at once instead of waiting out the #527 comms-loss
|
||||
/// watchdog (~90 s of frozen <see cref="DriverHealth.LastSuccessfulRead"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Definitive only.</b> An AMBIGUOUS failure — a timeout, a stalled socket, "no communication" — must NOT
|
||||
/// raise: the connection may yet resume in place (a <c>docker pause</c>, a congested link), and tearing it
|
||||
/// down would cost the session, its subscriptions and the in-place recovery. Ambiguous failures keep going
|
||||
/// through the watchdog, whose conjunction (non-Healthy AND a frozen read stamp) is the honest test for them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The host (<c>DriverInstanceActor</c>) acts on a raise only while the driver is Connected — in any other
|
||||
/// state a re-initialise is already pending or running — and it subscribes for its own lifetime, not per
|
||||
/// connect, because the <see cref="IDriver"/> instance can outlive the actor. A driver should raise at most
|
||||
/// once per connection and re-arm when it is re-initialised.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IDriverConnectionLossSource
|
||||
{
|
||||
/// <summary>Raised (on the driver's own thread) when the driver's connection is definitively lost.</summary>
|
||||
event EventHandler<DriverConnectionLossEventArgs>? ConnectionLost;
|
||||
}
|
||||
|
||||
/// <summary>Event payload for <see cref="IDriverConnectionLossSource.ConnectionLost"/>.</summary>
|
||||
/// <param name="Reason">Driver-supplied reason for the diagnostic log (e.g. the protocol status that proved the loss).</param>
|
||||
public sealed record DriverConnectionLossEventArgs(string Reason);
|
||||
@@ -125,6 +125,28 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>false</c>: the gateway's <c>ReadEvents</c> streams ascending only (Gitea #675). The server therefore refuses an
|
||||
/// explicit reverse events read without calling, and keeps an end-only capped read forward.
|
||||
/// </remarks>
|
||||
public bool SupportsReverseEvents => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A reverse (newest-first) read is refused with <see cref="NotSupportedException"/> — the server reports
|
||||
/// <c>BadHistoryOperationUnsupported</c> (Gitea #675). The gateway's <c>ReadEvents</c> streams ascending only and
|
||||
/// rejects <c>end < start</c>, so the only way to serve "the latest N" would be to drain the whole window and
|
||||
/// keep its tail, an unbounded read the cap exists to prevent. Nothing is sent.
|
||||
/// </remarks>
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse,
|
||||
CancellationToken cancellationToken) =>
|
||||
reverse
|
||||
? throw new NotSupportedException(
|
||||
"The HistorianGateway backend reads events oldest-first only; a reverse (newest-first) events read is not supported.")
|
||||
: ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
|
||||
@@ -29,6 +29,14 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
|
||||
/// </summary>
|
||||
private const uint DefaultStorageRateMs = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Integral divisor stamped on every provisioned analog tag (#677). AVEVA's <c>Total</c> integral is
|
||||
/// <c>∫ value dt (seconds) ÷ IntegralDivisor</c>; the proto's <c>double</c> default of 0 would reach the gateway as
|
||||
/// "unset" (or a divide-by-zero), so 1 is sent explicitly — the value·seconds unit the SQL backends' <c>Total</c>
|
||||
/// uses. (The live magnitude check against AVEVA is still open: it needs the VPN.)
|
||||
/// </summary>
|
||||
private const double DefaultIntegralDivisor = 1.0;
|
||||
|
||||
private readonly IHistorianGatewayClient _client;
|
||||
private readonly ILogger<GatewayTagProvisioner> _logger;
|
||||
|
||||
@@ -70,6 +78,8 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
|
||||
Description = request.Description ?? string.Empty,
|
||||
// Required: a 0 storage rate makes the gateway's EnsureTags throw (see the constant).
|
||||
StorageRateMs = DefaultStorageRateMs,
|
||||
// Explicit: the proto3 double default (0) is not "1" (see the constant).
|
||||
IntegralDivisor = DefaultIntegralDivisor,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,21 +9,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
||||
/// per <see cref="AggregateFamily"/>, mirroring the SQL Server backend's <c>SqlAggregateQueries</c> family split and
|
||||
/// column order. Every query takes five positional parameters:
|
||||
/// <list type="number">
|
||||
/// <item><c>$1</c> — the (rounded) window start in 100 ns ticks since the Unix epoch
|
||||
/// (<see cref="BucketPlan.StartUnixTicks"/>, <c>Bigint</c>);</item>
|
||||
/// <item><c>$1</c> — the bucket base in 100 ns ticks since the Unix epoch
|
||||
/// (<see cref="BucketPlan.BucketBaseUnixTicks"/>, <c>Bigint</c>: the rounded window start forward; re-anchored at
|
||||
/// the later time for a reverse read, #677);</item>
|
||||
/// <item><c>$2</c> — the bucket width in ticks (<see cref="BucketPlan.IntervalTicks"/>, <c>Bigint</c>);</item>
|
||||
/// <item><c>$3</c> — the tag (<c>Varchar</c>);</item>
|
||||
/// <item><c>$4</c>/<c>$5</c> — the forward window <c>[start, end)</c> (<c>TimestampTz</c> via
|
||||
/// <see cref="QuestDbConnection.BindTimestamp"/>).</item>
|
||||
/// <item><c>$4</c>/<c>$5</c> — the earlier / later window edges (<c>TimestampTz</c> via
|
||||
/// <see cref="QuestDbConnection.BindTimestamp"/>): <c>[$4, $5)</c> forward, <c>($4, $5]</c> reverse.</item>
|
||||
/// </list>
|
||||
/// Each returns one row per <b>non-empty</b> bucket; the caller fills the empty ones.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bucketing.</b> <c>b = (cast(ts AS LONG) * 10 − $1) / $2</c>. <c>cast(ts AS LONG)</c> is µs since 1970, so
|
||||
/// <c>× 10</c> is ticks since 1970; both it and <c>$1</c> are µs-aligned (R6), so the integer division is
|
||||
/// start-inclusive / end-exclusive to the tick, exactly like SQL Server's. Every row is at or after the start,
|
||||
/// so the dividend is never negative (no truncation-toward-zero surprise), and nothing overflows before 9999.
|
||||
/// <c>× 10</c> is ticks since 1970, so the integer division is exact to the tick, exactly like SQL Server's
|
||||
/// (<see cref="BucketPlan.IndexOf"/> is the C# statement of the math). Every in-window row is at or after the base
|
||||
/// (forward) or strictly after the window start with the base one tick past the grid origin (reverse), so the
|
||||
/// dividend is never negative (no truncation-toward-zero surprise), and nothing overflows before 9999.
|
||||
/// <c>SAMPLE BY</c> is deliberately not used: its alignment rules differ from ours.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -60,13 +62,14 @@ internal static class QuestDbAggregateQueries
|
||||
/// </summary>
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string Simple(string sample) =>
|
||||
/// <param name="reverse">True for a reverse read (window <c>($4, $5]</c>).</param>
|
||||
public static string Simple(string sample, bool reverse = false) =>
|
||||
"SELECT b, " + CommonCounts() + ", " +
|
||||
"avg(CASE WHEN u = 1 THEN num_value END) AS avg_value, " +
|
||||
"min(CASE WHEN u = 1 THEN num_value END) AS min_value, " +
|
||||
"max(CASE WHEN u = 1 THEN num_value END) AS max_value, " +
|
||||
"sum(CASE WHEN u = 1 THEN num_value END) AS sum_value " +
|
||||
"FROM (" + Classified(sample) + ") GROUP BY b ORDER BY b";
|
||||
"FROM (" + Classified(sample, reverse) + ") GROUP BY b ORDER BY b";
|
||||
|
||||
/// <summary>
|
||||
/// Start / End / Delta: the first and last usable sample of each bucket by <c>ts</c>.
|
||||
@@ -76,8 +79,9 @@ internal static class QuestDbAggregateQueries
|
||||
/// </summary>
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string StartEnd(string sample) =>
|
||||
"WITH s AS (" + Classified(sample) + "), " +
|
||||
/// <param name="reverse">True for a reverse read (window <c>($4, $5]</c>).</param>
|
||||
public static string StartEnd(string sample, bool reverse = false) =>
|
||||
"WITH s AS (" + Classified(sample, reverse) + "), " +
|
||||
"st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
|
||||
"f AS (SELECT b, kind, num_value, int_value, str_value, ts, " +
|
||||
"row_number() OVER (PARTITION BY b ORDER BY ts ASC) AS rf, " +
|
||||
@@ -96,8 +100,9 @@ internal static class QuestDbAggregateQueries
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
/// <param name="maximum">True for MaximumActualTime.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string ActualTime(string sample, bool maximum) =>
|
||||
"WITH s AS (" + Classified(sample) + "), " +
|
||||
/// <param name="reverse">True for a reverse read (window <c>($4, $5]</c>).</param>
|
||||
public static string ActualTime(string sample, bool maximum, bool reverse = false) =>
|
||||
"WITH s AS (" + Classified(sample, reverse) + "), " +
|
||||
"st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
|
||||
"r AS (SELECT b, num_value, ts, " +
|
||||
"row_number() OVER (PARTITION BY b ORDER BY num_value " + (maximum ? "DESC" : "ASC") + ", ts ASC) AS rn " +
|
||||
@@ -144,12 +149,22 @@ internal static class QuestDbAggregateQueries
|
||||
/// <summary>The bucket-index expression (exposed for tests).</summary>
|
||||
internal const string BucketExpression = "(cast(ts AS LONG) * 10 - $1) / $2";
|
||||
|
||||
/// <summary>
|
||||
/// The tag's earliest live sample (any quality) before the window end: the start-of-data Partial rule (#677; forward
|
||||
/// reads only, so the forward window's exclusive end bounds the scan). Parameters <c>$1</c> tag, <c>$2</c> the window
|
||||
/// end. Column: <c>ts</c>; no row when the tag has none before the end.
|
||||
/// </summary>
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string EarliestSample(string sample) =>
|
||||
$"SELECT ts FROM {sample} WHERE tag = $1 AND ts < $2 AND NOT deleted ORDER BY ts ASC LIMIT 1";
|
||||
|
||||
/// <summary>The classified subquery <c>s</c>: the window's live samples with bucket index and the usable/good flags.</summary>
|
||||
private static string Classified(string sample) =>
|
||||
private static string Classified(string sample, bool reverse) =>
|
||||
"SELECT " + BucketExpression + " AS b, ts, kind, num_value, int_value, str_value, " +
|
||||
$"CASE WHEN status < {NotBadLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS u, " +
|
||||
$"CASE WHEN status < {GoodLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS g " +
|
||||
$"FROM {sample} WHERE tag = $3 AND ts >= $4 AND ts < $5 AND NOT deleted";
|
||||
$"FROM {sample} WHERE tag = $3 AND " + (reverse ? "ts > $4 AND ts <= $5" : "ts >= $4 AND ts < $5") + " AND NOT deleted";
|
||||
|
||||
/// <summary>The five common count columns (after <c>b</c>).</summary>
|
||||
private static string CommonCounts() =>
|
||||
|
||||
+51
-24
@@ -354,6 +354,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Events
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
|
||||
public bool SupportsReverseEvents => true;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>[start, end)</c> by event time (a reversed window is swapped), ordered by <c>(ts, event_id)</c> ascending.
|
||||
@@ -361,8 +366,18 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
/// <c>maxEvents + 1</c> rows are fetched; when the extra row arrives it is dropped and the continuation point is
|
||||
/// the UTF-8 of <see cref="EventContinuation.Encode"/> for that first not-returned event.
|
||||
/// </remarks>
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) =>
|
||||
ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Forward is the five-argument read. Reverse (Gitea #675) is the (rounded) <c>(lo, hi]</c> ordered by
|
||||
/// <c>(ts, event_id)</c> DESCENDING, so <c>LIMIT</c> keeps the latest events; truncation and the continuation point
|
||||
/// work as forward.
|
||||
/// </remarks>
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken)
|
||||
{
|
||||
// No stored event can carry a source longer than the limit, so an over-long filter matches nothing.
|
||||
if (sourceName is not null && sourceName.Length > HistorianTagNames.MaxLength)
|
||||
@@ -376,9 +391,9 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
|
||||
var sql =
|
||||
$"SELECT event_id, source, ts, received_utc, message, severity FROM {_conn.EventTable} " +
|
||||
"WHERE ts >= $1 AND ts < $2" +
|
||||
(reverse ? "WHERE ts > $1 AND ts <= $2" : "WHERE ts >= $1 AND ts < $2") +
|
||||
(sourceName is null ? "" : " AND source = $3") +
|
||||
" ORDER BY ts ASC, event_id ASC" +
|
||||
(reverse ? " ORDER BY ts DESC, event_id DESC" : " ORDER BY ts ASC, event_id ASC") +
|
||||
(bounded ? (sourceName is null ? " LIMIT $3" : " LIMIT $4") : "");
|
||||
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -425,12 +440,13 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
/// <remarks>
|
||||
/// The (rounded) window is planned by <see cref="BucketPlan.Create"/>: bucket <c>i</c> =
|
||||
/// <c>[start + i·interval, start + (i+1)·interval)</c>, start-inclusive / end-exclusive, the last bucket truncated
|
||||
/// at <c>end</c>, interval <= 0 ⇒ one bucket, <c>start == end</c> ⇒ none, a reversed window computed forward
|
||||
/// and returned descending, at most <see cref="BucketPlan.MaxBuckets"/> buckets. One statement per node returns
|
||||
/// the <see cref="BucketRow"/> statistics of each non-empty bucket (bucket index
|
||||
/// <c>(cast(ts AS LONG)*10 − StartUnixTicks) / IntervalTicks</c>, never <c>SAMPLE BY</c>);
|
||||
/// <see cref="AggregateEvaluator.Evaluate"/> produces each value and <see cref="BucketPlan.Assemble"/> fills the
|
||||
/// empty buckets with <c>BadNoData</c>. The time-weighted family (#671) instead streams the window plus four bounding
|
||||
/// at <c>end</c>, interval <= 0 ⇒ one bucket, <c>start == end</c> ⇒ none, at most
|
||||
/// <see cref="BucketPlan.MaxBuckets"/> buckets. A reversed window (#677) is anchored at its later time per Part 13 /
|
||||
/// the SDK: window <c>(end, start]</c>, buckets stepping back from <c>start</c>, the partial one earliest, each stamped
|
||||
/// with its later edge, returned latest first. One statement per node returns the <see cref="BucketRow"/> statistics
|
||||
/// of each non-empty bucket (bucket index <c>(cast(ts AS LONG)*10 − BucketBaseUnixTicks) / IntervalTicks</c>, never
|
||||
/// <c>SAMPLE BY</c>); a second fetches the tag's earliest sample for the start-of-data Partial bit; and
|
||||
/// <see cref="AggregateEvaluator.EvaluateAll"/> produces the values and fills the empty buckets with <c>BadNoData</c>. The time-weighted family (#671) instead streams the window plus four bounding
|
||||
/// rows into <see cref="TimeWeightedAggregator"/>, exactly as the SQL Server backend does.
|
||||
/// </remarks>
|
||||
/// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the seventeen served.</exception>
|
||||
@@ -446,7 +462,6 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
try
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var evaluated = new Dictionary<long, DataValueSnapshot>();
|
||||
if (family == AggregateFamily.TimeWeighted)
|
||||
{
|
||||
var tw = new TimeWeightedAggregator(aggregate, plan);
|
||||
@@ -456,31 +471,43 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
RecordSuccess();
|
||||
return new HistoryReadResult(twResult, ContinuationPoint: null);
|
||||
}
|
||||
var rows = new List<BucketRow>();
|
||||
DateTime? earliest = null;
|
||||
if (plan.BucketCount > 0 && await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var sample = _conn.SampleTable;
|
||||
var sql = family switch
|
||||
{
|
||||
AggregateFamily.Simple => QuestDbAggregateQueries.Simple(sample),
|
||||
AggregateFamily.StartEnd => QuestDbAggregateQueries.StartEnd(sample),
|
||||
_ => QuestDbAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime),
|
||||
AggregateFamily.Simple => QuestDbAggregateQueries.Simple(sample, plan.Reversed),
|
||||
AggregateFamily.StartEnd => QuestDbAggregateQueries.StartEnd(sample, plan.Reversed),
|
||||
_ => QuestDbAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime, plan.Reversed),
|
||||
};
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
QuestDbConnection.BindLong(cmd, plan.StartUnixTicks);
|
||||
QuestDbConnection.BindLong(cmd, plan.IntervalTicks);
|
||||
QuestDbConnection.BindText(cmd, fullReference);
|
||||
QuestDbConnection.BindTimestamp(cmd, plan.StartUtc);
|
||||
QuestDbConnection.BindTimestamp(cmd, plan.EndUtc);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var cmd = _conn.CreateCommand(conn, sql))
|
||||
{
|
||||
var row = ReadBucket(reader, family);
|
||||
evaluated[row.Bucket] = AggregateEvaluator.Evaluate(aggregate, row, plan.BucketStart(row.Bucket), now);
|
||||
QuestDbConnection.BindLong(cmd, plan.BucketBaseUnixTicks);
|
||||
QuestDbConnection.BindLong(cmd, plan.IntervalTicks);
|
||||
QuestDbConnection.BindText(cmd, fullReference);
|
||||
QuestDbConnection.BindTimestamp(cmd, plan.StartUtc);
|
||||
QuestDbConnection.BindTimestamp(cmd, plan.EndUtc);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
rows.Add(ReadBucket(reader, family));
|
||||
}
|
||||
// The tag's earliest live sample before the window end: the start-of-data Partial rule (#677) — forward
|
||||
// reads of an aggregate other than Average only; skipped otherwise.
|
||||
if (AggregateEvaluator.NeedsEarliestSample(aggregate, plan))
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, QuestDbAggregateQueries.EarliestSample(sample));
|
||||
QuestDbConnection.BindText(cmd, fullReference);
|
||||
QuestDbConnection.BindTimestamp(cmd, plan.EndUtc);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false) && !reader.IsDBNull(0))
|
||||
earliest = QuestDbConnection.ReadUtc(reader, 0);
|
||||
}
|
||||
}
|
||||
|
||||
var result = plan.Assemble(evaluated, bucketStart => AggregateEvaluator.NoData(bucketStart, now));
|
||||
var result = AggregateEvaluator.EvaluateAll(aggregate, plan, rows, earliest, now);
|
||||
|
||||
RecordSuccess();
|
||||
return new HistoryReadResult(result, ContinuationPoint: null);
|
||||
|
||||
@@ -137,7 +137,10 @@ internal sealed class QuestDbSchemaBootstrapper
|
||||
// The ALTER is a WAL transaction: tables() keeps the old value until it applies, so do NOT re-read to verify.
|
||||
await using (var alter = _conn.CreateCommand(conn, QuestDbTables.AlterTtl(table, _options.RetentionDays)))
|
||||
await alter.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_log.LogInformation("Applied TTL {Days} day(s) to QuestDB table {Table}.", _options.RetentionDays, table);
|
||||
if (_options.RetentionDays is { } days)
|
||||
_log.LogInformation("Applied TTL {Days} day(s) to QuestDB table {Table}.", days, table);
|
||||
else
|
||||
_log.LogInformation("Cleared TTL on QuestDB table {Table}.", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
/// every sample, status Good. Otherwise no usable sample ⇒ <c>BadNoData</c>; a numeric aggregate with a usable
|
||||
/// String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>; else the value, Good when every sample is Good,
|
||||
/// <c>UncertainDataSubNormal</c> when any is Bad or Uncertain. Every status — including <c>BadNoData</c> —
|
||||
/// carries the Calculated info bits. Timestamps are the bucket start, except Minimum/MaximumActualTime
|
||||
/// (the sample's time; ties ⇒ earliest).
|
||||
/// carries the Calculated info bits. Timestamps are the bucket's <see cref="BucketPlan.BucketTimestamp"/> (its
|
||||
/// start forward, its later edge reverse), except Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest).
|
||||
/// Every aggregate but <c>Average</c> also carries the Partial bit on the bucket the data starts inside
|
||||
/// (<see cref="EvaluateAll"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Values: Count is <see cref="int"/>; Start/End keep the sample's type; every other aggregate is
|
||||
@@ -26,7 +28,7 @@ public static class AggregateEvaluator
|
||||
/// <summary>Evaluates <paramref name="aggregate"/> over one non-empty bucket.</summary>
|
||||
/// <param name="aggregate">Requested aggregate.</param>
|
||||
/// <param name="r">The bucket's statistics.</param>
|
||||
/// <param name="bucketStart">The bucket's start time (the result timestamp).</param>
|
||||
/// <param name="bucketStart">The bucket's result timestamp (<see cref="BucketPlan.BucketTimestamp"/>).</param>
|
||||
/// <param name="now">Server timestamp.</param>
|
||||
/// <returns>The calculated value.</returns>
|
||||
/// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the per-sample (Simple /
|
||||
@@ -70,6 +72,58 @@ public static class AggregateEvaluator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for the per-sample aggregates the SDK sets the start-of-data Partial bit on (every calculator but
|
||||
/// <c>AverageAggregateCalculator</c>'s plain <c>Average</c>, which turns <c>SetPartialBit</c> off).
|
||||
/// </summary>
|
||||
/// <param name="aggregate">Requested aggregate.</param>
|
||||
/// <returns>Whether the start-of-data Partial rule applies.</returns>
|
||||
public static bool SetsStartOfDataPartial(HistoryAggregateType aggregate) => aggregate != HistoryAggregateType.Average;
|
||||
|
||||
/// <summary>
|
||||
/// True when a per-sample read must fetch the tag's earliest sample: the start-of-data Partial rule applies only to a
|
||||
/// <b>forward</b> read (as in the SDK, whose reverse read keys <c>m_startOfData</c> on the first value it is fed — the
|
||||
/// latest) of an aggregate other than <c>Average</c>. Otherwise the backend skips that query.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">Requested aggregate.</param>
|
||||
/// <param name="plan">The bucket plan.</param>
|
||||
/// <returns>Whether the earliest sample is needed.</returns>
|
||||
public static bool NeedsEarliestSample(HistoryAggregateType aggregate, BucketPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
return !plan.Reversed && plan.BucketCount > 0 && SetsStartOfDataPartial(aggregate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates every non-empty bucket row and assembles one result per interval in the plan's order (descending for a
|
||||
/// reverse read): each value stamped with <see cref="BucketPlan.BucketTimestamp"/> (the earlier edge forward, the
|
||||
/// later edge reverse); empty buckets <c>BadNoData</c>; and — on a forward read, for every aggregate but
|
||||
/// <c>Average</c> — the Partial bit on a non-Bad result of the bucket the tag's data starts inside (#677, the SDK's
|
||||
/// <c>m_startOfData</c> rule; the SDK applies it to forward reads only, <see cref="NeedsEarliestSample"/>).
|
||||
/// </summary>
|
||||
/// <param name="aggregate">A per-sample (Simple / StartEnd / ActualTime) aggregate.</param>
|
||||
/// <param name="plan">The bucket plan.</param>
|
||||
/// <param name="rows">The backend's non-empty bucket rows (ascending bucket index).</param>
|
||||
/// <param name="earliestSampleUtc">The tag's earliest stored sample of any quality, or null when none.</param>
|
||||
/// <param name="now">Server timestamp.</param>
|
||||
/// <returns><see cref="BucketPlan.BucketCount"/> results.</returns>
|
||||
public static IReadOnlyList<DataValueSnapshot> EvaluateAll(HistoryAggregateType aggregate, BucketPlan plan,
|
||||
IEnumerable<BucketRow> rows, DateTime? earliestSampleUtc, DateTime now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
var evaluated = new Dictionary<long, DataValueSnapshot>();
|
||||
var partialRule = NeedsEarliestSample(aggregate, plan);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var v = Evaluate(aggregate, row, plan.BucketTimestamp(row.Bucket), now);
|
||||
if (partialRule && !StatusBits.IsBad(v.StatusCode) && plan.DataStartsInside(row.Bucket, earliestSampleUtc))
|
||||
v = v with { StatusCode = v.StatusCode | HistorianInfoBits.HistorianPartial };
|
||||
evaluated[row.Bucket] = v;
|
||||
}
|
||||
return plan.Assemble(evaluated, t => NoData(t, now));
|
||||
}
|
||||
|
||||
/// <summary>The result for a bucket with no samples (or no usable sample): <c>BadNoData</c> + Calculated.</summary>
|
||||
/// <param name="t">The bucket's start time.</param>
|
||||
/// <param name="now">Server timestamp.</param>
|
||||
|
||||
@@ -3,12 +3,29 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
/// <summary>
|
||||
/// The bucket layout of a processed (aggregate) read. Bucket <c>i</c> = <c>[start + i·interval,
|
||||
/// start + (i+1)·interval)</c> (start-inclusive, end-exclusive to the tick; the last bucket is truncated at
|
||||
/// <c>end</c>). An interval <= 0 ⇒ one interval <c>[start, end)</c>. <c>start == end</c> ⇒ no intervals.
|
||||
/// <c>end < start</c> (reverse) ⇒ the window is swapped, computed forward, and assembled in descending bucket
|
||||
/// order (buckets stay anchored at the earlier time).
|
||||
/// The bucket layout of a processed (aggregate) read, per OPC UA Part 13 / the SDK's <c>AggregateCalculator</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Forward</b> (<c>start < end</c>): the window is <c>[start, end)</c>; bucket <c>i</c> =
|
||||
/// <c>[start + i·interval, start + (i+1)·interval)</c>, the last bucket truncated at <c>end</c> (the partial one is
|
||||
/// the <i>latest</i>); each bucket's timestamp is its start (earlier edge); results ascend.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reverse</b> (<c>end < start</c>, #677): the read is anchored at the request's start — its <i>later</i>
|
||||
/// time — and steps back. The window is <c>(end, start]</c> (Part 11: the request's start is inclusive, its end
|
||||
/// exclusive, so a reverse read includes its later time and excludes its earlier one — the same window as a reverse
|
||||
/// raw read); result <c>k</c> = <c>(start − (k+1)·interval, start − k·interval]</c>, clipped at <c>end</c> (the
|
||||
/// partial bucket is the <i>earliest</i>); each bucket's timestamp is its later edge <c>start − k·interval</c>;
|
||||
/// results descend. This is exactly the SDK's <c>CreateSlice</c> / <c>GetTimestamp</c> / backward
|
||||
/// <c>GetValues</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Internally buckets are indexed in ascending time (<c>0</c> = earliest) for both directions; <see cref="Assemble"/>
|
||||
/// reverses the list for a reverse read. <see cref="StartUtc"/>/<see cref="EndUtc"/> are always the earlier / later
|
||||
/// window edges. An interval <= 0 ⇒ one interval over the whole window. <c>start == end</c> ⇒ no intervals.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class BucketPlan
|
||||
{
|
||||
/// <summary>Spec §9.7: a processed read covering more intervals than this is refused.</summary>
|
||||
@@ -21,12 +38,15 @@ public sealed class BucketPlan
|
||||
Reversed = reversed;
|
||||
IntervalTicks = intervalTicks;
|
||||
BucketCount = bucketCount;
|
||||
// Forward buckets are anchored at the earlier edge; reverse ones at the later edge, so the grid origin sits a
|
||||
// whole number of intervals before it (possibly before the window start — the earliest bucket is clipped).
|
||||
OriginTicks = reversed ? endUtc.Ticks - bucketCount * intervalTicks : startUtc.Ticks;
|
||||
}
|
||||
|
||||
/// <summary>The earlier end of the window (the forward start).</summary>
|
||||
/// <summary>The earlier edge of the window.</summary>
|
||||
public DateTime StartUtc { get; }
|
||||
|
||||
/// <summary>The later end of the window (the forward, exclusive end).</summary>
|
||||
/// <summary>The later edge of the window.</summary>
|
||||
public DateTime EndUtc { get; }
|
||||
|
||||
/// <summary>True when the request came in reversed (<c>end < start</c>).</summary>
|
||||
@@ -38,9 +58,30 @@ public sealed class BucketPlan
|
||||
/// <summary>Number of buckets (0 for an empty window).</summary>
|
||||
public long BucketCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The tick at which the (unclipped) bucket grid starts: <see cref="StartUtc"/> forward, <c>EndUtc − n·interval</c>
|
||||
/// reverse. May precede <see cref="StartUtc"/> (and even <see cref="DateTime.MinValue"/>), so it is a raw tick count.
|
||||
/// </summary>
|
||||
public long OriginTicks { get; }
|
||||
|
||||
/// <summary><see cref="StartUtc"/> as 100 ns ticks since the Unix epoch.</summary>
|
||||
public long StartUnixTicks => (StartUtc - DateTime.UnixEpoch).Ticks;
|
||||
|
||||
/// <summary>
|
||||
/// The constant a backend adds to <c>elapsed = t − StartUtc</c> (ticks) before the integer division by
|
||||
/// <see cref="IntervalTicks"/> to get a sample's ascending bucket index: <c>0</c> forward (<c>[lo, hi)</c> buckets);
|
||||
/// <c>(StartUtc − OriginTicks) − 1</c> reverse, where buckets are <c>(lo, hi]</c> — index <c>⌈(t − origin)/I⌉ − 1
|
||||
/// = (t − origin − 1) / I</c>. Never makes the dividend negative for an in-window sample (reverse excludes
|
||||
/// <c>t == StartUtc</c>).
|
||||
/// </summary>
|
||||
public long BucketIndexAdjustTicks => Reversed ? StartUtc.Ticks - OriginTicks - 1 : 0;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="StartUnixTicks"/> minus <see cref="BucketIndexAdjustTicks"/>: the base a backend subtracts from a
|
||||
/// sample's Unix-epoch ticks to get the same dividend (<c>(t − base) / IntervalTicks</c>).
|
||||
/// </summary>
|
||||
public long BucketBaseUnixTicks => StartUnixTicks - BucketIndexAdjustTicks;
|
||||
|
||||
/// <summary>Plans the buckets of a processed read.</summary>
|
||||
/// <param name="startUtc">Requested start.</param>
|
||||
/// <param name="endUtc">Requested end (earlier than start for a reverse read).</param>
|
||||
@@ -64,15 +105,59 @@ public sealed class BucketPlan
|
||||
return new BucketPlan(start, end, reverse, intervalTicks, bucketCount);
|
||||
}
|
||||
|
||||
/// <summary>The start time of bucket <paramref name="index"/> (UTC kind).</summary>
|
||||
/// <param name="index">Zero-based bucket index.</param>
|
||||
/// <returns>The bucket start.</returns>
|
||||
public DateTime BucketStart(long index) =>
|
||||
DateTime.SpecifyKind(new DateTime(StartUtc.Ticks + index * IntervalTicks), DateTimeKind.Utc);
|
||||
/// <summary>The earlier edge of bucket <paramref name="index"/> (ascending index), in ticks, clipped to the window.</summary>
|
||||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||||
/// <returns>The earlier edge.</returns>
|
||||
public long LowerTicks(long index) => Math.Max(OriginTicks + index * IntervalTicks, StartUtc.Ticks);
|
||||
|
||||
/// <summary>One snapshot per bucket in window order; missing buckets get <paramref name="empty"/>; reversed if the window was.</summary>
|
||||
/// <param name="byBucket">Evaluated snapshots of the non-empty buckets, by bucket index.</param>
|
||||
/// <param name="empty">Builds the snapshot of a bucket with no row, from its start time.</param>
|
||||
/// <summary>The later edge of bucket <paramref name="index"/> (ascending index), in ticks, clipped to the window.</summary>
|
||||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||||
/// <returns>The later edge.</returns>
|
||||
public long UpperTicks(long index) => Math.Min(OriginTicks + (index + 1) * IntervalTicks, EndUtc.Ticks);
|
||||
|
||||
/// <summary>
|
||||
/// The result timestamp of bucket <paramref name="index"/> (UTC kind): its earlier edge forward, its later edge
|
||||
/// reverse (the SDK's <c>GetTimestamp</c>).
|
||||
/// </summary>
|
||||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||||
/// <returns>The bucket's timestamp.</returns>
|
||||
public DateTime BucketTimestamp(long index) =>
|
||||
new(Reversed ? UpperTicks(index) : LowerTicks(index), DateTimeKind.Utc);
|
||||
|
||||
/// <summary>True when bucket <paramref name="index"/> is shorter than the interval (the clipped one).</summary>
|
||||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||||
/// <returns>Whether the bucket is truncated.</returns>
|
||||
public bool IsTruncated(long index) => UpperTicks(index) - LowerTicks(index) < IntervalTicks;
|
||||
|
||||
/// <summary>
|
||||
/// The ascending bucket index holding a sample at <paramref name="t"/>, or null when it is outside the window
|
||||
/// (<c>[start, end)</c> forward, <c>(start, end]</c> reverse). The C# statement of the bucket math every backend's
|
||||
/// SQL mirrors.
|
||||
/// </summary>
|
||||
/// <param name="t">Sample time.</param>
|
||||
/// <returns>The bucket index, or null.</returns>
|
||||
public long? IndexOf(DateTime t)
|
||||
{
|
||||
var x = t.Ticks;
|
||||
if (BucketCount == 0) return null;
|
||||
var inside = Reversed ? x > StartUtc.Ticks && x <= EndUtc.Ticks : x >= StartUtc.Ticks && x < EndUtc.Ticks;
|
||||
if (!inside) return null;
|
||||
return (x - StartUtc.Ticks + BucketIndexAdjustTicks) / IntervalTicks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the earliest stored sample of the tag lies strictly inside bucket <paramref name="index"/> — the data
|
||||
/// starts part-way through the interval (no bounding value before it), so Part 13 / the SDK set the Partial bit.
|
||||
/// </summary>
|
||||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||||
/// <param name="earliestSampleUtc">The tag's earliest stored sample (any quality), or null when it has none.</param>
|
||||
/// <returns>Whether the data starts inside the bucket.</returns>
|
||||
public bool DataStartsInside(long index, DateTime? earliestSampleUtc) =>
|
||||
earliestSampleUtc is { } e && e.Ticks > LowerTicks(index) && e.Ticks < UpperTicks(index);
|
||||
|
||||
/// <summary>One snapshot per bucket in result order; missing buckets get <paramref name="empty"/>; descending if reversed.</summary>
|
||||
/// <param name="byBucket">Evaluated snapshots of the non-empty buckets, by ascending bucket index.</param>
|
||||
/// <param name="empty">Builds the snapshot of a bucket with no row, from its <see cref="BucketTimestamp"/>.</param>
|
||||
/// <returns><see cref="BucketCount"/> snapshots.</returns>
|
||||
public IReadOnlyList<DataValueSnapshot> Assemble(IReadOnlyDictionary<long, DataValueSnapshot> byBucket,
|
||||
Func<DateTime, DataValueSnapshot> empty)
|
||||
@@ -81,10 +166,7 @@ public sealed class BucketPlan
|
||||
ArgumentNullException.ThrowIfNull(empty);
|
||||
var result = new List<DataValueSnapshot>((int)BucketCount);
|
||||
for (long i = 0; i < BucketCount; i++)
|
||||
{
|
||||
var bucketStart = BucketStart(i);
|
||||
result.Add(byBucket.TryGetValue(i, out var v) ? v : empty(bucketStart));
|
||||
}
|
||||
result.Add(byBucket.TryGetValue(i, out var v) ? v : empty(BucketTimestamp(i)));
|
||||
if (Reversed) result.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
+23
-15
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The signal (server defaults: TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
|
||||
/// UseSlopedExtrapolation = false; a client <c>AggregateConfiguration</c> is not honoured).</b> Between a usable
|
||||
/// UseSlopedExtrapolation = false; the node manager refuses any other client <c>AggregateConfiguration</c>).</b> Between a usable
|
||||
/// sample <c>p</c> and the next sample <c>s</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>s</c> usable, both Double/Int64 ⇒ <b>sloped</b> (linear <c>p → s</c>); quality = worse of the two.</item>
|
||||
@@ -51,7 +51,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
/// otherwise (PercentDataGood = PercentDataBad = 100) <c>UncertainDataSubNormal</c>. Time before the first sample
|
||||
/// counts as Bad (no data) time. A usable String/DateTime sample contributing to the interval ⇒
|
||||
/// <c>BadAggregateInvalidInputs</c>. Every status carries the Calculated bit, plus the Partial bit on a non-Bad
|
||||
/// result of a truncated last interval; the timestamp is the interval start; the value is a
|
||||
/// result of the truncated interval or of the interval the data starts inside (the first sample fed lies strictly
|
||||
/// within it — forward reads only, as in the SDK, #677); the timestamp is the interval's <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its
|
||||
/// later edge reverse); the value is a
|
||||
/// <see cref="double"/> (Boolean = 0/1, Int64 as double).
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -112,6 +114,7 @@ public sealed class TimeWeightedAggregator
|
||||
|
||||
private StoredSample? _prevAny;
|
||||
private StoredSample? _prevUsable;
|
||||
private DateTime? _firstSampleUtc; // the earliest sample fed (any quality): where the data starts
|
||||
private long _nextPoint; // next point (ascending time) not yet classified
|
||||
private readonly List<PendingPoint> _pending = []; // points awaiting the next usable sample
|
||||
private bool _finished;
|
||||
@@ -169,6 +172,7 @@ public sealed class TimeWeightedAggregator
|
||||
if (_finished) throw new InvalidOperationException("The aggregation is already finished.");
|
||||
var t = s.TimeUtc.Ticks;
|
||||
if (_prevAny is { } last && t <= last.TimeUtc.Ticks) return;
|
||||
_firstSampleUtc ??= s.TimeUtc;
|
||||
|
||||
// Interpolative: classify every bucket start before this sample by the simple-bounding signal there.
|
||||
ClassifyPointsBefore(t);
|
||||
@@ -234,19 +238,21 @@ public sealed class TimeWeightedAggregator
|
||||
? point with { ServerTimestampUtc = now }
|
||||
: new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, PointTime(i), now);
|
||||
|
||||
var bucketStart = _plan.BucketStart(i);
|
||||
var stamp = _plan.BucketTimestamp(i);
|
||||
var duration = BucketEnd(i) - BucketStartTicks(i);
|
||||
var partial = duration < _plan.IntervalTicks; // only the truncated last interval
|
||||
// Partial: the truncated interval (the latest forward, the earliest reverse), or the one the data starts inside
|
||||
// (no bounding value before it — the SDK's m_startOfData rule, #677), which the SDK applies to forward reads only.
|
||||
var partial = _plan.IsTruncated(i) || (!_plan.Reversed && _plan.DataStartsInside(i, _firstSampleUtc));
|
||||
DataValueSnapshot Calc(object? value, uint status)
|
||||
{
|
||||
var code = HistorianInfoBits.WithCalculated(status);
|
||||
if (partial && !StatusBits.IsBad(status)) code |= HistorianInfoBits.HistorianPartial;
|
||||
return new DataValueSnapshot(value, code, bucketStart, now);
|
||||
return new DataValueSnapshot(value, code, stamp, now);
|
||||
}
|
||||
|
||||
if (_invalid[i]) return Calc(null, HistorianStatusCodes.BadAggregateInvalidInputs);
|
||||
if (_usableTicks[i] == 0)
|
||||
return _badTicks[i] > 0 ? Calc(null, HistorianStatusCodes.Bad) : AggregateEvaluator.NoData(bucketStart, now);
|
||||
return _badTicks[i] > 0 ? Calc(null, HistorianStatusCodes.Bad) : AggregateEvaluator.NoData(stamp, now);
|
||||
|
||||
var status = _usableTicks[i] == duration ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal;
|
||||
return _aggregate switch
|
||||
@@ -269,9 +275,12 @@ public sealed class TimeWeightedAggregator
|
||||
a.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64
|
||||
&& b.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64;
|
||||
|
||||
private long BucketStartTicks(long i) => _start + i * _plan.IntervalTicks;
|
||||
private long BucketStartTicks(long i) => _plan.LowerTicks(i);
|
||||
|
||||
private long BucketEnd(long i) => Math.Min(BucketStartTicks(i) + _plan.IntervalTicks, _end);
|
||||
private long BucketEnd(long i) => _plan.UpperTicks(i);
|
||||
|
||||
/// <summary>The ascending index of the bucket whose (half-open) span holds tick <paramref name="t"/> (t ≥ window start).</summary>
|
||||
private long FirstBucketAt(long t) => Math.Max(0, (t - _plan.OriginTicks) / _plan.IntervalTicks);
|
||||
|
||||
private void AddRaw(StoredSample from, long toTicks, double toValue) =>
|
||||
Distribute(from, toTicks, toValue, (i, area, ticks) =>
|
||||
@@ -286,7 +295,7 @@ public sealed class TimeWeightedAggregator
|
||||
var lo = Math.Max(from.TimeUtc.Ticks, _start);
|
||||
var hi = Math.Min(toTicks, _end);
|
||||
if (lo >= hi || _plan.BucketCount == 0) return;
|
||||
for (var i = (lo - _start) / _plan.IntervalTicks; i < _plan.BucketCount; i++)
|
||||
for (var i = FirstBucketAt(lo); i < _plan.BucketCount; i++)
|
||||
{
|
||||
var bs = BucketStartTicks(i);
|
||||
if (bs >= hi) break;
|
||||
@@ -320,7 +329,7 @@ public sealed class TimeWeightedAggregator
|
||||
var span = (double)(toTicks - t0);
|
||||
double ValueAt(long x) => toValue == v0 ? v0 : v0 + (toValue - v0) * ((x - t0) / span);
|
||||
|
||||
for (var i = (lo - _start) / _plan.IntervalTicks; i < _plan.BucketCount; i++)
|
||||
for (var i = FirstBucketAt(lo); i < _plan.BucketCount; i++)
|
||||
{
|
||||
var bs = BucketStartTicks(i);
|
||||
if (bs >= hi) break;
|
||||
@@ -335,13 +344,12 @@ public sealed class TimeWeightedAggregator
|
||||
// ------------------------------------------------------------------------------------------ Interpolative
|
||||
|
||||
/// <summary>
|
||||
/// The time of Interpolative point <paramref name="j"/> (ascending time). Forward: the bucket starts
|
||||
/// <c>start + j·interval</c>. Reverse (Part 13 anchors a reverse read at its later time, the request's start):
|
||||
/// <c>End − (n−1−j)·interval</c>, i.e. <c>End, End − interval, …</c> once <see cref="BucketPlan.Assemble"/> has
|
||||
/// The time of Interpolative point <paramref name="j"/> (ascending time): the bucket's timestamp. Forward: the
|
||||
/// bucket starts <c>start + j·interval</c>. Reverse (Part 13 anchors a reverse read at its later time, the
|
||||
/// request's start): the later edges <c>End, End − interval, …</c> once <see cref="BucketPlan.Assemble"/> has
|
||||
/// reversed the order — so a reverse read 10:00 → 09:00 at 1 h yields the value at 10:00.
|
||||
/// </summary>
|
||||
private long PointTicks(long j) =>
|
||||
_plan.Reversed ? _end - (_plan.BucketCount - 1 - j) * _plan.IntervalTicks : BucketStartTicks(j);
|
||||
private long PointTicks(long j) => _plan.BucketTimestamp(j).Ticks;
|
||||
|
||||
private DateTime PointTime(long j) => new(PointTicks(j), DateTimeKind.Utc);
|
||||
|
||||
|
||||
@@ -5,13 +5,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
/// <summary>
|
||||
/// Push-down T-SQL for processed (aggregate) reads (spec §4 "Processed"), one query per
|
||||
/// <see cref="AggregateFamily"/> (<see cref="AggregateFamilies.FamilyOf"/> picks it). Every query takes
|
||||
/// <c>@id</c> (int TagId), <c>@start</c>/<c>@end</c> (<c>datetime2(7)</c>, the forward window
|
||||
/// <c>[start, end)</c>) and <c>@intervalTicks</c> (bigint bucket width in 100 ns ticks), and returns one row
|
||||
/// per <b>non-empty</b> bucket. The caller generates every interval and fills the empty ones.
|
||||
/// <c>@id</c> (int TagId), <c>@start</c>/<c>@end</c> (<c>datetime2(7)</c>, the earlier / later window edges —
|
||||
/// <see cref="BucketPlan.StartUtc"/>/<see cref="BucketPlan.EndUtc"/>), <c>@intervalTicks</c> (bigint bucket width in
|
||||
/// 100 ns ticks) and <c>@bucketAdjustTicks</c> (bigint, <see cref="BucketPlan.BucketIndexAdjustTicks"/>), and returns
|
||||
/// one row per <b>non-empty</b> bucket (ascending bucket index). The caller generates every interval and fills the
|
||||
/// empty ones.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bucketing.</b> <c>b = elapsedTicks / @intervalTicks</c>, where
|
||||
/// <b>Bucketing.</b> <c>b = (elapsedTicks + @bucketAdjustTicks) / @intervalTicks</c> (#677: the adjust is 0 for a
|
||||
/// forward read and re-anchors a reverse read's grid at its later time — <see cref="BucketPlan.IndexOf"/> is the C#
|
||||
/// statement of the same math). The window is <c>[@start, @end)</c> forward and <c>(@start, @end]</c> reverse (a
|
||||
/// reverse read includes its later time), selected by the <c>reverse</c> argument. <c>elapsedTicks</c> is
|
||||
/// <c>elapsedTicks = DATEDIFF_BIG(microsecond, @start, t) * 10 + tick-of-microsecond(t) − tick-of-microsecond(@start)</c>
|
||||
/// and <c>tick-of-microsecond(x) = (DATEPART(nanosecond, x) / 100) % 10</c>. The spec sketched
|
||||
/// <c>millisecond</c>, but <c>DATEDIFF</c> counts unit <i>boundaries crossed</i>, not elapsed time, so with a
|
||||
@@ -47,8 +52,9 @@ internal static class SqlAggregateQueries
|
||||
/// </summary>
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string Simple(string sample) =>
|
||||
Classified(sample) +
|
||||
/// <param name="reverse">True for a reverse read (window <c>(@start, @end]</c>).</param>
|
||||
public static string Simple(string sample, bool reverse = false) =>
|
||||
Classified(sample, reverse) +
|
||||
"SELECT b, " + CommonCounts() + ", " +
|
||||
"AVG(CASE WHEN u = 1 THEN NumValue END) AS AvgValue, " +
|
||||
"MIN(CASE WHEN u = 1 THEN NumValue END) AS MinValue, " +
|
||||
@@ -63,8 +69,9 @@ internal static class SqlAggregateQueries
|
||||
/// </summary>
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string StartEnd(string sample) =>
|
||||
Classified(sample) +
|
||||
/// <param name="reverse">True for a reverse read (window <c>(@start, @end]</c>).</param>
|
||||
public static string StartEnd(string sample, bool reverse = false) =>
|
||||
Classified(sample, reverse) +
|
||||
", st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b)" +
|
||||
", f AS (SELECT b, ValueKind, NumValue, IntValue, StrValue, SourceTimeUtc, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY b ORDER BY SourceTimeUtc ASC) AS rf, " +
|
||||
@@ -82,8 +89,9 @@ internal static class SqlAggregateQueries
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <param name="maximum">True for MaximumActualTime.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string ActualTime(string sample, bool maximum) =>
|
||||
Classified(sample) +
|
||||
/// <param name="reverse">True for a reverse read (window <c>(@start, @end]</c>).</param>
|
||||
public static string ActualTime(string sample, bool maximum, bool reverse = false) =>
|
||||
Classified(sample, reverse) +
|
||||
", st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b)" +
|
||||
", r AS (SELECT b, NumValue, SourceTimeUtc, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY b ORDER BY NumValue " + (maximum ? "DESC" : "ASC") + ", SourceTimeUtc ASC) AS rn " +
|
||||
@@ -121,14 +129,26 @@ internal static class SqlAggregateQueries
|
||||
$"SELECT TOP 1 {cols} {key}SourceTimeUtc >= @end{usable} ORDER BY SourceTimeUtc ASC;";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The earliest stored sample of the tag (any quality), one row (<c>NULL</c> when none): the start-of-data Partial
|
||||
/// rule (#677; forward reads only). A <c>MIN</c> over the <c>(TagId, SourceTimeUtc)</c> key — a single seek — bounded
|
||||
/// by the window end (a sample at or past it cannot lie inside an interval).
|
||||
/// </summary>
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string EarliestSample(string sample) =>
|
||||
$"SELECT MIN(SourceTimeUtc) FROM {sample} WHERE TagId = @id AND SourceTimeUtc < @end;";
|
||||
|
||||
/// <summary>The <c>s</c> CTE: the window's samples with bucket index and the usable/good flags.</summary>
|
||||
private static string Classified(string sample) =>
|
||||
private static string Classified(string sample, bool reverse) =>
|
||||
"WITH s AS (SELECT (DATEDIFF_BIG(microsecond, @start, SourceTimeUtc) * 10 " +
|
||||
"+ (DATEPART(nanosecond, SourceTimeUtc) / 100) % 10 - (DATEPART(nanosecond, @start) / 100) % 10) / @intervalTicks AS b, " +
|
||||
"+ (DATEPART(nanosecond, SourceTimeUtc) / 100) % 10 - (DATEPART(nanosecond, @start) / 100) % 10 " +
|
||||
"+ @bucketAdjustTicks) / @intervalTicks AS b, " +
|
||||
"SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, " +
|
||||
"CASE WHEN StatusCode >= 0 AND ValueKind <> 0 THEN 1 ELSE 0 END AS u, " +
|
||||
"CASE WHEN StatusCode >= 0 AND StatusCode < 1073741824 AND ValueKind <> 0 THEN 1 ELSE 0 END AS g " +
|
||||
$"FROM {sample} WHERE TagId = @id AND SourceTimeUtc >= @start AND SourceTimeUtc < @end) ";
|
||||
$"FROM {sample} WHERE TagId = @id AND " +
|
||||
(reverse ? "SourceTimeUtc > @start AND SourceTimeUtc <= @end) " : "SourceTimeUtc >= @start AND SourceTimeUtc < @end) ");
|
||||
|
||||
/// <summary>The five common count columns (after <c>b</c>).</summary>
|
||||
private static string CommonCounts() =>
|
||||
|
||||
+47
-17
@@ -154,6 +154,12 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
var bounded = maxValues > 0 && maxValues < int.MaxValue;
|
||||
try
|
||||
{
|
||||
// #674: a v1 schema has no SampleModified. NotSupportedException is the interface's "no trail" signal (the
|
||||
// node manager reports BadHistoryOperationUnsupported); it is not a backend failure and sends no query.
|
||||
await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!_bootstrap.SupportsModifiedTrail)
|
||||
throw new NotSupportedException(
|
||||
$"Historian schema [{_conn.Schema}] is below version {SqlServerSchemaBootstrapper.ModifiedTrailVersion}: no modified-value history.");
|
||||
var values = new List<HistoryModifiedValue>();
|
||||
var hasMore = false;
|
||||
var tagId = await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
|
||||
@@ -219,7 +225,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
RecordSuccess();
|
||||
return new HistoryModifiedReadResult(values, hasMore);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not NotSupportedException)
|
||||
{
|
||||
RecordFailure(ex);
|
||||
throw;
|
||||
@@ -305,6 +311,11 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Events
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
|
||||
public bool SupportsReverseEvents => true;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>[start, end)</c> by <c>EventTimeUtc</c> (a reversed window is swapped), ordered by
|
||||
@@ -314,8 +325,18 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
/// of <c>"{EventTimeUtc.Ticks}|{EventId}"</c> of that first not-returned event (opaque to callers; only
|
||||
/// its presence is contractual).
|
||||
/// </remarks>
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) =>
|
||||
ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Forward is the five-argument read. Reverse (Gitea #675) is <c>(lo, hi]</c> ordered by
|
||||
/// <c>(EventTimeUtc, EventId)</c> DESCENDING — the clustered index scanned backwards — so <c>TOP</c> keeps the latest
|
||||
/// events; truncation and the continuation point work as forward.
|
||||
/// </remarks>
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken)
|
||||
{
|
||||
// No stored event can carry a source longer than the column (the alarm writer dead-letters it rather than
|
||||
// truncating), so an over-long filter matches nothing — never a truncated prefix of someone else's source.
|
||||
@@ -328,9 +349,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
|
||||
var sql =
|
||||
$"SELECT {(bounded ? "TOP (@take) " : "")}EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity " +
|
||||
$"FROM {_conn.Q("Event")} WHERE EventTimeUtc >= @lo AND EventTimeUtc < @hi" +
|
||||
$"FROM {_conn.Q("Event")} WHERE " +
|
||||
(reverse ? "EventTimeUtc > @lo AND EventTimeUtc <= @hi" : "EventTimeUtc >= @lo AND EventTimeUtc < @hi") +
|
||||
(sourceName is null ? "" : " AND SourceName = @source") +
|
||||
" ORDER BY EventTimeUtc ASC, EventId ASC";
|
||||
(reverse ? " ORDER BY EventTimeUtc DESC, EventId DESC" : " ORDER BY EventTimeUtc ASC, EventId ASC");
|
||||
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
@@ -378,16 +400,18 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
/// One result per interval of <c>[start, end)</c>, bucket <c>i</c> = <c>[start + i·interval,
|
||||
/// start + (i+1)·interval)</c> (start-inclusive, end-exclusive to the tick; the last bucket is truncated at
|
||||
/// <c>end</c>). <paramref name="interval"/> <= 0 ⇒ one interval <c>[start, end)</c>. <c>start == end</c>
|
||||
/// ⇒ no intervals. <c>end < start</c> (reverse) ⇒ the window is swapped, computed forward, and returned
|
||||
/// in descending bucket order (buckets stay anchored at the earlier time).
|
||||
/// ⇒ no intervals. <c>end < start</c> (reverse, #677) ⇒ Part 13 / SDK anchoring: window <c>(end, start]</c>,
|
||||
/// buckets stepping back from <c>start</c> (the partial one is the earliest), each stamped with its later edge,
|
||||
/// returned latest first (<see cref="BucketPlan"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per interval: no samples ⇒ <c>BadNoData</c>. PercentGood / PercentBad ⇒ <c>100 × good|bad / all</c> over
|
||||
/// every sample, status Good. Otherwise no usable sample ⇒ <c>BadNoData</c>; a numeric aggregate with a usable
|
||||
/// String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>; else the value, Good when every sample is Good,
|
||||
/// <c>UncertainDataSubNormal</c> when any is Bad or Uncertain. Every status — including <c>BadNoData</c> —
|
||||
/// carries the Calculated info bits. Timestamps are the bucket start, except Minimum/MaximumActualTime
|
||||
/// (the sample's time; ties ⇒ earliest).
|
||||
/// carries the Calculated info bits. Timestamps are the bucket start (its later edge on a reverse read), except
|
||||
/// Minimum/MaximumActualTime (the sample's time; ties ⇒ earliest). Every aggregate but Average sets the Partial
|
||||
/// bit on the bucket the tag's earliest sample lies strictly inside (#677).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Values: Count is <see cref="int"/>; Start/End keep the sample's type; every other aggregate is
|
||||
@@ -410,7 +434,6 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
try
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var evaluated = new Dictionary<long, DataValueSnapshot>();
|
||||
var tagId = plan.BucketCount == 0 ? null : await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
|
||||
if (family == AggregateFamily.TimeWeighted)
|
||||
{
|
||||
@@ -420,30 +443,37 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
RecordSuccess();
|
||||
return new HistoryReadResult(twResult, ContinuationPoint: null);
|
||||
}
|
||||
var rows = new List<BucketRow>();
|
||||
DateTime? earliest = null;
|
||||
if (tagId is { } id)
|
||||
{
|
||||
var sample = _conn.Q("Sample");
|
||||
var sql = family switch
|
||||
{
|
||||
AggregateFamily.Simple => SqlAggregateQueries.Simple(sample),
|
||||
AggregateFamily.StartEnd => SqlAggregateQueries.StartEnd(sample),
|
||||
_ => SqlAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime),
|
||||
AggregateFamily.Simple => SqlAggregateQueries.Simple(sample, plan.Reversed),
|
||||
AggregateFamily.StartEnd => SqlAggregateQueries.StartEnd(sample, plan.Reversed),
|
||||
_ => SqlAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime, plan.Reversed),
|
||||
};
|
||||
// Second result set: the tag's earliest sample (the start-of-data Partial rule, #677) — forward reads of an
|
||||
// aggregate other than Average only.
|
||||
var needsEarliest = AggregateEvaluator.NeedsEarliestSample(aggregate, plan);
|
||||
if (needsEarliest) sql += " " + SqlAggregateQueries.EarliestSample(sample);
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@start", DateTime.SpecifyKind(plan.StartUtc, DateTimeKind.Utc));
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@end", DateTime.SpecifyKind(plan.EndUtc, DateTimeKind.Utc));
|
||||
cmd.Parameters.Add(new SqlParameter("@intervalTicks", SqlDbType.BigInt) { Value = plan.IntervalTicks });
|
||||
cmd.Parameters.Add(new SqlParameter("@bucketAdjustTicks", SqlDbType.BigInt) { Value = plan.BucketIndexAdjustTicks });
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var row = ReadBucket(reader, family);
|
||||
evaluated[row.Bucket] = AggregateEvaluator.Evaluate(aggregate, row, plan.BucketStart(row.Bucket), now);
|
||||
}
|
||||
rows.Add(ReadBucket(reader, family));
|
||||
if (needsEarliest && await reader.NextResultAsync(cancellationToken).ConfigureAwait(false)
|
||||
&& await reader.ReadAsync(cancellationToken).ConfigureAwait(false) && !reader.IsDBNull(0))
|
||||
earliest = DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
var result = plan.Assemble(evaluated, bucketStart => AggregateEvaluator.NoData(bucketStart, now));
|
||||
var result = AggregateEvaluator.EvaluateAll(aggregate, plan, rows, earliest, now);
|
||||
|
||||
RecordSuccess();
|
||||
return new HistoryReadResult(result, ContinuationPoint: null);
|
||||
|
||||
+27
-1
@@ -27,21 +27,44 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
/// value from <c>deleted.*</c> (NULL for an insert), the caller's <see cref="HistoryUpdateContext"/>, the
|
||||
/// <see cref="HistoryModificationType"/> and <c>SYSUTCDATETIME()</c>. One statement means the sample change and its
|
||||
/// audit row commit or roll back together: a failed update leaves no audit row.</para>
|
||||
/// <para><b>Schema v1 (Gitea #674).</b> Without the trail (<see cref="SqlServerSchemaBootstrapper.SupportsModifiedTrail"/>
|
||||
/// false) a change could not be audited, so every call throws <see cref="NotSupportedException"/> — which the node
|
||||
/// manager reports as a node-level <c>BadHistoryOperationUnsupported</c>, on all three arms alike — and nothing is
|
||||
/// sent to the database beyond the one-time schema check.</para>
|
||||
/// </summary>
|
||||
internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
{
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerTagCatalog _tags;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
|
||||
/// <summary>Creates the updater.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper — decides whether the modification trail exists (#674).</param>
|
||||
/// <param name="tags">Tag catalog (runs the schema bootstrap itself).</param>
|
||||
public SqlServerHistorianDataUpdater(SqlHistorianConnection conn, SqlServerTagCatalog tags)
|
||||
public SqlServerHistorianDataUpdater(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap,
|
||||
SqlServerTagCatalog tags)
|
||||
{
|
||||
_conn = conn;
|
||||
_bootstrap = bootstrap;
|
||||
_tags = tags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: ensures the schema and refuses the call when it has no Part 11 trail (no SQL after the first success).
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>A task completing when the trail is available.</returns>
|
||||
/// <exception cref="NotSupportedException">The schema is below <see cref="SqlServerSchemaBootstrapper.ModifiedTrailVersion"/>.</exception>
|
||||
private async Task RequireTrailAsync(CancellationToken ct)
|
||||
{
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
if (!_bootstrap.SupportsModifiedTrail)
|
||||
throw new NotSupportedException(
|
||||
$"Historian schema [{_conn.Schema}] is below version {SqlServerSchemaBootstrapper.ModifiedTrailVersion}: " +
|
||||
"HistoryUpdate needs the Part 11 modification trail (apply 002_sample_modified.sql and restart).");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
|
||||
IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct)
|
||||
@@ -49,6 +72,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
await RequireTrailAsync(ct).ConfigureAwait(false);
|
||||
var plan = HistoryUpdatePlanner.Plan(tag, mode, values);
|
||||
if (plan.Winners.Count == 0) return HistoryUpdatePlanner.Complete(plan, mode, new Dictionary<long, UpsertOutcome>());
|
||||
|
||||
@@ -68,6 +92,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
await RequireTrailAsync(ct).ConfigureAwait(false);
|
||||
if (!HistorianTagNames.IsValid(tag)) return HistorianStatusCodes.BadInvalidArgument;
|
||||
var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false);
|
||||
if (tagId is null) return HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 0);
|
||||
@@ -95,6 +120,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(timesUtc);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
await RequireTrailAsync(ct).ConfigureAwait(false);
|
||||
if (!HistorianTagNames.IsValid(tag))
|
||||
{
|
||||
var refused = new uint[timesUtc.Count];
|
||||
|
||||
+4
-2
@@ -18,8 +18,10 @@ public sealed partial class SqlServerHistorianOptions
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> creates the schema and applies missing scripts on first use; <c>false</c> only verifies
|
||||
/// that the required schema version (<c>SqlServerSchemaBootstrapper.RequiredVersion</c>, currently 2) exists and
|
||||
/// fails every historian call with a clear error otherwise.
|
||||
/// that the base schema version (<c>SqlServerSchemaBootstrapper.RequiredVersion</c>, 1) exists and fails every
|
||||
/// historian call with a clear error otherwise. A verified v1 schema (no Part 11 modification trail, script 002
|
||||
/// not applied) is served with modified reads and HistoryUpdate refused <c>BadHistoryOperationUnsupported</c>
|
||||
/// and one Warning per boot (Gitea #674).
|
||||
/// </summary>
|
||||
public bool AutoCreateSchema { get; set; } = true;
|
||||
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ public sealed class SqlServerHistorianProvider : IHistorianBackendProvider
|
||||
public IHistorianDataUpdater? CreateUpdater(IServiceProvider sp)
|
||||
{
|
||||
var s = Get(sp);
|
||||
return new SqlServerHistorianDataUpdater(s.Conn, s.Catalog);
|
||||
return new SqlServerHistorianDataUpdater(s.Conn, s.Bootstrap, s.Catalog);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+26
-12
@@ -11,7 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
/// <c>DELETE TOP(n)</c> batches (under the lock-escalation threshold). Samples are purged per tag so each
|
||||
/// delete is a seek on the clustered key <c>(TagId, SourceTimeUtc)</c>; events seek on <c>CX_Event_Time</c>.
|
||||
/// A session <c>sp_getapplock</c> (timeout 0) serialises purgers that share one database; a loser skips.
|
||||
/// To purge another table later, add it to <see cref="PurgeAsync"/>.
|
||||
/// To purge another table later, add it to <see cref="PurgeAsync"/>. On a v1 schema (no Part 11 trail, Gitea #674)
|
||||
/// the <c>SampleModified</c> purge is skipped and absent from the results.
|
||||
/// </summary>
|
||||
internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
||||
{
|
||||
@@ -42,6 +43,9 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
||||
/// <summary>Test seam for <see cref="SqlConnection.ClearPool"/>.</summary>
|
||||
internal Action<SqlConnection> ClearPool { get; set; } = SqlConnection.ClearPool;
|
||||
|
||||
/// <summary>Test seam: the release statement. Tests point it at a failing statement to exercise the pool-clear fallback.</summary>
|
||||
internal string ReleaseSql { get; set; } = "EXEC sp_releaseapplock @Resource = @res, @LockOwner = 'Session';";
|
||||
|
||||
public async Task<IReadOnlyList<RetentionPurgeResult>> PurgeAsync(CancellationToken ct)
|
||||
{
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
@@ -69,6 +73,8 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
||||
return [];
|
||||
}
|
||||
|
||||
// #674: a v1 schema has no SampleModified table — skip its purge rather than fail the whole pass.
|
||||
var trail = _bootstrap.SupportsModifiedTrail;
|
||||
long samples = 0, modified = 0;
|
||||
var tagIds = new List<int>();
|
||||
await using (var cmd = _conn.CreateCommand(conn, $"SELECT TagId FROM {_conn.Q("Tag")}"))
|
||||
@@ -82,21 +88,30 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
||||
cutoff, id, ct).ConfigureAwait(false);
|
||||
// The Part 11 modification trail (#668) ages out on the SAME source-time cutoff as the samples it
|
||||
// describes; a purge never writes to it (no trigger, no OUTPUT INTO).
|
||||
modified += await DeleteLoopAsync(conn,
|
||||
$"DELETE TOP (@n) FROM {_conn.Q("SampleModified")} WHERE TagId = @id AND SourceTimeUtc < @cutoff",
|
||||
cutoff, id, ct).ConfigureAwait(false);
|
||||
if (trail)
|
||||
{
|
||||
modified += await DeleteLoopAsync(conn,
|
||||
$"DELETE TOP (@n) FROM {_conn.Q("SampleModified")} WHERE TagId = @id AND SourceTimeUtc < @cutoff",
|
||||
cutoff, id, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var events = await DeleteLoopAsync(conn,
|
||||
$"DELETE TOP (@n) FROM {_conn.Q("Event")} WHERE EventTimeUtc < @cutoff",
|
||||
cutoff, null, ct).ConfigureAwait(false);
|
||||
|
||||
return
|
||||
[
|
||||
new RetentionPurgeResult("Sample", samples),
|
||||
new RetentionPurgeResult("SampleModified", modified),
|
||||
new RetentionPurgeResult("Event", events),
|
||||
];
|
||||
return trail
|
||||
?
|
||||
[
|
||||
new RetentionPurgeResult("Sample", samples),
|
||||
new RetentionPurgeResult("SampleModified", modified),
|
||||
new RetentionPurgeResult("Event", events),
|
||||
]
|
||||
:
|
||||
[
|
||||
new RetentionPurgeResult("Sample", samples),
|
||||
new RetentionPurgeResult("Event", events),
|
||||
];
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -137,8 +152,7 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn,
|
||||
"EXEC sp_releaseapplock @Resource = @res, @LockOwner = 'Session';");
|
||||
await using var cmd = _conn.CreateCommand(conn, ReleaseSql);
|
||||
cmd.Parameters.Add(new SqlParameter("@res", SqlDbType.NVarChar, 255) { Value = LockResource });
|
||||
await cmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
+30
-4
@@ -15,15 +15,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
/// SQL, so a missing schema (or a SQL outage) does not serialise every write, read and alarm event behind one
|
||||
/// semaphore and one round-trip each.
|
||||
/// With <c>AutoCreateSchema=false</c> it only verifies <c>MAX(Version) >= </c><see cref="RequiredVersion"/>.
|
||||
/// <para><b>Schema v1 is accepted (Gitea #674).</b> A database at version 1 (no <c>SampleModified</c>) still serves
|
||||
/// live writes, raw / processed / at-time reads, alarm events and retention; only the Part 11 modification trail is
|
||||
/// unavailable — <see cref="SupportsModifiedTrail"/> is false, modified reads and HistoryUpdate report
|
||||
/// <c>BadHistoryOperationUnsupported</c> without touching SQL, and one Warning is logged per instance (= per boot: the
|
||||
/// provider shares one bootstrapper). Before #674 a v1 database refused EVERY historian call, continuous-historization
|
||||
/// drains included, until script 002 was applied by hand.</para>
|
||||
/// </summary>
|
||||
internal sealed partial class SqlServerSchemaBootstrapper
|
||||
{
|
||||
/// <summary>
|
||||
/// The schema version this build requires: 2 = <c>002_sample_modified.sql</c> (Part 11 modified-value
|
||||
/// history, Gitea #668). An existing v1 schema is upgraded in place on the next bootstrap when
|
||||
/// <c>AutoCreateSchema</c> is on; otherwise the script must be applied by hand.
|
||||
/// The schema version this build requires to serve at all: 1 = <c>001_initial.sql</c>. Gitea #674 lowered it from
|
||||
/// 2 — a missing modification trail disables only the features that need it (<see cref="ModifiedTrailVersion"/>).
|
||||
/// </summary>
|
||||
public const int RequiredVersion = 2;
|
||||
public const int RequiredVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The schema version that carries the Part 11 modification trail: 2 = <c>002_sample_modified.sql</c> (Gitea #668).
|
||||
/// An auto-creating bootstrap always upgrades to it; with <c>AutoCreateSchema=false</c> the script must be applied by
|
||||
/// hand (and the node restarted — the verdict is taken once per instance).
|
||||
/// </summary>
|
||||
public const int ModifiedTrailVersion = 2;
|
||||
|
||||
private const string LockResource = "OtOpcUaHistorianSchema";
|
||||
private const int LockTimeoutMs = 30_000;
|
||||
@@ -34,6 +46,13 @@ internal sealed partial class SqlServerSchemaBootstrapper
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly BootstrapFailureCache _failure; // guarded by _gate
|
||||
private volatile bool _ready;
|
||||
private volatile bool _supportsModifiedTrail;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the schema carries the Part 11 modification trail (version >= <see cref="ModifiedTrailVersion"/>).
|
||||
/// Meaningful only after <see cref="EnsureAsync"/> has succeeded (false before). Taken once per instance.
|
||||
/// </summary>
|
||||
public bool SupportsModifiedTrail => _supportsModifiedTrail;
|
||||
|
||||
/// <summary>Creates the bootstrapper.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
@@ -99,6 +118,13 @@ internal sealed partial class SqlServerSchemaBootstrapper
|
||||
throw new HistorianSchemaMissingException(
|
||||
$"Historian schema [{_conn.Schema}] is missing version {RequiredVersion} (found {v}) and AutoCreateSchema is false. " +
|
||||
"Apply the docs/historian/sqlserver/NNN_*.sql scripts above version " + v + " in order, or enable AutoCreateSchema.");
|
||||
_supportsModifiedTrail = v >= ModifiedTrailVersion;
|
||||
if (!_supportsModifiedTrail)
|
||||
_log.LogWarning("Historian schema [{Schema}] is at version {Version}, below {TrailVersion}, and AutoCreateSchema is false: " +
|
||||
"the Part 11 modification trail is unavailable, so modified-value history reads and HistoryUpdate report " +
|
||||
"BadHistoryOperationUnsupported and retention skips SampleModified. Live historization, reads, events and retention " +
|
||||
"are unaffected. Apply docs/historian/sqlserver/002_sample_modified.sql and restart the node to enable them.",
|
||||
_conn.Schema, v, ModifiedTrailVersion);
|
||||
}
|
||||
|
||||
private async Task ApplyAsync(SqlConnection conn, CancellationToken ct)
|
||||
|
||||
@@ -101,12 +101,6 @@ public sealed record OpcUaClientDriverOptions
|
||||
/// <summary>Client-side keep-alive interval.</summary>
|
||||
public TimeSpan KeepAliveInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Retained for config compatibility only — no longer used (Gitea #670). The driver has no in-driver reconnect
|
||||
/// handler; a lost session is recovered by the host's comms-loss watchdog re-initialising the driver.
|
||||
/// </summary>
|
||||
public TimeSpan ReconnectPeriod { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the driver accepts any self-signed / untrusted server certificate.
|
||||
/// Dev-only — must be <c>false</c> in production so MITM attacks against the opc.tcp
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// back on its own recovers in place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
|
||||
/// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
|
||||
@@ -83,6 +83,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
_sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger);
|
||||
PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval;
|
||||
}
|
||||
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
@@ -135,6 +136,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>Occurs when the host connectivity status of the remote OPC UA server changes.</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Gitea #676 — raised at most once per session, and only on a DEFINITIVE loss
|
||||
/// (<see cref="IsDefinitiveSessionLoss"/>, or a tracked subscription the SERVER reported dropped — never the
|
||||
/// publishing-stall heuristic). Re-armed when the session is released (re-initialise / shutdown). Known gap: the host
|
||||
/// acts on it only while Connected, so a raise it receives before it has processed the successful initialise is
|
||||
/// swallowed and that session falls back to the #527 watchdog.</remarks>
|
||||
public event EventHandler<DriverConnectionLossEventArgs>? ConnectionLost;
|
||||
|
||||
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
|
||||
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
|
||||
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
|
||||
@@ -164,6 +173,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>Test-only seam (#670): the most recent in-place recovery task.</summary>
|
||||
internal Task RecoveryTaskForTest => _recoveryTask;
|
||||
|
||||
/// <summary>Test seam (#676): the trailing reconcile read scheduled by the last in-place recovery.</summary>
|
||||
internal Task ReconcileTaskForTest => _reconcileTask;
|
||||
|
||||
/// <summary>Test-only seam (#670 review M1): raise the session's PublishError as the SDK publish loop does.</summary>
|
||||
/// <param name="status">The publish error status.</param>
|
||||
/// <param name="subscriptionId">The subscription id the error names (0 = none).</param>
|
||||
@@ -282,6 +294,33 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>The last in-place recovery started (#670). Test seam: lets a test await it deterministically.</summary>
|
||||
private Task _recoveryTask = Task.CompletedTask;
|
||||
|
||||
/// <summary>#676 — 1 once <see cref="ConnectionLost"/> has been raised for the CURRENT session (once per session);
|
||||
/// cleared when the session is released, so a re-initialised driver reports its next loss.</summary>
|
||||
private int _connectionLostRaised;
|
||||
|
||||
/// <summary>#676 live gate A1 — closed-channel keep-alive failures (<see cref="IsClosedChannel"/>) in the current
|
||||
/// outage; reset by a good keep-alive and when the session is released. Two make a closed channel "sustained".</summary>
|
||||
private int _closedChannelFailures;
|
||||
|
||||
/// <summary>Closed-channel keep-alive failures in one outage that make the loss definitive (a single one may be a
|
||||
/// transient that the next good keep-alive contradicts).</summary>
|
||||
private const int SustainedClosedChannelFailures = 2;
|
||||
|
||||
/// <summary>#676 — the trailing reconcile read scheduled by the last in-place recovery. Test seam.</summary>
|
||||
private Task _reconcileTask = Task.CompletedTask;
|
||||
|
||||
/// <summary>#676 — late notifications the replay guard dropped since it was armed, and the refs they were for;
|
||||
/// summarised in ONE Information line when the window disarms. Guarded by <see cref="_replayGuardLock"/>.</summary>
|
||||
private int _replayDrops;
|
||||
private readonly HashSet<string> _replayDropRefs = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Test seam (#676): how long after an in-place recovery the trailing reconcile read runs. Production: the
|
||||
/// operation timeout plus one keep-alive interval — by then every request that was in flight across the stall
|
||||
/// (including the Publish responses whose notifications the SDK can lose) has completed or timed out.
|
||||
/// </summary>
|
||||
internal Func<TimeSpan> PostRecoveryReconcileDelay { get; set; }
|
||||
|
||||
/// <summary>Test seam (#670 review M1): whether a subscription has stopped publishing. Production asks the SDK.</summary>
|
||||
internal Func<Subscription, bool> IsPublishingStopped { get; set; } = static s => s.Created && s.PublishingStopped;
|
||||
|
||||
@@ -326,6 +365,37 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>Serialises arming against expiry, so an expiry racing a re-arm cannot wipe the new seeds.</summary>
|
||||
private readonly object _replayGuardLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Review of 3b88056f — serialises "decide whether this upstream value may be published" with "publish it" across
|
||||
/// the three paths that publish upstream data: live notifications, the recovery read and the trailing reconcile
|
||||
/// read. Without it a path could pass its newer-than check, lose the CPU to another path publishing a NEWER value,
|
||||
/// and then deliver its older one last — regressing the node. It also covers the comms-loss Bad fan
|
||||
/// (<see cref="PublishCommsLoss"/>). One driver-wide lock rather than one per ref: it also serialises delivery
|
||||
/// ACROSS subscriptions — with several subscriptions the SDK dispatches each subscription's messages on its own
|
||||
/// worker, so their notifications no longer run in parallel. That is the cost, and it is small: each critical
|
||||
/// section is a dictionary update plus a subscriber call that only <c>Tell</c>s an actor (tens of nanoseconds to
|
||||
/// a few microseconds), nothing awaits under it, and per-subscription message order is unaffected. Because
|
||||
/// subscribers are invoked under it, a subscriber must never block on, or call back into, this driver.
|
||||
/// </summary>
|
||||
private readonly Lock _deliveryLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Re-review of 30a9d5f0 — per RawPath, a counter bumped (under <see cref="_deliveryLock"/>) every time a value is
|
||||
/// delivered for it. The trailing re-read captures it before issuing its read and skips any ref delivered to since:
|
||||
/// without source timestamps the read's value cannot be ordered against a notification that landed while the read
|
||||
/// was in flight, and publishing it would regress the node to the value the read sampled.
|
||||
/// </summary>
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _deliverySeq =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Review of 3b88056f — cancels the post-recovery background work (trailing read, replay-guard disarm timer)
|
||||
/// of the current session; cancelled and replaced whenever the session is released, and on dispose.</summary>
|
||||
private CancellationTokenSource _sessionWorkCts = new();
|
||||
|
||||
/// <summary>Test seam (review of 3b88056f): invoked inside the trailing read's critical section, after it decided
|
||||
/// to publish a ref and before it does — where a racing notification would otherwise overtake it.</summary>
|
||||
internal Action<string>? BeforeReconcilePublishForTest { get; set; }
|
||||
|
||||
/// <summary>Floor of the replay-guard window; the live gate saw the late replay 4–5 s after recovery.</summary>
|
||||
private static readonly TimeSpan ReplayGuardMinimum = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -883,9 +953,40 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||
$"Server advertises: {advertised}");
|
||||
}
|
||||
// #676: connect where the operator pointed us, not where the server says it lives.
|
||||
match.EndpointUrl = RewriteEndpointUrl(match.EndpointUrl, endpointUrl);
|
||||
return match;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #676 — the address to open the session on. A server reports its endpoints with ITS view of its own
|
||||
/// address; behind NAT, a port map, a container port publish or a proxy that address is unreachable from here
|
||||
/// (or bypasses the proxy). When the advertised URL's scheme matches the configured one, the configured host
|
||||
/// AND port (when it names one) replace the advertised ones and the advertised path is kept — the SDK's own
|
||||
/// <c>CoreClientUtils.SelectEndpoint</c> rule, which this driver's policy-aware selection bypassed. A different
|
||||
/// scheme is a different transport and is left alone; an unparseable advertised URL falls back to the
|
||||
/// configured one.
|
||||
/// </summary>
|
||||
/// <param name="advertisedUrl">The <c>EndpointUrl</c> the server returned from GetEndpoints.</param>
|
||||
/// <param name="configuredUrl">The URL the driver was configured with (the one GetEndpoints was sent to).</param>
|
||||
/// <returns>The URL to connect the session to.</returns>
|
||||
internal static string RewriteEndpointUrl(string? advertisedUrl, string configuredUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var configured)) return advertisedUrl ?? configuredUrl;
|
||||
if (string.IsNullOrWhiteSpace(advertisedUrl)
|
||||
|| !Uri.TryCreate(advertisedUrl, UriKind.Absolute, out var advertised))
|
||||
return configuredUrl;
|
||||
if (!string.Equals(advertised.Scheme, configured.Scheme, StringComparison.OrdinalIgnoreCase)) return advertisedUrl;
|
||||
// A port-less configured URL (Uri.Port == -1 for opc.tcp) keeps the advertised port — copying -1 would drop it
|
||||
// and the SDK would fall back to 4840 (review of 3b88056f; same fix as the client's EndpointSelector).
|
||||
var builder = new UriBuilder(advertised)
|
||||
{
|
||||
Host = configured.DnsSafeHost,
|
||||
Port = configured.Port > 0 ? configured.Port : advertised.Port,
|
||||
};
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication
|
||||
/// certificate loaded from <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>.
|
||||
@@ -986,6 +1087,21 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
Interlocked.Exchange(ref _sessionLost, 0);
|
||||
Interlocked.Exchange(ref _subscriptionLost, 0);
|
||||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||||
// #676: re-arm the definitive-loss signal for the next session.
|
||||
Interlocked.Exchange(ref _connectionLostRaised, 0);
|
||||
Interlocked.Exchange(ref _closedChannelFailures, 0);
|
||||
// Review of 3b88056f: the released session's post-recovery timers must not outlive it.
|
||||
CancelSessionWork();
|
||||
}
|
||||
|
||||
/// <summary>Cancel the current session's background work and start a fresh token for the next one.</summary>
|
||||
private void CancelSessionWork()
|
||||
{
|
||||
// Cancelled but deliberately NOT disposed (re-review of 30a9d5f0): the keep-alive thread may be reading the old
|
||||
// source's Token concurrently, and a disposed source throws ObjectDisposedException there. Nothing here is
|
||||
// timer-backed (no CancelAfter), so an undisposed source holds no unmanaged resource.
|
||||
var old = Interlocked.Exchange(ref _sessionWorkCts, new CancellationTokenSource());
|
||||
old.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the best-effort wire calls a teardown makes against a possibly-dead upstream.</summary>
|
||||
@@ -1021,8 +1137,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// previous connection's value bookkeeping applies to them.
|
||||
_lastValues.Clear();
|
||||
_lastGoodSourceTicks.Clear();
|
||||
_replayGuard.Clear();
|
||||
Interlocked.Exchange(ref _replayGuardUntilTicks, 0);
|
||||
lock (_replayGuardLock)
|
||||
{
|
||||
_replayGuard.Clear();
|
||||
Interlocked.Exchange(ref _replayGuardUntilTicks, 0);
|
||||
_replayDrops = 0;
|
||||
_replayDropRefs.Clear();
|
||||
}
|
||||
_awaitingRecovery.Clear();
|
||||
// #619 re-review: the last-told alarm states (and ack targets) deliberately SURVIVE a shutdown. A
|
||||
// ReinitializeAsync (comms-loss watchdog / Force Reconnect) is followed by the host re-subscribing the
|
||||
@@ -1762,20 +1883,33 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
StatusCode: dv.StatusCode.Code,
|
||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
|
||||
// F2: inside the post-recovery replay window only, a late OLDER notification must not regress the node.
|
||||
if (IsLateReplay(fullRef, snapshot.SourceTimestampUtc, out var newest))
|
||||
// Review of 3b88056f: the replay check and the publish are one critical section shared with the recovery and
|
||||
// trailing reads, so an older value can never be delivered after a newer one (see _deliveryLock).
|
||||
lock (_deliveryLock)
|
||||
{
|
||||
_diag.Record(OpcUaClientDiagnostics.StaleNotificationDropped);
|
||||
_logger.LogDebug(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': dropped a late replayed notification for {Ref} after a recovery " +
|
||||
"(source {Source:O} is not newer than {Newest:O}).", _driverInstanceId, fullRef, snapshot.SourceTimestampUtc,
|
||||
new DateTime(newest, DateTimeKind.Utc));
|
||||
return;
|
||||
// F2: inside the post-recovery replay window only, a late OLDER notification must not regress the node.
|
||||
if (IsLateReplay(fullRef, snapshot.SourceTimestampUtc, out var newest))
|
||||
{
|
||||
_diag.Record(OpcUaClientDiagnostics.StaleNotificationDropped);
|
||||
// #676: the OTel counter and the Debug line below are invisible on a default-configured node; the
|
||||
// per-recovery Information summary logged at disarm is what makes the guard observable.
|
||||
lock (_replayGuardLock)
|
||||
{
|
||||
_replayDrops++;
|
||||
_replayDropRefs.Add(fullRef);
|
||||
}
|
||||
_logger.LogDebug(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': dropped a late replayed notification for {Ref} after a recovery " +
|
||||
"(source {Source:O} is not newer than {Newest:O}).", _driverInstanceId, fullRef, snapshot.SourceTimestampUtc,
|
||||
new DateTime(newest, DateTimeKind.Utc));
|
||||
return;
|
||||
}
|
||||
RecordUpstreamGood(fullRef, snapshot);
|
||||
_lastValues[fullRef] = snapshot;
|
||||
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
|
||||
_awaitingRecovery.TryRemove(fullRef, out _);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
|
||||
}
|
||||
RecordUpstreamGood(fullRef, snapshot);
|
||||
_lastValues[fullRef] = snapshot;
|
||||
_awaitingRecovery.TryRemove(fullRef, out _);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
|
||||
}
|
||||
|
||||
/// <summary>#670 F2 — remember the last accepted Good upstream source time for <paramref name="fullRef"/>.</summary>
|
||||
@@ -1798,14 +1932,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
if (until == 0) return false;
|
||||
if (Clock.GetUtcNow().UtcDateTime.Ticks >= until)
|
||||
{
|
||||
lock (_replayGuardLock)
|
||||
{
|
||||
if (Interlocked.Read(ref _replayGuardUntilTicks) == until)
|
||||
{
|
||||
Interlocked.Exchange(ref _replayGuardUntilTicks, 0);
|
||||
_replayGuard.Clear();
|
||||
}
|
||||
}
|
||||
DisarmReplayGuard(until);
|
||||
return false;
|
||||
}
|
||||
if (source is not { } src) return false;
|
||||
@@ -1821,11 +1948,40 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expire the replay guard armed until <paramref name="until"/> — a no-op when it was re-armed or already disarmed
|
||||
/// since — and log ONE Information summary of what it dropped (#676). The guard's OTel counter and per-drop Debug
|
||||
/// line are invisible on a default-configured node, so before this a recovery's drops left no trace at all.
|
||||
/// </summary>
|
||||
/// <param name="until">The expiry the caller observed; only that arming is disarmed.</param>
|
||||
private void DisarmReplayGuard(long until)
|
||||
{
|
||||
int drops, refs;
|
||||
lock (_replayGuardLock)
|
||||
{
|
||||
if (Interlocked.Read(ref _replayGuardUntilTicks) != until) return;
|
||||
Interlocked.Exchange(ref _replayGuardUntilTicks, 0);
|
||||
_replayGuard.Clear();
|
||||
drops = _replayDrops;
|
||||
refs = _replayDropRefs.Count;
|
||||
_replayDrops = 0;
|
||||
_replayDropRefs.Clear();
|
||||
}
|
||||
LogReplaySummary(drops, refs);
|
||||
}
|
||||
|
||||
/// <summary>One line per replay window: Information when the guard dropped something, Debug otherwise.</summary>
|
||||
private void LogReplaySummary(int drops, int refs) =>
|
||||
_logger.Log(drops > 0 ? LogLevel.Information : LogLevel.Debug,
|
||||
"OpcUaClientDriver '{DriverInstanceId}': post-recovery replay window closed — dropped {Drops} late " +
|
||||
"notifications across {Refs} refs after recovery.", _driverInstanceId, drops, refs);
|
||||
|
||||
/// <summary>
|
||||
/// #670 F2 — arm the replay guard on a same-session recovery: every ref with a known Good upstream source time,
|
||||
/// for max(30 s, 2 × the longest tracked subscription keep-alive window).
|
||||
/// </summary>
|
||||
private void ArmReplayGuard(DateTime now)
|
||||
/// <returns>The window's expiry in clock ticks.</returns>
|
||||
private long ArmReplayGuard(DateTime now)
|
||||
{
|
||||
var window = ReplayGuardMinimum;
|
||||
foreach (var sub in _subscriptions.Values.Select(r => r.Subscription))
|
||||
@@ -1833,12 +1989,39 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var keepAliveWindow = TimeSpan.FromMilliseconds(sub.CurrentPublishingInterval * (double)sub.CurrentKeepAliveCount);
|
||||
if (keepAliveWindow * 2 > window) window = keepAliveWindow * 2;
|
||||
}
|
||||
int supersededDrops, supersededRefs;
|
||||
lock (_replayGuardLock)
|
||||
{
|
||||
// A re-arm inside an open window ends that window: report what it dropped and count afresh.
|
||||
supersededDrops = _replayDrops;
|
||||
supersededRefs = _replayDropRefs.Count;
|
||||
_replayDrops = 0;
|
||||
_replayDropRefs.Clear();
|
||||
_replayGuard.Clear();
|
||||
foreach (var kv in _lastGoodSourceTicks) _replayGuard[kv.Key] = kv.Value;
|
||||
Interlocked.Exchange(ref _replayGuardUntilTicks, (now + window).Ticks);
|
||||
}
|
||||
if (supersededDrops > 0) LogReplaySummary(supersededDrops, supersededRefs);
|
||||
return (now + window).Ticks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676 — disarm the replay guard (and log its summary) when its window ends, even if no notification arrives to
|
||||
/// disarm it lazily in <see cref="IsLateReplay"/> — a quiet subscription would otherwise never log the summary.
|
||||
/// </summary>
|
||||
private async Task DisarmReplayGuardWhenDueAsync(long until, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wait = new DateTime(until, DateTimeKind.Utc) - Clock.GetUtcNow().UtcDateTime;
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait + TimeSpan.FromMilliseconds(100), Clock, ct).ConfigureAwait(false);
|
||||
if (Clock.GetUtcNow().UtcDateTime.Ticks >= until) DisarmReplayGuard(until);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* session released */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "OpcUaClientDriver '{DriverInstanceId}': replay-guard disarm timer faulted.", _driverInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2683,7 +2866,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// session-recreate path cloned subscriptions the driver never re-tracked, and it kept health Healthy while
|
||||
/// it did so — the #670 symptom. A session that does not come back keeps health Degraded with the read stamp
|
||||
/// frozen, which is exactly what the host's #527 comms-loss watchdog trips on: ShutdownAsync + InitializeAsync
|
||||
/// + re-subscribe, whose initial values come back Good.
|
||||
/// + re-subscribe, whose initial values come back Good. When the keep-alive status proves the session is gone
|
||||
/// for good (<see cref="IsDefinitiveSessionLoss"/>) the driver also raises <see cref="ConnectionLost"/> (#676),
|
||||
/// so the host runs that sequence at once instead of after the watchdog's silence window.
|
||||
/// </remarks>
|
||||
private void OnKeepAlive(ISession sender, KeepAliveEventArgs e)
|
||||
{
|
||||
@@ -2693,6 +2878,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
if (!ServiceResult.IsBad(e.Status))
|
||||
{
|
||||
Interlocked.Exchange(ref _closedChannelFailures, 0);
|
||||
TransitionTo(HostState.Running);
|
||||
// M1: the session answers but a subscription is dead — no stamp, no recovery; the watchdog reinit is the fix.
|
||||
if (Volatile.Read(ref _subscriptionLost) == 1)
|
||||
@@ -2706,18 +2892,23 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
UpdateHealth(_ => new DriverHealth(DriverState.Healthy, now, null));
|
||||
// A fresh grace: every subscription reports stopped right after an outage until its next message.
|
||||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||||
ArmReplayGuard(now);
|
||||
var guardUntil = ArmReplayGuard(now);
|
||||
_diag.Record(OpcUaClientDiagnostics.KeepAliveRecovered);
|
||||
_logger.LogInformation(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': upstream keep-alive recovered on the same session; " +
|
||||
"re-reading the refs published Bad during the outage.", _driverInstanceId);
|
||||
_recoveryTask = Task.Run(() => RecoverAfterOutageAsync(sender));
|
||||
var recovery = Task.Run(() => RecoverAfterOutageAsync(sender));
|
||||
_recoveryTask = recovery;
|
||||
var sessionWork = _sessionWorkCts.Token;
|
||||
_reconcileTask = ReconcileAfterRecoveryAsync(sender, recovery, PostRecoveryReconcileDelay(), sessionWork);
|
||||
_ = DisarmReplayGuardWhenDueAsync(guardUntil, sessionWork);
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindStalledSubscription(now) is { } stalled)
|
||||
{
|
||||
DeclareSubscriptionLost($"subscription '{stalled}' stopped publishing while the session stayed up", now);
|
||||
DeclareSubscriptionLost($"subscription '{stalled}' stopped publishing while the session stayed up", now,
|
||||
serverReported: false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2729,14 +2920,76 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
TransitionTo(HostState.Stopped);
|
||||
_diag.Record(OpcUaClientDiagnostics.KeepAliveMiss);
|
||||
UpdateHealth(h => new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, $"keep-alive: {e.Status}"));
|
||||
if (Interlocked.Exchange(ref _sessionLost, 1) == 1) return;
|
||||
if (Volatile.Read(ref _subscriptionLost) == 1) return; // already published Bad for this connection
|
||||
// #676: the first bad keep-alive is often ambiguous and a definitive one follows, so this is checked on EVERY
|
||||
// bad keep-alive — but only after the outage below has been published, so the Bad reaches subscribers first.
|
||||
var keepAliveCode = e.Status?.StatusCode ?? StatusCodes.Good;
|
||||
var definitive = IsDefinitiveSessionLoss(keepAliveCode)
|
||||
|| (IsClosedChannel(keepAliveCode)
|
||||
&& Interlocked.Increment(ref _closedChannelFailures) >= SustainedClosedChannelFailures);
|
||||
// A subscription loss already published Bad for this connection.
|
||||
if (Interlocked.Exchange(ref _sessionLost, 1) == 0 && Volatile.Read(ref _subscriptionLost) == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': upstream session keep-alive failed ({Status}); subscribed values " +
|
||||
"are BadNoCommunication until it recovers or the host's comms-loss watchdog reconnects the driver.",
|
||||
_driverInstanceId, e.Status);
|
||||
PublishCommsLoss(now);
|
||||
}
|
||||
if (definitive)
|
||||
RaiseConnectionLost($"upstream session keep-alive reported {StatusCodes.GetBrowseName(keepAliveCode.Code)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676 live gate A1 — the connection under the session is CLOSED (not merely silent). A real upstream process stop
|
||||
/// (docker restart, SIGINT, docker kill) never produced a session status on the docker-dev gate: the keep-alives
|
||||
/// reported <c>BadSecureChannelClosed</c> once, then <c>BadConnectionClosed</c> interleaved with
|
||||
/// <c>BadNoCommunication</c> for the whole outage. Reproduced in-process (a proxy that resets every connection
|
||||
/// while the server and its session stay alive): the SDK 1.5.378 session never opens a new connection on its
|
||||
/// own — over 48 s with the server reachable again, not one connection attempt — so a closed channel can never
|
||||
/// recover in place. It is treated as definitive once SUSTAINED (<see cref="SustainedClosedChannelFailures"/> in
|
||||
/// one outage), so a single transient contradicted by the next good keep-alive does not count. A stall (a paused
|
||||
/// upstream, a congested link) produces <c>BadRequestTimeout</c>/<c>BadNoCommunication</c> instead and keeps
|
||||
/// its in-place recovery.
|
||||
/// </summary>
|
||||
/// <param name="status">A keep-alive status.</param>
|
||||
/// <returns>True for a closed secure channel or connection.</returns>
|
||||
internal static bool IsClosedChannel(StatusCode status) =>
|
||||
status == StatusCodes.BadSecureChannelClosed || status == StatusCodes.BadConnectionClosed;
|
||||
|
||||
/// <summary>
|
||||
/// #676 — keep-alive statuses that prove the session can NEVER recover in place, so waiting out the #527
|
||||
/// watchdog would only delay the inevitable re-initialise. The server forgot the session
|
||||
/// (<c>BadSessionIdInvalid</c> — what a re-opened channel to a restarted server answers), closed it
|
||||
/// (<c>BadSessionClosed</c>) or never activated it (<c>BadSessionNotActivated</c>); or the server HALTED
|
||||
/// (<c>BadServerHalted</c>) — measured on the #676 restart wire test, the SDK never re-opens a channel the
|
||||
/// server halted: 15 s after the server was back the same session still failed every request with
|
||||
/// <c>BadServerHalted</c>, so a halted session is dead for good. A closed connection is definitive too, but only
|
||||
/// once sustained — see <see cref="IsClosedChannel"/>. Everything else — <c>BadNoCommunication</c>,
|
||||
/// <c>BadNotConnected</c>, request timeouts — is AMBIGUOUS (a stalled link that resumes keeps its session, see the
|
||||
/// stall wire test) and stays with the watchdog.
|
||||
/// </summary>
|
||||
/// <param name="status">A keep-alive status.</param>
|
||||
/// <returns>True when the session is definitively gone.</returns>
|
||||
internal static bool IsDefinitiveSessionLoss(StatusCode status) =>
|
||||
status == StatusCodes.BadSessionIdInvalid
|
||||
|| status == StatusCodes.BadSessionClosed
|
||||
|| status == StatusCodes.BadSessionNotActivated
|
||||
|| status == StatusCodes.BadServerHalted;
|
||||
|
||||
/// <summary>#676 — raise <see cref="ConnectionLost"/> once for the current session, isolating a faulting subscriber.</summary>
|
||||
/// <param name="reason">Why the connection is definitively gone, for the host's log.</param>
|
||||
private void RaiseConnectionLost(string reason)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _connectionLostRaised, 1) == 1) return;
|
||||
_logger.LogWarning(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': upstream session keep-alive failed ({Status}); subscribed values " +
|
||||
"are BadNoCommunication until it recovers or the host's comms-loss watchdog reconnects the driver.",
|
||||
_driverInstanceId, e.Status);
|
||||
PublishCommsLoss(now);
|
||||
"OpcUaClientDriver '{DriverInstanceId}': connection definitively lost ({Reason}); asking the host to " +
|
||||
"re-initialise now instead of waiting for the comms-loss watchdog.", _driverInstanceId, reason);
|
||||
try { ConnectionLost?.Invoke(this, new DriverConnectionLossEventArgs(reason)); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': a ConnectionLost subscriber faulted.",
|
||||
_driverInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2771,14 +3024,23 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// frozen (sticky until the session is released), and the outage published once, so the host's watchdog
|
||||
/// re-initialises and re-subscribes.
|
||||
/// </summary>
|
||||
private void DeclareSubscriptionLost(string reason, DateTime now)
|
||||
/// <param name="reason">Why, for the log and health.</param>
|
||||
/// <param name="now">Detection time.</param>
|
||||
/// <param name="serverReported">True when the SERVER said the subscription is gone (BadNoSubscription /
|
||||
/// BadSubscriptionIdInvalid) — definitive, so <see cref="ConnectionLost"/> is raised. False for the publishing-stall
|
||||
/// heuristic, an inference from silence that stays with the #527 watchdog (review of 3b88056f).</param>
|
||||
private void DeclareSubscriptionLost(string reason, DateTime now, bool serverReported)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _subscriptionLost, 1) == 1) return;
|
||||
UpdateHealth(h => new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, $"subscription lost: {reason}"));
|
||||
_logger.LogWarning(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': {Reason}; subscribed values are BadNoCommunication until the " +
|
||||
"host's comms-loss watchdog re-initialises the driver and re-subscribes.", _driverInstanceId, reason);
|
||||
"host re-initialises the driver and re-subscribes.", _driverInstanceId, reason);
|
||||
if (Volatile.Read(ref _sessionLost) == 0) PublishCommsLoss(now);
|
||||
// #676: a subscription the SERVER says is gone never comes back on this session — only the re-subscribe after a
|
||||
// re-initialise recreates it — so ask the host now rather than after the watchdog's 90 s. The stall heuristic is
|
||||
// an inference, not a server statement, and keeps going through the watchdog.
|
||||
if (serverReported) RaiseConnectionLost($"subscription lost: {reason}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2801,7 +3063,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
else
|
||||
return;
|
||||
if (tracked)
|
||||
DeclareSubscriptionLost($"server reported {code} for a tracked subscription", Clock.GetUtcNow().UtcDateTime);
|
||||
DeclareSubscriptionLost($"server reported {code} for a tracked subscription", Clock.GetUtcNow().UtcDateTime,
|
||||
serverReported: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2811,18 +3074,23 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// </summary>
|
||||
private void PublishCommsLoss(DateTime now)
|
||||
{
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
// Under the delivery lock (re-review of 30a9d5f0) so the Bad cannot interleave with a recovery publish.
|
||||
lock (_deliveryLock)
|
||||
{
|
||||
foreach (var pair in rs.ItemHandlers)
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
{
|
||||
var fullRef = RefOf(pair.Item);
|
||||
_lastValues.TryGetValue(fullRef, out var last);
|
||||
_awaitingRecovery[fullRef] = 0;
|
||||
// F1: stamped with the DETECTION time, as the host's own comms-loss fan does. The recorder historizes
|
||||
// SourceTimestampUtc ?? ServerTimestampUtc, so the last Good's source time here put the Bad at the
|
||||
// Good row's (TagId, SourceTimeUtc) key, where the insert-if-absent historian dropped it.
|
||||
RaiseDataChange(new DataChangeEventArgs(rs.Handle, fullRef, new DataValueSnapshot(
|
||||
last?.Value, StatusBadNoCommunication, now, now)));
|
||||
foreach (var pair in rs.ItemHandlers)
|
||||
{
|
||||
var fullRef = RefOf(pair.Item);
|
||||
_lastValues.TryGetValue(fullRef, out var last);
|
||||
_awaitingRecovery[fullRef] = 0;
|
||||
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
|
||||
// F1: stamped with the DETECTION time, as the host's own comms-loss fan does. The recorder historizes
|
||||
// SourceTimestampUtc ?? ServerTimestampUtc, so the last Good's source time here put the Bad at the
|
||||
// Good row's (TagId, SourceTimeUtc) key, where the insert-if-absent historian dropped it.
|
||||
RaiseDataChange(new DataChangeEventArgs(rs.Handle, fullRef, new DataValueSnapshot(
|
||||
last?.Value, StatusBadNoCommunication, now, now)));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnnotateAlarmQuality(StatusBadNoCommunication, now);
|
||||
@@ -2856,15 +3124,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
for (var i = 0; i < refs.Count; i++)
|
||||
{
|
||||
if (!Current()) return;
|
||||
if (!_awaitingRecovery.TryRemove(refs[i], out _)) continue; // a notification got there first
|
||||
_lastValues[refs[i]] = results[i];
|
||||
RecordUpstreamGood(refs[i], results[i]);
|
||||
// The read is upstream data published inside the replay window: a replay older than it is late too.
|
||||
if (results[i].SourceTimestampUtc is { } readAt && StatusCode.IsGood(results[i].StatusCode))
|
||||
_replayGuard.AddOrUpdate(refs[i], readAt.Ticks, (_, prior) => Math.Max(prior, readAt.Ticks));
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
if (rs.ItemHandlers.Any(p => RefOf(p.Item) == refs[i]))
|
||||
RaiseDataChange(new DataChangeEventArgs(rs.Handle, refs[i], results[i]));
|
||||
// One critical section with the live notification path (_deliveryLock): a notification that
|
||||
// removed the ref first wins, and none can overtake this publish once it has decided.
|
||||
lock (_deliveryLock)
|
||||
{
|
||||
if (!_awaitingRecovery.TryRemove(refs[i], out _)) continue; // a notification got there first
|
||||
PublishUpstreamLocked(refs[i], results[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2880,6 +3146,108 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish an upstream value read (not notified) for <paramref name="fullRef"/> to every subscription carrying it,
|
||||
/// recording it as the last delivered value and — when Good with a source time — as the replay guard's mark, so a
|
||||
/// replay older than the read is late too. Caller holds <see cref="_deliveryLock"/>.
|
||||
/// </summary>
|
||||
private void PublishUpstreamLocked(string fullRef, DataValueSnapshot value)
|
||||
{
|
||||
_lastValues[fullRef] = value;
|
||||
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
|
||||
RecordUpstreamGood(fullRef, value);
|
||||
if (value.SourceTimestampUtc is { } readAt && StatusCode.IsGood(value.StatusCode)
|
||||
&& Interlocked.Read(ref _replayGuardUntilTicks) != 0)
|
||||
_replayGuard.AddOrUpdate(fullRef, readAt.Ticks, (_, prior) => Math.Max(prior, readAt.Ticks));
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
if (rs.ItemHandlers.Any(p => RefOf(p.Item) == fullRef))
|
||||
RaiseDataChange(new DataChangeEventArgs(rs.Handle, fullRef, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676 — the trailing reconcile read after an in-place recovery. Found by the TCP-stall wire test: a value change
|
||||
/// the server published just after a stall ended rode a Publish response that had been in flight across the
|
||||
/// stall; the SDK timed that request out (<c>BadTimeout</c>), discarded the response, and never republished the
|
||||
/// message (the following keep-alives advanced the sequence number past it). The node stayed on the value from
|
||||
/// the recovery read — stale, indefinitely, for a value that does not change again. Once every request that was
|
||||
/// in flight across the stall has completed or timed out (<see cref="PostRecoveryReconcileDelay"/>), every
|
||||
/// subscribed ref is read once more and published when <see cref="IsReconcileRepair"/> says the read differs from
|
||||
/// what was last delivered and is not older than it. The decision and the publish are one critical section with
|
||||
/// the live notification path (<see cref="_deliveryLock"/>), so a newer notification can never be overtaken.
|
||||
/// Bound to the session's work token: a re-initialise or shutdown cancels it.
|
||||
/// </summary>
|
||||
/// <param name="session">The session that recovered; nothing happens if it is no longer current or is in outage.</param>
|
||||
/// <param name="recovery">The recovery read, which runs first so this read is compared against its result.</param>
|
||||
/// <param name="delay">How long to wait before reading (on <see cref="Clock"/>).</param>
|
||||
/// <param name="ct">The session's background-work token.</param>
|
||||
private async Task ReconcileAfterRecoveryAsync(ISession session, Task recovery, TimeSpan delay, CancellationToken ct)
|
||||
{
|
||||
bool Current() => !ct.IsCancellationRequested
|
||||
&& ReferenceEquals(session, Session)
|
||||
&& Volatile.Read(ref _sessionLost) == 0
|
||||
&& Volatile.Read(ref _subscriptionLost) == 0;
|
||||
try
|
||||
{
|
||||
if (delay > TimeSpan.Zero) await Task.Delay(delay, Clock, ct).ConfigureAwait(false);
|
||||
await recovery.WaitAsync(ct).ConfigureAwait(false);
|
||||
if (!Current()) return;
|
||||
var refs = _subscriptions.Values
|
||||
.SelectMany(rs => rs.ItemHandlers.Select(p => RefOf(p.Item)))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (refs.Count == 0) return;
|
||||
// Captured BEFORE the read is issued: a ref delivered to while the read is in flight has a value at least as
|
||||
// new as anything the read could have sampled, so the read must not overwrite it.
|
||||
var seqAtIssue = refs.Select(r => _deliverySeq.GetValueOrDefault(r)).ToArray();
|
||||
var (results, transportOk) = await ReadCoreAsync(refs, ct, session).ConfigureAwait(false);
|
||||
if (!transportOk) return; // a new outage — the keep-alive reports it
|
||||
var published = 0;
|
||||
for (var i = 0; i < refs.Count; i++)
|
||||
{
|
||||
if (!Current()) return;
|
||||
lock (_deliveryLock)
|
||||
{
|
||||
if (_deliverySeq.GetValueOrDefault(refs[i]) != seqAtIssue[i]) continue;
|
||||
if (!IsReconcileRepair(refs[i], results[i])) continue;
|
||||
BeforeReconcilePublishForTest?.Invoke(refs[i]);
|
||||
_awaitingRecovery.TryRemove(refs[i], out _);
|
||||
PublishUpstreamLocked(refs[i], results[i]);
|
||||
}
|
||||
published++;
|
||||
}
|
||||
if (published > 0)
|
||||
_logger.LogInformation(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': trailing re-read after recovery found {Count} of {Total} refs " +
|
||||
"whose latest change never arrived through the subscription; published them.",
|
||||
_driverInstanceId, published, refs.Count);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* session released */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': trailing re-read after a recovery failed.",
|
||||
_driverInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the trailing read's <paramref name="read"/> repairs <paramref name="fullRef"/>: its (value, status)
|
||||
/// differs from the last value delivered for the ref, and it is not OLDER than it. Any status counts — a Good→Bad
|
||||
/// or Good→Uncertain change lost in the stalled pipeline is as stale as a lost value. "Older" is judged on source
|
||||
/// timestamps only when both carry one; a read with no source timestamp (a server that does not send them) is the
|
||||
/// server's CURRENT state, read after the stalled pipeline drained, so it counts as newer. An EQUAL source
|
||||
/// timestamp with different content is published too: it is not a replay but the server's current state, and it
|
||||
/// repairs the one change the replay guard drops by design (a status-only change that kept its source time inside
|
||||
/// the window). Caller holds <see cref="_deliveryLock"/>.
|
||||
/// </summary>
|
||||
private bool IsReconcileRepair(string fullRef, DataValueSnapshot read)
|
||||
{
|
||||
if (!_lastValues.TryGetValue(fullRef, out var last)) return true;
|
||||
if (last.StatusCode == read.StatusCode && Utils.IsEqual(last.Value, read.Value)) return false;
|
||||
return read.SourceTimestampUtc is not { } src
|
||||
|| last.SourceTimestampUtc is not { } lastSrc
|
||||
|| src >= lastSrc;
|
||||
}
|
||||
|
||||
/// <summary>Quality-only annotation (#626 pattern) of every subscribed alarm condition.</summary>
|
||||
private void AnnotateAlarmQuality(uint quality, DateTime now)
|
||||
{
|
||||
@@ -2981,6 +3349,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
catch { /* timeout or already disposed — proceed */ }
|
||||
_gate.Dispose();
|
||||
try { _sessionWorkCts.Cancel(); } catch (ObjectDisposedException) { }
|
||||
SdkTelemetry.Release(_sdkTelemetry);
|
||||
}
|
||||
|
||||
@@ -3010,6 +3379,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
catch { /* timeout or already disposed */ }
|
||||
_gate.Dispose();
|
||||
try { _sessionWorkCts.Cancel(); } catch (ObjectDisposedException) { }
|
||||
SdkTelemetry.Release(_sdkTelemetry);
|
||||
}
|
||||
}
|
||||
|
||||
-8
@@ -53,11 +53,6 @@
|
||||
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Reconnect period (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max discovered nodes</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
@@ -330,7 +325,6 @@
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
public int SessionTimeoutSeconds { get; set; } = 120;
|
||||
public int KeepAliveIntervalSeconds { get; set; } = 5;
|
||||
public int ReconnectPeriodSeconds { get; set; } = 5;
|
||||
public int MaxDiscoveredNodes { get; set; } = 10_000;
|
||||
public int MaxBrowseDepth { get; set; } = 10;
|
||||
|
||||
@@ -370,7 +364,6 @@
|
||||
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
|
||||
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
|
||||
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
|
||||
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
|
||||
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = r.MaxBrowseDepth,
|
||||
SecurityMode = r.SecurityMode,
|
||||
@@ -404,7 +397,6 @@
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
||||
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
|
||||
MaxDiscoveredNodes = MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = MaxBrowseDepth,
|
||||
SecurityMode = SecurityMode,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Names the per-role appsettings overlay (<c>appsettings.{suffix}.json</c>) for a node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The suffix is built from the node's PLAIN roles only. <c>cluster-{ClusterId}</c> roles are mesh-membership
|
||||
/// tags (and carry a verbatim, operator-chosen ClusterId), not configuration profiles — folding one into the
|
||||
/// filename produced names like <c>appsettings.admin-cluster-MAIN-driver.json</c> that never exist, so on every
|
||||
/// node with a cluster role the shipped overlays silently never loaded.
|
||||
/// </remarks>
|
||||
internal static class RoleOverlay
|
||||
{
|
||||
/// <summary>
|
||||
/// The overlay filename suffix: the plain (non-cluster) roles, ordinal-sorted and joined with <c>-</c>, or
|
||||
/// <see langword="null"/> when the node has none.
|
||||
/// </summary>
|
||||
public static string? Suffix(IReadOnlyList<string> gossipedRoles)
|
||||
{
|
||||
var plain = gossipedRoles
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r) && !RoleParser.IsClusterRole(r))
|
||||
.OrderBy(r => r, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return plain.Count == 0 ? null : string.Join('-', plain);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
@@ -46,6 +47,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
private readonly ILogger<OtOpcUaServerHostedService> _logger;
|
||||
private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
|
||||
private readonly HistorianUpdaterHolder _historianUpdater;
|
||||
private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
|
||||
|
||||
private OpcUaApplicationHost? _appHost;
|
||||
private OtOpcUaSdkServer? _server;
|
||||
@@ -98,6 +100,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="historianUpdater">The selected historian backend's optional HistoryUpdate capability.
|
||||
/// Optional: when unregistered it resolves to <see cref="HistorianUpdaterHolder.None"/> and HistoryUpdate is
|
||||
/// reported unsupported on every node.</param>
|
||||
/// <param name="historyUpdateRoleView">Gitea #674 — the HistoryUpdate verdict <c>DriverHostActor</c> publishes (the
|
||||
/// data-plane Primary verdict, closed until this node's first redundancy snapshot). Optional: registered on every
|
||||
/// driver-role node; an admin-only node has none and wires no HistoryUpdate Primary gate (allow).</param>
|
||||
public OtOpcUaServerHostedService(
|
||||
IOptions<OpcUaApplicationHostOptions> options,
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
@@ -113,7 +118,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
TimeProvider time,
|
||||
ILoggerFactory loggerFactory,
|
||||
IDriverPrimaryRoleSource? primaryRoleSource = null,
|
||||
HistorianUpdaterHolder? historianUpdater = null)
|
||||
HistorianUpdaterHolder? historianUpdater = null,
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(aclOptions);
|
||||
ArgumentNullException.ThrowIfNull(time);
|
||||
@@ -137,6 +143,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_loggerFactory = loggerFactory;
|
||||
_primaryRoleSource = primaryRoleSource;
|
||||
_historianUpdater = historianUpdater ?? HistorianUpdaterHolder.None;
|
||||
_historyUpdateRoleView = historyUpdateRoleView;
|
||||
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
@@ -317,6 +324,14 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_server.NodeManager.MaxConcurrentHistoryReadBatches = _serverHistorianOptions.MaxConcurrentHistoryReadBatches;
|
||||
_server.NodeManager.HistoryReadDeadline = _serverHistorianOptions.HistoryReadDeadline;
|
||||
|
||||
// Gitea #674: HistoryUpdate is Primary-only (BadInvalidState elsewhere, and closed until this node's first
|
||||
// redundancy snapshot) and refuses a SourceTimestamp beyond now + HistoryUpdateMaxFutureSkew (BadOutOfRange),
|
||||
// judged against the host clock.
|
||||
var historyUpdate = BuildHistoryUpdatePosture();
|
||||
_server.NodeManager.HistoryUpdatePrimaryGate = historyUpdate.PrimaryGate;
|
||||
_server.NodeManager.HistoryUpdateMaxFutureSkew = historyUpdate.MaxFutureSkew;
|
||||
_server.NodeManager.Clock = historyUpdate.Clock;
|
||||
|
||||
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
||||
if (_server.CurrentInstance is { } serverInternal)
|
||||
{
|
||||
@@ -332,6 +347,28 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 — what <c>StartAsync</c> wires onto the node manager for HistoryUpdate: the Primary gate (read from
|
||||
/// the <see cref="IHistoryUpdateRoleView"/> on every call; null when none is registered, i.e. allow), the
|
||||
/// future-timestamp bound from <c>ServerHistorian:HistoryUpdateMaxFutureSkew</c>, and the host clock it is judged
|
||||
/// against. Separate from <c>StartAsync</c> so the wiring is testable without booting the SDK.
|
||||
/// </summary>
|
||||
/// <returns>The posture to apply.</returns>
|
||||
internal HistoryUpdatePosture BuildHistoryUpdatePosture()
|
||||
{
|
||||
Func<bool>? gate = _historyUpdateRoleView is { } view ? () => view.ShouldServeHistoryUpdate : null;
|
||||
if (gate is null)
|
||||
_logger.LogInformation(
|
||||
"OtOpcUaServerHostedService: no IHistoryUpdateRoleView registered (no redundancy manager on this node) — HistoryUpdate is not Primary-gated");
|
||||
return new HistoryUpdatePosture(gate, _serverHistorianOptions.HistoryUpdateMaxFutureSkew, _time);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #674 — the HistoryUpdate settings <see cref="BuildHistoryUpdatePosture"/> produces.</summary>
|
||||
/// <param name="PrimaryGate">The node manager's HistoryUpdate Primary gate; null allows.</param>
|
||||
/// <param name="MaxFutureSkew">The future-timestamp bound; non-positive disables it.</param>
|
||||
/// <param name="Clock">The clock the bound is judged against.</param>
|
||||
internal sealed record HistoryUpdatePosture(Func<bool>? PrimaryGate, TimeSpan MaxFutureSkew, TimeProvider Clock);
|
||||
|
||||
/// <summary>
|
||||
/// Arms the clock-driven monitored-item revalidation tick (arch-review 03/S19).
|
||||
/// </summary>
|
||||
@@ -382,6 +419,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
// Clear the native-alarm-ack router so a late ack doesn't Tell a stopping DriverHostActor.
|
||||
_server?.SetNativeAlarmAckRouter(null);
|
||||
_server?.SetNativeAlarmAckPrimaryGate(null);
|
||||
_server?.SetHistoryUpdatePrimaryGate(null);
|
||||
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
|
||||
_server?.SetHistorianDataSource(null);
|
||||
_server?.SetHistorianDataUpdater(null);
|
||||
|
||||
@@ -90,8 +90,9 @@ builder.WebHost.UseStaticWebAssets();
|
||||
builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost");
|
||||
|
||||
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
|
||||
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
|
||||
var roleSuffix = roles.Count == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
|
||||
// (both). Optional — base appsettings.json carries enough to boot if these don't exist. Built from the
|
||||
// PLAIN roles only (#678.6): a cluster-{ClusterId} role in the name pointed at a file that never exists.
|
||||
var roleSuffix = RoleOverlay.Suffix(roles);
|
||||
if (roleSuffix is not null)
|
||||
{
|
||||
builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Opc.Ua": "Debug",
|
||||
"Opc.Ua": "Information",
|
||||
"Akka": "Information"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// <see cref="HistoryCursorKind.Modified"/> only: the normalised window start, so a resumed page keeps the original
|
||||
/// window and direction (the modified read resumes by key, not by moving the window start).
|
||||
/// </param>
|
||||
/// <param name="Reverse">
|
||||
/// <see cref="HistoryCursorKind.Events"/> only (Gitea #675): the read is newest-first over
|
||||
/// <c>(EndUtc, NextStartUtc]</c>. A resumed events read takes its direction from here, never from the resume
|
||||
/// request's details. Raw keeps deriving it from the window (<c>NextStartUtc > EndUtc</c>).
|
||||
/// </param>
|
||||
internal sealed record HistoryContinuationState(
|
||||
string Tagname,
|
||||
DateTime NextStartUtc,
|
||||
@@ -54,7 +59,8 @@ internal sealed record HistoryContinuationState(
|
||||
uint NumValuesPerNode,
|
||||
HistoryCursorKind Kind = HistoryCursorKind.Raw,
|
||||
long LastModifiedId = 0,
|
||||
DateTime WindowStartUtc = default);
|
||||
DateTime WindowStartUtc = default,
|
||||
bool Reverse = false);
|
||||
|
||||
/// <summary>The HistoryRead kind that issued a <see cref="HistoryContinuationState"/>.</summary>
|
||||
internal enum HistoryCursorKind
|
||||
|
||||
@@ -102,7 +102,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
|
||||
private readonly Dictionary<RealmKey, AlarmNotifierWiring> _alarmNotifierWiring = new();
|
||||
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
|
||||
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
|
||||
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>. The equipment-folder NodeId
|
||||
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
|
||||
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
|
||||
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
|
||||
@@ -247,6 +247,48 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// </summary>
|
||||
public Func<bool>? NativeAlarmAckPrimaryGate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 — whether this node may serve an OPC UA HistoryUpdate right now: the redundancy data-plane
|
||||
/// verdict (<c>PrimaryGatePolicy.ShouldServiceAsPrimary</c>, as published to <c>IDriverPrimaryRoleSource</c>) —
|
||||
/// the same source as <see cref="NativeAlarmAckPrimaryGate"/>.
|
||||
/// <para>
|
||||
/// <c>false</c> ⇒ every node of the request that passed authorization is refused <c>BadInvalidState</c>
|
||||
/// and nothing reaches the updater. Rewriting history is a single-writer operation: the SQL backends'
|
||||
/// prior-value capture (and QuestDB's per-process per-tag lock) assume one node serialises a tag's updates,
|
||||
/// so both pair nodes accepting updates concurrently could record a wrong prior value in the modification
|
||||
/// trail. HistoryRead is NOT gated — reads are served from any node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Evaluated AFTER authorization (a caller who may not update learns nothing about the node's role). It must
|
||||
/// be a cheap, non-blocking read (the host passes a volatile-backed property). Null (the default) allows — the
|
||||
/// single-node posture, the same default <c>UnmanagedDriverPrimaryRoleSource</c> answers with.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Func<bool>? HistoryUpdatePrimaryGate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 — how far beyond <see cref="Clock"/>'s now a HistoryUpdate (Insert / Replace / Update) item's
|
||||
/// <c>SourceTimestamp</c> may lie. An item past <c>now + skew</c> is refused <c>BadOutOfRange</c> before it
|
||||
/// reaches any backend: a far-future row (a 2062 typo) is never a legitimate history edit, and on a backend whose
|
||||
/// retention is judged against the newest timestamp it could age out every older partition. Mirrors
|
||||
/// <c>ServerHistorianOptions.HistoryUpdateMaxFutureSkew</c>; the Host sets it at <c>StartAsync</c>. Non-positive
|
||||
/// disables the bound. Default 1 day. Live recorder writes are not bounded by this.
|
||||
/// </summary>
|
||||
public TimeSpan HistoryUpdateMaxFutureSkew { get; set; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// The clock the HistoryUpdate future-skew bound (<see cref="HistoryUpdateMaxFutureSkew"/>) reads. The Host
|
||||
/// assigns its <see cref="TimeProvider"/> at <c>StartAsync</c>; tests inject a fixed clock. Defaults to
|
||||
/// <see cref="TimeProvider.System"/>; assigning null restores it.
|
||||
/// </summary>
|
||||
public TimeProvider Clock
|
||||
{
|
||||
get => _clock;
|
||||
set => _clock = value ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
private volatile TimeProvider _clock = TimeProvider.System;
|
||||
|
||||
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
|
||||
|
||||
/// <summary>
|
||||
@@ -5009,6 +5051,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
|
||||
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
|
||||
// #677: a client AggregateConfiguration other than the server defaults is refused per node, never silently ignored.
|
||||
var configurationServable = ServerAggregateConfiguration.IsServable(details.AggregateConfiguration);
|
||||
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
@@ -5027,6 +5071,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!configurationServable)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadAggregateConfigurationRejected;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
|
||||
// NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
|
||||
// and the single-shot backend returns every bucket in one read, so there is no "full page ⇒
|
||||
@@ -5147,12 +5197,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
|
||||
var resumed = inboundCp is { Length: > 0 };
|
||||
// One snapshot of the (hot-swappable) source for the whole read: the capability check and every call must agree.
|
||||
var source = _historianDataSource;
|
||||
try
|
||||
{
|
||||
var startUtc = details.StartTime;
|
||||
var endUtc = details.EndTime;
|
||||
var numValuesPerNode = details.NumValuesPerNode;
|
||||
var boundarySkip = 0;
|
||||
bool reverse;
|
||||
|
||||
if (resumed)
|
||||
{
|
||||
@@ -5172,36 +5225,72 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
endUtc = state.EndUtc;
|
||||
boundarySkip = state.BoundarySkipCount;
|
||||
numValuesPerNode = state.NumValuesPerNode;
|
||||
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
|
||||
// reverse read resumes reverse, a forward one forward.
|
||||
reverse = state.Reverse;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
|
||||
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
|
||||
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
|
||||
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — but only on a backend that can read
|
||||
// newest-first. Elsewhere (Gateway, the default backend) and without a count cap an end-only read keeps its
|
||||
// historical forward [MinValue, EndTime) meaning rather than becoming a refused reverse read.
|
||||
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
||||
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue
|
||||
&& source.SupportsReverseEvents)
|
||||
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
||||
|
||||
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
|
||||
reverse = startUtc > endUtc;
|
||||
}
|
||||
|
||||
// Part 11: an unset EndTime (MinValue) with a StartTime means "page forward from StartTime". Normalise it
|
||||
// to an open upper bound, or the cursor would store MinValue and a backend would swap the reversed
|
||||
// resume window. A resumed read is always forward (the cursor is only ever stored for a forward read),
|
||||
// so its direction comes from the cursor, not from the resume request's details.
|
||||
if (!resumed && endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
||||
// Reverse event reads (start > end) are not paged: the cursor below is forward-only.
|
||||
var forward = resumed || startUtc <= endUtc;
|
||||
// An explicit reverse read (or a reverse cursor resumed after the source was swapped) on a backend that cannot
|
||||
// read newest-first: refuse it BEFORE calling the source — never the earliest events in ascending order as if
|
||||
// they were the answer. Checked here rather than by catching an exception, so a NotSupportedException thrown
|
||||
// from inside a backend (a driver library, the projection) stays a backend fault.
|
||||
if (reverse && !source.SupportsReverseEvents)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
return;
|
||||
}
|
||||
|
||||
// NOT under the node-manager Lock — awaiting the async source is safe here.
|
||||
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
|
||||
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel.
|
||||
var sourceResult = await _historianDataSource.ReadEventsAsync(
|
||||
sourceName, startUtc, endUtc, EventMaxEvents(numValuesPerNode), ct).ConfigureAwait(false);
|
||||
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel. A resumed page re-reads its
|
||||
// boundary timestamp inclusively and then drops the `boundarySkip` ties already emitted, so it asks for that
|
||||
// many more rows than the cap — otherwise every resumed page would come back short by the skip.
|
||||
var fetch = EventMaxEvents(numValuesPerNode);
|
||||
if (numValuesPerNode > 0 && boundarySkip > 0)
|
||||
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
|
||||
var sourceResult = await source.ReadEventsAsync(
|
||||
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
|
||||
|
||||
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
|
||||
// page that happens to be exactly cap-sized but complete never emits a point.
|
||||
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0 && forward;
|
||||
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0;
|
||||
|
||||
IReadOnlyList<HistoricalEvent> events = sourceResult.Events;
|
||||
if (resumed && boundarySkip > 0)
|
||||
{
|
||||
// Drop the events at the boundary timestamp already emitted on the prior page.
|
||||
// Drop the events at the boundary timestamp already emitted on the prior page. The resumed read is in
|
||||
// the cursor's direction, so in both directions those ties lead the page.
|
||||
var i = 0;
|
||||
while (i < events.Count && i < boundarySkip && events[i].EventTimeUtc == startUtc) i++;
|
||||
if (i > 0) events = events.Skip(i).ToList();
|
||||
}
|
||||
|
||||
// The skip over-fetch can leave more than the cap (fewer boundary ties came back than were skipped — pruned
|
||||
// between pages): keep the cap, and the dropped tail means the read continues.
|
||||
if (numValuesPerNode > 0 && events.Count > numValuesPerNode)
|
||||
{
|
||||
events = events.Take((int)Math.Min(int.MaxValue, numValuesPerNode)).ToList();
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// The cursor to store, if any. Saved only AFTER the page is projected, so a projection throw can
|
||||
// never orphan a stored cursor.
|
||||
HistoryContinuationState? pendingCursor = null;
|
||||
@@ -5212,12 +5301,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Oversized tie cluster: more events share startUtc than the page cap, so (time, skip) alone
|
||||
// cannot advance. Over-fetch the whole cluster over the half-open [T, T+resolution) window
|
||||
// with an explicit bounded cap and page WITHIN it (same scheme as raw).
|
||||
var resolution = Math.Max(1L, _historianDataSource.TimestampResolutionTicks);
|
||||
var resolution = Math.Max(1L, source.TimestampResolutionTicks);
|
||||
var overfetchCap = (int)Math.Min(int.MaxValue - 1L, (long)Math.Max(1, MaxTieClusterOverfetch) + 1);
|
||||
var cluster = (await _historianDataSource.ReadEventsAsync(
|
||||
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, ct)
|
||||
IReadOnlyList<HistoricalEvent> cluster = (await source.ReadEventsAsync(
|
||||
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
|
||||
.ConfigureAwait(false)).Events;
|
||||
|
||||
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
|
||||
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
|
||||
if (reverse) cluster = cluster.Reverse().ToList();
|
||||
|
||||
if (cluster.Count > MaxTieClusterOverfetch)
|
||||
{
|
||||
_logger.LogError(
|
||||
@@ -5232,7 +5325,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
HistoryPaging.SliceTieCluster(
|
||||
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
|
||||
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
|
||||
reverse: false, resolutionTicks: resolution);
|
||||
reverse: reverse, resolutionTicks: resolution);
|
||||
|
||||
var slice = new List<HistoricalEvent>(sliceCount);
|
||||
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
|
||||
@@ -5241,17 +5334,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (nextStartUtc is { } resumeAt)
|
||||
{
|
||||
pendingCursor = new HistoryContinuationState(
|
||||
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events);
|
||||
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
|
||||
Reverse: reverse);
|
||||
}
|
||||
}
|
||||
else if (truncated && events.Count > 0)
|
||||
{
|
||||
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted.
|
||||
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted. In a
|
||||
// reverse read the last event is the OLDEST on the page, and the resumed (end, T] read starts there.
|
||||
var boundary = events[^1].EventTimeUtc;
|
||||
var skip = 0;
|
||||
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
|
||||
pendingCursor = new HistoryContinuationState(
|
||||
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events);
|
||||
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
|
||||
Reverse: reverse);
|
||||
}
|
||||
|
||||
var historyEvent = ProjectEvents(events, selectClauses);
|
||||
@@ -5365,6 +5461,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
// #674: one "now" per request, so every item of the batch is judged against the same bound.
|
||||
var latest = LatestAcceptedSourceTime();
|
||||
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
|
||||
{
|
||||
var details = nodesToUpdate[handle.Index];
|
||||
@@ -5378,7 +5476,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
|
||||
var items = (IList<DataValue>?)details.UpdateValues ?? Array.Empty<DataValue>();
|
||||
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, who, ct);
|
||||
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, latest, who, ct);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5440,22 +5538,54 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674 review: a HistoryUpdate timestamp as UTC. Wire timestamps are UTC; an in-process caller can hand a
|
||||
/// <see cref="DateTimeKind.Local"/> value, which is converted (comparing its raw ticks against a UTC bound would be
|
||||
/// off by the zone offset). <see cref="DateTimeKind.Unspecified"/> is treated as UTC, as the backends treat it.
|
||||
/// </summary>
|
||||
/// <param name="value">The item's source timestamp.</param>
|
||||
/// <returns>The same instant as a UTC <see cref="DateTime"/>.</returns>
|
||||
private static DateTime ToUtc(DateTime value) => value.Kind switch
|
||||
{
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||
_ => value,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// #674: the latest <c>SourceTimestamp</c> a HistoryUpdate item may carry — <see cref="Clock"/>'s now plus
|
||||
/// <see cref="HistoryUpdateMaxFutureSkew"/> — or <c>null</c> when the bound is disabled (non-positive skew).
|
||||
/// Saturates at <see cref="DateTime.MaxValue"/> rather than overflowing for an absurd configured skew.
|
||||
/// </summary>
|
||||
/// <returns>The inclusive upper bound, or null for unbounded.</returns>
|
||||
private DateTime? LatestAcceptedSourceTime()
|
||||
{
|
||||
var skew = HistoryUpdateMaxFutureSkew;
|
||||
if (skew <= TimeSpan.Zero) return null;
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
return skew.Ticks > DateTime.MaxValue.Ticks - now.Ticks
|
||||
? new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc)
|
||||
: now + skew;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies one node's UpdateDataDetails: items the server can refuse on its own
|
||||
/// (<c>SourceTimestamp == DateTime.MinValue</c> ⇒ <c>BadInvalidTimestamp</c>; a value
|
||||
/// <see cref="HistorianValue.TryFromObject"/> cannot type ⇒ <c>BadTypeMismatch</c>) are answered locally,
|
||||
/// the rest go to the updater in ONE call, and the per-item codes are stitched back in request order.
|
||||
/// (<c>SourceTimestamp == DateTime.MinValue</c> ⇒ <c>BadInvalidTimestamp</c>; a <c>SourceTimestamp</c> later than
|
||||
/// <paramref name="latest"/> ⇒ <c>BadOutOfRange</c>, #674; a value <see cref="HistorianValue.TryFromObject"/>
|
||||
/// cannot type ⇒ <c>BadTypeMismatch</c>) are answered locally, the rest go to the updater in ONE call, and the
|
||||
/// per-item codes are stitched back in request order.
|
||||
/// </summary>
|
||||
/// <param name="updater">The wired updater.</param>
|
||||
/// <param name="tagname">The node's historian tagname.</param>
|
||||
/// <param name="mode">The update mode.</param>
|
||||
/// <param name="items">The request's values, in order.</param>
|
||||
/// <param name="latest">The latest accepted source time (<see cref="LatestAcceptedSourceTime"/>); null = unbounded.</param>
|
||||
/// <param name="who">The caller, recorded in the modification trail.</param>
|
||||
/// <param name="ct">The request deadline.</param>
|
||||
/// <returns>The node's result, carrying one operation result per item.</returns>
|
||||
private static async Task<HistoryUpdateResult> UpdateDataAsync(
|
||||
IHistorianDataUpdater updater, string tagname, HistoryUpdateMode mode, IList<DataValue> items,
|
||||
HistoryUpdateContext who, CancellationToken ct)
|
||||
DateTime? latest, HistoryUpdateContext who, CancellationToken ct)
|
||||
{
|
||||
var codes = new uint[items.Count];
|
||||
var toSend = new List<HistorizationValue>(items.Count);
|
||||
@@ -5471,13 +5601,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
codes[i] = StatusCodes.BadInvalidTimestamp;
|
||||
}
|
||||
else if (latest is { } bound && ToUtc(item.SourceTimestamp) > bound)
|
||||
{
|
||||
// #674: a far-future history edit is refused before any backend sees it.
|
||||
codes[i] = StatusCodes.BadOutOfRange;
|
||||
}
|
||||
else if (!HistorianValue.TryFromObject(item.Value, out var value))
|
||||
{
|
||||
codes[i] = StatusCodes.BadTypeMismatch;
|
||||
}
|
||||
else
|
||||
{
|
||||
toSend.Add(new HistorizationValue(item.SourceTimestamp, value, item.StatusCode.Code));
|
||||
toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp), value, item.StatusCode.Code));
|
||||
sentIndex.Add(i);
|
||||
}
|
||||
}
|
||||
@@ -5521,28 +5656,57 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors);
|
||||
if (nodesToProcess.Count == 0) return;
|
||||
|
||||
// Plan BEFORE the Primary gate (#674 review): a request this server can never serve (no updater, a node that is
|
||||
// not historized, Remove, IsDeleteModified) is answered with the honest BadHistoryOperationUnsupported on every
|
||||
// node, so a Secondary does not send the client to the Primary for something the Primary would also refuse.
|
||||
// Planning is pure — it builds closures and reads the in-memory tagname map; nothing reaches a backend.
|
||||
var updater = _historianDataUpdater;
|
||||
var planned = new List<(NodeHandle Handle, string Tagname,
|
||||
Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>> Operation)>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
var operation = updater is null ? null : plan(handle);
|
||||
if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
continue;
|
||||
}
|
||||
|
||||
planned.Add((handle, tagname, operation));
|
||||
}
|
||||
|
||||
if (planned.Count == 0) return;
|
||||
|
||||
// #674 — PRIMARY GATE, after authorization (a caller who may not update learns nothing about the node's role)
|
||||
// and after planning. Off-Primary every servable node is refused BadInvalidState and nothing reaches the updater.
|
||||
if (HistoryUpdatePrimaryGate is { } primary && !primary())
|
||||
{
|
||||
var refusal = new ServiceResult(StatusCodes.BadInvalidState,
|
||||
new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel)."));
|
||||
foreach (var (handle, _, _) in planned)
|
||||
{
|
||||
errors[handle.Index] = refusal;
|
||||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidState };
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Who is rewriting history — recorded on every modification-trail row (Part 11 ModificationInfo.UserName, #668).
|
||||
var who = HistoryUpdateCallerOf(context);
|
||||
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
WithHistoryReadBatchLimiter(planned.Select(p => p.Handle).ToList(), results, errors, () =>
|
||||
{
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var updater = _historianDataUpdater;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
var work = new List<Func<Task>>(planned.Count);
|
||||
foreach (var (handle, tagname, operation) in planned)
|
||||
{
|
||||
var operation = updater is null ? null : plan(handle);
|
||||
if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
continue;
|
||||
}
|
||||
|
||||
var h = handle;
|
||||
var tag = tagname;
|
||||
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => operation(updater!, tag, who, token), ct));
|
||||
var op = operation;
|
||||
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => op(updater!, tag, who, token), ct));
|
||||
}
|
||||
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
@@ -5595,6 +5759,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTimeout };
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// The backend cannot perform this update at all (e.g. a SqlServer historian still at schema v1, with no
|
||||
// modification trail to record it in, #674) — the spec status for the whole node, not a server fault.
|
||||
// Mirrors the HistoryRead side's handling of the same exception.
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryUpdate failed for node {NodeId}", handle.NodeId);
|
||||
@@ -5611,7 +5783,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
|
||||
|
||||
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
|
||||
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> <c>maxEvents</c> argument, honouring the OPC UA
|
||||
/// <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> <c>maxEvents</c> argument, honouring the OPC UA
|
||||
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
|
||||
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
|
||||
/// the backend's <c>maxEvents <= 0</c> "use the default cap" sentinel, so a client asking for the
|
||||
|
||||
@@ -83,6 +83,21 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 — wire the redundancy verdict that decides whether this node serves an OPC UA HistoryUpdate
|
||||
/// (<see cref="OtOpcUaNodeManager.HistoryUpdatePrimaryGate"/>); on any other node every authorized node of a
|
||||
/// HistoryUpdate is refused <c>BadInvalidState</c>. Passing <c>null</c> clears it (allow). No-op (returns
|
||||
/// <c>false</c>) when the node manager has not been created yet (mirrors <see cref="SetNativeAlarmAckPrimaryGate"/>).
|
||||
/// </summary>
|
||||
/// <param name="isPrimary">A cheap, non-blocking read of the data-plane Primary verdict; null to allow.</param>
|
||||
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
|
||||
public bool SetHistoryUpdatePrimaryGate(Func<bool>? isPrimary)
|
||||
{
|
||||
if (_otOpcUaNodeManager is null) return false;
|
||||
_otOpcUaNodeManager.HistoryUpdatePrimaryGate = isPrimary;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the reverse-path gateway for inbound operator writes to writable equipment-tag nodes onto the
|
||||
/// created <see cref="OtOpcUaNodeManager"/>. The host calls this after start with an
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// The Part 13 <see cref="AggregateConfiguration"/> processed reads are evaluated under (#677), and the rule for a
|
||||
/// client-supplied one in <c>ReadProcessedDetails</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The aggregate evaluators (<c>AggregateEvaluator</c> / <c>TimeWeightedAggregator</c> in
|
||||
/// <c>Driver.Historian.Sql.Common</c>) are written against fixed settings, not parameterised: Uncertain samples and
|
||||
/// Uncertain time count as Good (<see cref="TreatUncertainAsBad"/> = false); an interval is Good only when all of
|
||||
/// it is (<see cref="PercentDataGood"/> = 100) and Bad only when none of it is non-Bad
|
||||
/// (<see cref="PercentDataBad"/> = 100), otherwise <c>UncertainDataSubNormal</c>; the value past the last sample
|
||||
/// is held (stepped, <see cref="UseSlopedExtrapolation"/> = false). These are also the SDK's own server defaults
|
||||
/// (<c>AggregateManager.GetDefaultConfiguration</c>), which a test pins.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A request asking for <c>UseServerCapabilitiesDefaults</c>, or spelling out exactly these values, is served;
|
||||
/// any other configuration is refused per node with <c>BadAggregateConfigurationRejected</c> rather than silently
|
||||
/// answered under settings the client did not ask for (it used to be ignored).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ServerAggregateConfiguration
|
||||
{
|
||||
/// <summary>Uncertain data is not treated as Bad.</summary>
|
||||
public const bool TreatUncertainAsBad = false;
|
||||
|
||||
/// <summary>An interval is Bad only when 100 % of it is Bad.</summary>
|
||||
public const byte PercentDataBad = 100;
|
||||
|
||||
/// <summary>An interval is Good only when 100 % of it is Good.</summary>
|
||||
public const byte PercentDataGood = 100;
|
||||
|
||||
/// <summary>The value past the last sample is held (stepped), not sloped.</summary>
|
||||
public const bool UseSlopedExtrapolation = false;
|
||||
|
||||
/// <summary>The server defaults as an <see cref="AggregateConfiguration"/> (explicit values, not "use defaults").</summary>
|
||||
public static AggregateConfiguration Defaults => new()
|
||||
{
|
||||
UseServerCapabilitiesDefaults = false,
|
||||
TreatUncertainAsBad = TreatUncertainAsBad,
|
||||
PercentDataBad = PercentDataBad,
|
||||
PercentDataGood = PercentDataGood,
|
||||
UseSlopedExtrapolation = UseSlopedExtrapolation,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True when a processed read with <paramref name="requested"/> can be served: none supplied,
|
||||
/// <c>UseServerCapabilitiesDefaults</c>, or every setting equal to the server's.
|
||||
/// </summary>
|
||||
/// <param name="requested">The request's configuration (may be null).</param>
|
||||
/// <returns>Whether to serve; false ⇒ <c>BadAggregateConfigurationRejected</c>.</returns>
|
||||
public static bool IsServable(AggregateConfiguration? requested) =>
|
||||
requested is null
|
||||
|| requested.UseServerCapabilitiesDefaults
|
||||
|| (requested.TreatUncertainAsBad == TreatUncertainAsBad
|
||||
&& requested.PercentDataBad == PercentDataBad
|
||||
&& requested.PercentDataGood == PercentDataGood
|
||||
&& requested.UseSlopedExtrapolation == UseSlopedExtrapolation);
|
||||
}
|
||||
@@ -452,6 +452,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// </summary>
|
||||
private readonly IHistorizationRoleView? _historizationRoleView;
|
||||
|
||||
/// <summary>Gitea #674 review: where the HistoryUpdate verdict is published. Null on nodes that wire no redundancy manager.</summary>
|
||||
private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
|
||||
|
||||
/// <summary>Gitea #674 review: whether any redundancy snapshot has reached this host — the HistoryUpdate gate stays
|
||||
/// closed until one has (see <see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/>).</summary>
|
||||
private bool _redundancySnapshotReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Tests whether a node named in a redundancy snapshot serves this node's address space — i.e.
|
||||
/// is a per-cluster mesh partner recording the very same historized refs. Null (the default)
|
||||
@@ -689,7 +696,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
TimeSpan? commsRecoveryAuditDelay = null,
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null,
|
||||
bool captureWhilePrimaryUnreachable = true) =>
|
||||
bool captureWhilePrimaryUnreachable = true,
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null) =>
|
||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
|
||||
@@ -702,7 +710,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
||||
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
|
||||
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
|
||||
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable));
|
||||
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -775,6 +783,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// reported UNREACHABLE by the cluster failure detector, stop deferring to it and capture — rather than waiting
|
||||
/// for auto-down + a fresh redundancy snapshot. Affects ONLY the historization capture verdict; the alarm drain
|
||||
/// and the Primary data-plane verdict are unchanged. <c>false</c> restores the pre-#672 behaviour.</param>
|
||||
/// <param name="historyUpdateRoleView">Gitea #674 review: where the OPC UA HistoryUpdate verdict
|
||||
/// (<see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> — the data-plane verdict, but closed until the first
|
||||
/// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no
|
||||
/// redundancy manager.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -807,9 +819,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
TimeSpan? commsRecoveryAuditDelay = null,
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null,
|
||||
bool captureWhilePrimaryUnreachable = true)
|
||||
bool captureWhilePrimaryUnreachable = true,
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null)
|
||||
{
|
||||
_captureWhilePrimaryUnreachable = captureWhilePrimaryUnreachable;
|
||||
_historyUpdateRoleView = historyUpdateRoleView;
|
||||
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
|
||||
_sharesAlarmQueueWithLocal = sharesAlarmQueueWithLocal;
|
||||
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
|
||||
@@ -909,6 +923,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// </summary>
|
||||
private void StartPrimaryVerdictPublishing()
|
||||
{
|
||||
PublishHistoryUpdateVerdict();
|
||||
if (_primaryRoleSource is null) return;
|
||||
PublishPrimaryVerdict();
|
||||
try
|
||||
@@ -948,6 +963,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// The snapshot path publishes on every snapshot, as the other verdicts do.</summary>
|
||||
private void PublishPrimaryVerdict()
|
||||
{
|
||||
PublishHistoryUpdateVerdict();
|
||||
if (_primaryRoleSource is null) return;
|
||||
var verdict = ShouldServiceAsPrimary();
|
||||
_lastPublishedPrimaryVerdict = verdict;
|
||||
@@ -958,10 +974,18 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// role information, so republishing an unchanged verdict on each would be noise.</summary>
|
||||
private void PublishPrimaryVerdictIfChanged()
|
||||
{
|
||||
PublishHistoryUpdateVerdict();
|
||||
if (_primaryRoleSource is null || ShouldServiceAsPrimary() == _lastPublishedPrimaryVerdict) return;
|
||||
PublishPrimaryVerdict();
|
||||
}
|
||||
|
||||
/// <summary>Gitea #674 review: publishes <see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> (no-op when no view
|
||||
/// is wired). Rides every Primary-verdict publish point — host start, every snapshot, membership changes — so it is
|
||||
/// closed from host start and opens only once a snapshot has arrived and the data-plane verdict allows.</summary>
|
||||
private void PublishHistoryUpdateVerdict() =>
|
||||
_historyUpdateRoleView?.PublishHistoryUpdateRole(
|
||||
PrimaryGatePolicy.ShouldServeHistoryUpdate(_redundancySnapshotReceived, _localRole, DriverMemberCount()));
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #672: follow cluster reachability so the historization capture gate stops deferring to a recording
|
||||
/// Primary the failure detector already considers dead. Its OWN subscription, deliberately not folded into
|
||||
@@ -2270,6 +2294,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// <c>ScriptedAlarmHostActor.OnRedundancyStateChanged</c> / <c>OpcUaPublishActor</c>.</summary>
|
||||
private void OnRedundancyStateChanged(RedundancyStateChanged msg)
|
||||
{
|
||||
_redundancySnapshotReceived = true;
|
||||
var local = msg.Nodes.FirstOrDefault(n => n.NodeId == _localNode);
|
||||
if (local is not null)
|
||||
{
|
||||
|
||||
@@ -153,6 +153,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// behaviour for the same reason as <see cref="RediscoveryRaised"/> — a probe tick can land while the
|
||||
/// driver is between connects.</summary>
|
||||
private sealed record HostStatusRaised;
|
||||
/// <summary>Self-sent when the wrapped driver raises <see cref="IDriverConnectionLossSource.ConnectionLost"/> (#676)
|
||||
/// — it KNOWS its connection is gone for good. Marshals the event off the driver's thread. Acted on only in
|
||||
/// Connected (it becomes a disconnect at once, instead of after the #527 watchdog's silence window); every other
|
||||
/// behaviour is already reconnecting or connecting and ignores it.</summary>
|
||||
private sealed record ConnectionLossRaised(DriverConnectionLossEventArgs Args);
|
||||
|
||||
public sealed class RetryConnect
|
||||
{
|
||||
@@ -233,6 +238,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
|
||||
private EventHandler<RediscoveryEventArgs>? _rediscoveryHandler;
|
||||
private EventHandler<HostStatusChangedEventArgs>? _hostStatusHandler;
|
||||
private EventHandler<DriverConnectionLossEventArgs>? _connectionLossHandler;
|
||||
|
||||
/// <summary>When the driver last raised <see cref="IRediscoverable.OnRediscoveryNeeded"/>, and the reason
|
||||
/// it gave. Null until the first raise. Carried on every subsequent health snapshot so the AdminUI can
|
||||
@@ -400,6 +406,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// has no connection affinity, and a driver can observe a remote change while disconnected.
|
||||
AttachRediscoverySource();
|
||||
AttachHostStatusSource();
|
||||
AttachConnectionLossSource();
|
||||
PublishHealthSnapshot();
|
||||
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
||||
}
|
||||
@@ -427,6 +434,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
|
||||
Receive<RouteAlarmAck>(_ => { /* stubbed drivers have no alarm backend */ });
|
||||
Receive<DisconnectObserved>(_ => { /* stubbed drivers don't disconnect */ });
|
||||
Receive<ConnectionLossRaised>(IgnoreConnectionLoss);
|
||||
Receive<ForceReconnect>(_ => { /* stubbed drivers don't reconnect */ });
|
||||
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
|
||||
// Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a
|
||||
@@ -468,6 +476,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
});
|
||||
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
|
||||
Receive<ForceReconnect>(_ => { /* already connecting — no-op */ });
|
||||
Receive<ConnectionLossRaised>(IgnoreConnectionLoss);
|
||||
// ResubscribeDesired self-Tells Subscribe; HandleSubscribeAsync replies SubscriptionEstablished to the
|
||||
// sender, which on the self-resubscribe path is Self. Swallow it (trace only) so it doesn't dead-letter.
|
||||
Receive<SubscriptionEstablished>(msg =>
|
||||
@@ -500,15 +509,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
|
||||
_connectedSinceUtc = DateTime.UtcNow;
|
||||
|
||||
Receive<DisconnectObserved>(msg =>
|
||||
{
|
||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
||||
_driverInstanceId, msg.Reason);
|
||||
DetachSubscription();
|
||||
RecordFault();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
});
|
||||
Receive<DisconnectObserved>(msg => HandleDisconnectObserved(msg.Reason));
|
||||
// #676 — the driver KNOWS its connection is gone (e.g. OpcUaClient: the upstream server halted or forgot the
|
||||
// session). Same handling as the watchdog's disconnect, minus the ~90 s of silence it would need to prove it.
|
||||
// Handled inline rather than re-told as DisconnectObserved (Reconnecting also swallows a stray DisconnectObserved).
|
||||
Receive<ConnectionLossRaised>(msg => HandleDisconnectObserved($"driver reported connection lost: {msg.Args.Reason}"));
|
||||
Receive<ForceReconnect>(_ =>
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
|
||||
@@ -764,6 +769,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
Receive<InitializeFailed>(_ => { /* keep retrying via timer */ });
|
||||
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
|
||||
Receive<ForceReconnect>(_ => { /* already reconnecting — no-op */ });
|
||||
Receive<ConnectionLossRaised>(IgnoreConnectionLoss);
|
||||
// A watchdog DisconnectObserved self-sent on the Connected tick can still be queued when a ConnectionLossRaised
|
||||
// (or another trip) has already moved us here — already reconnecting, so it is a no-op, not an Unhandled.
|
||||
Receive<DisconnectObserved>(_ => { /* already reconnecting — no-op */ });
|
||||
// ResubscribeDesired self-Tells Subscribe; HandleSubscribeAsync replies SubscriptionEstablished to the
|
||||
// sender, which on the self-resubscribe path is Self. Swallow it (trace only) so it doesn't dead-letter.
|
||||
Receive<SubscriptionEstablished>(msg =>
|
||||
@@ -1101,6 +1110,50 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_rediscoveryHandler = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe the driver's <see cref="IDriverConnectionLossSource.ConnectionLost"/> (#676), if it is one,
|
||||
/// marshalling each raise to the actor thread — actor state is never touched on the driver's thread. Attached
|
||||
/// once in <c>PreStart</c> and detached in <c>PostStop</c>, like <see cref="AttachRediscoverySource"/>: the
|
||||
/// <see cref="IDriver"/> instance can outlive this actor, so a per-connect attach would leak a handler (holding
|
||||
/// a dead <c>Self</c>) per respawn.
|
||||
/// </summary>
|
||||
private void AttachConnectionLossSource()
|
||||
{
|
||||
if (_driver is not IDriverConnectionLossSource src || _connectionLossHandler is not null) return;
|
||||
var self = Self;
|
||||
_connectionLossHandler = (_, e) => self.Tell(new ConnectionLossRaised(e));
|
||||
src.ConnectionLost += _connectionLossHandler;
|
||||
}
|
||||
|
||||
/// <summary>Symmetric teardown, called from PostStop — same leak argument as <see cref="DetachRediscoverySource"/>.</summary>
|
||||
private void DetachConnectionLossSource()
|
||||
{
|
||||
if (_driver is IDriverConnectionLossSource src && _connectionLossHandler is not null)
|
||||
src.ConnectionLost -= _connectionLossHandler;
|
||||
_connectionLossHandler = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A connection loss reported while NOT Connected (#676): a (re)initialise is already pending or running, so
|
||||
/// there is nothing to tear down — and acting on it could tear down the connection that attempt is building.
|
||||
/// </summary>
|
||||
private void IgnoreConnectionLoss(ConnectionLossRaised msg) =>
|
||||
_log.Debug("DriverInstance {Id}: driver reported connection lost ({Reason}) while not connected — ignored",
|
||||
_driverInstanceId, msg.Args.Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Connected → Reconnecting on an observed disconnect — from the #527 watchdog or a driver's definitive
|
||||
/// <see cref="IDriverConnectionLossSource.ConnectionLost"/> (#676).
|
||||
/// </summary>
|
||||
private void HandleDisconnectObserved(string reason)
|
||||
{
|
||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting", _driverInstanceId, reason);
|
||||
DetachSubscription();
|
||||
RecordFault();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>Subscribe the driver's <see cref="IHostConnectivityProbe.OnHostStatusChanged"/> (if it is
|
||||
/// one), marshaling each transition to the actor thread. Idempotent; mirrors
|
||||
/// <see cref="AttachRediscoverySource"/>, including the PreStart-not-per-connect placement — a probe
|
||||
@@ -1497,6 +1550,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
|
||||
DetachRediscoverySource();
|
||||
DetachHostStatusSource();
|
||||
DetachConnectionLossSource();
|
||||
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
|
||||
@@ -26,6 +26,36 @@ public static class PrimaryGatePolicy
|
||||
_ => driverMemberCount <= 1, // unknown: allow only when no driver peer exists
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 review: decide whether this node may serve an OPC UA <b>HistoryUpdate</b>. The
|
||||
/// <see cref="ShouldServiceAsPrimary"/> verdict, but CLOSED until this node has received its first
|
||||
/// redundancy snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="ShouldServiceAsPrimary"/> deliberately allows an unknown role while no driver peer is
|
||||
/// visible, which covers the pre-join boot window: a node spawned before it has joined its mesh sees a
|
||||
/// member count of 0. For a live data-plane operation (a device write, an ack) that is the right trade —
|
||||
/// denying would stall a genuine single node during its own join. HistoryUpdate is not live: a client can
|
||||
/// retry it, and both nodes of a pair accepting one concurrently during that window is exactly the
|
||||
/// wrong-prior-value race the Primary gate exists for. So it waits for a real verdict.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// "No snapshot yet" is told apart from "single node" by the snapshot itself: the redundancy singleton runs
|
||||
/// on every driver node (one per mesh, a single node included) and publishes on every change and every
|
||||
/// 10 s heartbeat, so a single node is named Primary within seconds of joining. Once a snapshot has
|
||||
/// arrived this is exactly <see cref="ShouldServiceAsPrimary"/> (including its unknown-role member-count
|
||||
/// rule for a snapshot that omits this node). A node with no redundancy manager at all (admin-only, or a
|
||||
/// harness) wires no gate and so allows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="redundancySnapshotReceived">Whether any redundancy snapshot has reached this node yet.</param>
|
||||
/// <param name="localRole">This node's last-known redundancy role, or <c>null</c> when unknown.</param>
|
||||
/// <param name="driverMemberCount">Count of Up cluster members carrying the <c>driver</c> role.</param>
|
||||
/// <returns><c>true</c> to serve HistoryUpdate; <c>false</c> to refuse it (<c>BadInvalidState</c>).</returns>
|
||||
public static bool ShouldServeHistoryUpdate(bool redundancySnapshotReceived, RedundancyRole? localRole, int driverMemberCount) =>
|
||||
redundancySnapshotReceived && ShouldServiceAsPrimary(localRole, driverMemberCount);
|
||||
|
||||
/// <summary>
|
||||
/// Decide whether this node should drain the replicated alarm store-and-forward queue.
|
||||
/// </summary>
|
||||
|
||||
@@ -122,6 +122,16 @@ public sealed class ServerHistorianOptions
|
||||
/// </summary>
|
||||
public int MaxTimeWeightedRawRows { get; init; } = 1_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674: how far into the future an OPC UA HistoryUpdate (Insert / Replace / Update) item's
|
||||
/// <c>SourceTimestamp</c> may lie, measured from the server clock. An item beyond <c>now + skew</c> is refused
|
||||
/// <c>BadOutOfRange</c> by the node manager before any backend sees it — a far-future row is never a legitimate
|
||||
/// history edit, and on a backend that judges retention against the newest timestamp it could age out every
|
||||
/// older partition. Applies to every backend. The live recorder is not bounded. Non-positive disables the bound.
|
||||
/// Default 1 day.
|
||||
/// </summary>
|
||||
public TimeSpan HistoryUpdateMaxFutureSkew { get; init; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.
|
||||
/// <para>
|
||||
|
||||
@@ -75,9 +75,29 @@ public interface IHistorizationRoleView
|
||||
void PublishHistorizationRole(bool shouldRecordHistorizedValues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 review: a singleton snapshot of the OPC UA <b>HistoryUpdate</b> verdict
|
||||
/// (<see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/>), read by the node manager's HistoryUpdate Primary gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A separate verdict from <see cref="IDriverPrimaryRoleSource.ShouldActAsPrimary"/> because it is closed until the
|
||||
/// first redundancy snapshot, where that one mirrors the live data-plane gate and is open through the pre-join boot
|
||||
/// window. Registered unconditionally on every driver-role node (same object as the other views); a node that does
|
||||
/// not register it wires no gate.
|
||||
/// </remarks>
|
||||
public interface IHistoryUpdateRoleView
|
||||
{
|
||||
/// <summary>Whether this node may serve HistoryUpdate right now.</summary>
|
||||
bool ShouldServeHistoryUpdate { get; }
|
||||
|
||||
/// <summary>Records a fresh decision. Called by <c>DriverHostActor</c> whenever it publishes its Primary verdict.</summary>
|
||||
/// <param name="shouldServeHistoryUpdate">The decision <see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> produced.</param>
|
||||
void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <see cref="IRedundancyRoleView"/> + <see cref="IDriverPrimaryRoleSource"/> +
|
||||
/// <see cref="IHistorizationRoleView"/>, each backed by its own volatile field.
|
||||
/// <see cref="IHistorizationRoleView"/> + <see cref="IHistoryUpdateRoleView"/>, each backed by its own volatile field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -90,8 +110,15 @@ public interface IHistorizationRoleView
|
||||
/// them is the bug that live gate already found once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRoleSource, IHistorizationRoleView
|
||||
public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRoleSource, IHistorizationRoleView,
|
||||
IHistoryUpdateRoleView
|
||||
{
|
||||
// Gitea #674 review: seeded CLOSED, unlike the data-plane verdict. HistoryUpdate waits for a real redundancy verdict
|
||||
// (PrimaryGatePolicy.ShouldServeHistoryUpdate); a node that never publishes here is not a driver node with a
|
||||
// redundancy manager, and such a node does not register this view as its gate source.
|
||||
private volatile bool _shouldServeHistoryUpdate =
|
||||
PrimaryGatePolicy.ShouldServeHistoryUpdate(redundancySnapshotReceived: false, localRole: null, driverMemberCount: 1);
|
||||
|
||||
// Seeded with the decision for "role unknown" rather than a bare false. An unpublished view and a
|
||||
// node whose role nothing ever reports are genuinely the same situation, and they must behave the
|
||||
// same: a deployment that runs no redundancy at all never publishes here, and defaulting closed
|
||||
@@ -121,6 +148,12 @@ public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRole
|
||||
/// <inheritdoc/>
|
||||
public bool ShouldActAsPrimary => _shouldActAsPrimary;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ShouldServeHistoryUpdate => _shouldServeHistoryUpdate;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate) => _shouldServeHistoryUpdate = shouldServeHistoryUpdate;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Publish(bool shouldDrainAlarmHistory) => _shouldDrainAlarmHistory = shouldDrainAlarmHistory;
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ public static class ServiceCollectionExtensions
|
||||
// with no gate at all, and both nodes would write every sample twice with nothing to say so.
|
||||
services.TryAddSingleton<IHistorizationRoleView>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
||||
|
||||
// Gitea #674 review: the OPC UA HistoryUpdate verdict — the data-plane verdict, but closed until this node's first
|
||||
// redundancy snapshot. Same instance; registered here (driver-role nodes only) so an admin-only node, which has no
|
||||
// redundancy manager, wires no HistoryUpdate gate at all.
|
||||
services.TryAddSingleton<IHistoryUpdateRoleView>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
||||
|
||||
// The node→central ack transport under MeshTransport:Mode = Grpc (mesh gRPC command plane,
|
||||
// Task 1B.2). Registered unconditionally but resolved only by the Grpc arm of
|
||||
// WithOtOpcUaRuntimeActors — the mode is not knowable here (this runs before configuration is
|
||||
@@ -631,7 +636,9 @@ public static class ServiceCollectionExtensions
|
||||
// Review R6 MINOR-4: the passive half of a LocalDb pair has no PeerAddress, so it is told
|
||||
// its queue-sharing peer as its mesh partner instead — otherwise it never stands down.
|
||||
sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate(
|
||||
resolver.GetService<IConfiguration>(), roleInfo, replicationPeerHost)),
|
||||
resolver.GetService<IConfiguration>(), roleInfo, replicationPeerHost),
|
||||
// Gitea #674 review: the HistoryUpdate verdict (closed until the first redundancy snapshot).
|
||||
historyUpdateRoleView: resolver.GetService<IHistoryUpdateRoleView>()),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
/// </summary>
|
||||
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; } = new();
|
||||
|
||||
/// <summary>Gets or sets an exception thrown by HistoryReadRawAsync (simulates a refused read).</summary>
|
||||
public Exception? HistoryReadException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the history read result returned by HistoryReadRawAsync and HistoryReadAggregateAsync.</summary>
|
||||
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
|
||||
{
|
||||
@@ -323,6 +326,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
|
||||
{
|
||||
HistoryReadRawCalls.Add((nodeId, startTime, endTime, maxValues));
|
||||
if (HistoryReadException != null) throw HistoryReadException;
|
||||
return Task.FromResult(HistoryReadResult);
|
||||
}
|
||||
|
||||
|
||||
@@ -197,4 +197,53 @@ public class HistoryReadCommandTests
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CliFx.Exceptions.CommandException>(async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
/// <summary>#677.5: the new aggregate names parse (and the aliases).</summary>
|
||||
[Theory]
|
||||
[InlineData("TimeAverage", AggregateType.TimeAverage)]
|
||||
[InlineData("timeaverage2", AggregateType.TimeAverage2)]
|
||||
[InlineData("Total", AggregateType.Total)]
|
||||
[InlineData("total2", AggregateType.Total2)]
|
||||
[InlineData("Interpolative", AggregateType.Interpolative)]
|
||||
[InlineData("interp", AggregateType.Interpolative)]
|
||||
public async Task Execute_NewAggregates_Parse(string name, AggregateType expected)
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = name,
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>#674.6: a refused read is a one-line CommandException (non-zero exit), not a stack trace.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_RefusedRead_ThrowsCommandException_WithOneLineMessage()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryReadException = new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported,
|
||||
"HistoryRead of ns=2;s=HistNode failed: BadHistoryOperationUnsupported (0x80B40000)."),
|
||||
};
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CliFx.Exceptions.CommandException>(async () => await command.ExecuteAsync(console));
|
||||
|
||||
ex.Message.ShouldContain("BadHistoryOperationUnsupported");
|
||||
ex.Message.ShouldNotContain("\n");
|
||||
ex.Message.ShouldNotContain(" at ");
|
||||
ex.ExitCode.ShouldNotBe(0);
|
||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,4 +160,67 @@ public class EndpointSelectorTests
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
EndpointSelector.SelectBest(Array.Empty<EndpointDescription>(), "", MessageSecurityMode.None));
|
||||
}
|
||||
|
||||
/// <summary>#678.5: a server advertising its internal port must not redirect the session off the mapped port.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_RewritesHostAndPort_WhenSchemeMatches()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4841);
|
||||
uri.AbsolutePath.ShouldBe("/OtOpcUa");
|
||||
}
|
||||
|
||||
/// <summary>A different scheme (e.g. https endpoint advertised to an opc.tcp caller) is left untouched.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_LeavesEndpointUntouched_WhenSchemeDiffers()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "https://internal-host:4843/x") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
new Uri(best.EndpointUrl).Host.ShouldBe("internal-host");
|
||||
new Uri(best.EndpointUrl).Port.ShouldBe(4843);
|
||||
}
|
||||
|
||||
/// <summary>Identical host, port and scheme: nothing to rewrite.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_Unchanged_WhenHostPortAndSchemeMatch()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://server:4840") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://server:4840", MessageSecurityMode.None);
|
||||
|
||||
best.EndpointUrl.ShouldBe("opc.tcp://server:4840");
|
||||
}
|
||||
|
||||
/// <summary>A URL typed without a port keeps the advertised port (opc.tcp has no default; Uri.Port is -1).</summary>
|
||||
[Fact]
|
||||
public void SelectBest_KeepsAdvertisedPort_WhenRequestedUrlHasNoPort()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4840);
|
||||
}
|
||||
|
||||
/// <summary>An IPv6 host is rewritten with its brackets intact.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_RewritesIpv6Host()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("[::1]");
|
||||
uri.Port.ShouldBe(4841);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -18,6 +18,11 @@ public class AggregateTypeMapperTests
|
||||
[InlineData(AggregateType.Start)]
|
||||
[InlineData(AggregateType.End)]
|
||||
[InlineData(AggregateType.StandardDeviation)]
|
||||
[InlineData(AggregateType.TimeAverage)]
|
||||
[InlineData(AggregateType.TimeAverage2)]
|
||||
[InlineData(AggregateType.Total)]
|
||||
[InlineData(AggregateType.Total2)]
|
||||
[InlineData(AggregateType.Interpolative)]
|
||||
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
|
||||
{
|
||||
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
|
||||
@@ -82,4 +87,15 @@ public class AggregateTypeMapperTests
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
AggregateTypeMapper.ToNodeId((AggregateType)99));
|
||||
}
|
||||
|
||||
/// <summary>#677.5: the time-weighted Part 13 aggregates map to their ObjectIds.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_TimeWeightedAggregates_MapCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.TimeAverage).ShouldBe(ObjectIds.AggregateFunction_TimeAverage);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.TimeAverage2).ShouldBe(ObjectIds.AggregateFunction_TimeAverage2);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Total).ShouldBe(ObjectIds.AggregateFunction_Total);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Total2).ShouldBe(ObjectIds.AggregateFunction_Total2);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Interpolative).ShouldBe(ObjectIds.AggregateFunction_Interpolative);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,6 @@ public class ModelConstructionTests
|
||||
[Fact]
|
||||
public void AggregateType_Enum_HasExpectedValues()
|
||||
{
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(7);
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(12);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ public class HistoryViewModelTests
|
||||
public void AggregateTypes_ContainsNullForRaw()
|
||||
{
|
||||
_vm.AggregateTypes.ShouldContain((AggregateType?)null);
|
||||
_vm.AggregateTypes.Count.ShouldBe(8); // null + 7 enum values
|
||||
_vm.AggregateTypes.Count.ShouldBe(13); // null + 12 enum values
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Clear method resets the view model state.</summary>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #675 review: the reverse-events surface is two interface members with defaults
|
||||
/// (<see cref="IHistorianDataSource.SupportsReverseEvents"/> = false and the direction-aware
|
||||
/// <c>ReadEventsAsync(…, bool reverse, …)</c>). A future wrapper / decorator that forwards only the five-argument read
|
||||
/// and the other members would compile and silently fall back to the defaults — turning every reverse read into
|
||||
/// <c>BadHistoryOperationUnsupported</c> on a backend that supports it. So every production implementation must declare
|
||||
/// BOTH members itself.
|
||||
/// </summary>
|
||||
public sealed class HistorianDataSourceReverseEventsGuardTests
|
||||
{
|
||||
private static IReadOnlyList<Type> ProductionImplementations()
|
||||
{
|
||||
// Every ZB.MOM.WW.OtOpcUa production assembly next to the tests (this project references every historian backend).
|
||||
var dir = AppContext.BaseDirectory;
|
||||
var assemblies = Directory.GetFiles(dir, "ZB.MOM.WW.OtOpcUa.*.dll")
|
||||
.Where(f => !Path.GetFileName(f).Contains(".Tests", StringComparison.Ordinal)
|
||||
&& !Path.GetFileName(f).Contains("IntegrationTests", StringComparison.Ordinal))
|
||||
.Select(f => Assembly.LoadFrom(f));
|
||||
return assemblies
|
||||
.SelectMany(a =>
|
||||
{
|
||||
try { return a.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { return e.Types.Where(t => t is not null).Cast<Type>(); }
|
||||
})
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IHistorianDataSource).IsAssignableFrom(t))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_scan_finds_every_known_backend()
|
||||
{
|
||||
// Positive control: a scan that finds nothing would pass the guard below vacuously.
|
||||
var names = ProductionImplementations().Select(t => t.Name).ToList();
|
||||
names.ShouldContain("NullHistorianDataSource");
|
||||
names.ShouldContain("GatewayHistorianDataSource");
|
||||
names.ShouldContain("SqlServerHistorianDataSource");
|
||||
names.ShouldContain("QuestDbHistorianDataSource");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_implementation_declares_the_reverse_overload_and_the_capability_itself()
|
||||
{
|
||||
foreach (var type in ProductionImplementations())
|
||||
{
|
||||
var map = type.GetInterfaceMap(typeof(IHistorianDataSource));
|
||||
MethodInfo Impl(Func<MethodInfo, bool> pick) =>
|
||||
map.TargetMethods[Array.FindIndex(map.InterfaceMethods, m => pick(m))];
|
||||
|
||||
var reverseRead = Impl(m => m.Name == nameof(IHistorianDataSource.ReadEventsAsync)
|
||||
&& m.GetParameters().Any(p => p.Name == "reverse"));
|
||||
reverseRead.DeclaringType.ShouldBe(type,
|
||||
$"{type.FullName} must declare ReadEventsAsync(…, bool reverse, …) itself, not inherit the interface default");
|
||||
|
||||
var capability = Impl(m => m.Name == "get_" + nameof(IHistorianDataSource.SupportsReverseEvents));
|
||||
capability.DeclaringType.ShouldBe(type,
|
||||
$"{type.FullName} must declare SupportsReverseEvents itself, not inherit the interface default");
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-14
@@ -45,9 +45,10 @@ public sealed class IHistorianDataSourceContractTests
|
||||
[InlineData("ReadEventsAsync", typeof(Task<HistoricalEventsResult>))]
|
||||
public void ReadMethods_ReturnExpectedTaskShape(string methodName, Type expectedReturnType)
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod(methodName);
|
||||
method.ShouldNotBeNull();
|
||||
method!.ReturnType.ShouldBe(expectedReturnType);
|
||||
// ReadEventsAsync is overloaded (the #675 direction-aware overload) — every overload must keep the shape.
|
||||
var methods = Overloads(methodName);
|
||||
methods.ShouldNotBeEmpty();
|
||||
foreach (var method in methods) method.ReturnType.ShouldBe(expectedReturnType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetHealthSnapshot is synchronous.</summary>
|
||||
@@ -146,11 +147,14 @@ public sealed class IHistorianDataSourceContractTests
|
||||
[InlineData("ReadEventsAsync", "maxEvents", typeof(int))]
|
||||
public void HistoryRead_MaxParameter_TypePinned(string methodName, string parameterName, Type expectedType)
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod(methodName);
|
||||
method.ShouldNotBeNull();
|
||||
var parameter = method!.GetParameters().FirstOrDefault(p => p.Name == parameterName);
|
||||
parameter.ShouldNotBeNull($"Method {methodName} should expose a '{parameterName}' parameter.");
|
||||
parameter!.ParameterType.ShouldBe(expectedType);
|
||||
var methods = Overloads(methodName);
|
||||
methods.ShouldNotBeEmpty();
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var parameter = method.GetParameters().FirstOrDefault(p => p.Name == parameterName);
|
||||
parameter.ShouldNotBeNull($"Method {methodName} should expose a '{parameterName}' parameter.");
|
||||
parameter!.ParameterType.ShouldBe(expectedType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,13 +167,17 @@ public sealed class IHistorianDataSourceContractTests
|
||||
[Fact]
|
||||
public void ReadEventsAsync_sentinel_and_continuation_contract_types_pinned()
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod("ReadEventsAsync");
|
||||
method.ShouldNotBeNull();
|
||||
var methods = Overloads("ReadEventsAsync");
|
||||
methods.Count.ShouldBe(2, "the five-argument read plus the #675 direction-aware overload");
|
||||
methods.ShouldContain(m => m.GetParameters().Any(p => p.Name == "reverse" && p.ParameterType == typeof(bool)));
|
||||
|
||||
var maxEventsParam = method!.GetParameters().FirstOrDefault(p => p.Name == "maxEvents");
|
||||
maxEventsParam.ShouldNotBeNull("maxEvents parameter must exist for the sentinel contract");
|
||||
maxEventsParam!.ParameterType.ShouldBe(typeof(int),
|
||||
"maxEvents is int (not uint) so callers can pass <=0 as a 'use backend default cap' sentinel");
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var maxEventsParam = method.GetParameters().FirstOrDefault(p => p.Name == "maxEvents");
|
||||
maxEventsParam.ShouldNotBeNull("maxEvents parameter must exist for the sentinel contract");
|
||||
maxEventsParam!.ParameterType.ShouldBe(typeof(int),
|
||||
"maxEvents is int (not uint) so callers can pass <=0 as a 'use backend default cap' sentinel");
|
||||
}
|
||||
|
||||
// HistoricalEventsResult must carry a nullable ContinuationPoint so implementations
|
||||
// can signal 'more results exist' (non-null) vs 'all results returned' (null).
|
||||
@@ -179,4 +187,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
continuationProp!.PropertyType.ShouldBe(typeof(byte[]),
|
||||
"ContinuationPoint is byte[]? — null means no more pages, non-null means caller must page");
|
||||
}
|
||||
|
||||
private static List<System.Reflection.MethodInfo> Overloads(string name) =>
|
||||
typeof(IHistorianDataSource).GetMethods().Where(m => m.Name == name).ToList();
|
||||
}
|
||||
|
||||
+14
@@ -67,6 +67,20 @@ public sealed class NullHistorianDataSourceTests
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #675: the direction-aware overload's default — forward is the five-argument read, reverse is refused
|
||||
/// (the server answers <c>BadHistoryOperationUnsupported</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Direction_aware_ReadEventsAsync_forwards_forward_and_refuses_reverse()
|
||||
{
|
||||
IHistorianDataSource source = Source;
|
||||
(await source.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 0, reverse: false, Ct))
|
||||
.Events.ShouldBeEmpty();
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct));
|
||||
}
|
||||
|
||||
/// <summary>GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape.</summary>
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_is_disabled()
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
|
||||
/// <summary>
|
||||
/// The direction-aware event read (Gitea #675): forward is <c>[lo, hi)</c> ascending by <c>(EventTimeUtc, EventId)</c>;
|
||||
/// reverse is the Part 11 reverse window <c>(lo, hi]</c> DESCENDING by the same key, so a capped reverse read returns the
|
||||
/// LATEST events and flags truncation. Paging a reverse read the way the server does — resume at the last returned
|
||||
/// time inclusive and drop the boundary ties already emitted — must visit every event exactly once, newest-first,
|
||||
/// through a same-timestamp tie cluster.
|
||||
/// </summary>
|
||||
public abstract class EventReadConformance : ConformanceBase
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
/// <summary>e0@0, e1@1, then the ties t0..t4@2, then e5@3 and e6@4 (all source "s"), plus o0@2 from another source.</summary>
|
||||
private async Task<IHistorianDataSource> SeedAsync()
|
||||
{
|
||||
await B.SeedEventsAsync(
|
||||
(At(0), "e0", "s"), (At(1), "e1", "s"),
|
||||
(At(2), "t0", "s"), (At(2), "t1", "s"), (At(2), "t2", "s"), (At(2), "t3", "s"), (At(2), "t4", "s"),
|
||||
(At(3), "e5", "s"), (At(4), "e6", "s"),
|
||||
(At(2), "o0", "other"));
|
||||
return B.CreateDataSource();
|
||||
}
|
||||
|
||||
private static readonly string[] NewestFirst = ["e6", "e5", "t4", "t3", "t2", "t1", "t0", "e1", "e0"];
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_read_is_newest_first_by_time_then_EventId_descending()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
var r = await src.ReadEventsAsync("s", At(-1), At(10), 0, reverse: true, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(NewestFirst);
|
||||
r.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_window_is_end_exclusive_start_inclusive_whichever_order_the_bounds_come_in()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
// (At(1), At(3)]: e1 at the lower bound is excluded, e5 at the upper bound included.
|
||||
(await src.ReadEventsAsync("s", At(3), At(1), 0, reverse: true, Ct)).Events.Select(e => e.EventId)
|
||||
.ShouldBe(["e5", "t4", "t3", "t2", "t1", "t0"]);
|
||||
(await src.ReadEventsAsync("s", At(1), At(3), 0, reverse: true, Ct)).Events.Select(e => e.EventId)
|
||||
.ShouldBe(["e5", "t4", "t3", "t2", "t1", "t0"]);
|
||||
// Forward is unchanged: [At(1), At(3)) ascending.
|
||||
(await src.ReadEventsAsync("s", At(1), At(3), 0, reverse: false, Ct)).Events.Select(e => e.EventId)
|
||||
.ShouldBe(["e1", "t0", "t1", "t2", "t3", "t4"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_capped_reverse_read_keeps_the_LATEST_events_and_flags_truncation()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
var r = await src.ReadEventsAsync("s", At(10), At(-1), 3, reverse: true, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(["e6", "e5", "t4"]);
|
||||
r.ContinuationPoint.ShouldNotBeNull();
|
||||
// An exact fit is complete.
|
||||
(await src.ReadEventsAsync("s", At(10), At(-1), 9, reverse: true, Ct)).ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paging_a_reverse_read_through_a_tie_cluster_visits_every_event_once_newest_first()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
foreach (var cap in new[] { 1, 2, 3, 4 })
|
||||
{
|
||||
var all = new List<string>();
|
||||
var lo = At(-1);
|
||||
var from = At(10);
|
||||
var skip = 0;
|
||||
for (var page = 0; ; page++)
|
||||
{
|
||||
page.ShouldBeLessThan(40, "paging must terminate");
|
||||
var r = await src.ReadEventsAsync("s", from, lo, cap + skip, reverse: true, Ct);
|
||||
var events = r.Events.ToList();
|
||||
var drop = 0;
|
||||
while (drop < skip && drop < events.Count && events[drop].EventTimeUtc == from) drop++;
|
||||
events = events.Skip(drop).ToList();
|
||||
// The server asks for cap + skip rows (it re-reads the boundary ties it then drops), so a page is never
|
||||
// emptied by the trim while more remains.
|
||||
if (r.ContinuationPoint is not null) events.Count.ShouldBe(cap);
|
||||
|
||||
all.AddRange(events.Select(e => e.EventId));
|
||||
if (r.ContinuationPoint is null) break;
|
||||
var boundary = events[^1].EventTimeUtc;
|
||||
var ties = events.AsEnumerable().Reverse().TakeWhile(e => e.EventTimeUtc == boundary).Count();
|
||||
skip = boundary == from ? skip + ties : ties;
|
||||
from = boundary;
|
||||
}
|
||||
|
||||
all.SequenceEqual(NewestFirst).ShouldBeTrue($"cap {cap}: {string.Join(",", all)}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_backend_reports_reverse_support()
|
||||
{
|
||||
using var src = B.CreateDataSource();
|
||||
src.SupportsReverseEvents.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_oversized_reverse_tie_cluster_sliced_the_way_the_server_does_matches_the_reverse_read_order()
|
||||
{
|
||||
// The server pages WITHIN a tie cluster larger than the page by over-fetching it FORWARD over [T, T + resolution)
|
||||
// and reversing it for a reverse read (OtOpcUaNodeManager.ServeEventsAsync + HistoryPaging.SliceTieCluster). That is
|
||||
// only correct if this backend's forward cluster, reversed, is exactly the order its reverse read emits the ties in
|
||||
// — the tie-breaker must be the same key in both directions. Drive both through the real backend.
|
||||
await B.SeedEventsAsync(Enumerable.Range(0, 12).Select(i => (At(2), $"c{i:00}", "s")).Append((At(3), "z", "s")).ToArray());
|
||||
using var src = B.CreateDataSource();
|
||||
|
||||
var reverseOrder = (await src.ReadEventsAsync("s", At(10), At(0), 0, reverse: true, Ct)).Events
|
||||
.Where(e => e.EventTimeUtc == B.Round(At(2))).Select(e => e.EventId).ToList();
|
||||
var clusterReversed = (await src.ReadEventsAsync("s", B.Round(At(2)), B.Round(At(2)).AddTicks(B.ResolutionTicks), 1000, reverse: false, Ct))
|
||||
.Events.Select(e => e.EventId).Reverse().ToList();
|
||||
|
||||
clusterReversed.Count.ShouldBe(12);
|
||||
clusterReversed.ShouldBe(reverseOrder);
|
||||
|
||||
// And slicing it at a cap of 5 (skip 0, 5, 10) walks the cluster newest-first with no duplicate or gap.
|
||||
var slices = new List<string>();
|
||||
for (var skip = 0; skip < clusterReversed.Count; skip += 5) slices.AddRange(clusterReversed.Skip(skip).Take(5));
|
||||
slices.ShouldBe(Enumerable.Range(0, 12).Reverse().Select(i => $"c{i:00}"));
|
||||
|
||||
// A capped reverse read stopping inside the cluster flags truncation, so the server's cluster branch is reachable.
|
||||
var capped = await src.ReadEventsAsync("s", At(10), At(0), 4, reverse: true, Ct);
|
||||
capped.Events.Select(e => e.EventId).ShouldBe(["z", "c11", "c10", "c09"]);
|
||||
capped.ContinuationPoint.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_read_with_a_null_source_spans_every_source()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
var r = await src.ReadEventsAsync(null, At(2.5), At(1.5), 0, reverse: true, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(["t4", "t3", "t2", "t1", "t0", "o0"]);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@ public interface IConformanceBackend : IAsyncDisposable
|
||||
/// <param name="samples">The rows to store.</param>
|
||||
Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples);
|
||||
|
||||
/// <summary>
|
||||
/// Writes historical events directly in the backend's event table (never through the alarm writer): event id,
|
||||
/// source and time as given (rounded to the storage resolution), severity 500, received time = event time.
|
||||
/// </summary>
|
||||
/// <param name="events">The events to store.</param>
|
||||
Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events);
|
||||
|
||||
/// <summary>
|
||||
/// Live (non-deleted) rows for a tag, ascending by time, read directly from storage. The status is returned
|
||||
/// VERBATIM as stored — there is no read-side clamp (an Empty row stored non-Bad reads back non-Bad here).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
|
||||
@@ -171,7 +172,8 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
// The time-weighted path over the whole range: ≈ the extrapolated 50 that dominates eight millennia.
|
||||
var tw = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.TimeAverage2, Ct)).Samples.Single();
|
||||
((double)tw.Value!).ShouldBe(50d, 1e-6);
|
||||
tw.StatusCode.ShouldBe(UncertainCalc);
|
||||
// The data starts (+0) strictly inside the one interval ⇒ Partial as well (#677, the SDK's m_startOfData rule).
|
||||
tw.StatusCode.ShouldBe(UncertainCalc | 0x4u);
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
@@ -196,12 +198,165 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_window_is_computed_forward_and_returned_descending()
|
||||
public async Task A_reverse_window_is_anchored_at_its_later_time()
|
||||
{
|
||||
// #677 (Part 13 / SDK): At(80) → At(0) is the window (0,80] in intervals (60,80], (40,60], (20,40], (0,20], each
|
||||
// stamped with its LATER edge, latest first. A sample on an edge belongs to the interval it ends: +40 is in
|
||||
// (20,40], +20 (Bad) in (0,20], and +0 is outside the window. (The pre-#677 layout answered [60,80) … [0,20)
|
||||
// stamped 60/40/20/0 with values null, 50, 40, 15.)
|
||||
await ConformanceData.SeedAAsync(B);
|
||||
var s = (await B.CreateDataSource().ReadProcessedAsync("A", At(80), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(60)), B.Round(At(40)), B.Round(At(20)), B.Round(At(0))]);
|
||||
s.Select(x => x.Value).ShouldBe([null, 50d, 40d, 15d]);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(80)), B.Round(At(60)), B.Round(At(40)), B.Round(At(20))]);
|
||||
s.Select(x => x.Value).ShouldBe([null, null, 45d, 20d]);
|
||||
s.Select(x => x.StatusCode).ShouldBe([NoDataCalc, NoDataCalc, GoodCalc, UncertainCalc]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_window_truncates_its_earliest_interval()
|
||||
{
|
||||
// At(50) → At(0), 20 s over tag A (+0=10, +10=20, +20=Bad, +30=40, +40=50): (30,50] = {+40} → 50 (+30 is its
|
||||
// earlier edge, excluded); (10,30] = {+20 Bad, +30} → 40, Uncertain; (0,10] = {+10} → 20 — the truncated interval
|
||||
// is the EARLIEST one. (Pre-#677: [0,20), [20,40), [40,50) stamped 40/20/0 with values 50, 40, 15.)
|
||||
await ConformanceData.SeedAAsync(B);
|
||||
var s = (await B.CreateDataSource().ReadProcessedAsync("A", At(50), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(50)), B.Round(At(30)), B.Round(At(10))]);
|
||||
s.Select(x => x.Value).ShouldBe([50d, 40d, 20d]);
|
||||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_bucket_edges_are_later_edge_inclusive_to_the_tick()
|
||||
{
|
||||
// The mirror of the forward edge test: At(40) → At(0), 20 s ⇒ (20,40], (0,20]. The bucket math is pushed down,
|
||||
// so this pins the SQL, not just the C# plan.
|
||||
var r = B.ResolutionTicks;
|
||||
await B.SeedAsync("ER",
|
||||
(At(0), HistorianValue.FromDouble(1000), Good), // == earlier edge: excluded
|
||||
(At(0).AddTicks(r), HistorianValue.FromDouble(1), Good), // first tick of (0,20]
|
||||
(At(20), HistorianValue.FromDouble(2), Good), // == edge: the LATER edge of (0,20]
|
||||
(At(20).AddTicks(r), HistorianValue.FromDouble(4), Good), // first tick of (20,40]
|
||||
(At(40), HistorianValue.FromDouble(8), Good), // == request start: included, in (20,40]
|
||||
(At(40).AddTicks(r), HistorianValue.FromDouble(2000), Good)); // after the window: excluded
|
||||
var s = (await B.CreateDataSource().ReadProcessedAsync("ER", At(40), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(40)), B.Round(At(20))]);
|
||||
s.Select(x => x.Value).ShouldBe([6d, 1.5d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_interval_the_data_starts_inside_carries_the_Partial_bit()
|
||||
{
|
||||
// #677 (SDK m_startOfData): the tag's first sample is +5, inside [0,20) ⇒ Partial there for every aggregate but
|
||||
// Average; [20,40) is fully covered. With an earlier sample stored (even outside the window) nothing is Partial.
|
||||
await B.SeedAsync("LS",
|
||||
(At(5), HistorianValue.FromDouble(10), Good),
|
||||
(At(15), HistorianValue.FromDouble(30), Good),
|
||||
(At(25), HistorianValue.FromDouble(20), Good),
|
||||
(At(60), HistorianValue.FromDouble(20), Good));
|
||||
var src = B.CreateDataSource();
|
||||
foreach (var agg in new[] { HistoryAggregateType.Count, HistoryAggregateType.Minimum, HistoryAggregateType.TimeAverage2 })
|
||||
{
|
||||
var s = (await src.ReadProcessedAsync("LS", At(0), At(40), Twenty, agg, Ct)).Samples;
|
||||
(s[0].StatusCode & 0x4u).ShouldBe(0x4u, agg.ToString());
|
||||
(s[1].StatusCode & 0x4u).ShouldBe(0u, agg.ToString());
|
||||
}
|
||||
(await src.ReadProcessedAsync("LS", At(0), At(40), Twenty, HistoryAggregateType.Average, Ct)).Samples
|
||||
.ShouldAllBe(x => (x.StatusCode & 0x4u) == 0);
|
||||
|
||||
await B.SeedAsync("LS2",
|
||||
(At(-100), HistorianValue.FromDouble(10), Good),
|
||||
(At(5), HistorianValue.FromDouble(10), Good),
|
||||
(At(15), HistorianValue.FromDouble(30), Good),
|
||||
(At(25), HistorianValue.FromDouble(20), Good));
|
||||
(await src.ReadProcessedAsync("LS2", At(0), At(40), Twenty, HistoryAggregateType.Count, Ct)).Samples
|
||||
.ShouldAllBe(x => (x.StatusCode & 0x4u) == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_read_sets_no_start_of_data_Partial_and_matches_the_SDK()
|
||||
{
|
||||
// Review F1: the SDK applies the start-of-data rule to FORWARD reads only. Reverse 40 → 0 at 10 s over data that
|
||||
// starts at +7: (0,10] holds the data's start but is not Partial — checked through the real SQL and the SDK oracle.
|
||||
var rows = Enumerable.Range(0, 20)
|
||||
.Select(i => (At(7 + i * 5), HistorianValue.FromDouble(i % 2 == 0 ? 20 : 40), Good)).ToArray();
|
||||
await B.SeedAsync("RLS", rows);
|
||||
var sdkRows = rows.Select(r => (B.Round(r.Item1), r.Item2, r.Item3)).ToArray();
|
||||
var src = B.CreateDataSource();
|
||||
var ten = TimeSpan.FromSeconds(10);
|
||||
foreach (var agg in new[]
|
||||
{
|
||||
HistoryAggregateType.Count, HistoryAggregateType.Minimum, HistoryAggregateType.Range,
|
||||
HistoryAggregateType.Delta, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2,
|
||||
HistoryAggregateType.Total2,
|
||||
})
|
||||
{
|
||||
var ours = (await src.ReadProcessedAsync("RLS", At(40), At(0), ten, agg, Ct)).Samples;
|
||||
ours.ShouldAllBe(x => (x.StatusCode & 0x4u) == 0, agg.ToString());
|
||||
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
|
||||
SdkAggregateOracle.ShouldMatch(ours, SdkAggregateOracle.Run(agg, B.Round(At(40)), B.Round(At(0)), ten, sdkRows),
|
||||
$"{agg} reverse late start", compareCalculatedInterpolatedBits: tw,
|
||||
// pre-existing, #680: the SDK's Delta is Good with a Bad sample present; ours Uncertain (none here).
|
||||
compareCodeBits: agg != HistoryAggregateType.Delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 (no empty interval).</summary>
|
||||
private Task SeedDenseAsync() => B.SeedAsync("P", DenseRows());
|
||||
|
||||
private static (DateTime T, HistorianValue V, uint Status)[] DenseRows() => Enumerable.Range(0, 45)
|
||||
.Select(i => (At(-10 + i * 2.5), HistorianValue.FromDouble(10 + (i * 7 % 13)), i == 13 ? Bad : Good))
|
||||
.ToArray();
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 80, 20)]
|
||||
[InlineData(80, 0, 20)]
|
||||
[InlineData(77, 3, 20)]
|
||||
[InlineData(3, 77, 20)]
|
||||
public async Task Processed_reads_match_the_SDK_aggregate_calculators(double from, double to, double intervalS)
|
||||
{
|
||||
// #677: the real backend (pushed-down SQL bucket math + the shared evaluators) against the pinned SDK's own Part 13
|
||||
// calculators over the same stored rows — timestamps, values, status code bits and the Partial bit, forward and
|
||||
// reverse. See SdkAggregateParityTests (Sql.Common.Tests) for the documented divergences this avoids/masks.
|
||||
await SeedDenseAsync();
|
||||
var rows = DenseRows().Select(r => (B.Round(r.T), r.V, r.Status)).ToArray();
|
||||
var src = B.CreateDataSource();
|
||||
var interval = TimeSpan.FromSeconds(intervalS);
|
||||
// The SDK sees the window the backend actually serves (QuestDB rounds the bounds to µs).
|
||||
var (start, end) = (B.Round(At(from)), B.Round(At(to)));
|
||||
foreach (var agg in new[]
|
||||
{
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||
HistoryAggregateType.Count, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2,
|
||||
HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Interpolative,
|
||||
HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
|
||||
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
|
||||
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad,
|
||||
})
|
||||
{
|
||||
var ours = (await src.ReadProcessedAsync("P", At(from), At(to), interval, agg, Ct)).Samples.ToList();
|
||||
var startOrEnd = agg is HistoryAggregateType.Start or HistoryAggregateType.End;
|
||||
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
|
||||
if (tw && agg != HistoryAggregateType.Interpolative)
|
||||
{
|
||||
// Documented divergence: ours flags the truncated interval Partial, the SDK does not.
|
||||
var plan = BucketPlan.Create(start, end, interval);
|
||||
for (long i = 0; i < plan.BucketCount; i++)
|
||||
if (plan.IsTruncated(i))
|
||||
{
|
||||
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
|
||||
ours[k] = ours[k] with { StatusCode = ours[k].StatusCode & ~0x4u };
|
||||
}
|
||||
}
|
||||
var reverseInterpolative = agg == HistoryAggregateType.Interpolative && from > to;
|
||||
SdkAggregateOracle.ShouldMatch(ours, SdkAggregateOracle.Run(agg, start, end, interval, rows),
|
||||
$"{agg} {from}→{to}/{intervalS}s",
|
||||
compareCalculatedInterpolatedBits: tw && !reverseInterpolative,
|
||||
// pre-existing, #680: TimeAverage/Total status rule; Start/End raw-sample timestamp + status; Delta status;
|
||||
// PercentGood/Bad per-sample vs per-duration values (see SdkAggregateParityTests).
|
||||
compareCodeBits: agg is not (HistoryAggregateType.TimeAverage or HistoryAggregateType.Total
|
||||
or HistoryAggregateType.Delta) && !reverseInterpolative && !startOrEnd,
|
||||
compareTimestamps: !startOrEnd,
|
||||
compareValues: agg is not (HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -245,7 +400,8 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
(await One(HistoryAggregateType.Count)).StatusCode.ShouldBe(NoDataCalc);
|
||||
var pb = await One(HistoryAggregateType.PercentBad);
|
||||
pb.Value.ShouldBe(100d);
|
||||
pb.StatusCode.ShouldBe(GoodCalc);
|
||||
// The tag's data starts (+1) inside the interval ⇒ Partial on the non-Bad percentage (#677).
|
||||
pb.StatusCode.ShouldBe(GoodCalc | 0x4u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the pinned OPC UA SDK's own Part 13 aggregate calculators (<c>Opc.Ua.Server</c> — the classes the SDK's
|
||||
/// <c>AggregateManager</c> registers) in-process over a sample set, so our processed reads can be compared with the
|
||||
/// reference implementation bucket by bucket (#677). The calculators run under the server's default
|
||||
/// <see cref="AggregateConfiguration"/> (TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
|
||||
/// UseSlopedExtrapolation = false), sloped interpolation, source timestamps.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites.
|
||||
/// </remarks>
|
||||
public static class SdkAggregateOracle
|
||||
{
|
||||
/// <summary>One processed value: value (double / int / null), full status code, timestamp.</summary>
|
||||
public readonly record struct Result(object? Value, uint StatusCode, DateTime Timestamp);
|
||||
|
||||
/// <summary>The server-default aggregate configuration (what <c>AggregateManager.GetDefaultConfiguration</c> returns).</summary>
|
||||
public static AggregateConfiguration DefaultConfiguration => new()
|
||||
{
|
||||
TreatUncertainAsBad = false,
|
||||
PercentDataBad = 100,
|
||||
PercentDataGood = 100,
|
||||
UseSlopedExtrapolation = false,
|
||||
UseServerCapabilitiesDefaults = false,
|
||||
};
|
||||
|
||||
/// <summary>Runs the SDK calculator for <paramref name="aggregate"/> over <paramref name="samples"/>.</summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
|
||||
/// <param name="end">Request end.</param>
|
||||
/// <param name="interval">Processing interval.</param>
|
||||
/// <param name="samples">Every stored sample of the tag, any order (sorted here).</param>
|
||||
/// <returns>The SDK's processed values, in the order it returns them.</returns>
|
||||
public static IReadOnlyList<Result> Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval,
|
||||
IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples)
|
||||
{
|
||||
var id = AggregateId(aggregate);
|
||||
var ms = interval.TotalMilliseconds;
|
||||
var cfg = DefaultConfiguration;
|
||||
IAggregateCalculator calc = aggregate switch
|
||||
{
|
||||
HistoryAggregateType.Average or HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2
|
||||
or HistoryAggregateType.Total or HistoryAggregateType.Total2 =>
|
||||
new AverageAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||
HistoryAggregateType.Minimum or HistoryAggregateType.Maximum or HistoryAggregateType.Range
|
||||
or HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime =>
|
||||
new MinMaxAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||
HistoryAggregateType.Count => new CountAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||
HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad =>
|
||||
new StatusAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||
HistoryAggregateType.Start or HistoryAggregateType.End or HistoryAggregateType.Delta =>
|
||||
new StartEndAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||
HistoryAggregateType.Interpolative => new AggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "no SDK calculator mapped"),
|
||||
};
|
||||
|
||||
// The SDK expects values in the direction of the read: ascending forward, descending reverse.
|
||||
var ordered = samples.OrderBy(s => s.Time).ToList();
|
||||
if (end < start) ordered.Reverse();
|
||||
foreach (var s in ordered) calc.QueueRawValue(ToDataValue(s.Time, s.Value, s.Status));
|
||||
|
||||
var results = new List<Result>();
|
||||
for (var guard = 0; guard < 100_000; guard++)
|
||||
{
|
||||
var dv = calc.GetProcessedValue(true);
|
||||
if (dv is null) break;
|
||||
results.Add(new Result(dv.Value, dv.StatusCode.Code, DateTime.SpecifyKind(dv.SourceTimestamp, DateTimeKind.Utc)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts our snapshots equal the SDK's: same count, same timestamps, same status code bits (severity + sub-code)
|
||||
/// and Partial bit (and optionally the Calculated / Interpolated bits), same value (doubles to 1e-9; ints exactly;
|
||||
/// null on a Bad status). A failure lists every differing interval side by side.
|
||||
/// </summary>
|
||||
/// <param name="ours">Our processed values.</param>
|
||||
/// <param name="sdk">The SDK's.</param>
|
||||
/// <param name="label">Context for failure messages.</param>
|
||||
/// <param name="compareCalculatedInterpolatedBits">Also compare the Calculated / Interpolated aggregate bits.</param>
|
||||
/// <param name="compareCodeBits">Compare the severity + sub-code (false only for a documented status divergence).</param>
|
||||
/// <param name="compareValues">Compare the values (false only for a documented value divergence).</param>
|
||||
/// <param name="compareTimestamps">Compare the timestamps (false only for a documented timestamp divergence).</param>
|
||||
/// <param name="comparePartialBit">Compare the Partial bit (false only for a documented divergence).</param>
|
||||
public static void ShouldMatch(IReadOnlyList<DataValueSnapshot> ours, IReadOnlyList<Result> sdk, string label,
|
||||
bool compareCalculatedInterpolatedBits = true, bool compareCodeBits = true, bool compareValues = true,
|
||||
bool compareTimestamps = true, bool comparePartialBit = true)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
if (ours.Count != sdk.Count) problems.Add($"interval count: ours {ours.Count}, sdk {sdk.Count}");
|
||||
for (var i = 0; i < Math.Min(ours.Count, sdk.Count); i++)
|
||||
{
|
||||
var o = ours[i];
|
||||
var s = sdk[i];
|
||||
var bitMask = (comparePartialBit ? 0x4u : 0u) | (compareCodeBits ? 0xFFFF0000u : 0u)
|
||||
| (compareCalculatedInterpolatedBits ? 0x3u : 0u);
|
||||
var same = (!compareTimestamps || o.SourceTimestampUtc == s.Timestamp)
|
||||
&& (o.StatusCode & bitMask) == (s.StatusCode & bitMask);
|
||||
if (!compareValues || (!compareCodeBits && StatusCode.IsBad(s.StatusCode)))
|
||||
{
|
||||
// Values not compared (or the SDK answered Bad under a status this comparison does not check).
|
||||
}
|
||||
else if (same && !StatusCode.IsBad(s.StatusCode))
|
||||
{
|
||||
same = s.Value switch
|
||||
{
|
||||
null => o.Value is null,
|
||||
int n => o.Value is not null && Convert.ToInt32(o.Value) == n,
|
||||
_ => o.Value is not null && Math.Abs(Convert.ToDouble(o.Value) - Convert.ToDouble(s.Value)) <= 1e-9,
|
||||
};
|
||||
}
|
||||
else if (same && compareCodeBits) same = o.Value is null;
|
||||
if (!same)
|
||||
problems.Add($"[{i}] ours {o.SourceTimestampUtc:HH:mm:ss.fffffff} {o.Value ?? "null"} 0x{o.StatusCode:X8}" +
|
||||
$" | sdk {s.Timestamp:HH:mm:ss.fffffff} {s.Value ?? "null"} 0x{s.StatusCode:X8}");
|
||||
}
|
||||
problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems));
|
||||
}
|
||||
|
||||
private static DataValue ToDataValue(DateTime t, HistorianValue v, uint status) => new()
|
||||
{
|
||||
WrappedValue = v.Kind switch
|
||||
{
|
||||
HistorianValueKind.Empty => Variant.Null,
|
||||
HistorianValueKind.Int64 => new Variant(v.Integer),
|
||||
HistorianValueKind.Boolean => new Variant(v.Number != 0),
|
||||
HistorianValueKind.String => new Variant(v.Text),
|
||||
_ => new Variant(v.Number),
|
||||
},
|
||||
StatusCode = status,
|
||||
SourceTimestamp = t,
|
||||
ServerTimestamp = t,
|
||||
};
|
||||
|
||||
private static NodeId AggregateId(HistoryAggregateType a) => a switch
|
||||
{
|
||||
HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average,
|
||||
HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum,
|
||||
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
|
||||
HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
|
||||
HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
|
||||
HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
|
||||
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
|
||||
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
|
||||
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
|
||||
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
|
||||
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
|
||||
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
|
||||
HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
|
||||
HistoryAggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
|
||||
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
|
||||
HistoryAggregateType.Total2 => ObjectIds.AggregateFunction_Total2,
|
||||
HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(a), a, "no SDK aggregate id mapped"),
|
||||
};
|
||||
}
|
||||
+15
-6
@@ -71,7 +71,7 @@ public sealed class WireBackendTests
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, TimeSpan.FromSeconds(5));
|
||||
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, WaitTimeout);
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
@@ -82,7 +82,7 @@ public sealed class WireBackendTests
|
||||
|
||||
await WaitFor(() =>
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/X/AbsolutePosition"),
|
||||
TimeSpan.FromSeconds(3));
|
||||
WaitTimeout);
|
||||
state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"].ShouldBe(123456);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public sealed class WireBackendTests
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { LastProgramInfo: not null },
|
||||
TimeSpan.FromSeconds(5));
|
||||
WaitTimeout);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Program/Name",
|
||||
@@ -194,7 +194,7 @@ public sealed class WireBackendTests
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state is not null && state.LastTimers.Count == 4;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
}, WaitTimeout);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Timers/PowerOnSeconds",
|
||||
@@ -265,8 +265,11 @@ public sealed class WireBackendTests
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMotorSpeed: true }
|
||||
&& state.LastSpindleLoads.Count >= 2;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
&& state.LastSpindleLoads.Count >= 2
|
||||
// Motor speeds are cached after a SECOND wire round-trip (FocasDriver spindle poll), so the loads
|
||||
// arriving first does not mean the speeds have — waiting on loads alone raced under load (#678.3).
|
||||
&& state.LastSpindleMotorSpeeds.Count >= 2;
|
||||
}, WaitTimeout);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Spindle/S1/Load",
|
||||
@@ -282,6 +285,10 @@ public sealed class WireBackendTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Generous: the sim fixture answers fast, but a loaded CI host (e.g. a concurrent docker build) does not.</summary>
|
||||
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Polls <paramref name="pred"/>; FAILS the test with a clear message on timeout rather than returning silently.</summary>
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
@@ -290,5 +297,7 @@ public sealed class WireBackendTests
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
if (pred()) return;
|
||||
Assert.Fail($"Condition not met within {timeout.TotalSeconds:0.#}s waiting on the FOCAS wire backend.");
|
||||
}
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
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.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #675: the Gateway backend's event-read bounds and direction, offline.
|
||||
/// <list type="bullet">
|
||||
/// <item>A reverse (newest-first) read is refused before anything is sent — the gateway streams ascending only.</item>
|
||||
/// <item>The open upper bound a start-only forward read uses (<c>DateTime.MaxValue</c>, UTC kind) survives the
|
||||
/// PUBLISHED client package's proto conversion — captured on the wire by an in-process gRPC server — and the
|
||||
/// Windows FILETIME encoding the gateway converts it to. The live AVEVA leg (what the historian does with a
|
||||
/// year-9999 bound) stays open: it needs the VPN.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class GatewayEventBoundsTests
|
||||
{
|
||||
private static readonly DateTime UtcMax = new(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_events_read_is_refused_and_sends_nothing()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
ds.SupportsReverseEvents.ShouldBeFalse("the server must keep end-only reads forward on the default backend");
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct));
|
||||
fake.ReadEventsCallCount.ShouldBe(0);
|
||||
|
||||
// Forward through the same overload is the five-argument read, unchanged.
|
||||
await ds.ReadEventsAsync("Pump1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10, reverse: false, Ct);
|
||||
fake.ReadEventsCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UtcMax_round_trips_through_the_proto_Timestamp_and_FILETIME_encodings()
|
||||
{
|
||||
var ts = Timestamp.FromDateTime(UtcMax);
|
||||
ts.ToDateTime().ShouldBe(UtcMax);
|
||||
ts.ToDateTime().Kind.ShouldBe(DateTimeKind.Utc);
|
||||
|
||||
// The gateway hands its bounds to the historian as FILETIME (100 ns since 1601) — in range up to year 30828.
|
||||
var fileTime = ts.ToDateTime().ToFileTimeUtc();
|
||||
DateTime.FromFileTimeUtc(fileTime).ShouldBe(UtcMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_start_only_bound_of_UtcMax_reaches_the_wire_intact_through_the_published_client()
|
||||
{
|
||||
var capture = new CapturingReadService();
|
||||
await using var server = await StartServerAsync(capture);
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(
|
||||
new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = $"http://127.0.0.1:{server.Port}",
|
||||
ApiKey = "histgw_test_key",
|
||||
UseTls = false,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
IHistorianDataSource ds = new GatewayHistorianDataSource(adapter, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
|
||||
var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var r = await ds.ReadEventsAsync("Pump1", start, UtcMax, 5, Ct);
|
||||
|
||||
r.Events.ShouldBeEmpty();
|
||||
capture.Request.ShouldNotBeNull();
|
||||
capture.Request!.StartTime.ToDateTime().ShouldBe(start);
|
||||
capture.Request.EndTime.ToDateTime().ShouldBe(UtcMax);
|
||||
DateTime.FromFileTimeUtc(capture.Request.EndTime.ToDateTime().ToFileTimeUtc()).ShouldBe(UtcMax);
|
||||
}
|
||||
|
||||
private sealed class CapturingReadService : HistorianRead.HistorianReadBase
|
||||
{
|
||||
public ReadEventsRequest? Request { get; private set; }
|
||||
|
||||
public override Task ReadEvents(
|
||||
ReadEventsRequest request, IServerStreamWriter<HistorianEvent> responseStream, ServerCallContext context)
|
||||
{
|
||||
Request = request;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Server(WebApplication app, int port) : IAsyncDisposable
|
||||
{
|
||||
public int Port { get; } = port;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Server> StartServerAsync(CapturingReadService 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<CapturingReadService>();
|
||||
await app.StartAsync();
|
||||
var port = new Uri(app.Urls.First()).Port;
|
||||
return new Server(app, port);
|
||||
}
|
||||
}
|
||||
+21
@@ -78,6 +78,27 @@ public sealed class GatewayTagProvisionerTests
|
||||
Assert.True(defs[0].StorageRateMs > 0, "provisioned tags must carry a positive storage rate (0 fails EnsureTags).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Definition_carries_an_integral_divisor_of_one()
|
||||
{
|
||||
// #677: AVEVA's Total integral is value·seconds ÷ IntegralDivisor. The proto3 double default is 0, which the
|
||||
// gateway passes through, so every provisioned tag must say 1 explicitly — the unit the SQL backends' Total uses.
|
||||
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
|
||||
var p = Provisioner(fake);
|
||||
|
||||
await p.EnsureTagsAsync(
|
||||
new[]
|
||||
{
|
||||
new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null),
|
||||
new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null),
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var defs = fake.LastEnsureDefinitions!;
|
||||
Assert.Equal(2, defs.Count);
|
||||
Assert.All(defs, d => Assert.Equal(1.0, d.IntegralDivisor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_metadata_and_coalesces_null_metadata_to_empty()
|
||||
{
|
||||
|
||||
+6
@@ -15,12 +15,18 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- ServiceCollection + BuildServiceProvider for the CreateDataSource factory test. -->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection"/>
|
||||
<!-- In-process gRPC server capturing the published client's ReadEvents request (#675 bounds pin). -->
|
||||
<PackageReference Include="Grpc.AspNetCore"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
+7
@@ -60,3 +60,10 @@ public sealed class QuestDbModifiedValueConformance(QuestDbFixture fx) : Modifie
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbEventReadConformance(QuestDbFixture fx) : EventReadConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
+21
@@ -143,6 +143,27 @@ internal sealed class QuestDbConformanceBackend : IConformanceBackend
|
||||
await Wal.WaitAsync(Conn.SampleTable, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events)
|
||||
{
|
||||
await using (var c = await Conn.OpenAsync(CancellationToken.None))
|
||||
{
|
||||
foreach (var (t, id, source) in events)
|
||||
{
|
||||
await using var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT INTO {Conn.EventTable} (ts, event_id, source, severity, message, received_utc) VALUES ($1, $2, $3, $4, $5, $6)");
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
QuestDbConnection.BindText(cmd, id);
|
||||
QuestDbConnection.BindText(cmd, source);
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = 500 });
|
||||
QuestDbConnection.BindText(cmd, null);
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
await Wal.WaitAsync(Conn.EventTable, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Runs a statement against this table set (test-only escape hatch for hand-made rows), then waits for WAL visibility of the sample table.</summary>
|
||||
/// <param name="sql">The statement; parameters are bound positionally by <paramref name="bind"/>.</param>
|
||||
/// <param name="bind">Binds the parameters.</param>
|
||||
|
||||
+2
-1
@@ -82,7 +82,8 @@ public sealed class ReadTests(QuestDbFixture fx) : IAsyncLifetime
|
||||
foreach (var agg in new[] { HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.MaximumActualTime })
|
||||
{
|
||||
var s = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, agg, Ct)).Samples.Single();
|
||||
s.StatusCode.ShouldBe(UncertainCalc, agg.ToString());
|
||||
// The data starts (+0) strictly inside the one interval ⇒ Partial on every aggregate but Average (#677).
|
||||
s.StatusCode.ShouldBe(agg == HistoryAggregateType.Average ? UncertainCalc : UncertainCalc | 0x4u, agg.ToString());
|
||||
}
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single().Value.ShouldBe(30d);
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Start, Ct)).Samples.Single().Value.ShouldBe(10d);
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674.1 — pins the QuestDB TTL behaviour the historian's retention relies on: a table TTL is judged against
|
||||
/// the wall clock (<c>min(max timestamp, now)</c>), so one far-future row — a 2066 typo that reached the backend
|
||||
/// without going through the node manager's <c>HistoryUpdateMaxFutureSkew</c> bound — cannot age out every older
|
||||
/// partition, including the Part 11 modification trail (the record of who changed history).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Positive control.</b> A partition genuinely older than the TTL (40 days, against a 30-day TTL) is kept until
|
||||
/// the far-future commit and evicted BY it — proving TTL housekeeping ran on exactly the commit that could have
|
||||
/// dropped the in-TTL partitions. Without it the "nothing evicted" assertion would also pass against a table
|
||||
/// whose TTL never ran.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ Observed on QuestDB 10.0.1 (2026-10-11): the in-TTL partitions also survived with
|
||||
/// <c>cairo.ttl.use.wall.clock=false</c> (verified effective via <c>SHOW PARAMETERS</c>), so this test does not
|
||||
/// isolate that flag — it pins the outcome. Keep the flag at its default (<c>true</c>) regardless; the server-side
|
||||
/// future-skew bound is the primary guard.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class TtlWallClockTests(QuestDbFixture fx)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly HistoryUpdateContext Who = new("ttl-test", null);
|
||||
|
||||
[Fact]
|
||||
public async Task A_far_future_row_evicts_only_partitions_older_than_the_ttl_by_the_wall_clock()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var o = fx.Options(fx.UniquePrefix(), retentionDays: 30);
|
||||
var c = new QuestDbConnection(o);
|
||||
var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
|
||||
var tags = new QuestDbTagCatalog(c, new QuestDbSchemaBootstrapper(c, o), wal);
|
||||
var updater = new QuestDbHistorianDataUpdater(c, tags, wal);
|
||||
|
||||
// Whole seconds (µs-aligned), one row per DAY partition. With the newest row 10 days old, the 40-day partition is
|
||||
// still inside the TTL measured from the table's max timestamp, so this commit evicts nothing.
|
||||
var now = DateTime.UtcNow;
|
||||
var today = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, DateTimeKind.Utc);
|
||||
DateTime[] older = [today.AddDays(-40), today.AddDays(-20), today.AddDays(-10)];
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, older.Select((t, i) => V(t, i)).ToList(), Who, Ct))
|
||||
.ShouldAllBe(code => code == HistorianStatusCodes.GoodEntryInserted);
|
||||
(await DaysAsync(c, c.SampleTable)).ShouldBe(older.Select(t => t.Date));
|
||||
(await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(older.Select(t => t.Date));
|
||||
|
||||
// The far-future row, then one more ordinary commit (TTL housekeeping runs on commits).
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert,
|
||||
[V(new DateTime(2066, 1, 1, 0, 0, 0, DateTimeKind.Utc), 9)], Who, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, [V(today, 10)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await wal.WaitAsync(c.SampleTable, Ct)).ShouldBeTrue();
|
||||
(await wal.WaitAsync(c.SampleModifiedTable, Ct)).ShouldBeTrue();
|
||||
|
||||
// Only the genuinely expired 40-day partition is gone; the 20- and 10-day partitions survive in both tables.
|
||||
DateTime[] expected = [today.AddDays(-20).Date, today.AddDays(-10).Date, today.Date, new(2066, 1, 1)];
|
||||
(await DaysAsync(c, c.SampleTable)).ShouldBe(expected);
|
||||
(await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
private static HistorizationValue V(DateTime t, double v) => new(t, HistorianValue.FromDouble(v), HistorianStatusCodes.Good);
|
||||
|
||||
/// <summary>The distinct UTC days holding rows, ascending.</summary>
|
||||
private static async Task<List<DateTime>> DaysAsync(QuestDbConnection c, string table)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(Ct);
|
||||
await using var cmd = c.CreateCommand(conn, $"SELECT ts FROM {table} ORDER BY ts");
|
||||
await using var r = await cmd.ExecuteReaderAsync(Ct);
|
||||
var days = new SortedSet<DateTime>();
|
||||
while (await r.ReadAsync(Ct)) days.Add(QuestDbConnection.ReadUtc(r, 0).Date);
|
||||
return days.ToList();
|
||||
}
|
||||
}
|
||||
+2
@@ -14,6 +14,8 @@
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- SDK Part 13 aggregate calculators: the conformance suite's processed-read parity oracle (#677). -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
+1
-3
@@ -37,9 +37,7 @@ public sealed class AggregateEvaluatorTests
|
||||
private static IReadOnlyList<DataValueSnapshot> ReadA(HistoryAggregateType agg, bool maximum = false)
|
||||
{
|
||||
var plan = BucketPlan.Create(At(0), At(80), TimeSpan.FromSeconds(20));
|
||||
var evaluated = RowsA(maximum).ToDictionary(kv => kv.Key,
|
||||
kv => AggregateEvaluator.Evaluate(agg, kv.Value, plan.BucketStart(kv.Key), Now));
|
||||
return plan.Assemble(evaluated, t => AggregateEvaluator.NoData(t, Now));
|
||||
return AggregateEvaluator.EvaluateAll(agg, plan, RowsA(maximum).Values, At(0), Now);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
+101
-4
@@ -28,8 +28,10 @@ public sealed class BucketPlanTests
|
||||
{
|
||||
var p = BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20));
|
||||
p.BucketCount.ShouldBe(3);
|
||||
p.BucketStart(2).ShouldBe(At(40));
|
||||
p.BucketStart(2).Kind.ShouldBe(DateTimeKind.Utc);
|
||||
p.BucketTimestamp(2).ShouldBe(At(40));
|
||||
p.BucketTimestamp(2).Kind.ShouldBe(DateTimeKind.Utc);
|
||||
p.IsTruncated(2).ShouldBeTrue();
|
||||
p.IsTruncated(1).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,14 +45,104 @@ public sealed class BucketPlanTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reverse_window_is_planned_forward_and_assembled_descending()
|
||||
public void A_reverse_window_is_anchored_at_its_later_time_and_stamped_with_later_edges()
|
||||
{
|
||||
// #677 (Part 13 / SDK CreateSlice + GetTimestamp): At(80) → At(0), 20 s ⇒ (60,80], (40,60], (20,40], (0,20],
|
||||
// each stamped with its later edge, latest first.
|
||||
var p = BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20));
|
||||
p.Reversed.ShouldBeTrue();
|
||||
p.StartUtc.ShouldBe(At(0));
|
||||
p.EndUtc.ShouldBe(At(80));
|
||||
var s = p.Assemble(new Dictionary<long, DataValueSnapshot>(), t => new DataValueSnapshot(null, 0, t, t));
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(60), At(40), At(20), At(0)]);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reverse_window_puts_the_partial_interval_at_its_earliest_end()
|
||||
{
|
||||
// At(50) → At(0), 20 s: (30,50], (10,30], (0,10] — the truncated bucket is the EARLIEST (the old layout truncated
|
||||
// the latest: [0,20), [20,40), [40,50)).
|
||||
var p = BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20));
|
||||
p.BucketCount.ShouldBe(3);
|
||||
p.LowerTicks(0).ShouldBe(At(0).Ticks);
|
||||
p.UpperTicks(0).ShouldBe(At(10).Ticks);
|
||||
p.IsTruncated(0).ShouldBeTrue();
|
||||
p.LowerTicks(2).ShouldBe(At(30).Ticks);
|
||||
p.UpperTicks(2).ShouldBe(At(50).Ticks);
|
||||
p.IsTruncated(2).ShouldBeFalse();
|
||||
var s = p.Assemble(new Dictionary<long, DataValueSnapshot>(), t => new DataValueSnapshot(null, 0, t, t));
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(50), At(30), At(10)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forward_buckets_are_start_inclusive_and_reverse_buckets_later_edge_inclusive()
|
||||
{
|
||||
var fwd = BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20));
|
||||
fwd.IndexOf(At(0)).ShouldBe(0);
|
||||
fwd.IndexOf(At(20)).ShouldBe(1);
|
||||
fwd.IndexOf(At(20).AddTicks(-1)).ShouldBe(0);
|
||||
fwd.IndexOf(At(49.9999999)).ShouldBe(2);
|
||||
fwd.IndexOf(At(50)).ShouldBeNull();
|
||||
fwd.IndexOf(At(0).AddTicks(-1)).ShouldBeNull();
|
||||
|
||||
// (0,10] (10,30] (30,50]: the later time is in the window, the earlier one is not (as a reverse raw read).
|
||||
var rev = BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20));
|
||||
rev.IndexOf(At(50)).ShouldBe(2);
|
||||
rev.IndexOf(At(30)).ShouldBe(1);
|
||||
rev.IndexOf(At(30).AddTicks(1)).ShouldBe(2);
|
||||
rev.IndexOf(At(10)).ShouldBe(0);
|
||||
rev.IndexOf(At(10).AddTicks(1)).ShouldBe(1);
|
||||
rev.IndexOf(At(0).AddTicks(1)).ShouldBe(0);
|
||||
rev.IndexOf(At(0)).ShouldBeNull();
|
||||
rev.IndexOf(At(50).AddTicks(1)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_backend_bucket_formula_matches_IndexOf_in_both_directions()
|
||||
{
|
||||
// b = (t − start + adjust) / interval and b = (unixTicks(t) − base) / interval — what the SQL computes.
|
||||
foreach (var p in new[]
|
||||
{
|
||||
BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20)),
|
||||
BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20)),
|
||||
BucketPlan.Create(At(60), At(0), TimeSpan.FromSeconds(20)),
|
||||
BucketPlan.Create(At(0).AddTicks(70), At(0), TimeSpan.FromTicks(3)),
|
||||
})
|
||||
{
|
||||
for (var t = p.StartUtc.Ticks - 2; t <= p.EndUtc.Ticks + 2; t += Math.Max(1, (p.EndUtc.Ticks - p.StartUtc.Ticks) / 997))
|
||||
{
|
||||
var at = new DateTime(t, DateTimeKind.Utc);
|
||||
if (p.IndexOf(at) is not { } idx) continue;
|
||||
((t - p.StartUtc.Ticks + p.BucketIndexAdjustTicks) / p.IntervalTicks).ShouldBe(idx);
|
||||
(((at - DateTime.UnixEpoch).Ticks - p.BucketBaseUnixTicks) / p.IntervalTicks).ShouldBe(idx);
|
||||
p.LowerTicks(idx).ShouldBeLessThanOrEqualTo(t);
|
||||
p.UpperTicks(idx).ShouldBeGreaterThanOrEqualTo(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_starts_inside_only_strictly_between_the_bucket_edges()
|
||||
{
|
||||
var p = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(20));
|
||||
p.DataStartsInside(0, At(5)).ShouldBeTrue();
|
||||
p.DataStartsInside(1, At(5)).ShouldBeFalse();
|
||||
p.DataStartsInside(1, At(20)).ShouldBeFalse(); // on the edge: data covers the whole interval
|
||||
p.DataStartsInside(0, null).ShouldBeFalse();
|
||||
p.DataStartsInside(0, At(-5)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_earliest_sample_is_needed_only_for_forward_reads_of_aggregates_other_than_Average()
|
||||
{
|
||||
// Review F2: backends skip the earliest-sample query when its answer cannot change a result.
|
||||
var fwd = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(10));
|
||||
var rev = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(10));
|
||||
AggregateEvaluator.NeedsEarliestSample(HistoryAggregateType.Count, fwd).ShouldBeTrue();
|
||||
AggregateEvaluator.NeedsEarliestSample(HistoryAggregateType.Average, fwd).ShouldBeFalse();
|
||||
AggregateEvaluator.NeedsEarliestSample(HistoryAggregateType.Count, rev).ShouldBeFalse();
|
||||
AggregateEvaluator.NeedsEarliestSample(HistoryAggregateType.Count, BucketPlan.Create(At(0), At(0), TimeSpan.Zero))
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -64,6 +156,11 @@ public sealed class BucketPlanTests
|
||||
s[2].SourceTimestampUtc.ShouldBe(At(40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BucketBaseUnixTicks_is_the_start_forward()
|
||||
=> BucketPlan.Create(DateTime.UnixEpoch.AddTicks(1234567), DateTime.UnixEpoch.AddSeconds(10), TimeSpan.FromSeconds(1))
|
||||
.BucketBaseUnixTicks.ShouldBe(1234567L);
|
||||
|
||||
[Fact]
|
||||
public void StartUnixTicks_is_ticks_since_the_unix_epoch()
|
||||
=> BucketPlan.Create(DateTime.UnixEpoch.AddTicks(1234567), DateTime.UnixEpoch.AddSeconds(10), TimeSpan.Zero)
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #677: our processed reads against the pinned SDK's own Part 13 aggregate calculators (<see cref="SdkAggregateOracle"/>),
|
||||
/// forward AND reverse, bucket by bucket — timestamps, values and status. The per-sample aggregates are bucketed with
|
||||
/// <see cref="BucketPlan.IndexOf"/> (the C# statement of the backends' SQL bucket math; the integration suites run the
|
||||
/// same comparison through the real SQL) and evaluated by <see cref="AggregateEvaluator.EvaluateAll"/>; the
|
||||
/// time-weighted ones by <see cref="TimeWeightedAggregator"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberate, documented divergences the datasets avoid (they are not anchoring questions):
|
||||
/// <list type="bullet">
|
||||
/// <item>an <b>empty</b> interval inside the data range: the SDK's Count answers 0/Good, ours <c>BadNoData</c>;</item>
|
||||
/// <item><b>Uncertain</b> samples in a per-sample aggregate: the SDK's Average/Count/Min/Max use Good samples only,
|
||||
/// ours every usable (Good + Uncertain) sample (TreatUncertainAsBad = false applied consistently);</item>
|
||||
/// <item>the per-sample <b>Calculated / MultipleValues</b> info bits (the SDK's MinMax sets Calculated only when the
|
||||
/// extreme is not at the interval start, and flags ties) — those comparisons check code bits + Partial only;</item>
|
||||
/// <item>a <b>truncated</b> interval: ours sets Partial on a non-Bad time-weighted result (the SDK does not), so the
|
||||
/// non-multiple windows mask that one bucket's Partial bit;</item>
|
||||
/// <item>the SDK's <b>end-of-data</b> Partial rule (forward, the interval holding the last sample when nothing follows
|
||||
/// and nothing was extrapolated) is not implemented: every dataset carries data past the window end.</item>
|
||||
/// </list>
|
||||
/// Pre-existing forward divergences this parity test surfaced (#671 behaviour, unchanged here — forward results were
|
||||
/// to stay as they are; recorded for a follow-up rather than fixed in place):
|
||||
/// <list type="bullet">
|
||||
/// <item><b>TimeAverage / Total status:</b> the SDK (interpolated bounds) reports <c>UncertainDataSubNormal</c> when
|
||||
/// <i>any</i> region of the interval is non-Good (e.g. a segment bridged across a Bad sample); ours applies the
|
||||
/// time-based rule (Uncertain time counts as Good) to these two as well, so a bridged-but-fully-covered interval
|
||||
/// is Good. Values and timestamps agree; the status code bits are not compared for these two.</item>
|
||||
/// <item><b>Total over an interval the data starts inside:</b> the SDK integrates only the defined part
|
||||
/// (value·s over the covered seconds); ours is <c>TimeAverage × interval seconds</c>. The Partial bit and timestamp
|
||||
/// agree; that value is not compared.</item>
|
||||
/// <item><b>Interpolative, reverse, a sample exactly at the point:</b> the SDK's backward <c>UpdateSlice</c> files
|
||||
/// a sample AT the point as the early bound (<c>>= 0</c>), so it interpolates from it to the next Good sample —
|
||||
/// same value, but the Interpolated bit, and Uncertain when a Bad sample sits between. Ours returns the raw
|
||||
/// sample with its own status in both directions (as the SDK itself does forward). Reverse Interpolative
|
||||
/// therefore compares timestamps and values (the anchoring question) but not status.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class SdkAggregateParityTests
|
||||
{
|
||||
private static readonly DateTime Now = At(3600);
|
||||
|
||||
/// <summary>Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 — no empty interval.</summary>
|
||||
private static readonly StoredSample[] Dense = Enumerable.Range(0, 45)
|
||||
.Select(i => (t: -10 + i * 2.5, i))
|
||||
.Select(x => S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)), x.t == 22.5 ? Bad : Good))
|
||||
.ToArray();
|
||||
|
||||
/// <summary>Tag A with Good bounds on both sides, plus an Uncertain sample and an outage marker further on.</summary>
|
||||
private static readonly StoredSample[] Bounded =
|
||||
[
|
||||
S(-10, HistorianValue.FromDouble(5), Good),
|
||||
.. A,
|
||||
S(55, HistorianValue.FromDouble(70), Uncertain),
|
||||
S(62, HistorianValue.Empty, BadNoCommunication),
|
||||
S(71, HistorianValue.FromDouble(30), Good),
|
||||
S(100, HistorianValue.FromDouble(60), Good),
|
||||
];
|
||||
|
||||
/// <summary>Data that starts inside the window (no sample before +7) and runs past its end.</summary>
|
||||
private static readonly StoredSample[] LateStart = Enumerable.Range(0, 20)
|
||||
.Select(i => S(7 + i * 5, HistorianValue.FromDouble(i % 2 == 0 ? 20 : 40), Good))
|
||||
.ToArray();
|
||||
|
||||
public static TheoryData<double, double, double> Windows => new()
|
||||
{
|
||||
{ 0, 80, 20 }, { 80, 0, 20 }, // whole intervals (samples sit on the edges: the inclusivity question)
|
||||
{ 0, 50, 20 }, { 50, 0, 20 }, // a truncated interval: latest forward, EARLIEST reverse
|
||||
{ 5, 80, 15 }, { 80, 5, 15 }, // a misaligned window
|
||||
{ 3, 77, 20 }, { 77, 3, 20 }, // misaligned and truncated
|
||||
{ 0, 60, 0 }, { 60, 0, 0 }, // one interval
|
||||
};
|
||||
|
||||
private static IReadOnlyList<DataValueSnapshot> Ours(HistoryAggregateType agg, double from, double to, double intervalS,
|
||||
StoredSample[] data)
|
||||
{
|
||||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||||
if (AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted)
|
||||
return TimeWeightedAggregator.Evaluate(agg, plan, data, Now);
|
||||
return AggregateEvaluator.EvaluateAll(agg, plan, InMemoryRows(plan, data, agg == HistoryAggregateType.MaximumActualTime),
|
||||
data.Min(s => s.TimeUtc), Now);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SdkAggregateOracle.Result> Sdk(HistoryAggregateType agg, double from, double to,
|
||||
double intervalS, StoredSample[] data)
|
||||
{
|
||||
var interval = intervalS > 0 ? TimeSpan.FromSeconds(intervalS) : (At(to) - At(from)).Duration();
|
||||
return SdkAggregateOracle.Run(agg, At(from), At(to), interval, data.Select(s => (s.TimeUtc, s.Value, s.Status)));
|
||||
}
|
||||
|
||||
/// <summary>Masks the Partial bit of our one truncated interval (a documented divergence: ours flags it, the SDK does not).</summary>
|
||||
private static IReadOnlyList<DataValueSnapshot> MaskTruncation(IReadOnlyList<DataValueSnapshot> ours, double from,
|
||||
double to, double intervalS)
|
||||
{
|
||||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||||
var list = ours.ToList();
|
||||
for (long i = 0; i < plan.BucketCount; i++)
|
||||
{
|
||||
if (!plan.IsTruncated(i)) continue;
|
||||
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
|
||||
list[k] = list[k] with { StatusCode = list[k].StatusCode & ~HistorianInfoBits.HistorianPartial };
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Windows))]
|
||||
public void Time_weighted_aggregates_match_the_SDK(double from, double to, double intervalS)
|
||||
{
|
||||
foreach (var agg in new[]
|
||||
{
|
||||
HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total,
|
||||
HistoryAggregateType.Total2, HistoryAggregateType.Interpolative,
|
||||
})
|
||||
foreach (var (name, data) in new[] { ("Bounded", Bounded), ("Dense", Dense) })
|
||||
SdkAggregateOracle.ShouldMatch(MaskTruncation(Ours(agg, from, to, intervalS, data), from, to, intervalS),
|
||||
Sdk(agg, from, to, intervalS, data), $"{agg} {name} {from}→{to}/{intervalS}s",
|
||||
compareCalculatedInterpolatedBits: !(agg == HistoryAggregateType.Interpolative && from > to),
|
||||
compareCodeBits: agg is not (HistoryAggregateType.TimeAverage or HistoryAggregateType.Total)
|
||||
&& !(agg == HistoryAggregateType.Interpolative && from > to));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Windows))]
|
||||
public void Per_sample_aggregates_match_the_SDK(double from, double to, double intervalS)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
foreach (var agg in PerSample)
|
||||
{
|
||||
try
|
||||
{
|
||||
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, Dense), Sdk(agg, from, to, intervalS, Dense),
|
||||
$"{agg} Dense {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false,
|
||||
compareValues: !PercentAggregate(agg),
|
||||
// pre-existing, #680: the SDK's Start/End return the raw first/last sample (any quality) with its own
|
||||
// timestamp and stored status; ours return the first/last USABLE value stamped with the interval, under the
|
||||
// per-sample status rule. The SDK's Delta is Good even when a Bad sample sits in the interval; ours is
|
||||
// Uncertain. Values are compared wherever the SDK's answer is not Bad.
|
||||
compareTimestamps: !StartOrEnd(agg),
|
||||
compareCodeBits: !StartOrEnd(agg) && agg != HistoryAggregateType.Delta);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(ex.Message);
|
||||
}
|
||||
}
|
||||
failures.ShouldBeEmpty(string.Join("\n", failures));
|
||||
}
|
||||
|
||||
private static readonly HistoryAggregateType[] PerSample =
|
||||
[
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||
HistoryAggregateType.Count, HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
|
||||
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
|
||||
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// pre-existing, #680: ours is the per-SAMPLE percentage (100 × Good samples / all samples); the SDK's
|
||||
/// <c>StatusAggregateCalculator</c> is the per-DURATION one (Good time / interval time). Different definitions, so the
|
||||
/// values are not compared; timestamp, status and Partial still are.
|
||||
/// </summary>
|
||||
private static bool StartOrEnd(HistoryAggregateType agg) =>
|
||||
agg is HistoryAggregateType.Start or HistoryAggregateType.End;
|
||||
|
||||
private static bool PercentAggregate(HistoryAggregateType agg) =>
|
||||
agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad;
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||||
[InlineData(HistoryAggregateType.TimeAverage2)]
|
||||
[InlineData(HistoryAggregateType.Total)]
|
||||
[InlineData(HistoryAggregateType.Total2)]
|
||||
[InlineData(HistoryAggregateType.Minimum)]
|
||||
[InlineData(HistoryAggregateType.Maximum)]
|
||||
[InlineData(HistoryAggregateType.Count)]
|
||||
[InlineData(HistoryAggregateType.Range)]
|
||||
[InlineData(HistoryAggregateType.Delta)]
|
||||
[InlineData(HistoryAggregateType.Start)]
|
||||
[InlineData(HistoryAggregateType.Average)]
|
||||
public void A_reverse_read_over_data_starting_inside_the_window_sets_no_start_of_data_Partial(HistoryAggregateType agg)
|
||||
{
|
||||
// Review F1: the SDK applies its start-of-data rule only on a FORWARD read (a reverse read keys m_startOfData on the
|
||||
// first value it is fed — the latest — which is past the window here). Reverse 40 → 0 at 10 s, first sample +7:
|
||||
// (0,10] holds the start of the data but is NOT Partial.
|
||||
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
|
||||
var ours = Ours(agg, 40, 0, 10, LateStart);
|
||||
SdkAggregateOracle.ShouldMatch(ours, Sdk(agg, 40, 0, 10, LateStart), $"{agg} reverse LateStart",
|
||||
compareCalculatedInterpolatedBits: tw,
|
||||
// pre-existing, #680: Total over the interval the data starts inside (TimeAverage × interval vs the SDK's
|
||||
// integral of the covered part); Start's raw-sample timestamp/status (see Per_sample_aggregates_match_the_SDK).
|
||||
compareValues: agg != HistoryAggregateType.Total,
|
||||
compareTimestamps: !StartOrEnd(agg),
|
||||
compareCodeBits: !StartOrEnd(agg) && agg != HistoryAggregateType.Delta);
|
||||
ours.ShouldAllBe(x => (x.StatusCode & HistorianInfoBits.HistorianPartial) == 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||||
[InlineData(HistoryAggregateType.TimeAverage2)]
|
||||
[InlineData(HistoryAggregateType.Total)]
|
||||
[InlineData(HistoryAggregateType.Total2)]
|
||||
[InlineData(HistoryAggregateType.Minimum)]
|
||||
[InlineData(HistoryAggregateType.Maximum)]
|
||||
[InlineData(HistoryAggregateType.Count)]
|
||||
[InlineData(HistoryAggregateType.Average)]
|
||||
public void The_interval_the_data_starts_inside_matches_the_SDK_Partial_bit(HistoryAggregateType agg)
|
||||
{
|
||||
// Forward [0,40) at 10 s: the data starts at +7, inside [0,10) — the SDK sets Partial there (m_startOfData) for
|
||||
// every calculator but plain Average; [10,20) … are fully covered.
|
||||
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
|
||||
var ours = Ours(agg, 0, 40, 10, LateStart);
|
||||
SdkAggregateOracle.ShouldMatch(ours, Sdk(agg, 0, 40, 10, LateStart), $"{agg} LateStart",
|
||||
compareCalculatedInterpolatedBits: tw, compareValues: agg != HistoryAggregateType.Total);
|
||||
(ours[0].StatusCode & HistorianInfoBits.HistorianPartial).ShouldBe(
|
||||
agg == HistoryAggregateType.Average ? 0u : HistorianInfoBits.HistorianPartial);
|
||||
ours.Skip(1).ShouldAllBe(x => (x.StatusCode & HistorianInfoBits.HistorianPartial) == 0);
|
||||
}
|
||||
|
||||
/// <summary>The per-bucket statistics the backends' SQL computes, built in memory with <see cref="BucketPlan.IndexOf"/>.</summary>
|
||||
internal static IEnumerable<BucketRow> InMemoryRows(BucketPlan plan, IEnumerable<StoredSample> samples, bool maximum = false)
|
||||
{
|
||||
foreach (var g in samples.Select(s => (s, b: plan.IndexOf(s.TimeUtc))).Where(x => x.b is not null)
|
||||
.GroupBy(x => x.b!.Value).OrderBy(g => g.Key))
|
||||
{
|
||||
var all = g.Select(x => x.s).OrderBy(s => s.TimeUtc).ToList();
|
||||
var usable = all.Where(s => s.IsUsable).ToList();
|
||||
var numeric = usable.Where(s => s.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64
|
||||
or HistorianValueKind.Boolean).ToList();
|
||||
// ActualTime: the extreme usable value, ties ⇒ earliest (the SQL's ROW_NUMBER order).
|
||||
var extreme = numeric.OrderBy(s => maximum ? -s.Value.Number : s.Value.Number).ThenBy(s => s.TimeUtc)
|
||||
.Cast<StoredSample?>().FirstOrDefault();
|
||||
yield return new BucketRow
|
||||
{
|
||||
Bucket = g.Key,
|
||||
Total = all.Count,
|
||||
Usable = usable.Count,
|
||||
Good = all.Count(s => s.IsGood),
|
||||
Bad = all.Count - usable.Count,
|
||||
NonNumeric = usable.Count - numeric.Count,
|
||||
Avg = numeric.Count == 0 ? null : numeric.Average(s => s.Value.Number),
|
||||
Min = numeric.Count == 0 ? null : numeric.Min(s => s.Value.Number),
|
||||
Max = numeric.Count == 0 ? null : numeric.Max(s => s.Value.Number),
|
||||
Sum = numeric.Count == 0 ? null : numeric.Sum(s => s.Value.Number),
|
||||
First = usable.Count == 0 ? HistorianValue.Empty : usable[0].Value,
|
||||
Last = usable.Count == 0 ? HistorianValue.Empty : usable[^1].Value,
|
||||
Extreme = extreme?.Value.Number,
|
||||
ExtremeTime = extreme?.TimeUtc,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-2
@@ -322,14 +322,46 @@ public sealed class TimeWeightedAggregatorTests
|
||||
// ---------------------------------------------------------------- plan shape
|
||||
|
||||
[Fact]
|
||||
public void A_reverse_window_is_computed_forward_and_returned_descending()
|
||||
public void A_reverse_window_is_returned_latest_first_stamped_with_later_edges()
|
||||
{
|
||||
// #677: the four whole intervals are the same spans as forward, but each is stamped with its later edge.
|
||||
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.TimeAverage2,
|
||||
BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)), A, Now);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(60), At(40), At(20), At(0)]);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]);
|
||||
Values(s, 50, 50, 45, 17.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reverse_window_anchors_at_its_later_time_and_truncates_the_earliest_interval()
|
||||
{
|
||||
// Tag A, At(50) → At(0), 20 s ⇒ (30,50], (10,30], (0,10]. Raw signal: [0,10) 10→20 Good (150); [10,20) 20 held
|
||||
// into the outage, Uncertain (200); [20,30) Bad; [30,40) 40→50 Good (450); [40,50) 50 extrapolated (500).
|
||||
// (30,50]: 950/20 = 47.5 Good; (10,30]: 200/10 = 20, the Bad 10 s ⇒ Uncertain; (0,10]: 150/10 = 15, truncated ⇒
|
||||
// Partial. The old layout would have answered [0,20), [20,40), [40,50) stamped 0/20/40, partial at the LATEST end.
|
||||
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.TimeAverage2,
|
||||
BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20)), A, Now);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(50), At(30), At(10)]);
|
||||
Values(s, 47.5, 20, 15);
|
||||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalcPartial]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_interval_the_data_starts_inside_is_Partial()
|
||||
{
|
||||
// #677 (SDK m_startOfData): no sample before +5 at all ⇒ [0,10) holds the start of the data ⇒ Partial (its first
|
||||
// 5 s are no-data time ⇒ Uncertain); [10,20) is fully covered ⇒ no Partial.
|
||||
StoredSample[] late = [S(5, HistorianValue.FromDouble(10), Good), S(30, HistorianValue.FromDouble(10), Good)];
|
||||
var s = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, late);
|
||||
Values(s, 10, 10);
|
||||
s.Select(x => x.StatusCode).ShouldBe([UncertainCalc | 0x4u, GoodCalc]);
|
||||
// A bound before the window (the data started earlier) ⇒ no Partial.
|
||||
StoredSample[] early = [S(-5, HistorianValue.FromDouble(10), Good), S(30, HistorianValue.FromDouble(10), Good)];
|
||||
Run(HistoryAggregateType.TimeAverage, 0, 20, 10, early).ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||||
// Data starting exactly on an interval edge covers that interval ⇒ no Partial.
|
||||
StoredSample[] edge = [S(10, HistorianValue.FromDouble(10), Good), S(30, HistorianValue.FromDouble(10), Good)];
|
||||
Run(HistoryAggregateType.TimeAverage, 0, 20, 10, edge)[1].StatusCode.ShouldBe(GoodCalc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_truncated_last_bucket_uses_its_own_duration_for_Total_and_is_Partial()
|
||||
{
|
||||
|
||||
+6
@@ -15,12 +15,18 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- SDK oracle for the status-code / aggregate-bit tests. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
|
||||
<!-- SDK Part 13 aggregate calculators: the parity oracle for processed reads (#677). -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Historian.Conformance\SdkAggregateOracle.cs" Link="SdkAggregateOracle.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
+3
-3
@@ -54,7 +54,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
public async Task A_replace_whose_trail_insert_fails_changes_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], Who, CancellationToken.None))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await CountTrailAsync(db)).ShouldBe(1);
|
||||
@@ -78,7 +78,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
public async Task Deletes_whose_trail_insert_fails_delete_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1), V(T2, 2)], Who, CancellationToken.None);
|
||||
|
||||
await BreakTheTrailAsync(db);
|
||||
@@ -99,7 +99,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
public async Task An_over_long_principal_is_truncated_never_refused()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var who = new HistoryUpdateContext(new string('p', 500), new string('k', 500));
|
||||
|
||||
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], who, CancellationToken.None))
|
||||
|
||||
+91
@@ -178,6 +178,97 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
|
||||
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelling_mid_command_while_the_delete_is_blocked_still_releases_the_applock()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (c, b, _, s) = Make();
|
||||
await SeedAsync(b, s);
|
||||
|
||||
// A second session holds an exclusive table lock, so the purger's first DELETE blocks server-side.
|
||||
await using var holder = new SqlConnection(fx.ConnectionString);
|
||||
await holder.OpenAsync();
|
||||
await using var tx = (SqlTransaction)await holder.BeginTransactionAsync();
|
||||
await using (var cmd = holder.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = $"SELECT COUNT(*) FROM [{s}].Sample WITH (TABLOCKX, HOLDLOCK)";
|
||||
await cmd.ExecuteScalarAsync();
|
||||
}
|
||||
short holderSpid;
|
||||
await using (var cmd = holder.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "SELECT CAST(@@SPID AS smallint)";
|
||||
holderSpid = (short)(await cmd.ExecuteScalarAsync())!;
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now));
|
||||
var purge = purger.PurgeAsync(cts.Token);
|
||||
|
||||
// Cancel only once the server reports a DELETE actually blocked behind the holder — a fixed delay could
|
||||
// cancel during bootstrap/applock acquisition on a slow host and never exercise the mid-command path.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(15);
|
||||
while (await CountAsync(
|
||||
$"SELECT COUNT(*) FROM sys.dm_exec_requests WHERE blocking_session_id = {holderSpid} AND command = 'DELETE'") == 0)
|
||||
{
|
||||
purge.IsCompleted.ShouldBeFalse("the purge finished without ever blocking on the holder's table lock");
|
||||
DateTime.UtcNow.ShouldBeLessThan(deadline, "the purge's DELETE never blocked behind the holder");
|
||||
await Task.Delay(50);
|
||||
}
|
||||
cts.Cancel();
|
||||
|
||||
// SqlClient surfaces the attention as OperationCanceledException or as a SqlException.
|
||||
var ex = await Should.ThrowAsync<Exception>(() => purge);
|
||||
(ex is OperationCanceledException or SqlException).ShouldBeTrue($"unexpected {ex.GetType().Name}: {ex.Message}");
|
||||
|
||||
await tx.RollbackAsync();
|
||||
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(21);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_release_falls_back_to_clearing_the_pool_so_the_applock_is_freed()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (c, b, _, s) = Make();
|
||||
await SeedAsync(b, s);
|
||||
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now))
|
||||
{
|
||||
ReleaseSql = "EXEC sp_this_procedure_does_not_exist;",
|
||||
};
|
||||
|
||||
await purger.PurgeAsync(CancellationToken.None);
|
||||
|
||||
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Falsification_control_a_failing_release_with_a_no_op_ClearPool_leaves_the_applock_held()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (c, b, _, s) = Make();
|
||||
await SeedAsync(b, s);
|
||||
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now))
|
||||
{
|
||||
ReleaseSql = "EXEC sp_this_procedure_does_not_exist;",
|
||||
ClearPool = _ => { },
|
||||
};
|
||||
|
||||
await purger.PurgeAsync(CancellationToken.None);
|
||||
|
||||
try
|
||||
{
|
||||
// The pooled session still owns the Session-scoped lock: proves the fallback test above is not vacuous.
|
||||
(await LockIsFreeAsync(s)).ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqlConnection.ClearAllPools(); // drop the leaked session so the lock does not outlive the test
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_purge_releases_the_applock()
|
||||
{
|
||||
|
||||
+14
-9
@@ -32,7 +32,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // fresh instance => re-runs the guarded path
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM sys.indexes WHERE name='CX_Event_Time' AND object_id=OBJECT_ID('[{s}].Event')")).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -51,13 +51,14 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
cmd.CommandText = b;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #668 upgrade path: a schema left at version 1 (only <c>001_initial.sql</c> applied, as every pre-#668 install is)
|
||||
/// is upgraded in place by an auto-creating bootstrap — <c>SampleModified</c> appears, both version rows exist, and
|
||||
/// existing samples are untouched. With <c>AutoCreateSchema=false</c> the same v1 schema is refused.
|
||||
/// existing samples are untouched. With <c>AutoCreateSchema=false</c> the same v1 schema is accepted with the
|
||||
/// modification trail switched off and left at v1 (#674).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_version_1_schema_is_upgraded_to_version_2_in_place()
|
||||
@@ -81,11 +82,15 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
}
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM sys.tables WHERE name = 'SampleModified' AND schema_id = SCHEMA_ID('{s}')")).ShouldBe(0);
|
||||
|
||||
var refused = await Should.ThrowAsync<HistorianSchemaMissingException>(
|
||||
() => Boot(Opts(s, auto: false)).EnsureAsync(CancellationToken.None));
|
||||
refused.Message.ShouldContain("missing version 2 (found 1)");
|
||||
// #674: verify-only accepts v1 — with the modification trail switched off — and does not upgrade it.
|
||||
var verifyOnly = Boot(Opts(s, auto: false));
|
||||
await verifyOnly.EnsureAsync(CancellationToken.None);
|
||||
verifyOnly.SupportsModifiedTrail.ShouldBeFalse();
|
||||
(await ScalarAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1);
|
||||
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
var upgrading = Boot(Opts(s));
|
||||
await upgrading.EnsureAsync(CancellationToken.None);
|
||||
upgrading.SupportsModifiedTrail.ShouldBeTrue();
|
||||
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(2);
|
||||
(await ScalarAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(2);
|
||||
@@ -101,7 +106,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() => Boot(Opts(s)).EnsureAsync(CancellationToken.None))).ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -110,7 +115,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
fx.SkipIfUnavailable();
|
||||
var ex = await Should.ThrowAsync<HistorianSchemaMissingException>(
|
||||
() => Boot(Opts(SqlHistorianFixture.UniqueSchema(), auto: false)).EnsureAsync(CancellationToken.None));
|
||||
ex.Message.ShouldContain("missing version 2");
|
||||
ex.Message.ShouldContain("missing version 1 (found 0)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674: a database left at schema version 1 (no Part 11 <c>SampleModified</c> trail) with
|
||||
/// <c>AutoCreateSchema=false</c> keeps historizing. Before #674 the bootstrapper required v2 and refused EVERY call —
|
||||
/// live writes, raw reads, alarm events, retention — until script 002 was applied by hand. Now only the features
|
||||
/// that need the trail are refused (<c>BadHistoryOperationUnsupported</c>, nothing sent to SQL), and the
|
||||
/// bootstrapper logs ONE warning for the whole provider. Built through <see cref="SqlServerHistorianProvider"/>, the
|
||||
/// production wiring, so every seam shares the one bootstrapper the way a node does.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SchemaV1PostureTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly DateTime Recent = DateTime.UtcNow.AddHours(-1);
|
||||
private static readonly HistoryUpdateContext Who = new("editor", "k1");
|
||||
|
||||
[Fact]
|
||||
public async Task A_v1_schema_keeps_historizing_and_refuses_only_the_modified_trail_features()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await ApplyV1Async(s);
|
||||
var old = DateTime.UtcNow.AddDays(-40);
|
||||
var loggers = new CapturingLoggerFactory();
|
||||
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = false, RetentionDays = 30,
|
||||
});
|
||||
var sp = new LoggerServices(loggers);
|
||||
|
||||
// Live writes (the continuous-historization drain) and raw reads work.
|
||||
var writer = provider.CreateValueWriter(sp);
|
||||
(await writer.WriteLiveValuesAsync("Plant.Speed",
|
||||
[new HistorizationValue(old, HistorianValue.FromDouble(1), 0), new HistorizationValue(Recent, HistorianValue.FromDouble(2), 0)],
|
||||
Ct)).ShouldBeTrue();
|
||||
var source = provider.CreateDataSource(sp);
|
||||
var raw = await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct);
|
||||
raw.Samples.Select(v => v.Value).ShouldBe([2d]);
|
||||
|
||||
// Alarm events are written.
|
||||
var alarms = provider.CreateAlarmWriter(sp);
|
||||
(await alarms.WriteBatchAsync(
|
||||
[new AlarmHistorianEvent("alarm-1", "Plant/Line/Eq", "HiTemp", "LimitAlarm", AlarmSeverity.High, "Activated", "m", "u", null, Recent)],
|
||||
Ct)).ShouldBe([HistorianWriteOutcome.Ack]);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Event")).ShouldBe(1);
|
||||
|
||||
// Modified reads: the interface's "no trail" signal (the node manager reports BadHistoryOperationUnsupported),
|
||||
// not a backend failure — health stays clean.
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
source.ReadModifiedAsync("Plant.Speed", Recent.AddDays(-1), Recent.AddDays(1), 10, null, Ct));
|
||||
source.GetHealthSnapshot().LastError.ShouldBeNull();
|
||||
|
||||
// HistoryUpdate: every arm refused as a whole (NotSupportedException ⇒ the node manager's node-level
|
||||
// BadHistoryOperationUnsupported), and NOTHING reaches SQL — a sent MERGE/DELETE would either change the samples or
|
||||
// fail on the missing SampleModified target (SqlException, not NotSupported); an Insert on an unknown tag would
|
||||
// create its Tag row.
|
||||
var updater = provider.CreateUpdater(sp)!;
|
||||
var tagsBefore = await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag");
|
||||
await Should.ThrowAsync<NotSupportedException>(() => updater.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Update,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(99), 0), new HistorizationValue(Recent.AddSeconds(1), HistorianValue.FromDouble(98), 0)],
|
||||
Who, Ct));
|
||||
await Should.ThrowAsync<NotSupportedException>(() => updater.UpdateDataAsync("Plant.Brand.New", HistoryUpdateMode.Insert,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(1), 0)], Who, Ct));
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
updater.DeleteRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), Who, Ct));
|
||||
await Should.ThrowAsync<NotSupportedException>(() => updater.DeleteAtTimeAsync("Plant.Speed", [Recent], Who, Ct));
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag")).ShouldBe(tagsBefore);
|
||||
(await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct))
|
||||
.Samples.Select(v => v.Value).ShouldBe([2d]);
|
||||
|
||||
// Retention purges samples and skips the trail it does not have.
|
||||
var purge = await provider.CreateRetention(sp)!.PurgeAsync(Ct);
|
||||
purge.Select(r => r.Table).ShouldBe(["Sample", "Event"]);
|
||||
purge.Single(r => r.Table == "Sample").Rows.ShouldBe(1);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(1);
|
||||
|
||||
// One warning for the whole provider (= per boot), however many seams and calls touched the bootstrapper.
|
||||
loggers.Warnings.Count(w => w.Contains("Part 11 modification trail is unavailable")).ShouldBe(1);
|
||||
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1); // verify-only: never upgraded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: <c>AutoCreateSchema=true</c> still upgrades a v1 schema to v2 — the modification trail appears and a
|
||||
/// HistoryUpdate is recorded in it — and logs no v1 warning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Auto_create_still_upgrades_a_v1_schema_and_enables_the_trail()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await ApplyV1Async(s);
|
||||
var loggers = new CapturingLoggerFactory();
|
||||
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = true,
|
||||
});
|
||||
var sp = new LoggerServices(loggers);
|
||||
|
||||
(await provider.CreateUpdater(sp)!.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Insert,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(5), 0)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
|
||||
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
var trail = await provider.CreateDataSource(sp)
|
||||
.ReadModifiedAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, null, Ct);
|
||||
trail.Values.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert);
|
||||
loggers.Warnings.ShouldNotContain(w => w.Contains("Part 11 modification trail is unavailable"));
|
||||
}
|
||||
|
||||
private async Task ApplyV1Async(string schema)
|
||||
{
|
||||
var v1 = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd();
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync(Ct);
|
||||
foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(v1.Replace("$(Schema)", schema)))
|
||||
{
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = b;
|
||||
await cmd.ExecuteNonQueryAsync(Ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> CountAsync(string sql)
|
||||
{
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync(Ct);
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>The only service the provider resolves is <see cref="ILoggerFactory"/>.</summary>
|
||||
private sealed class LoggerServices(ILoggerFactory loggers) : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => serviceType == typeof(ILoggerFactory) ? loggers : null;
|
||||
}
|
||||
|
||||
/// <summary>Captures every Warning-or-above message from every logger it creates.</summary>
|
||||
private sealed class CapturingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly List<string> _warnings = [];
|
||||
|
||||
public IReadOnlyList<string> Warnings
|
||||
{
|
||||
get { lock (_warnings) return _warnings.ToList(); }
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Capturing(this);
|
||||
|
||||
public void AddProvider(ILoggerProvider provider)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class Capturing(CapturingLoggerFactory owner) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel < LogLevel.Warning) return;
|
||||
lock (owner._warnings) owner._warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -60,3 +60,10 @@ public sealed class SqlServerModifiedValueConformance(SqlHistorianFixture fx) :
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerEventReadConformance(SqlHistorianFixture fx) : EventReadConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
+17
-1
@@ -34,12 +34,28 @@ internal sealed class SqlServerConformanceBackend : IConformanceBackend
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IHistorianDataUpdater CreateUpdater() =>
|
||||
new SqlServerHistorianDataUpdater(_db.Conn, new SqlServerTagCatalog(_db.Conn, _db.Boot));
|
||||
new SqlServerHistorianDataUpdater(_db.Conn, _db.Boot, new SqlServerTagCatalog(_db.Conn, _db.Boot));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples) =>
|
||||
await _db.SeedAsync(tag, samples);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events)
|
||||
{
|
||||
await using var c = await _db.Conn.OpenAsync(CancellationToken.None);
|
||||
foreach (var (t, id, source) in events)
|
||||
{
|
||||
await using var cmd = _db.Conn.CreateCommand(c,
|
||||
$"INSERT {_db.Conn.Q("Event")} (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, " +
|
||||
"UserName, Comment, EventTimeUtc, ReceivedTimeUtc) VALUES (@id, @src, 'a', 'n', 'T', 500, 'Activated', NULL, NULL, NULL, @t, @t)");
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@src", source);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<(DateTime T, HistorianValue V, uint Status)>> ReadBackAsync(string tag)
|
||||
{
|
||||
|
||||
+2
@@ -13,6 +13,8 @@
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- SDK Part 13 aggregate calculators: the conformance suite's processed-read parity oracle (#677). -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
+3
-2
@@ -32,7 +32,7 @@ public sealed class SchemaScriptTests
|
||||
|
||||
/// <summary>
|
||||
/// #668: schema v2 (the Part 11 modification trail) is embedded, matches its docs copy, records version 2 (which
|
||||
/// must equal <see cref="SqlServerSchemaBootstrapper.RequiredVersion"/>), and carries none of the constructs an
|
||||
/// must equal <see cref="SqlServerSchemaBootstrapper.ModifiedTrailVersion"/>), and carries none of the constructs an
|
||||
/// <c>OUTPUT … INTO</c> target forbids (FOREIGN KEY / CHECK / trigger) — any of them would fail every HistoryUpdate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -45,7 +45,8 @@ public sealed class SchemaScriptTests
|
||||
text.ShouldContain("[SampleModified]");
|
||||
text.ShouldContain("PRIMARY KEY CLUSTERED (TagId, SourceTimeUtc, ModifiedId)");
|
||||
text.ShouldContain("VALUES (2, SYSUTCDATETIME())");
|
||||
SqlServerSchemaBootstrapper.RequiredVersion.ShouldBe(2);
|
||||
SqlServerSchemaBootstrapper.ModifiedTrailVersion.ShouldBe(2);
|
||||
SqlServerSchemaBootstrapper.RequiredVersion.ShouldBe(1); // #674: v1 is still served, without the trail
|
||||
var ddl = string.Join('\n', text.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
|
||||
ddl.ShouldNotContain("FOREIGN KEY", Case.Insensitive);
|
||||
ddl.ShouldNotContain("CHECK", Case.Insensitive);
|
||||
|
||||
+13
@@ -137,4 +137,17 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
sut.LastUsedUtc.ShouldBeGreaterThan(before,
|
||||
"AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676: a config still carrying the deleted <c>reconnectPeriod</c> key must deserialise — the browser's own
|
||||
/// validation (here: Certificate auth refused) is what fires, not a JSON error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OpenAsync_with_a_legacy_reconnectPeriod_key_still_parses()
|
||||
{
|
||||
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","reconnectPeriod":"00:00:05","AuthType":"Certificate"}""";
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Certificate");
|
||||
}
|
||||
}
|
||||
|
||||
+708
@@ -0,0 +1,708 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #676 — the driver half of the fast recovery path (<see cref="IDriverConnectionLossSource"/>), the
|
||||
/// per-recovery replay-drop summary, and the trailing reconcile read after an in-place recovery.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientConnectionLossSignalTests
|
||||
{
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
private const string CounterRef = "Plant/dev/Counter";
|
||||
|
||||
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = start;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
public void Advance(TimeSpan by) => Now += by;
|
||||
}
|
||||
|
||||
private sealed class ListLogger : ILogger<OpcUaClientDriver>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
public string[] Info(string contains) => At(LogLevel.Information, contains);
|
||||
|
||||
public string[] At(LogLevel level, string contains)
|
||||
{
|
||||
lock (Entries)
|
||||
return [.. Entries.Where(e => e.Level == level && e.Message.Contains(contains)).Select(e => e.Message)];
|
||||
}
|
||||
}
|
||||
|
||||
private static RawTagEntry Tag(string rawPath, string nodeId) =>
|
||||
new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true);
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[
|
||||
Tag(SpeedRef, "ns=0;s=Speed"),
|
||||
Tag(CounterRef, "ns=0;s=Counter"),
|
||||
];
|
||||
|
||||
private static Mock<ISession> NewSession()
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session, ManualClock Clock, ListLogger Log) NewConnectedDriver()
|
||||
{
|
||||
var log = new ListLogger();
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-676", log);
|
||||
var session = NewSession();
|
||||
var clock = new ManualClock(new DateTimeOffset(2026, 10, 11, 12, 0, 0, TimeSpan.Zero));
|
||||
drv.Clock = clock;
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null));
|
||||
return (drv, session, clock, log);
|
||||
}
|
||||
|
||||
private static DataValue Good(object value, DateTime ts) => new(new Variant(value), StatusCodes.Good, ts, ts);
|
||||
|
||||
/// <summary>Every read returns <paramref name="values"/> (one per requested node, in order) at <paramref name="at"/>.</summary>
|
||||
private static void SetupRead(Mock<ISession> session, Func<DateTime> at, params object[] values) =>
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select((_, i) => Good(values[i], at()))],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
|
||||
// ── IDriverConnectionLossSource ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public static TheoryData<uint> DefinitiveStatuses =>
|
||||
[
|
||||
StatusCodes.BadSessionIdInvalid,
|
||||
StatusCodes.BadSessionClosed,
|
||||
StatusCodes.BadSessionNotActivated,
|
||||
StatusCodes.BadServerHalted,
|
||||
];
|
||||
|
||||
public static TheoryData<uint> AmbiguousStatuses =>
|
||||
[
|
||||
StatusCodes.BadNoCommunication,
|
||||
StatusCodes.BadNotConnected,
|
||||
StatusCodes.BadRequestTimeout,
|
||||
StatusCodes.BadTimeout,
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(DefinitiveStatuses))]
|
||||
public void A_definitive_keep_alive_raises_ConnectionLost_once_after_publishing_the_outage(uint status)
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var order = new List<string>();
|
||||
drv.OnDataChange += (_, e) => order.Add($"bad:{e.Snapshot.StatusCode:X8}");
|
||||
var raised = new List<DriverConnectionLossEventArgs>();
|
||||
drv.ConnectionLost += (_, e) => { raised.Add(e); order.Add("lost"); };
|
||||
|
||||
drv.RaiseKeepAliveForTest(status);
|
||||
drv.RaiseKeepAliveForTest(status);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid);
|
||||
|
||||
raised.ShouldHaveSingleItem("once per session, however many definitive keep-alives follow")
|
||||
.Reason.ShouldContain(StatusCodes.GetBrowseName(status));
|
||||
order.ShouldBe([$"bad:{BadNoCommunication:X8}", "lost"], "subscribers see the Bad before the host tears down");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AmbiguousStatuses))]
|
||||
public void An_ambiguous_keep_alive_does_not_raise_ConnectionLost(uint status)
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var raised = 0;
|
||||
drv.ConnectionLost += (_, _) => raised++;
|
||||
var seen = new List<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => seen.Add(e);
|
||||
|
||||
for (var i = 0; i < 5; i++) drv.RaiseKeepAliveForTest(status);
|
||||
|
||||
// Positive control: the keep-alives WERE processed as an outage — the outage just is not definitive.
|
||||
seen.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(BadNoCommunication);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
raised.ShouldBe(0, "an ambiguous loss may yet resume in place — it belongs to the #527 watchdog");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676 live gate A1: a real upstream process stop (docker restart / SIGINT / docker kill) never produced a
|
||||
/// session status — the keep-alives carried <c>BadSecureChannelClosed</c> once, then <c>BadConnectionClosed</c>
|
||||
/// (interleaved with <c>BadNoCommunication</c>) for the whole outage. Measured in-process (hard-closing proxy, the
|
||||
/// server and its session still alive): the SDK 1.5.378 session NEVER opens a new connection on its own, so a
|
||||
/// closed channel is dead for good. Sustained — a second closed-channel failure in the same outage — it raises.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_sustained_closed_channel_raises_ConnectionLost_once()
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var raised = new List<string>();
|
||||
drv.ConnectionLost += (_, e) => raised.Add(e.Reason);
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadSecureChannelClosed); // the live sequence …
|
||||
raised.ShouldBeEmpty("one closed-channel failure may be a transient");
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadConnectionClosed); // … sustained
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadConnectionClosed);
|
||||
|
||||
raised.ShouldHaveSingleItem().ShouldContain("BadConnectionClosed");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x80860000u)] // BadSecureChannelClosed
|
||||
[InlineData(0x80AE0000u)] // BadConnectionClosed
|
||||
public void A_single_closed_channel_blip_that_recovers_does_not_raise(uint closed)
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var raised = 0;
|
||||
drv.ConnectionLost += (_, _) => raised++;
|
||||
|
||||
drv.RaiseKeepAliveForTest(closed);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good); // recovered — the count starts again
|
||||
drv.RaiseKeepAliveForTest(closed);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
|
||||
raised.ShouldBe(0);
|
||||
// Positive control: the same driver DOES raise once a closed channel is sustained.
|
||||
drv.RaiseKeepAliveForTest(closed);
|
||||
drv.RaiseKeepAliveForTest(closed);
|
||||
raised.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ambiguous_outage_that_turns_definitive_raises_once()
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var raised = 0;
|
||||
drv.ConnectionLost += (_, _) => raised++;
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
raised.ShouldBe(0);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadServerHalted);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadServerHalted);
|
||||
|
||||
raised.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_signal_is_re_armed_by_a_re_initialise()
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var raised = 0;
|
||||
drv.ConnectionLost += (_, _) => raised++;
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid);
|
||||
raised.ShouldBe(1);
|
||||
|
||||
// The host's response: ShutdownAsync + InitializeAsync, i.e. a NEW session.
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
var fresh = NewSession();
|
||||
drv.SetSessionForTest(fresh.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid);
|
||||
|
||||
raised.ShouldBe(2, "the next session's definitive loss must be reported too");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_definitive_keep_alive_from_a_superseded_session_is_ignored()
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var raised = 0;
|
||||
drv.ConnectionLost += (_, _) => raised++;
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid, NewSession().Object);
|
||||
|
||||
raised.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_subscription_the_server_dropped_raises_ConnectionLost_once()
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var raised = new List<DriverConnectionLossEventArgs>();
|
||||
drv.ConnectionLost += (_, e) => raised.Add(e);
|
||||
|
||||
drv.RaisePublishErrorForTest(StatusCodes.BadSubscriptionIdInvalid, subscriptionId: 4242); // not ours
|
||||
raised.ShouldBeEmpty();
|
||||
|
||||
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
|
||||
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
|
||||
|
||||
raised.ShouldHaveSingleItem().Reason.ShouldContain("subscription lost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_faulting_ConnectionLost_subscriber_does_not_break_the_keep_alive_path()
|
||||
{
|
||||
var (drv, _, _, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.ConnectionLost += (_, _) => throw new InvalidOperationException("subscriber bug");
|
||||
|
||||
Should.NotThrow(() => drv.RaiseKeepAliveForTest(StatusCodes.BadSessionClosed));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review of 3b88056f: only a SERVER-reported subscription loss is definitive. The publishing-stall heuristic
|
||||
/// (SDK <c>PublishingStopped</c> persisting past a grace) is an inference from silence — it still degrades health
|
||||
/// and freezes the stamp, but recovery stays with the #527 watchdog.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_publishing_stall_heuristic_does_not_raise_ConnectionLost()
|
||||
{
|
||||
var (drv, _, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.AddSubscriptionForTest([SpeedRef]);
|
||||
drv.IsPublishingStopped = _ => true;
|
||||
drv.PublishingStallGrace = _ => TimeSpan.FromSeconds(10);
|
||||
var raised = 0;
|
||||
drv.ConnectionLost += (_, _) => raised++;
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
clock.Advance(TimeSpan.FromSeconds(11));
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
|
||||
// Positive control: the heuristic DID fire.
|
||||
drv.GetHealth().LastError.ShouldNotBeNull().ShouldContain("subscription lost");
|
||||
raised.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Replay-drop summary ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task The_replay_window_logs_one_Information_summary_of_its_drops_when_it_closes()
|
||||
{
|
||||
var (drv, session, clock, log) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.FromDays(1); // keep the trailing read out of this test
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef, CounterRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0.AddSeconds(5)));
|
||||
drv.DeliverNotificationForTest(handle, CounterRef, Good(2, t0.AddSeconds(5)));
|
||||
SetupRead(session, () => t0.AddSeconds(5), 1, 2);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
|
||||
// Three late replays across two refs, one fresh notification that is accepted.
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(0, t0));
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0.AddSeconds(5)));
|
||||
drv.DeliverNotificationForTest(handle, CounterRef, Good(0, t0));
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(3, t0.AddSeconds(6)));
|
||||
log.Info("replay window closed").ShouldBeEmpty("the summary is logged once, when the window closes");
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(4, t0.AddSeconds(40))); // first notification after expiry
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(5, t0.AddSeconds(41)));
|
||||
|
||||
log.Info("replay window closed").ShouldHaveSingleItem()
|
||||
.ShouldContain("dropped 3 late notifications across 2 refs after recovery");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_window_with_no_drops_closes_at_Debug_not_Information()
|
||||
{
|
||||
var (drv, session, clock, log) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.FromDays(1);
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0));
|
||||
SetupRead(session, () => t0, 1);
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(2, t0.AddSeconds(40)));
|
||||
|
||||
log.Info("replay window closed").ShouldBeEmpty("nothing was dropped — not worth an Information line per recovery");
|
||||
log.At(LogLevel.Debug, "replay window closed").ShouldHaveSingleItem().ShouldContain("dropped 0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_re_arm_inside_an_open_window_reports_the_earlier_drops_once_and_starts_counting_afresh()
|
||||
{
|
||||
var (drv, session, clock, log) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.FromDays(1);
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0));
|
||||
SetupRead(session, () => t0, 1);
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(0, t0.AddSeconds(-1))); // one drop in window 1
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(5)); // window 1 still open …
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); // … a second outage re-arms it
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(3, t0.AddSeconds(80)));
|
||||
|
||||
log.Info("replay window closed").ShouldBe(
|
||||
["OpcUaClientDriver 'opcua-676': post-recovery replay window closed — dropped 1 late notifications across 1 refs after recovery."],
|
||||
"window 1's drop is reported once, when the re-arm supersedes it — not carried into window 2's summary");
|
||||
log.At(LogLevel.Debug, "replay window closed").ShouldHaveSingleItem().ShouldContain("dropped 0");
|
||||
}
|
||||
|
||||
// ── Trailing reconcile read ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task The_trailing_re_read_publishes_a_change_the_subscription_lost_and_nothing_it_already_has()
|
||||
{
|
||||
var (drv, session, clock, log) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef, CounterRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10f, t0));
|
||||
drv.DeliverNotificationForTest(handle, CounterRef, Good(7, t0));
|
||||
|
||||
// The recovery read sees both unchanged; by the trailing read Speed has moved on (its notification was lost in
|
||||
// the stalled publish pipeline) while Counter has not.
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
var trailing = Interlocked.Increment(ref reads) > 1;
|
||||
return new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = trailing
|
||||
? [Good(40f, t0.AddSeconds(9)), Good(7, t0)]
|
||||
: [Good(10f, t0), Good(7, t0)],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
});
|
||||
var seen = new List<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => { lock (seen) seen.Add(e); };
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
await drv.ReconcileTaskForTest;
|
||||
|
||||
reads.ShouldBe(2, "the recovery read plus exactly one trailing read");
|
||||
DataChangeEventArgs[] afterRecovery;
|
||||
lock (seen) afterRecovery = [.. seen.Where(e => e.Snapshot.StatusCode == 0u)];
|
||||
// Recovery read republishes both Good (they were published Bad); the trailing read adds ONLY the lost change.
|
||||
afterRecovery.Select(e => (e.FullReference, e.Snapshot.Value))
|
||||
.ShouldBe([(SpeedRef, (object?)10f), (CounterRef, (object?)7), (SpeedRef, (object?)40f)]);
|
||||
log.Info("trailing re-read").ShouldHaveSingleItem().ShouldContain("found 1 of 2 refs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_trailing_re_read_never_publishes_an_older_value_than_a_notification_already_accepted()
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var gate = new TaskCompletionSource();
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10f, t0));
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async () =>
|
||||
{
|
||||
if (Interlocked.Increment(ref reads) > 1) await gate.Task; // hold the trailing read
|
||||
return new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [Good(reads > 1 ? 20f : 10f, t0.AddSeconds(reads > 1 ? 5 : 0))],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
});
|
||||
var seen = new List<(uint Status, object? Value)>();
|
||||
drv.OnDataChange += (_, e) => { lock (seen) seen.Add((e.Snapshot.StatusCode, e.Snapshot.Value)); };
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
// While the trailing read is in flight, a NEWER value arrives through the subscription.
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(30f, t0.AddSeconds(8)));
|
||||
gate.SetResult();
|
||||
await drv.ReconcileTaskForTest;
|
||||
|
||||
reads.ShouldBe(2, "the trailing read must actually have run, or the absence below proves nothing");
|
||||
lock (seen)
|
||||
seen.ShouldBe([(BadNoCommunication, (object?)10f), (0u, 10f), (0u, 30f)],
|
||||
"the trailing read's 20 @ t0+5 is older than the 30 @ t0+8 already accepted, so it is not published");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_trailing_re_read_does_nothing_if_the_session_was_replaced()
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.FromMilliseconds(300);
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10f, t0));
|
||||
SetupRead(session, () => t0.AddSeconds(5), 99f);
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
// A re-initialise lands between the recovery read and the trailing one.
|
||||
drv.SetSessionForTest(NewSession().Object);
|
||||
await drv.ReconcileTaskForTest;
|
||||
|
||||
session.Verify(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()), Times.Once,
|
||||
"only the recovery read — the trailing read must not run against a session that is no longer current");
|
||||
}
|
||||
|
||||
/// <summary>Wires the mock session so the first read (the recovery read) returns <paramref name="recovery"/> and
|
||||
/// every later one (the trailing read) returns <paramref name="trailing"/>.</summary>
|
||||
private static Func<int> SetupRecoveryThenTrailing(Mock<ISession> session, DataValue recovery, DataValue trailing)
|
||||
{
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [Interlocked.Increment(ref reads) > 1 ? trailing : recovery],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
return () => Volatile.Read(ref reads);
|
||||
}
|
||||
|
||||
private static async Task<List<(uint Status, object? Value)>> RecoverAndReconcileAsync(
|
||||
OpcUaClientDriver drv, string fullRef = SpeedRef)
|
||||
{
|
||||
var seen = new List<(uint Status, object? Value)>();
|
||||
drv.OnDataChange += (_, e) => { if (e.FullReference == fullRef) lock (seen) seen.Add((e.Snapshot.StatusCode, e.Snapshot.Value)); };
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
await drv.ReconcileTaskForTest;
|
||||
return seen;
|
||||
}
|
||||
|
||||
/// <summary>Review of 3b88056f: a Good→Bad change lost in the stalled pipeline left the node stale-GOOD; the
|
||||
/// trailing read repairs any (value, status) difference, not just Good ones.</summary>
|
||||
[Fact]
|
||||
public async Task The_trailing_re_read_repairs_a_lost_change_to_Bad()
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10f, t0));
|
||||
var reads = SetupRecoveryThenTrailing(session, Good(10f, t0),
|
||||
new DataValue(new Variant(10f), StatusCodes.BadSensorFailure, t0.AddSeconds(9), t0.AddSeconds(9)));
|
||||
|
||||
var seen = await RecoverAndReconcileAsync(drv);
|
||||
|
||||
reads().ShouldBe(2);
|
||||
seen.ShouldBe([(BadNoCommunication, (object?)10f), (0u, 10f), ((uint)StatusCodes.BadSensorFailure, 10f)]);
|
||||
}
|
||||
|
||||
/// <summary>A server that sends no source timestamp: the re-read is the server's CURRENT state, read after the
|
||||
/// stalled pipeline drained, so a differing value is newer than anything delivered before — it is published.</summary>
|
||||
[Fact]
|
||||
public async Task The_trailing_re_read_repairs_a_server_that_sends_no_source_timestamp()
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
DataValue NoSource(object v, DateTime server) =>
|
||||
new(new Variant(v), StatusCodes.Good, DateTime.MinValue, server);
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0));
|
||||
var reads = SetupRecoveryThenTrailing(session, NoSource(10f, t0.AddSeconds(1)), NoSource(40f, t0.AddSeconds(9)));
|
||||
|
||||
var seen = await RecoverAndReconcileAsync(drv);
|
||||
|
||||
reads().ShouldBe(2);
|
||||
seen.ShouldBe([(BadNoCommunication, (object?)10f), (0u, 10f), (0u, 40f)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equal source timestamp, different status: the re-read is the server's current state, not a replay, so it is
|
||||
/// published — which also repairs the one case the replay guard drops by design (a status-only change that kept
|
||||
/// its source timestamp inside the window). An IDENTICAL (value, status) is not republished.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_trailing_re_read_publishes_a_status_only_change_at_an_equal_source_time_but_not_an_identical_value()
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef, CounterRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10f, t0));
|
||||
drv.DeliverNotificationForTest(handle, CounterRef, Good(7, t0));
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = Interlocked.Increment(ref reads) > 1
|
||||
? [new DataValue(new Variant(10f), StatusCodes.UncertainLastUsableValue, t0, t0), Good(7, t0)]
|
||||
: [Good(10f, t0), Good(7, t0)],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
var seen = new List<(string Ref, uint Status)>();
|
||||
drv.OnDataChange += (_, e) => { lock (seen) seen.Add((e.FullReference, e.Snapshot.StatusCode)); };
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
await drv.ReconcileTaskForTest;
|
||||
|
||||
reads.ShouldBe(2);
|
||||
seen.Where(e => e.Status != BadNoCommunication).ShouldBe([
|
||||
(SpeedRef, 0u), (CounterRef, 0u), // recovery read
|
||||
(SpeedRef, (uint)StatusCodes.UncertainLastUsableValue), // trailing read: status-only change at t0
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review of 3b88056f: the trailing read's compare and its publish are one critical section with the live
|
||||
/// notification path. Here a NEWER notification arrives exactly between the trailing read's decision and its
|
||||
/// publish; it must wait, so the older re-read can never land after it and regress the node.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_notification_racing_the_trailing_publish_is_serialised_after_it()
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10f, t0));
|
||||
SetupRecoveryThenTrailing(session, Good(10f, t0), Good(40f, t0.AddSeconds(5)));
|
||||
Task? racer = null;
|
||||
drv.BeforeReconcilePublishForTest = _ =>
|
||||
{
|
||||
racer = Task.Run(() => drv.DeliverNotificationForTest(handle, SpeedRef, Good(50f, t0.AddSeconds(8))));
|
||||
// Bounded: with the critical section the racer cannot finish until this publish is done.
|
||||
racer.Wait(TimeSpan.FromMilliseconds(300));
|
||||
};
|
||||
|
||||
var seen = await RecoverAndReconcileAsync(drv);
|
||||
await racer!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
lock (seen) seen.Select(e => e.Value).ShouldBe([10f, 10f, 40f, 50f], "the newer 50 must be the last value delivered");
|
||||
}
|
||||
|
||||
/// <summary>Review of 3b88056f: the background timers are bound to the session — a shutdown cancels a pending
|
||||
/// trailing read instead of leaving it to fire (and read) against whatever session exists later.</summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_cancels_a_pending_trailing_read()
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.FromHours(1);
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10f, clock.Now.UtcDateTime));
|
||||
SetupRead(session, () => clock.Now.UtcDateTime, 10f);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
drv.ReconcileTaskForTest.IsCompleted.ShouldBeFalse("pending for an hour");
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.ReconcileTaskForTest.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-review of 30a9d5f0: with no source timestamps to compare, "newer" cannot be judged from the values. The
|
||||
/// trailing read samples V1; while it is in flight a live notification delivers V2; the read then returns V1 —
|
||||
/// different from the last delivered value and with no timestamp to prove it older. It must NOT be published: a
|
||||
/// ref delivered to after the read was issued is skipped (per-ref delivery sequence), or the node regresses to V1.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_notification_delivered_while_the_trailing_read_is_in_flight_is_not_overwritten(bool readHasSourceTime)
|
||||
{
|
||||
var (drv, session, clock, _) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
DataValue NoSource(object v, DateTime server) => new(new Variant(v), StatusCodes.Good, DateTime.MinValue, server);
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0));
|
||||
var reads = 0;
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
DataValue result;
|
||||
if (Interlocked.Increment(ref reads) == 1)
|
||||
result = NoSource(10f, t0.AddSeconds(1)); // recovery read: unchanged
|
||||
else
|
||||
{
|
||||
// The trailing read has sampled V1 at the server; before its response is processed, the live
|
||||
// subscription delivers the newer V2.
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(20f, t0.AddSeconds(9)));
|
||||
result = readHasSourceTime
|
||||
? Good(15f, t0.AddSeconds(8)) // read carries a source time, V2 did not
|
||||
: NoSource(15f, t0.AddSeconds(8));
|
||||
}
|
||||
return new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [result], DiagnosticInfos = [] };
|
||||
});
|
||||
|
||||
var seen = await RecoverAndReconcileAsync(drv);
|
||||
|
||||
reads.ShouldBe(2, "the trailing read must have run");
|
||||
lock (seen) seen.Select(e => e.Value).ShouldBe([10f, 10f, 20f], "the read's stale 15 must not overwrite the newer 20");
|
||||
}
|
||||
}
|
||||
+19
@@ -75,4 +75,23 @@ public class OpcUaClientDriverFactoryTests
|
||||
opts.RawTags[0].WriteIdempotent.ShouldBeFalse();
|
||||
opts.RawTags[1].WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676 deleted the dead <c>ReconnectPeriod</c> option (unused since #670 removed the SDK reconnect handler).
|
||||
/// DriverConfig rows stored before that still carry it and must keep parsing — and the keys around it must
|
||||
/// still bind, so the unknown member is skipped rather than aborting the read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_stored_config_still_carrying_reconnectPeriod_parses()
|
||||
{
|
||||
const string legacy =
|
||||
"""{"endpointUrl":"opc.tcp://host:4840","reconnectPeriod":"00:00:05","keepAliveInterval":"00:00:07"}""";
|
||||
|
||||
var opts = OpcUaClientDriverFactoryExtensions.ParseOptions("drv-legacy", legacy);
|
||||
opts.EndpointUrl.ShouldBe("opc.tcp://host:4840");
|
||||
opts.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance("drv-legacy", legacy, NullLoggerFactory.Instance);
|
||||
driver.DriverInstanceId.ShouldBe("drv-legacy");
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -134,4 +134,27 @@ public sealed class OpcUaClientDriverProbeTests
|
||||
await acceptTask.WaitAsync(TimeSpan.FromSeconds(5), testCt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676: a stored config still carrying the deleted <c>reconnectPeriod</c> key must reach the connect phase, not
|
||||
/// fail as invalid JSON — the probe parses with the same skip-unknown-members options as the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_config_still_carrying_reconnectPeriod_is_probed_not_rejected()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
|
||||
var result = await _probe.ProbeAsync(
|
||||
$$"""{"EndpointUrl":"opc.tcp://127.0.0.1:{{port}}","reconnectPeriod":"00:00:05"}""",
|
||||
TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
var message = result.Message.ShouldNotBeNull();
|
||||
message.ShouldNotContain("invalid", Case.Insensitive);
|
||||
message.ShouldContain("Connect failed", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -31,7 +31,6 @@ public sealed class OpcUaClientDriverScaffoldTests
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(120));
|
||||
opts.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that driver reports type and ID before connecting.</summary>
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #676 — the driver connects to the endpoint URL the server ADVERTISES in GetEndpoints. A server behind NAT,
|
||||
/// a port map or a proxy advertises an address the client cannot reach (or one that bypasses the proxy), so the
|
||||
/// driver must rewrite host AND port to the configured URL when the scheme matches — what the SDK's own
|
||||
/// <c>CoreClientUtils.SelectEndpoint</c> does.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientEndpointRewriteTests
|
||||
{
|
||||
[Fact]
|
||||
public void Host_and_port_of_the_advertised_url_are_replaced_by_the_configured_ones()
|
||||
{
|
||||
OpcUaClientDriver.RewriteEndpointUrl("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840/OtOpcUa")
|
||||
.ShouldBe("opc.tcp://10.1.2.3:54840/OtOpcUa");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_advertised_path_is_kept()
|
||||
{
|
||||
// The path is the server's to choose (several endpoints may share a host:port); only the address is ours.
|
||||
OpcUaClientDriver.RewriteEndpointUrl("opc.tcp://plc-a:4840/Server/Ua", "opc.tcp://gateway:6000/")
|
||||
.ShouldBe("opc.tcp://gateway:6000/Server/Ua");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_port_differs_still_rewrites()
|
||||
{
|
||||
// The in-process stall proxy shape: same host, the configured port is the proxy's.
|
||||
OpcUaClientDriver.RewriteEndpointUrl("opc.tcp://127.0.0.1:4840/OtOpcUa", "opc.tcp://127.0.0.1:61000/OtOpcUa")
|
||||
.ShouldBe("opc.tcp://127.0.0.1:61000/OtOpcUa");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_configured_url_without_a_port_keeps_the_advertised_port()
|
||||
{
|
||||
// Review of 3b88056f: Uri.Port is -1 for a port-less opc.tcp URL; copying it would drop the advertised port and
|
||||
// the SDK would fall back to 4840.
|
||||
OpcUaClientDriver.RewriteEndpointUrl("opc.tcp://internal-host:62541/Server", "opc.tcp://gateway/Server")
|
||||
.ShouldBe("opc.tcp://gateway:62541/Server");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_IPv6_configured_host_is_bracketed()
|
||||
{
|
||||
OpcUaClientDriver.RewriteEndpointUrl("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fd00::1]:54840/OtOpcUa")
|
||||
.ShouldBe("opc.tcp://[fd00::1]:54840/OtOpcUa");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_different_scheme_is_left_alone()
|
||||
{
|
||||
// An https endpoint advertised next to an opc.tcp one is a different transport, not a NAT artefact.
|
||||
OpcUaClientDriver.RewriteEndpointUrl("https://srv:443/ua", "opc.tcp://srv:4840/ua")
|
||||
.ShouldBe("https://srv:443/ua");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not a url")]
|
||||
public void An_unusable_advertised_url_falls_back_to_the_configured_one(string? advertised)
|
||||
{
|
||||
OpcUaClientDriver.RewriteEndpointUrl(advertised, "opc.tcp://srv:4840/ua").ShouldBe("opc.tcp://srv:4840/ua");
|
||||
}
|
||||
}
|
||||
+5
-19
@@ -4,29 +4,15 @@ using Xunit;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for the reconnect options. Since Gitea #670 the driver has no in-driver reconnect
|
||||
/// handler (session loss is reported and the host's comms-loss watchdog re-initialises the driver);
|
||||
/// <c>ReconnectPeriod</c> is retained for config compatibility only. Outage behaviour is covered by
|
||||
/// <see cref="OpcUaClientSessionLossTests"/> and, over the wire, <c>OpcUaClientDriverReconnectWireTests</c>.
|
||||
/// Reconnect scaffolding. Since Gitea #670 the driver has no in-driver reconnect handler (session loss is reported
|
||||
/// and the host re-initialises the driver — at once on a definitive loss, #676, else via the comms-loss watchdog),
|
||||
/// and #676 deleted the dead <c>ReconnectPeriod</c> option; <see cref="OpcUaClientDriverFactoryTests"/> pins that
|
||||
/// stored configs still carrying it parse. Outage behaviour is covered by <see cref="OpcUaClientSessionLossTests"/>,
|
||||
/// <see cref="OpcUaClientConnectionLossSignalTests"/> and, over the wire, <c>OpcUaClientDriverReconnectWireTests</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReconnectTests
|
||||
{
|
||||
/// <summary>Verifies that the default reconnect period matches the driver specification of 5 seconds.</summary>
|
||||
[Fact]
|
||||
public void Default_ReconnectPeriod_matches_driver_specs_5_seconds()
|
||||
{
|
||||
new OpcUaClientDriverOptions().ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reconnect period can be configured for aggressive or relaxed retries.</summary>
|
||||
[Fact]
|
||||
public void Options_ReconnectPeriod_is_configurable_for_aggressive_or_relaxed_retry()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { ReconnectPeriod = TimeSpan.FromMilliseconds(500) };
|
||||
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the driver starts in the Unknown state before initialization.</summary>
|
||||
[Fact]
|
||||
public void Driver_starts_unknown_pre_init()
|
||||
|
||||
-4
@@ -34,7 +34,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
Timeout = TimeSpan.FromSeconds(20),
|
||||
SessionTimeout = TimeSpan.FromSeconds(180),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(10),
|
||||
ReconnectPeriod = TimeSpan.FromSeconds(15),
|
||||
AutoAcceptCertificates = true,
|
||||
BrowseRoot = "i=85",
|
||||
MaxDiscoveredNodes = 5000,
|
||||
@@ -58,7 +57,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
back.Timeout.ShouldBe(TimeSpan.FromSeconds(20));
|
||||
back.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(180));
|
||||
back.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
back.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(15));
|
||||
back.AutoAcceptCertificates.ShouldBeTrue();
|
||||
back.BrowseRoot.ShouldBe("i=85");
|
||||
back.MaxDiscoveredNodes.ShouldBe(5000);
|
||||
@@ -105,7 +103,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
Timeout = TimeSpan.FromSeconds(12),
|
||||
SessionTimeout = TimeSpan.FromSeconds(150),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(7),
|
||||
ReconnectPeriod = TimeSpan.FromSeconds(8),
|
||||
AutoAcceptCertificates = true,
|
||||
BrowseRoot = "i=85",
|
||||
MaxDiscoveredNodes = 3000,
|
||||
@@ -133,7 +130,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
result.Timeout.ShouldBe(TimeSpan.FromSeconds(12));
|
||||
result.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(150));
|
||||
result.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
result.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(8));
|
||||
result.AutoAcceptCertificates.ShouldBeTrue();
|
||||
result.BrowseRoot.ShouldBe("i=85");
|
||||
result.MaxDiscoveredNodes.ShouldBe(3000);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// #678.6 — the per-role appsettings overlay name is built from plain roles only. A <c>cluster-*</c> role in the
|
||||
/// name produced a file that never exists, so no overlay ever loaded on a node with a cluster role.
|
||||
/// </summary>
|
||||
public sealed class RoleOverlayTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_driver_site_node_loads_the_driver_overlay()
|
||||
=> RoleOverlay.Suffix(["driver", "cluster-SITE-A"]).ShouldBe("driver");
|
||||
|
||||
[Fact]
|
||||
public void A_central_node_loads_the_admin_driver_overlay()
|
||||
=> RoleOverlay.Suffix(["admin", "driver", "cluster-MAIN"]).ShouldBe("admin-driver");
|
||||
|
||||
[Fact]
|
||||
public void Plain_roles_are_ordinal_sorted_regardless_of_input_order()
|
||||
=> RoleOverlay.Suffix(["driver", "cluster-MAIN", "admin"]).ShouldBe("admin-driver");
|
||||
|
||||
[Fact]
|
||||
public void Roles_without_a_cluster_role_keep_the_existing_names()
|
||||
{
|
||||
RoleOverlay.Suffix(["admin"]).ShouldBe("admin");
|
||||
RoleOverlay.Suffix(["driver"]).ShouldBe("driver");
|
||||
RoleOverlay.Suffix(["admin", "driver"]).ShouldBe("admin-driver");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cluster_role_prefix_is_matched_case_insensitively()
|
||||
=> RoleOverlay.Suffix(["driver", "CLUSTER-SITE-A"]).ShouldBe("driver");
|
||||
|
||||
[Fact]
|
||||
public void No_plain_role_means_no_overlay()
|
||||
{
|
||||
RoleOverlay.Suffix([]).ShouldBeNull();
|
||||
RoleOverlay.Suffix(["cluster-SITE-A"]).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- The shipped overlays: activating them on cluster-role nodes must not change production semantics. ----
|
||||
|
||||
private static string HostDir()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host");
|
||||
if (File.Exists(Path.Combine(candidate, "appsettings.json"))) return candidate;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Host project directory not found above " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin")]
|
||||
[InlineData("driver")]
|
||||
[InlineData("admin-driver")]
|
||||
public void Shipped_overlays_carry_only_the_audited_keys(string suffix)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(HostDir(), $"appsettings.{suffix}.json")));
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Only Serilog + Security:Ldap, and in Security:Ldap only the two audited keys.
|
||||
root.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["Security", "Serilog"]);
|
||||
var ldap = root.GetProperty("Security").GetProperty("Ldap");
|
||||
ldap.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["DevStubMode", "Transport"]);
|
||||
ldap.GetProperty("DevStubMode").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// No Debug-level (or lower) category override: activating an overlay must not raise log volume on a node.
|
||||
foreach (var o in root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Override").EnumerateObject())
|
||||
o.Value.GetString().ShouldNotBeOneOf("Debug", "Verbose");
|
||||
root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Default").GetString().ShouldBe("Information");
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using Akka.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using Xunit;
|
||||
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;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 review — pins what the hosted service wires onto the node manager for HistoryUpdate: the Primary gate
|
||||
/// from <see cref="IHistoryUpdateRoleView"/> (read live on every call), and the future-skew bound + clock from
|
||||
/// <c>ServerHistorian</c> options. Asserted through <see cref="OtOpcUaServerHostedService.BuildHistoryUpdatePosture"/>
|
||||
/// (what <c>StartAsync</c> applies) so the SDK need not boot.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_registered_view_wires_a_gate_that_reads_it_live()
|
||||
{
|
||||
var view = new FlippableView { Serve = false };
|
||||
var posture = Service(view).BuildHistoryUpdatePosture();
|
||||
|
||||
posture.PrimaryGate.ShouldNotBeNull();
|
||||
posture.PrimaryGate().ShouldBeFalse();
|
||||
view.Serve = true;
|
||||
posture.PrimaryGate().ShouldBeTrue("the gate must read the view on every call, not capture its value");
|
||||
}
|
||||
|
||||
/// <summary>The gate comes from the HistoryUpdate view, NOT the data-plane source — a source saying "Primary" must not
|
||||
/// open it while the HistoryUpdate verdict is closed (the boot-window distinction).</summary>
|
||||
[Fact]
|
||||
public void The_gate_ignores_the_data_plane_primary_source()
|
||||
{
|
||||
var posture = Service(new FlippableView { Serve = false }, new AlwaysPrimary()).BuildHistoryUpdatePosture();
|
||||
|
||||
posture.PrimaryGate!().ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>No view (an admin-only node: no redundancy manager) ⇒ no gate ⇒ HistoryUpdate allowed.</summary>
|
||||
[Fact]
|
||||
public void No_view_wires_no_gate()
|
||||
{
|
||||
Service(view: null).BuildHistoryUpdatePosture().PrimaryGate.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skew_and_clock_come_from_options_and_the_host_clock()
|
||||
{
|
||||
var clock = new FixedClock();
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:HistoryUpdateMaxFutureSkew"] = "00:07:00",
|
||||
}).Build();
|
||||
|
||||
var posture = Service(view: null, config: config, clock: clock).BuildHistoryUpdatePosture();
|
||||
|
||||
posture.MaxFutureSkew.ShouldBe(TimeSpan.FromMinutes(7));
|
||||
posture.Clock.ShouldBeSameAs(clock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_absent_section_keeps_the_one_day_default()
|
||||
{
|
||||
Service(view: null).BuildHistoryUpdatePosture().MaxFutureSkew.ShouldBe(TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
private static OtOpcUaServerHostedService Service(
|
||||
IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null,
|
||||
TimeProvider? clock = null) => new(
|
||||
Options.Create(new OpcUaApplicationHostOptions()),
|
||||
new DeferredAddressSpaceSink(),
|
||||
new DeferredServiceLevelPublisher(),
|
||||
NullOpcUaUserAuthenticator.Instance,
|
||||
NullHistorianDataSource.Instance,
|
||||
EmptyDataPlaneAuthoritySource.Instance,
|
||||
config ?? new ConfigurationBuilder().Build(),
|
||||
() => throw new NotSupportedException("no ActorSystem is needed for the HistoryUpdate posture"),
|
||||
new ActorRegistry(),
|
||||
dataPlaneAuthorizer: null,
|
||||
Options.Create(new AclOptions()),
|
||||
clock ?? TimeProvider.System,
|
||||
NullLoggerFactory.Instance,
|
||||
primaryRoleSource: primary,
|
||||
historyUpdateRoleView: view);
|
||||
|
||||
private sealed class FlippableView : IHistoryUpdateRoleView
|
||||
{
|
||||
public volatile bool Serve;
|
||||
|
||||
public bool ShouldServeHistoryUpdate => Serve;
|
||||
|
||||
public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate) => Serve = shouldServeHistoryUpdate;
|
||||
}
|
||||
|
||||
private sealed class AlwaysPrimary : IDriverPrimaryRoleSource
|
||||
{
|
||||
public bool ShouldActAsPrimary => true;
|
||||
|
||||
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedClock : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(2026, 10, 11, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user