diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index 0d1f53b7..fbbf8c27 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -106,37 +106,45 @@ calls `AddOtOpcUaDriverProbes` in the `hasAdmin` block, and --- -## IDriverSupervisor — Tier C out-of-process recycle +## IDriverSupervisor / process recycle — REMOVED (Gitea #522) -[`Core.Abstractions/IDriverSupervisor.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverSupervisor.cs) +`IDriverSupervisor`, `MemoryRecycle`, `MemoryTracking` and +`ScheduledRecycleScheduler` **no longer exist.** This section used to describe +them as the Tier C protection layer. -The process-level supervisor contract a **Tier C** (out-of-process) driver's -topology provides. Its concern is restarting the out-of-process Host when a -hard fault is detected (memory breach, wedge, scheduled recycle window). Tier -A/B drivers run in-process and do **not** have a supervisor — recycling them -would kill every OPC UA session and every co-hosted driver. The Core.Stability -layer only invokes this interface after asserting the tier. +They were removed because the thing they acted on is gone. "Tier C" meant a +driver running **out-of-process behind a supervisor that could restart its Host** +without tearing down the OPC UA session or co-hosted drivers. No such process +remains anywhere: Galaxy reaches MXAccess over gRPC to the external +`mxaccessgw` sidecar (PR 7.2 retired the in-process `Galaxy.Host` / `Proxy` / +`Shared` projects), and FOCAS has run in-process since its managed wire client +landed 2026-04-24. Consistent with that, `IDriverSupervisor` had **zero +implementations**, and `MemoryTracking` / `MemoryRecycle` / +`ScheduledRecycleScheduler` were constructed **only in their own unit tests** — +so this was not dormant-but-ready code, it was code with no path to running. -Members: +The operator-facing half went too: `RecycleIntervalSeconds` was authorable +through the AdminUI's driver resilience section and validated by the parser +while configuring nothing. A stored `ResilienceConfig` still carrying the key +parses cleanly (unknown keys are ignored) and the AdminUI preserves it rather +than silently rewriting an operator's config. -- `string DriverInstanceId { get; }` — the driver instance this supervisor - governs. -- `Task RecycleAsync(string reason, CancellationToken cancellationToken)` — - request a terminate+restart of the Host process; implementations are - expected to be idempotent under repeat calls during an in-flight recycle. +**`DriverTier` itself survives and is load-bearing** — do not delete it while +following this note. `DriverResilienceOptions.GetTierDefaults(tier)` supplies +the real per-capability timeout / retry / breaker policies, resolved via +`DriverFactoryRegistry.GetTier` and applied by `DriverCapabilityInvokerFactory`. +Every driver runs Tier A because no factory passes a tier, so today that means +one uniform policy set. -Callers (both in -[`Core/Stability/`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/)): +⚠️ Two `IDriver` members are now **consumerless**: `GetMemoryFootprint()` and +`FlushOptionalCachesAsync()` existed to feed `MemoryTracking`. All 12 drivers +still implement them and nothing calls them. They were left in place rather +than swept, since removing them touches every driver and every test stub — +tracked as Gitea **#525**. -- `ScheduledRecycleScheduler` - ([`Core/Stability/ScheduledRecycleScheduler.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/ScheduledRecycleScheduler.cs)) - — opt-in periodic recycle. A `TickAsync` method advanced by the caller's - ambient scheduler decides whether the configured interval has elapsed and, if - so, drives `RecycleAsync`. Its constructor throws unless the tier is C, making - in-process misuse structurally impossible. -- `MemoryRecycle` - ([`Core/Stability/MemoryRecycle.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryRecycle.cs)) - — on a memory hard-breach, calls `RecycleAsync` (when a supervisor is wired). +If per-driver memory protection is wanted again, build it for the architecture +as it is now: a process-wide watchdog, not a per-driver tier whose only remedy +requires a supervisor that cannot exist in-process. --- diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index 9373be48..1c17345c 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -27,7 +27,7 @@ The lifecycle facade `OpcUaApplicationHost` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUa ## Resilience and capability dispatch -Driver-capability calls (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync/UnsubscribeAsync`, the `IHostConnectivityProbe` probe loop, `IAlarmSource` surfaces, and the four `IHistoryProvider` reads) are routed through a `CapabilityInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`) so the Polly resilience pipeline (retry / timeout / breaker) applies. There is one invoker per `(DriverInstance, IDriver)` pair; all invokers share the process-singleton `DriverResiliencePipelineBuilder`, which keys pipelines on `(DriverInstanceId, hostName, DriverCapability)`. Per-instance resilience options come from `DriverTypeRegistry` (the driver's tier) plus per-instance JSON overrides parsed from `DriverInstance.ResilienceConfig` by `DriverResilienceOptionsParser`. +Driver-capability calls (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync/UnsubscribeAsync`, the `IHostConnectivityProbe` probe loop, `IAlarmSource` surfaces, and the four `IHistoryProvider` reads) are routed through a `CapabilityInvoker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`) so the Polly resilience pipeline (retry / timeout / breaker) applies. There is one invoker per `(DriverInstance, IDriver)` pair; all invokers share the process-singleton `DriverResiliencePipelineBuilder`, which keys pipelines on `(DriverInstanceId, hostName, DriverCapability)`. Per-instance resilience options come from the driver's tier — the `DriverTier` passed at factory registration, resolved via `DriverFactoryRegistry.GetTier` (every driver is Tier A today, since no factory passes one) — plus per-instance JSON overrides parsed from `DriverInstance.ResilienceConfig` by `DriverResilienceOptionsParser`. (It was never `DriverTypeRegistry`; that class was vestigial and was deleted in Gitea #522.) The `OTOPCUA0001` Roslyn analyzer (`src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`, category `OtOpcUa.Resilience`, severity Warning) flags direct driver-capability calls that bypass the invoker. diff --git a/docs/README.md b/docs/README.md index c610b09d..c8f6929d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,7 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess | [OpcUaServer.md](OpcUaServer.md) | Top-level server architecture — Core, driver dispatch, Config DB, generations | | [AddressSpace.md](AddressSpace.md) | `GenericDriverNodeManager` + `ITagDiscovery` + `IAddressSpaceBuilder` | | [ReadWriteOperations.md](ReadWriteOperations.md) | OPC UA Read/Write → `CapabilityInvoker` → `IReadable`/`IWritable` | -| [DriverLifecycle.md](DriverLifecycle.md) | Server-side driver lifecycle + infrastructure contracts (`IDriverFactory`, `IDriverProbe`, `IDriverSupervisor`, `IDriverHealthPublisher`, `IDriverConfigEditor`, `IHistorianDataSource`) + the Commons library | +| [DriverLifecycle.md](DriverLifecycle.md) | Server-side driver lifecycle + infrastructure contracts (`IDriverFactory`, `IDriverProbe`, `IDriverHealthPublisher`, `IDriverConfigEditor`, `IHistorianDataSource`) + the Commons library | | [Subscriptions.md](v1/Subscriptions.md) | Monitored items → `ISubscribable` + per-driver subscription refcount (v1 archive) | | [AlarmTracking.md](AlarmTracking.md) | `IAlarmSource` + `AlarmSurfaceInvoker` + OPC UA alarm conditions — native Galaxy alarms end-to-end (live) | | [AlarmTracking.md](v1/AlarmTracking.md) | Original alarm-tracking write-up (v1 archive) | diff --git a/docs/Telemetry.md b/docs/Telemetry.md index 9f04a30d..1b12a764 100644 --- a/docs/Telemetry.md +++ b/docs/Telemetry.md @@ -88,6 +88,38 @@ single stream per (central, driver-node) pair carries all four, rather than one Proto field evolution is additive-only (never renumber/reuse a tag), locked by a contract test that reflects over the `oneof` cases. +### Per-host connectivity rides `driver-health` (Gitea #521) + +`DriverHealthChanged.HostStatuses` carries each driver's `IHostConnectivityProbe.GetHostStatuses()` +result, rendered as the **Hosts** column on `/hosts`. It exists to show the one thing the driver-level +state chip structurally cannot: a **multi-device driver stays aggregate-`Healthy` while one of its +devices is unreachable**. A FOCAS or AbLegacy instance owning several PLCs previously hid that +entirely. + +Three things to know before touching it: + +- **This deliberately does NOT go through SQL.** A `DriverHostStatus` table existed, with an entity + doc describing a publisher hosted service that upserted rows from each driver node. That publisher + was never written, and **per-cluster mesh Phase 4 made it unbuildable as described** — `Program.cs` + gates `AddOtOpcUaConfigDb` on the `admin` role, so a driver-only node has no ConfigDb connection to + write rows with. The table was dropped (migration `DropDriverHostStatusTable`); it was empty on + every deployment. This channel needs no DB and already survives the mesh split. +- **null ≠ empty, on the wire too.** Null means "the driver has no probe"; an empty list means "it has + one that currently knows no hosts". proto3 cannot distinguish an absent repeated field from an empty + one, so `DriverHealth.has_host_statuses` carries that bit explicitly. Collapsing the two would render + every probe-less driver as one whose devices are all fine. +- **The host digest is part of the publish fingerprint, and must stay there.** `PublishHealthSnapshot` + dedups on that fingerprint, and on a single-host-down transition *every other component is + unchanged* — so without it the dedup swallows precisely the publish carrying the news. Same trap + that bit the rediscovery signal; pinned by + `DriverInstanceActorHostStatusTests.A_single_host_going_down_is_not_swallowed_by_the_unchanged_health_dedup`. + The digest is a flattened, host-name-ordered **string** for the converse reason: a tuple holding an + `IReadOnlyList` compares by reference and would re-publish on every 30 s heartbeat forever. + +⚠️ **`DriverInstanceResilienceStatus` is the same defect, still open.** That table also has no writer +and no reader — the only reference in the repo is its DbSet declaration — while the live data reaches +the AdminUI over the `driver-resilience-status` channel above. Keep-or-delete is Gitea **#524**. + ## The three deferred channels (NOT migrated in Phase 5 — do not read this as "seven done") The program sketch originally named seven observability topics for Phase 5. Three were scoped out, diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 0ddd9856..68d7ceca 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -17,9 +17,9 @@ Each driver opts into only the capabilities it supports. Every async capability Driver **factories** are registered at startup in `DriverFactoryRegistry` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs`) — all 12 in one place, `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`. Type names are the `DriverTypeNames` constants (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`), guarded bidirectionally by `DriverTypeNamesGuardTests`. -> ⚠️ **`DriverTypeRegistry` / `DriverTypeMetadata` are vestigial.** No production code constructs or reads them — the class is referenced only by its own unit tests. Its `AllowedNamespaceKinds` field was retired along with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`), and the `SystemPlatform` namespace kind no longer exists. +> ⚠️ **`DriverTypeRegistry` / `DriverTypeMetadata` were DELETED (Gitea #522, 2026-07-30).** They were vestigial — no production code constructed or read them, only their own unit tests did — and `AllowedNamespaceKinds` had already been orphaned by the retirement of the v3 `Namespace` entity. Older docs and plans that name them as the driver-metadata or tier authority describe something that no longer exists. > -> **Stability tier** likewise does not come from that registry: it is the `DriverTier tier = DriverTier.A` parameter on `DriverFactoryRegistry.cs:46`, and **no factory in `src/Drivers/` passes a tier**, so every shipped driver runs Tier A. The Tier-C-only protections described in [docs/v2/driver-stability.md](../v2/driver-stability.md) — `MemoryRecycle` hard-breach kill (`MemoryRecycle.cs:54`) and `ScheduledRecycleScheduler` (`:43`) — are therefore dormant for every driver. +> **Stability tier** never came from that registry anyway: it is the `DriverTier tier = DriverTier.A` parameter on `DriverFactoryRegistry.cs:46`, and **no factory in `src/Drivers/` passes a tier**, so every shipped driver runs Tier A. The Tier-C recycle protections that [docs/v2/driver-stability.md](../v2/driver-stability.md) describes — `MemoryTracking`, `MemoryRecycle`, `ScheduledRecycleScheduler`, `IDriverSupervisor` — are **gone**, not merely dormant: they required an out-of-process driver Host that no longer exists anywhere (Galaxy is gRPC-to-`mxaccessgw`, FOCAS is in-process), `IDriverSupervisor` had no implementations, and the trackers were only ever constructed in tests. The `Tier` column below is therefore uniformly A, and the tier's remaining effect is `DriverResilienceOptions.GetTierDefaults` — the per-capability timeout / retry / breaker policy set, which **is** live. ## Ground-truth driver list diff --git a/docs/v2/driver-stability.md b/docs/v2/driver-stability.md index b1d70a26..b18c185e 100644 --- a/docs/v2/driver-stability.md +++ b/docs/v2/driver-stability.md @@ -1,24 +1,29 @@ # Driver Stability & Isolation — OtOpcUa v2 -> ⚠️ **The tier assignments below are NOT what runs (verified against source 2026-07-27).** -> **Every one of the 12 drivers runs as Tier A.** `DriverFactoryRegistry` defaults `DriverTier tier = -> DriverTier.A` and **no factory in `src/Drivers/` passes a tier**, so nothing ever selects B or C. The -> Galaxy and FOCAS Tier-C assignments in the table below are aspirational. +> ⚠️ **HISTORICAL DESIGN RECORD — the isolation model below was never built, and its machinery was +> DELETED on 2026-07-30 (Gitea #522).** Read this document for the reasoning, not for what runs. > -> Consequences worth knowing: +> **What actually runs:** all 12 drivers are Tier A, in-process. `DriverFactoryRegistry` defaults +> `DriverTier tier = DriverTier.A` and no factory in `src/Drivers/` passes a tier, so nothing ever +> selected B or C. The Galaxy and FOCAS Tier-C assignments in the table below are aspirational. > -> - The **Tier-C-only protections are dormant for every driver** — `MemoryRecycle` gates on -> `when _tier == DriverTier.C`, and `ScheduledRecycleScheduler` refuses unless Tier C. Neither has ever -> engaged in production. `IDriverSupervisor` has zero implementations, yet the config parser still -> validates `RecycleIntervalSeconds`, so an operator can author a recycle interval that does nothing. -> - `DriverTypeRegistry` — which this document's model implies is the tier authority — is **vestigial**: -> referenced only by its own test, with nothing registering metadata at startup. -> - The **separate-Windows-service hosting** described for Tier C does not exist either. Galaxy reaches -> MXAccess over gRPC to the external **mxaccessgw** sidecar; the `Galaxy.Proxy`/`Host`/`Shared` pattern -> named below was retired in PR 7.2, and FOCAS runs in-process like everything else. +> **What was deleted, and why deletion rather than activation:** `MemoryTracking`, `MemoryRecycle`, +> `ScheduledRecycleScheduler`, `IDriverSupervisor`, the vestigial `DriverTypeRegistry`, and the +> operator-authorable `RecycleIntervalSeconds` knob. The premise was gone, not merely unused — Tier C +> meant an **out-of-process Host a supervisor could restart**, and no such process exists: Galaxy +> reaches MXAccess over gRPC to the external **mxaccessgw** sidecar (the `Galaxy.Proxy`/`Host`/`Shared` +> pattern named below was retired in PR 7.2) and FOCAS has run in-process since 2026-04-24. +> Consistently, `IDriverSupervisor` had zero implementations and the trackers were constructed only in +> their own unit tests. Activating the tiers would have armed a recycle that had nothing to recycle. > -> `docs/drivers/README.md` says Tier A for Galaxy and FOCAS and is the accurate one. Keep-or-delete of the -> tier machinery is tracked as Gitea **#522**; this document is retained as the design record. +> **What survives, and must not be deleted while following this document:** `DriverTier` itself and +> `DriverResilienceOptions.GetTierDefaults(tier)` — the real per-capability timeout / retry / breaker +> policies, resolved via `DriverFactoryRegistry.GetTier` and applied by `DriverCapabilityInvokerFactory`. +> The tier enum is live; only the isolation-and-recycle layer built on top of it is gone. +> +> `docs/drivers/README.md` is the accurate per-driver reference. If per-driver memory protection is +> wanted again, build it for the architecture as it is now — a process-wide watchdog, not a per-driver +> tier whose only remedy needs a supervisor that cannot exist in-process. > > **Status**: DRAFT — companion to `plan.md`. Defines the stability tier model, per-driver hosting decisions, cross-cutting protections every driver process must apply, and the canonical worked example (FOCAS) for the high-risk tier. > diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs index 88da2aaa..4c7eb74e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; /// @@ -26,6 +28,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; /// The driver-supplied reason string from the same event (e.g. "deploy-time-changed"), shown /// to the operator alongside the prompt. Null when is null. /// +/// +/// Per-host connectivity as reported by IHostConnectivityProbe.GetHostStatuses(), or null when +/// the driver does not implement that capability. Empty (not null) when it does but knows no hosts yet. +/// Why this rides the health snapshot rather than a table. The DriverHostStatus +/// entity used to own this, with a doc-comment describing a publisher hosted service that upserted rows +/// from each driver node. That publisher was never built, and since per-cluster mesh Phase 4 it is +/// unbuildable as described: Program.cs gates AddOtOpcUaConfigDb on the admin role, +/// so a driver-only node has no ConfigDb connection to write rows to. This channel already reaches +/// /hosts, already survives the mesh split via the Phase 5 gRPC telemetry stream, and already +/// replays a last-value snapshot on every (re)subscribe — so per-host state re-primes after a +/// reconnect without a durable store. The table was dropped; see Gitea #521. +/// Value-equality caveat. A record's generated Equals compares this list by +/// REFERENCE, so two DriverHealthChanged carrying equal-but-distinct lists are not equal. +/// Nothing dedups on record equality — DriverInstanceActor keeps its own flattened fingerprint +/// precisely because of this — but do not introduce such a comparison without fixing it here first. +/// public sealed record DriverHealthChanged( string ClusterId, string DriverInstanceId, @@ -35,7 +53,8 @@ public sealed record DriverHealthChanged( int ErrorCount5Min, DateTime PublishedUtc, DateTime? RediscoveryNeededUtc = null, - string? RediscoveryReason = null) + string? RediscoveryReason = null, + IReadOnlyList? HostStatuses = null) { /// /// DPS topic name. Both the runtime AkkaDriverHealthPublisher and the AdminUI diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto index f619d29b..a683b6cd 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto @@ -70,6 +70,19 @@ message DriverHealth { google.protobuf.Timestamp published_utc = 7; google.protobuf.Timestamp rediscovery_needed_utc = 8; // DateTime? — absent Timestamp encodes null optional string rediscovery_reason = 9; // nullable in the record + // Per-host connectivity (Gitea #521). proto3 cannot distinguish an absent repeated field from an + // empty one, and the two mean different things here — "driver is not an IHostConnectivityProbe" vs + // "it is one and currently knows no hosts" — so the presence flag carries that bit explicitly rather + // than letting an empty list silently claim the driver has no probe. + bool has_host_statuses = 10; + repeated HostConnectivity host_statuses = 11; +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostConnectivityStatus. +message HostConnectivity { + string host_name = 1; + string state = 2; // HostState-as-string, matching DriverHealth.state + google.protobuf.Timestamp last_changed_utc = 3; } // Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverResilienceStatusChanged. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverHostStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverHostStatus.cs deleted file mode 100644 index a8719af0..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverHostStatus.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -/// -/// Per-host connectivity snapshot the Server publishes for each driver's -/// IHostConnectivityProbe.GetHostStatuses entry. One row per -/// (, , ) triple — -/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6 -/// rows, not 3, because each server node owns its own runtime view. -/// -/// -/// -/// Supports the per-AppEngine Admin dashboard drill-down. The publisher hosted -/// service on the Server side subscribes to every -/// registered driver's OnHostStatusChanged and upserts rows on transitions + -/// periodic liveness heartbeats. advances on every -/// heartbeat so the Admin UI can flag stale rows from a crashed Server. -/// -/// -/// No foreign-key to — a Server may start reporting host -/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd -/// rather keep the status row than drop it. The Admin-side service left-joins on -/// NodeId when presenting rows. -/// -/// -public sealed class DriverHostStatus -{ - /// Server node that's running the driver. - public required string NodeId { get; set; } - - /// Driver instance's stable id (matches IDriver.DriverInstanceId). - public required string DriverInstanceId { get; set; } - - /// - /// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus - /// host:port, whatever the probe returns. Opaque to the Admin UI except as - /// a display string. - /// - public required string HostName { get; set; } - - /// Gets or sets the current connectivity state of the host. - public DriverHostState State { get; set; } = DriverHostState.Unknown; - - /// Timestamp of the last state transition (not of the most recent heartbeat). - public DateTime StateChangedUtc { get; set; } - - /// - /// Advances on every publisher heartbeat — the Admin UI uses - /// now - LastSeenUtc > threshold to flag rows whose owning Server has - /// stopped reporting (crashed, network-partitioned, etc.), independent of - /// . - /// - public DateTime LastSeenUtc { get; set; } - - /// - /// Optional human-readable detail populated when is - /// — e.g. the exception message from the - /// driver's probe. Null for Running / Stopped / Unknown transitions. - /// - public string? Detail { get; set; } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstanceResilienceStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstanceResilienceStatus.cs index cdec0b59..6e93569d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstanceResilienceStatus.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstanceResilienceStatus.cs @@ -1,17 +1,26 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; /// -/// Runtime resilience counters the CapabilityInvoker + MemoryTracking + MemoryRecycle -/// surfaces for each (DriverInstanceId, HostName) pair. Separate from -/// (which owns per-host connectivity state) so a -/// host that's Running but has tripped its breaker or is approaching its memory ceiling -/// shows up distinctly on Admin /hosts. +/// Runtime resilience counters per (DriverInstanceId, HostName) pair. +/// ⚠️ This table is DEAD: nothing writes it and nothing reads it. The only reference in +/// the repo is the DriverInstanceResilienceStatuses DbSet declaration. Do not treat a query +/// against it as a source of runtime state — it returns empty on every deployment. /// /// -/// Per docs/v2/implementation/phase-6-1-resilience-and-observability.md §Stream E.1. -/// The Admin UI left-joins this table on DriverHostStatus for display; rows are written -/// by the runtime via a HostedService that samples the tracker at a configurable -/// interval (default 5 s) — writes are non-critical, a missed sample is tolerated. +/// +/// The original design (docs/v2/implementation/phase-6-1-resilience-and-observability.md +/// §Stream E.1) called for a HostedService sampling the tracker every ~5 s into this table, and +/// an Admin UI that left-joined it on DriverHostStatus for display. Neither was built: +/// there is no sampler, the join exists in no razor file, and DriverHostStatus itself was +/// removed in Gitea #521. +/// +/// +/// The live data does exist — it just never goes through SQL. Resilience state reaches the +/// AdminUI as DriverResilienceStatusChanged over the Phase 5 telemetry stream into the +/// in-memory IDriverResilienceStatusStore, the same shape #521 adopted for per-host +/// connectivity, and for the same reason: per-cluster mesh Phase 4 leaves a driver-only node with +/// no ConfigDb connection to write rows with. Keep-or-delete is tracked as Gitea #524. +/// /// public sealed class DriverInstanceResilienceStatus { diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs deleted file mode 100644 index 8ef0c2b0..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -/// -/// Persisted mirror of Core.Abstractions.HostState — the lifecycle state each -/// IHostConnectivityProbe-capable driver reports for its per-host topology -/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams). -/// Defined here instead of re-using Core.Abstractions.HostState so the -/// Configuration project stays free of driver-runtime dependencies. -/// -/// -/// The server-side publisher (follow-up PR) translates -/// HostStatusChangedEventArgs.NewState to this enum on every transition and -/// upserts into . Admin UI reads from the DB. -/// -public enum DriverHostState -{ - Unknown, - Running, - Stopped, - Faulted, -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260730081232_DropDriverHostStatusTable.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260730081232_DropDriverHostStatusTable.Designer.cs new file mode 100644 index 00000000..e36bc83b --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260730081232_DropDriverHostStatusTable.Designer.cs @@ -0,0 +1,1599 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260730081232_DropDriverHostStatusTable")] + partial class DropDriverHostStatusTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AkkaPort") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4053); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GrpcPort") + .HasColumnType("int"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("MaintenanceMode") + .HasColumnType("bit"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Outcome") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); + + b.HasIndex("DriverInstanceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Device_Driver_Name"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("RawFolderId") + .HasDatabaseName("IX_DriverInstance_RawFolder"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_ClusterRoot_Name") + .HasFilter("[RawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "RawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Folder_Name") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b => + { + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BaselineFootprintBytes") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CurrentBulkheadDepth") + .HasColumnType("int"); + + b.Property("CurrentFootprintBytes") + .HasColumnType("bigint"); + + b.Property("LastCircuitBreakerOpenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastRecycleUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastSampledUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("DriverInstanceId", "HostName"); + + b.HasIndex("LastSampledUtc") + .HasDatabaseName("IX_DriverResilience_LastSampled"); + + b.ToTable("DriverInstanceResilienceStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_NodeAcl_Group"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b => + { + b.Property("RawFolderRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentRawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("RawFolderRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_RawFolder_Cluster"); + + b.HasIndex("RawFolderId") + .IsUnique() + .HasDatabaseName("UX_RawFolder_LogicalId") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_ClusterRoot_Name") + .HasFilter("[ParentRawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "ParentRawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_Parent_Name") + .HasFilter("[ParentRawFolderId] IS NOT NULL"); + + b.ToTable("RawFolder", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Device_Name") + .HasFilter("[TagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "TagGroupId") + .HasDatabaseName("IX_Tag_Device"); + + b.HasIndex("DeviceId", "TagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Group_Name") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b => + { + b.Property("TagGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentTagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("TagGroupRowId"); + + b.HasIndex("DeviceId") + .HasDatabaseName("IX_TagGroup_Device"); + + b.HasIndex("TagGroupId") + .IsUnique() + .HasDatabaseName("UX_TagGroup_LogicalId") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_DeviceRoot_Name") + .HasFilter("[ParentTagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "ParentTagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_Parent_Name") + .HasFilter("[ParentTagGroupId] IS NOT NULL"); + + b.ToTable("TagGroup", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b => + { + b.Property("UnsTagReferenceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DisplayNameOverride") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsTagReferenceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsTagReferenceRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_UnsTagReference_Equipment"); + + b.HasIndex("TagId") + .HasDatabaseName("IX_UnsTagReference_Tag"); + + b.HasIndex("UnsTagReferenceId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_LogicalId") + .HasFilter("[UnsTagReferenceId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "TagId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_Equip_Tag"); + + b.ToTable("UnsTagReference", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null) + .WithMany("RawFolders") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Nodes"); + + b.Navigation("RawFolders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260730081232_DropDriverHostStatusTable.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260730081232_DropDriverHostStatusTable.cs new file mode 100644 index 00000000..527abb17 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260730081232_DropDriverHostStatusTable.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + /// Drops the DriverHostStatus table (Gitea #521). The scaffolder warns this "may result in the loss + /// of data"; here it cannot. No code ever inserted a row — the publisher hosted service its entity + /// doc described was never written, and per-cluster mesh Phase 4 made it unbuildable as described, + /// since a driver-only node has no ConfigDb connection. Every deployment's copy of this table is + /// empty. Per-host connectivity now rides DriverHealthChanged.HostStatuses over the Phase 5 + /// telemetry stream to /hosts. Down() recreates the table exactly, so the rollback is lossless too. + /// + public partial class DropDriverHostStatusTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DriverHostStatus"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DriverHostStatus", + columns: table => new + { + NodeId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + DriverInstanceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + HostName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Detail = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + LastSeenUtc = table.Column(type: "datetime2(3)", nullable: false), + State = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + StateChangedUtc = table.Column(type: "datetime2(3)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName }); + }); + + migrationBuilder.CreateIndex( + name: "IX_DriverHostStatus_LastSeen", + table: "DriverHostStatus", + column: "LastSeenUtc"); + + migrationBuilder.CreateIndex( + name: "IX_DriverHostStatus_Node", + table: "DriverHostStatus", + column: "NodeId"); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index f10f9910..570dc685 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -394,46 +394,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations }); }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => - { - b.Property("NodeId") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("DriverInstanceId") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("HostName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Detail") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("LastSeenUtc") - .HasColumnType("datetime2(3)"); - - b.Property("State") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("StateChangedUtc") - .HasColumnType("datetime2(3)"); - - b.HasKey("NodeId", "DriverInstanceId", "HostName"); - - b.HasIndex("LastSeenUtc") - .HasDatabaseName("IX_DriverHostStatus_LastSeen"); - - b.HasIndex("NodeId") - .HasDatabaseName("IX_DriverHostStatus_Node"); - - b.ToTable("DriverHostStatus", (string)null); - }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => { b.Property("DriverInstanceRowId") diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index eed29d95..06841380 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -44,8 +44,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions ConfigAuditLogs => Set(); /// Gets the DbSet of external ID reservations. public DbSet ExternalIdReservations => Set(); - /// Gets the DbSet of driver host statuses. - public DbSet DriverHostStatuses => Set(); /// Gets the DbSet of driver instance resilience statuses. public DbSet DriverInstanceResilienceStatuses => Set(); /// Gets the DbSet of LDAP group role mappings. @@ -90,7 +88,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions(e => - { - e.ToTable("DriverHostStatus"); - // Composite key — one row per (server node, driver instance, probe-reported host). - // A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces - // 6 rows because each server node owns its own runtime view; the composite key is - // what lets both views coexist without shadowing each other. - e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName }); - e.Property(x => x.NodeId).HasMaxLength(64); - e.Property(x => x.DriverInstanceId).HasMaxLength(64); - e.Property(x => x.HostName).HasMaxLength(256); - e.Property(x => x.State).HasConversion().HasMaxLength(16); - e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)"); - e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)"); - e.Property(x => x.Detail).HasMaxLength(1024); - - // NodeId-only index drives the Admin UI's per-cluster drill-down (select all host - // statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId). - e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node"); - // LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N). - e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen"); - }); - } + // ConfigureDriverHostStatus is GONE (Gitea #521). The DriverHostStatus table held per-host + // connectivity that a publisher hosted service was supposed to upsert from each driver node. That + // publisher was never written, and per-cluster mesh Phase 4 made it unwritable as designed: ConfigDb + // is registered only on the admin role, so a driver-only node has no connection to write rows with. + // Per-host connectivity now rides DriverHealthChanged.HostStatuses to /hosts over the Phase 5 + // telemetry stream, which needs no DB and survives the mesh split. private static void ConfigureDriverInstanceResilienceStatus(ModelBuilder modelBuilder) { diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs deleted file mode 100644 index ac3e43fc..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs +++ /dev/null @@ -1,104 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -/// -/// Process-singleton registry of driver types known to this OtOpcUa instance. -/// Per-driver assemblies register their type metadata at startup; the Core uses -/// the registry to validate DriverInstance.DriverType values from the central config DB. -/// -/// -/// Per docs/v2/plan.md decisions on JSON content validation happening in the Admin app -/// (not SQL CLR), and on driver type → namespace kind mapping being enforced by -/// sp_ValidateDraft. The registry is the source of truth for both checks. -/// -/// Thread-safety: registration is typically single-threaded at startup; lookups happen on -/// every config-apply (multi-threaded). The check-then-act inside is -/// guarded by a private lock so concurrent registrations are atomic — the "registered only -/// once per process" guarantee holds even if two callers race. Readers operate against the -/// volatile snapshot reference produced by the last successful and -/// never block. -/// -public sealed class DriverTypeRegistry -{ - private readonly Lock _writeLock = new(); - - private IReadOnlyDictionary _types = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// Register a driver type. Throws if the type name is already registered. - /// - /// The check-then-act (duplicate check → copy-on-write rebuild → swap) is performed under - /// so concurrent calls cannot silently - /// discard each other's registrations. - /// - /// The driver type metadata to register. - public void Register(DriverTypeMetadata metadata) - { - ArgumentNullException.ThrowIfNull(metadata); - - lock (_writeLock) - { - var snapshot = _types; - if (snapshot.ContainsKey(metadata.TypeName)) - { - throw new InvalidOperationException( - $"Driver type '{metadata.TypeName}' is already registered. " + - $"Each driver type may be registered only once per process."); - } - - var next = new Dictionary(snapshot, StringComparer.OrdinalIgnoreCase) - { - [metadata.TypeName] = metadata, - }; - _types = next; - } - } - - /// Look up a driver type by name. Throws if unknown. - /// The driver type name to look up. - /// The registered metadata for the driver type. - public DriverTypeMetadata Get(string driverType) - { - ArgumentException.ThrowIfNullOrWhiteSpace(driverType); - - if (_types.TryGetValue(driverType, out var metadata)) - return metadata; - - throw new KeyNotFoundException( - $"Driver type '{driverType}' is not registered. " + - $"Known types: {string.Join(", ", _types.Keys)}."); - } - - /// Try to look up a driver type by name. Returns null if unknown (no exception). - /// The driver type name to look up. - /// The registered metadata, or if the type is not registered. - public DriverTypeMetadata? TryGet(string driverType) - { - ArgumentException.ThrowIfNullOrWhiteSpace(driverType); - return _types.GetValueOrDefault(driverType); - } - - /// Snapshot of all registered driver types. - /// A snapshot collection of all registered driver type metadata. - public IReadOnlyCollection All() => _types.Values.ToList(); -} - -/// Per-driver-type metadata used by the Core, validator, and Admin UI. -/// Driver type name (matches DriverInstance.DriverType column values). -/// JSON Schema (Draft 2020-12) the driver's DriverConfig column must validate against. -/// JSON Schema for DeviceConfig (multi-device drivers); null if the driver has no device layer. -/// JSON Schema for TagConfig; required for every driver since every driver has tags. -/// -/// Stability tier per docs/v2/driver-stability.md §2-4 and the tiering decisions in -/// docs/v2/plan.md. Drives the shared resilience pipeline defaults -/// ( × capability → CapabilityPolicy), the MemoryTracking -/// hybrid-formula constants, and whether process-level MemoryRecycle / scheduled- -/// recycle protections apply (Tier C only). Every registered driver type must declare one. -/// -// v3: AllowedNamespaceKinds retired with the Namespace entity — the two OPC UA namespaces (Raw/UNS) -// are implicit, so a driver type no longer declares a namespace-kind compatibility bitmask. -public sealed record DriverTypeMetadata( - string TypeName, - string DriverConfigJsonSchema, - string? DeviceConfigJsonSchema, - string TagConfigJsonSchema, - DriverTier Tier); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs index a3904797..566a68f3 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -48,20 +48,21 @@ public interface IDriver /// /// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables). - /// Polled every 30s by Core; on cache-budget breach, Core asks the driver to flush via - /// . + /// ⚠️ Nothing calls this (Gitea #525). Its only consumer was MemoryTracking, + /// deleted with the rest of the driver-tier recycle machinery in Gitea #522 — the doc-comment here + /// used to claim Core polled it every 30 s, which was never true in v3. All 12 drivers still + /// implement it; most return a real number, and returning 0 is equally correct today. Do not + /// read a value from it and act on it without first building a consumer that is actually wired. /// - /// - /// Per docs/v2/driver-stability.md §"In-process only (Tier A/B) — driver-instance - /// allocation tracking". Tier C drivers (process-isolated) report through the same - /// interface but the cache-flush is internal to their host. - /// /// The approximate memory footprint in bytes. long GetMemoryFootprint(); /// /// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget. /// Required-for-correctness state must NOT be flushed. + /// ⚠️ Nothing calls this (Gitea #525) — same reason as + /// : the memory-pressure path that would have invoked it is + /// gone. /// /// Cancellation token for the operation. /// A task that represents the asynchronous operation. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs index 8fff62c2..265d1f4f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverHealthPublisher.cs @@ -22,13 +22,20 @@ public interface IDriverHealthPublisher /// /// The driver-supplied reason from that event; null when /// is null. + /// + /// Per-host connectivity from , or null when the + /// driver is not an . Lets a multi-device driver (a FOCAS or + /// AbLegacy instance owning several PLCs) surface ONE unreachable device that would otherwise be + /// invisible behind an aggregate-Healthy driver row. + /// void Publish( string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min, DateTime? rediscoveryNeededUtc = null, - string? rediscoveryReason = null); + string? rediscoveryReason = null, + IReadOnlyList? hostStatuses = null); } /// @@ -49,6 +56,7 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher DriverHealth health, int errorCount5Min, DateTime? rediscoveryNeededUtc = null, - string? rediscoveryReason = null) + string? rediscoveryReason = null, + IReadOnlyList? hostStatuses = null) { /* no-op */ } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverSupervisor.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverSupervisor.cs deleted file mode 100644 index 6e169bd0..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverSupervisor.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -/// -/// Process-level supervisor contract a Tier C driver's out-of-process topology provides -/// (e.g. Driver.Galaxy.Proxy/Supervisor/). Concerns: restart the Host process when a -/// hard fault is detected (memory breach, wedge, scheduled recycle window). -/// -/// -/// Tier A/B drivers do NOT have a supervisor because they run in-process — recycling would -/// kill every OPC UA session and every co-hosted driver. The Core.Stability layer only invokes -/// this interface for Tier C instances after asserting the tier via -/// . -/// -public interface IDriverSupervisor -{ - /// Driver instance this supervisor governs. - string DriverInstanceId { get; } - - /// - /// Request the supervisor to recycle (terminate + restart) the Host process. Implementations - /// are expected to be idempotent under repeat calls during an in-flight recycle. - /// - /// Human-readable reason — flows into the supervisor's logs. - /// Cancels the recycle request; an in-flight restart is not interrupted. - /// A task that represents the asynchronous operation. - Task RecycleAsync(string reason, CancellationToken cancellationToken); -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs index 242cb44d..723cbb39 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs @@ -19,15 +19,17 @@ public sealed record DriverResilienceOptions public IReadOnlyDictionary CapabilityPolicies { get; init; } = new Dictionary(); - /// - /// Periodic scheduled recycle interval for Tier C out-of-process hosts, in seconds. - /// Null (the default) = no scheduled recycle; the driver's Host process keeps running - /// indefinitely unless a memory breach or operator action triggers a recycle. Only - /// respected for ; Tier A/B recycle would tear down every - /// OPC UA session, so the loader ignores non-null values for those tiers + logs a - /// warning. - /// - public int? RecycleIntervalSeconds { get; init; } + // RecycleIntervalSeconds is GONE (Gitea #522). It configured a scheduled recycle of a Tier C + // driver's out-of-process Host — a process that no longer exists anywhere: Galaxy reaches MXAccess + // over gRPC to the external mxaccessgw sidecar (PR 7.2 retired the in-process Host/Proxy/Shared + // projects) and FOCAS has run in-process since its managed wire client landed 2026-04-24. The + // scheduler it fed was constructed only in tests, and IDriverSupervisor — the thing that would + // have performed the recycle — had zero implementations. The parser nevertheless validated the + // knob, so an operator could author a recycle interval through the AdminUI and get nothing. + // + // The JSON key is not an error if present: DriverResilienceOptionsParser ignores unknown keys, so + // a ResilienceConfig blob still carrying "recycleIntervalSeconds" parses cleanly and the value is + // simply dropped, which is what it already did in effect. /// /// Look up the effective policy for a capability, falling back to tier defaults when no diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs index 658193ba..fc26669b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs @@ -94,25 +94,15 @@ public static class DriverResilienceOptionsParser } } - // Scheduled recycle is Tier C only — reject a configured interval on Tier A/B as a - // misconfiguration surface rather than silently honouring it (recycling an in-process - // driver would kill every OPC UA session + every co-hosted driver). - int? recycleIntervalSeconds = null; - if (shape.RecycleIntervalSeconds is int secs) - { - if (secs <= 0) - AppendDiagnostic(ref parseDiagnostic, $"RecycleIntervalSeconds must be positive; got {secs} — ignoring."); - else if (tier != DriverTier.C) - AppendDiagnostic(ref parseDiagnostic, $"RecycleIntervalSeconds is Tier C only; Tier {tier} driver will not scheduled-recycle."); - else - recycleIntervalSeconds = secs; - } - + // No recycle-interval handling: the scheduled-recycle machinery was deleted in Gitea #522 + // (nothing constructed it, and IDriverSupervisor — which would have performed the recycle — + // had no implementations). A "recycleIntervalSeconds" key surviving in an existing + // ResilienceConfig blob is harmless: the shape no longer declares it, and unknown JSON + // properties are ignored, so the config still parses and the value is dropped. return new DriverResilienceOptions { Tier = tier, CapabilityPolicies = merged, - RecycleIntervalSeconds = recycleIntervalSeconds, }; } @@ -196,8 +186,8 @@ public static class DriverResilienceOptionsParser private sealed class ResilienceConfigShape { - /// Gets or sets the scheduled recycle interval in seconds. - public int? RecycleIntervalSeconds { get; set; } + // No RecycleIntervalSeconds — see the note at the return site. Deserialization ignores unknown + // properties, so an existing blob carrying the key still binds. /// Gets or sets the per-capability resilience policies. public Dictionary? CapabilityPolicies { get; set; } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryRecycle.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryRecycle.cs deleted file mode 100644 index c0e89855..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryRecycle.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Core.Stability; - -/// -/// Tier C only process-recycle companion to . On a -/// signal, invokes the supplied -/// to restart the out-of-process Host. -/// -/// -/// Per docs/v2/plan.md. Tier A/B hard-breach on an in-process -/// driver would kill every OPC UA session and every co-hosted driver, so for Tier A/B this -/// class logs a promotion-to-Tier-C recommendation and does NOT invoke any supervisor. -/// A future tier-migration workflow acts on the recommendation. -/// -public sealed class MemoryRecycle -{ - private readonly DriverTier _tier; - private readonly IDriverSupervisor? _supervisor; - private readonly ILogger _logger; - - /// Initializes a new instance of the class. - /// The driver tier. - /// Optional supervisor for process recycling. - /// Logger for recycling events. - public MemoryRecycle(DriverTier tier, IDriverSupervisor? supervisor, ILogger logger) - { - _tier = tier; - _supervisor = supervisor; - _logger = logger; - } - - /// - /// Handle a classification for the driver. For Tier C with a - /// wired supervisor, HardBreach triggers . - /// All other combinations are no-ops with respect to process state (soft breaches + Tier A/B - /// hard breaches just log). - /// - /// The memory tracking action. - /// The current process footprint in bytes. - /// Cancellation token for the operation. - /// True when a recycle was requested; false otherwise. - public async Task HandleAsync(MemoryTrackingAction action, long footprintBytes, CancellationToken cancellationToken) - { - switch (action) - { - case MemoryTrackingAction.SoftBreach: - _logger.LogWarning( - "Memory soft-breach on driver {DriverId}: footprint={Footprint:N0} bytes, tier={Tier}. Surfaced to Admin; no action.", - _supervisor?.DriverInstanceId ?? "(unknown)", footprintBytes, _tier); - return false; - - case MemoryTrackingAction.HardBreach when _tier == DriverTier.C && _supervisor is not null: - _logger.LogError( - "Memory hard-breach on Tier C driver {DriverId}: footprint={Footprint:N0} bytes. Requesting supervisor recycle.", - _supervisor.DriverInstanceId, footprintBytes); - await _supervisor.RecycleAsync($"Memory hard-breach: {footprintBytes} bytes", cancellationToken).ConfigureAwait(false); - return true; - - case MemoryTrackingAction.HardBreach: - _logger.LogError( - "Memory hard-breach on Tier {Tier} in-process driver {DriverId}: footprint={Footprint:N0} bytes. " + - "Recommending promotion to Tier C; NOT auto-killing (decisions #74, #145).", - _tier, _supervisor?.DriverInstanceId ?? "(unknown)", footprintBytes); - return false; - - default: - return false; - } - } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryTracking.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryTracking.cs deleted file mode 100644 index 921b4929..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryTracking.cs +++ /dev/null @@ -1,144 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Core.Stability; - -/// -/// Tier-agnostic memory-footprint tracker. Captures the post-initialize baseline -/// from the first samples after IDriver.InitializeAsync, then classifies each -/// subsequent sample against a hybrid soft/hard threshold per -/// docs/v2/plan.mdsoft = max(multiplier × baseline, baseline + floor), -/// hard = 2 × soft. -/// -/// -/// This tracker never kills a process. Soft and hard breaches -/// log + surface to the Admin UI via DriverInstanceResilienceStatus. The matching -/// process-level recycle protection lives in a separate MemoryRecycle that activates -/// for Tier C drivers only (where the driver runs out-of-process behind a supervisor that -/// can safely restart it without tearing down the OPC UA session or co-hosted in-proc -/// drivers). -/// -/// Baseline capture: the tracker starts in for -/// (default 5 min). During that window samples are collected; -/// the baseline is computed as the median once the window elapses. Before that point every -/// classification returns . -/// -public sealed class MemoryTracking -{ - private readonly DriverTier _tier; - private readonly TimeSpan _baselineWindow; - private readonly List _warmupSamples = []; - private long _baselineBytes; - private TrackingPhase _phase = TrackingPhase.WarmingUp; - private DateTime? _warmupStartUtc; - - /// Tier-default multiplier/floor constants. - /// The driver tier. - /// The multiplier and floor-bytes constants for the tier. - public static (int Multiplier, long FloorBytes) GetTierConstants(DriverTier tier) => tier switch - { - DriverTier.A => (Multiplier: 3, FloorBytes: 50L * 1024 * 1024), - DriverTier.B => (Multiplier: 3, FloorBytes: 100L * 1024 * 1024), - DriverTier.C => (Multiplier: 2, FloorBytes: 500L * 1024 * 1024), - _ => throw new ArgumentOutOfRangeException(nameof(tier), tier, $"No memory-tracking constants defined for tier {tier}."), - }; - - /// Window over which post-init samples are collected to compute the baseline. - public TimeSpan BaselineWindow => _baselineWindow; - - /// Current phase: or . - public TrackingPhase Phase => _phase; - - /// Captured baseline; 0 until warmup completes. - public long BaselineBytes => _baselineBytes; - - /// Effective soft threshold (zero while warming up). - public long SoftThresholdBytes => _baselineBytes == 0 ? 0 : ComputeSoft(_tier, _baselineBytes); - - /// Effective hard threshold = 2 × soft (zero while warming up). - public long HardThresholdBytes => _baselineBytes == 0 ? 0 : ComputeSoft(_tier, _baselineBytes) * 2; - - /// Initializes a new instance of the class. - /// The driver tier for threshold constants. - /// Optional custom baseline window duration (default 5 minutes). - public MemoryTracking(DriverTier tier, TimeSpan? baselineWindow = null) - { - _tier = tier; - _baselineWindow = baselineWindow ?? TimeSpan.FromMinutes(5); - } - - /// - /// Submit a memory-footprint sample. Returns the action the caller should surface. - /// During warmup, always returns and accumulates - /// samples; once the window elapses the first steady-phase sample triggers baseline capture - /// (median of warmup samples). - /// - /// The current memory footprint in bytes. - /// The current UTC time. - /// The tracking action the caller should surface for this sample. - public MemoryTrackingAction Sample(long footprintBytes, DateTime utcNow) - { - if (_phase == TrackingPhase.WarmingUp) - { - _warmupStartUtc ??= utcNow; - _warmupSamples.Add(footprintBytes); - if (utcNow - _warmupStartUtc.Value >= _baselineWindow && _warmupSamples.Count > 0) - { - _baselineBytes = ComputeMedian(_warmupSamples); - _phase = TrackingPhase.Steady; - } - else - { - return MemoryTrackingAction.Warming; - } - } - - if (footprintBytes >= HardThresholdBytes) return MemoryTrackingAction.HardBreach; - if (footprintBytes >= SoftThresholdBytes) return MemoryTrackingAction.SoftBreach; - return MemoryTrackingAction.None; - } - - private static long ComputeSoft(DriverTier tier, long baseline) - { - var (multiplier, floor) = GetTierConstants(tier); - return Math.Max(multiplier * baseline, baseline + floor); - } - - private static long ComputeMedian(List samples) - { - var sorted = samples.Order().ToArray(); - var mid = sorted.Length / 2; - return sorted.Length % 2 == 1 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2; - } -} - -/// Phase of a lifecycle. -public enum TrackingPhase -{ - /// Collecting post-init samples; baseline not yet computed. - WarmingUp, - - /// Baseline captured; every sample classified against soft/hard thresholds. - Steady, -} - -/// Classification the tracker returns per sample. -public enum MemoryTrackingAction -{ - /// Baseline not yet captured; sample collected, no threshold check. - Warming, - - /// Below soft threshold. - None, - - /// Between soft and hard thresholds — log + surface, no action. - SoftBreach, - - /// - /// ≥ hard threshold. Log + surface + (Tier C only, via MemoryRecycle) request - /// process recycle via the driver supervisor. Tier A/B breach never invokes any - /// kill path. - /// - HardBreach, -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/ScheduledRecycleScheduler.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/ScheduledRecycleScheduler.cs deleted file mode 100644 index e92b2e93..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/ScheduledRecycleScheduler.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Core.Stability; - -/// -/// Tier C opt-in periodic-recycle driver. -/// A tick method advanced by the caller (fed by a background timer in prod; by test clock -/// in unit tests) decides whether the configured interval has elapsed and, if so, drives the -/// supplied to recycle the Host. -/// -/// -/// Tier A/B drivers MUST NOT use this class — scheduled recycle for in-process drivers would -/// kill every OPC UA session and every co-hosted driver. The ctor throws when constructed -/// with any tier other than C to make the misuse structurally impossible. -/// -/// Keeps no background thread of its own — callers invoke on -/// their ambient scheduler tick (Phase 6.1 Stream C's health-endpoint host runs one). That -/// decouples the unit under test from wall-clock time and thread-pool scheduling. -/// -public sealed class ScheduledRecycleScheduler -{ - private readonly TimeSpan _recycleInterval; - private readonly IDriverSupervisor _supervisor; - private readonly ILogger _logger; - private DateTime _nextRecycleUtc; - - /// - /// Construct the scheduler for a Tier C driver. Throws if isn't C. - /// - /// Driver tier; must be . - /// Interval between recycles (e.g. 7 days). - /// Anchor time; next recycle fires at + . - /// Supervisor that performs the actual recycle. - /// Diagnostic sink. - public ScheduledRecycleScheduler( - DriverTier tier, - TimeSpan recycleInterval, - DateTime startUtc, - IDriverSupervisor supervisor, - ILogger logger) - { - if (tier != DriverTier.C) - throw new ArgumentException( - $"ScheduledRecycleScheduler is Tier C only (got {tier}). " + - "In-process drivers must not use scheduled recycle; see decisions #74 and #145.", - nameof(tier)); - - if (recycleInterval <= TimeSpan.Zero) - throw new ArgumentException("RecycleInterval must be positive.", nameof(recycleInterval)); - - _recycleInterval = recycleInterval; - _supervisor = supervisor; - _logger = logger; - _nextRecycleUtc = startUtc + recycleInterval; - } - - /// Next scheduled recycle UTC. Advances by on each fire. - public DateTime NextRecycleUtc => _nextRecycleUtc; - - /// Recycle interval this scheduler was constructed with. - public TimeSpan RecycleInterval => _recycleInterval; - - /// - /// Tick the scheduler forward. If is past - /// , requests a recycle from the supervisor and advances - /// by exactly one interval. Returns true when a recycle fired. - /// - /// The current UTC time. - /// The cancellation token. - /// True if a recycle was triggered; false otherwise. - public async Task TickAsync(DateTime utcNow, CancellationToken cancellationToken) - { - if (utcNow < _nextRecycleUtc) - return false; - - _logger.LogInformation( - "Scheduled recycle due for Tier C driver {DriverId} at {Now:o}; advancing next to {Next:o}.", - _supervisor.DriverInstanceId, utcNow, _nextRecycleUtc + _recycleInterval); - - await _supervisor.RecycleAsync("Scheduled periodic recycle", cancellationToken).ConfigureAwait(false); - _nextRecycleUtc += _recycleInterval; - return true; - } - - /// Request an immediate recycle outside the schedule (e.g. MemoryRecycle hard-breach escalation). - /// The reason for requesting an immediate recycle. - /// The cancellation token. - /// A task representing the asynchronous recycle operation. - public Task RequestRecycleNowAsync(string reason, CancellationToken cancellationToken) => - _supervisor.RecycleAsync(reason, cancellationToken); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index f121a70c..75fbe44f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -168,6 +168,7 @@ else Driver Type Status + Hosts Last read Errors/5 min Last error @@ -177,7 +178,7 @@ else @if (g.Drivers.Count == 0) { - No drivers. + No drivers. } else @@ -204,6 +205,28 @@ else @(d.DriverType ?? "—") @d.State + + @* Per-host connectivity (Gitea #521). The point of this column is the case + the driver-level Status chip cannot express: a multi-device driver stays + Healthy in aggregate while ONE of its PLCs is unreachable. A driver with + no probe shows "—" — deliberately distinct from a probe reporting zero + hosts, which shows "0 hosts". *@ + @if (d.HostStatuses is null) + { + + } + else if (d.DegradedHosts.Count == 0) + { + @d.HostStatuses.Count @(d.HostStatuses.Count == 1 ? "host" : "hosts") + } + else + { + + @d.DegradedHosts.Count / @d.HostStatuses.Count down + + } + @(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") @d.ErrorCount5Min @(d.LastError ?? "—") @@ -355,6 +378,13 @@ else _ => "chip-idle", }; + // Tooltip listing each host that is not Running, with its state and when it last changed. Built in + // C# rather than inline in the markup so the string is composed once per render, and so the row can + // never be the thing that 500s the page — string.Join over an already-materialised list does no + // indexing (cf. #504, where slicing a short DB string in Razor took down the whole page). + private static string DegradedHostTitle(HostsDriverRow d) => + string.Join(" · ", d.DegradedHosts.Select(h => $"{h.HostName}: {h.State} since {h.LastChangedUtc:u}")); + public async ValueTask DisposeAsync() { // Unsubscribe first so the singleton store can't invoke a handler on a disposed component. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor index ca3e8df7..97585f32 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor @@ -20,10 +20,12 @@ } -
-
-
-
+ @* The "Recycle interval (s, Tier C only)" input was removed in Gitea #522. It authored a knob + with no effect: the scheduled-recycle machinery it fed was constructed only in tests, and + the IDriverSupervisor that would have performed the recycle had no implementations. The + tier model it belonged to assumed an out-of-process driver Host that no longer exists + anywhere. Offering an operator a control that silently does nothing is worse than not + offering it. *@
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs index e5f2a7ca..58af2e4b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs @@ -23,8 +23,12 @@ public sealed class ResilienceFormModel public static readonly string[] Capabilities = ["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"]; - /// Gets or sets the driver recycle-interval override, in seconds; null = use the tier default. - public int? RecycleIntervalSeconds { get; set; } + // RecycleIntervalSeconds is GONE (Gitea #522) — the scheduled-recycle machinery it authored was + // deleted, so the field configured nothing. It is deliberately NOT explicitly stripped from stored + // JSON: the model's preserve-unknown-keys contract now covers it, so an existing blob's + // "recycleIntervalSeconds" survives an unrelated edit in _bag rather than being silently dropped, + // and the parser ignores it. Removing the key is a migration decision, not a side effect of + // opening the form. // capability name -> (timeout, retry, breaker), each nullable. /// Gets or sets the per-capability resilience overrides, keyed by capability name. @@ -112,7 +116,6 @@ public sealed class ResilienceFormModel } model._bag = bag; - model.RecycleIntervalSeconds = GetIntOrNull(bag, "recycleIntervalSeconds"); if (bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject cp) { @@ -141,8 +144,6 @@ public sealed class ResilienceFormModel { if (ParseFailed) return RawStoredJson; - SetOrRemoveInt(_bag, "recycleIntervalSeconds", RecycleIntervalSeconds); - var cp = _bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject existing ? existing : null; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs index 4940f313..54717bd1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs @@ -1,6 +1,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// /// One configured host node within a cluster, as the /hosts page needs it: the cluster @@ -40,10 +41,26 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu /// is unchanged and an operator must re-browse the device via /raw to pick anything up. /// The driver-supplied reason for that report; null when /// is null. +/// Per-host connectivity for a multi-device driver, ordered by host name; null +/// when the driver reports no per-host detail. Lets one unreachable device show even while the driver +/// row itself is Healthy. public sealed record HostsDriverRow( string DriverInstanceId, string? Name, string? DriverType, string State, DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc, - DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null); + DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null, + IReadOnlyList? HostStatuses = null) +{ + /// Hosts that are not , ordered by name — the ones worth an + /// operator's attention. Empty when every host is fine or none are reported. + /// counts as degraded on purpose: a probe that has not yet + /// completed its first tick, or one a driver failed to start (AbCip logs exactly this case), reports + /// Unknown — and silently rendering that as healthy is how the gap got missed the first time. + public IReadOnlyList DegradedHosts { get; } = + (HostStatuses ?? []) + .Where(h => h.State != HostState.Running) + .OrderBy(h => h.HostName, StringComparer.OrdinalIgnoreCase) + .ToList(); +} /// /// One cluster's section on the /hosts page: its configured nodes plus its enriched @@ -118,7 +135,8 @@ public static class HostsDriverView s.ErrorCount5Min, s.PublishedUtc, s.RediscoveryNeededUtc, - s.RediscoveryReason); + s.RediscoveryReason, + s.HostStatuses); }) .OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase) .ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs index bae54518..26b03111 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Telemetry/TelemetryProtoMapCentral.cs @@ -3,6 +3,10 @@ using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; +// Aliased, not imported wholesale: Core.Abstractions also declares a DriverHealth, which would collide +// with the proto DriverHealth this file maps. +using HostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostConnectivityStatus; +using HostState = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostState; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry; @@ -120,7 +124,32 @@ public static class TelemetryProtoMapCentral ErrorCount5Min: msg.ErrorCount5Min, PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"), RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(), - RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null); + RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null, + HostStatuses: ToHostStatuses(msg)); + } + + /// + /// Projects the repeated host-connectivity field, honouring the explicit presence flag: null when + /// the node said the driver has no probe, an empty list when it has one that knows no hosts. + /// An unparseable state string degrades to rather than + /// throwing — a node running a newer build that added an enum member must not be able to kill + /// central's telemetry stream, which is observability and has no business failing closed. + /// + private static IReadOnlyList? ToHostStatuses(DriverHealth msg) + { + if (!msg.HasHostStatuses) return null; + + var result = new List(msg.HostStatuses.Count); + foreach (var h in msg.HostStatuses) + { + result.Add(new HostConnectivityStatus( + h.HostName, + // System.Enum qualified: Google.Protobuf.WellKnownTypes also declares an Enum type. + System.Enum.TryParse(h.State, ignoreCase: true, out var state) ? state : HostState.Unknown, + h.LastChangedUtc?.ToDateTime() ?? default)); + } + + return result; } /// Projects a onto a . diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs index 066c6de1..c8480ce4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs @@ -133,6 +133,21 @@ public static class TelemetryProtoMapNode if (e.RediscoveryReason is not null) msg.RediscoveryReason = e.RediscoveryReason; + // Presence flag first — an empty repeated field cannot say whether the driver has a probe at all. + if (e.HostStatuses is not null) + { + msg.HasHostStatuses = true; + foreach (var h in e.HostStatuses) + { + msg.HostStatuses.Add(new HostConnectivity + { + HostName = h.HostName ?? "", + State = h.State.ToString(), + LastChangedUtc = ToUtcTimestamp(h.LastChangedUtc), + }); + } + } + return msg; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs index d53a4799..854152ba 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs @@ -36,7 +36,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher DriverHealth health, int errorCount5Min, DateTime? rediscoveryNeededUtc = null, - string? rediscoveryReason = null) + string? rediscoveryReason = null, + IReadOnlyList? hostStatuses = null) { var msg = new DriverHealthChanged( clusterId, @@ -47,7 +48,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher errorCount5Min, DateTime.UtcNow, rediscoveryNeededUtc, - rediscoveryReason); + rediscoveryReason, + hostStatuses); DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg)); // Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC // client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 5fc2139e..b13216c5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -120,6 +120,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// connection affinity (a Galaxy redeploy or a TwinCAT symbol-version bump can land while the driver is /// between connects), and dropping it in one state would lose the signal silently. private sealed record RediscoveryRaised(RediscoveryEventArgs Args); + /// Self-sent when the wrapped driver raises + /// — one of its hosts went Running ↔ Stopped ↔ Faulted. Marshals the event off the driver's probe thread + /// onto the actor thread. Carries NO payload: the handler re-pulls + /// , so the driver stays the single source of truth + /// for the host set and a host that appeared since the last publish is picked up too. Handled in every + /// behaviour for the same reason as — a probe tick can land while the + /// driver is between connects. + private sealed record HostStatusRaised; public sealed class RetryConnect { @@ -171,6 +179,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private EventHandler? _dataChangeHandler; private EventHandler? _alarmEventHandler; private EventHandler? _rediscoveryHandler; + private EventHandler? _hostStatusHandler; /// When the driver last raised , and the reason /// it gave. Null until the first raise. Carried on every subsequent health snapshot so the AdminUI can @@ -306,6 +315,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // Attach the rediscovery signal before the first publish. Not per-connect: an IRediscoverable raise // has no connection affinity, and a driver can observe a remote change while disconnected. AttachRediscoverySource(); + AttachHostStatusSource(); PublishHealthSnapshot(); Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval); } @@ -325,6 +335,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a // re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message. Receive(HandleRediscoveryRaised); + Receive(_ => PublishHealthSnapshot()); Receive(_ => PublishHealthSnapshot()); } @@ -378,6 +389,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); Receive(HandleRediscoveryRaised); + Receive(_ => PublishHealthSnapshot()); Receive(_ => PublishHealthSnapshot()); } @@ -438,6 +450,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(msg => _log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason)); Receive(HandleRediscoveryRaised); + Receive(_ => PublishHealthSnapshot()); Receive(_ => { PublishHealthSnapshot(); @@ -541,6 +554,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); Receive(HandleRediscoveryRaised); + Receive(_ => PublishHealthSnapshot()); Receive(_ => PublishHealthSnapshot()); Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval); } @@ -809,6 +823,48 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _rediscoveryHandler = null; } + /// Subscribe the driver's (if it is + /// one), marshaling each transition to the actor thread. Idempotent; mirrors + /// , including the PreStart-not-per-connect placement — a probe + /// loop can report a host down while the driver itself is between connects. + private void AttachHostStatusSource() + { + if (_driver is not IHostConnectivityProbe src || _hostStatusHandler is not null) return; + var self = Self; + _hostStatusHandler = (_, _) => self.Tell(new HostStatusRaised()); + src.OnHostStatusChanged += _hostStatusHandler; + } + + /// Symmetric teardown, called from PostStop — same leak argument as + /// . + private void DetachHostStatusSource() + { + if (_driver is IHostConnectivityProbe src && _hostStatusHandler is not null) + src.OnHostStatusChanged -= _hostStatusHandler; + _hostStatusHandler = null; + } + + /// Current per-host connectivity, or null when the driver is not an + /// . Pulled fresh rather than cached: the driver owns the host set, + /// and a stale local copy would be a second source of truth to keep in sync. + /// GetHostStatuses() is called UNGUARDED (not through ) on purpose — + /// it is a pure in-memory snapshot with no I/O, which is exactly why + /// UnwrappedCapabilityCallAnalyzer exempts it. A driver that makes it do I/O breaks that + /// contract; the try/catch below keeps a misbehaving one from killing the health publish. + private IReadOnlyList? CurrentHostStatuses() + { + if (_driver is not IHostConnectivityProbe probe) return null; + try + { + return probe.GetHostStatuses(); + } + catch (Exception ex) + { + _log.Warning(ex, "DriverInstance {Id}: GetHostStatuses threw during health publish; omitting host detail", _driverInstanceId); + return null; + } + } + /// Records the driver's rediscovery raise and re-publishes health so the signal reaches the /// AdminUI promptly rather than waiting for the next 30 s heartbeat. /// Advisory only. The served address space is deliberately NOT rebuilt: v3 authors raw tags @@ -961,16 +1017,26 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { var health = _driver.GetHealth(); var errorCount = ErrorCount5Min(); + var hostStatuses = CurrentHostStatuses(); // _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an // otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount) // identical, so without it the dedup below would swallow the very publish that carries the // signal and the operator would never see the prompt. - var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount, _rediscoveryNeededUtc); + // + // The host-status digest is in the fingerprint for EXACTLY the same reason, and the failure + // mode is the more likely one of the two: a multi-device driver stays aggregate-Healthy when a + // single device drops, so every other fingerprint component is unchanged and the transition — + // the whole point of this channel — would be deduped away. It is a flattened STRING because a + // tuple holding IReadOnlyList compares by reference, which would never match and so would + // defeat the dedup in the opposite direction (re-publishing every 30 s heartbeat). + var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount, + _rediscoveryNeededUtc, HostStatusDigest(hostStatuses)); if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint)) return; _lastPublishedFingerprint = fingerprint; _healthPublisher.Publish( - _clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason); + _clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason, + hostStatuses); } catch (Exception ex) { @@ -978,8 +1044,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } + /// Order-insensitive value digest of a host-status list for the publish fingerprint. Null in, + /// null out — so "driver is not a probe" and "probe reports no hosts" stay distinguishable rather than + /// both collapsing to the empty string. + /// Ordered by host name because a driver is free to return its hosts in any order (several build + /// the list from a Dictionary), and an order flip would otherwise read as a real transition and + /// re-publish forever. + private static string? HostStatusDigest(IReadOnlyList? statuses) => + statuses is null + ? null + : string.Join('|', statuses + .OrderBy(s => s.HostName, StringComparer.Ordinal) + .Select(s => $"{s.HostName}={s.State}@{s.LastChangedUtc:O}")); + /// Fingerprint of the last call; null until first publish. - private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount, DateTime? RediscoveryNeededUtc)? _lastPublishedFingerprint; + private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount, + DateTime? RediscoveryNeededUtc, string? HostStatusDigest)? _lastPublishedFingerprint; /// protected override void PostStop() @@ -988,6 +1068,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the // same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self. DetachRediscoverySource(); + DetachHostStatusSource(); try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); } catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); } OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DriverHostStatusTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DriverHostStatusTests.cs deleted file mode 100644 index a3f9619a..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DriverHostStatusTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; - -/// -/// End-to-end round-trip through the DB for the entity -/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId, -/// HostName), string-backed DriverHostState conversion, and the two indexes the -/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc). -/// -[Trait("Category", "SchemaCompliance")] -[Collection(nameof(SchemaComplianceCollection))] -public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture) -{ - /// Verifies that the composite key allows the same host across different nodes or drivers. - [Fact] - public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers() - { - await using var ctx = NewContext(); - - // Same HostName + DriverInstanceId across two different server nodes — classic 2-node - // redundancy case. Both rows must be insertable because each server node owns its own - // runtime view of the shared host. - var now = DateTime.UtcNow; - ctx.DriverHostStatuses.Add(new DriverHostStatus - { - NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform", - State = DriverHostState.Running, - StateChangedUtc = now, LastSeenUtc = now, - }); - ctx.DriverHostStatuses.Add(new DriverHostStatus - { - NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform", - State = DriverHostState.Stopped, - StateChangedUtc = now, LastSeenUtc = now, - Detail = "secondary hasn't taken over yet", - }); - // Same server node + host, different driver instance — second driver doesn't clobber. - ctx.DriverHostStatuses.Add(new DriverHostStatus - { - NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform", - State = DriverHostState.Running, - StateChangedUtc = now, LastSeenUtc = now, - }); - await ctx.SaveChangesAsync(); - - var rows = await ctx.DriverHostStatuses.AsNoTracking() - .Where(r => r.HostName == "GRPlatform").ToListAsync(); - - rows.Count.ShouldBe(3); - rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1"); - rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet"); - rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1"); - } - - /// Verifies that the upsert pattern updates existing records in place. - [Fact] - public async Task Upsert_pattern_for_same_key_updates_in_place() - { - // The publisher hosted service (follow-up PR) upserts on every transition + - // heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update - // keyed on the composite PK. If the composite key ever changes, this test breaks - // loudly so the publisher gets a synchronized update. - await using var ctx = NewContext(); - var t0 = DateTime.UtcNow; - ctx.DriverHostStatuses.Add(new DriverHostStatus - { - NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host", - State = DriverHostState.Running, - StateChangedUtc = t0, LastSeenUtc = t0, - }); - await ctx.SaveChangesAsync(); - - var t1 = t0.AddSeconds(30); - await using (var ctx2 = NewContext()) - { - var existing = await ctx2.DriverHostStatuses.SingleAsync(r => - r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host"); - existing.State = DriverHostState.Faulted; - existing.StateChangedUtc = t1; - existing.LastSeenUtc = t1; - existing.Detail = "transport reset by peer"; - await ctx2.SaveChangesAsync(); - } - - await using var ctx3 = NewContext(); - var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r => - r.NodeId == "upsert-node" && r.HostName == "upsert-host"); - final.State.ShouldBe(DriverHostState.Faulted); - final.Detail.ShouldBe("transport reset by peer"); - // Only one row — a naive "always insert" would have created a duplicate PK and thrown. - (await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1); - } - - /// Verifies that the State enum is persisted as a string, not an integer. - [Fact] - public async Task Enum_persists_as_string_not_int() - { - // Fluent config sets HasConversion() on State — the DB stores 'Running' / - // 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw - // string back via ADO; if someone drops the conversion the column will contain '1' - // / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table - // directly should see readable state names, not enum ordinals. - await using var ctx = NewContext(); - ctx.DriverHostStatuses.Add(new DriverHostStatus - { - NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host", - State = DriverHostState.Faulted, - StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow, - }); - await ctx.SaveChangesAsync(); - - await using var conn = fixture.OpenConnection(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'"; - var rawValue = (string?)await cmd.ExecuteScalarAsync(); - rawValue.ShouldBe("Faulted"); - } - - private OtOpcUaConfigDbContext NewContext() - { - var options = new DbContextOptionsBuilder() - .UseSqlServer(fixture.ConnectionString) - .Options; - return new OtOpcUaConfigDbContext(options); - } -} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs index 19c8f436..4d9cfde6 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs @@ -33,7 +33,9 @@ public sealed class SchemaComplianceTests "RawFolder", "TagGroup", "UnsTagReference", "DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag", "NodeAcl", "ExternalIdReservation", - "DriverHostStatus", + // DriverHostStatus was dropped in Gitea #521 — nothing ever wrote a row, and per-cluster + // mesh Phase 4 made the publisher its entity doc described unbuildable (a driver-only node + // has no ConfigDb connection). Per-host connectivity now rides DriverHealthChanged to /hosts. "DriverInstanceResilienceStatus", "LdapGroupRoleMapping", // v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs deleted file mode 100644 index 61e401a0..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs +++ /dev/null @@ -1,214 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; - -public sealed class DriverTypeRegistryTests -{ - private static DriverTypeMetadata SampleMetadata( - string typeName = "Modbus", - DriverTier tier = DriverTier.B) => - new(typeName, - DriverConfigJsonSchema: "{\"type\": \"object\"}", - DeviceConfigJsonSchema: "{\"type\": \"object\"}", - TagConfigJsonSchema: "{\"type\": \"object\"}", - Tier: tier); - - /// - /// Verifies that Register followed by Get round-trips metadata correctly. - /// - [Fact] - public void Register_ThenGet_RoundTrips() - { - var registry = new DriverTypeRegistry(); - var metadata = SampleMetadata(); - - registry.Register(metadata); - - registry.Get("Modbus").ShouldBe(metadata); - } - - /// - /// Verifies that Register accepts and preserves non-null tier values. - /// - /// The driver tier to test. - [Theory] - [InlineData(DriverTier.A)] - [InlineData(DriverTier.B)] - [InlineData(DriverTier.C)] - public void Register_Requires_NonNullTier(DriverTier tier) - { - var registry = new DriverTypeRegistry(); - var metadata = SampleMetadata(typeName: $"Driver-{tier}", tier: tier); - - registry.Register(metadata); - - registry.Get(metadata.TypeName).Tier.ShouldBe(tier); - } - - /// - /// Verifies that Get is case-insensitive when looking up driver types. - /// - [Fact] - public void Get_IsCaseInsensitive() - { - var registry = new DriverTypeRegistry(); - registry.Register(SampleMetadata("Galaxy")); - - registry.Get("galaxy").ShouldNotBeNull(); - registry.Get("GALAXY").ShouldNotBeNull(); - } - - /// - /// Verifies that Get throws when looking up an unknown type. - /// - [Fact] - public void Get_UnknownType_Throws() - { - var registry = new DriverTypeRegistry(); - registry.Register(SampleMetadata("Modbus")); - - Should.Throw(() => registry.Get("UnregisteredType")); - } - - /// - /// Verifies that TryGet returns null when looking up an unknown type. - /// - [Fact] - public void TryGet_UnknownType_ReturnsNull() - { - var registry = new DriverTypeRegistry(); - registry.Register(SampleMetadata("Modbus")); - - registry.TryGet("UnregisteredType").ShouldBeNull(); - } - - /// - /// Verifies that Register throws when attempting to register a duplicate type. - /// - [Fact] - public void Register_DuplicateType_Throws() - { - var registry = new DriverTypeRegistry(); - registry.Register(SampleMetadata("Modbus")); - - Should.Throw(() => registry.Register(SampleMetadata("Modbus"))); - } - - /// - /// Verifies that duplicate type detection is case-insensitive. - /// - [Fact] - public void Register_DuplicateTypeIsCaseInsensitive() - { - var registry = new DriverTypeRegistry(); - registry.Register(SampleMetadata("Modbus")); - - Should.Throw(() => registry.Register(SampleMetadata("modbus"))); - } - - /// - /// Verifies that All returns all registered driver types. - /// - [Fact] - public void All_ReturnsRegisteredTypes() - { - var registry = new DriverTypeRegistry(); - registry.Register(SampleMetadata("Modbus")); - registry.Register(SampleMetadata("S7")); - registry.Register(SampleMetadata("Galaxy")); - - var all = registry.All(); - - all.Count.ShouldBe(3); - all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true); - } - - /// - /// Verifies that Get rejects empty or null type names. - /// - /// The type name to test (null, empty, or whitespace). - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Get_RejectsEmptyTypeName(string? typeName) - { - var registry = new DriverTypeRegistry(); - Should.Throw(() => registry.Get(typeName!)); - } - - /// - /// Core.Abstractions-004: concurrent calls for - /// two different driver types must both succeed and both end up visible to readers. A - /// non-atomic check-then-act would let the second swap silently discard the first - /// registration; this test asserts the "registered only once per process" guarantee - /// holds under concurrent writers too. - /// - [Fact] - public void Register_ConcurrentDistinctTypes_AllSucceed() - { - var registry = new DriverTypeRegistry(); - const int parallelism = 32; - var ready = new ManualResetEventSlim(false); - - var threads = Enumerable.Range(0, parallelism) - .Select(i => new Thread(() => - { - ready.Wait(); - registry.Register(SampleMetadata($"Driver-{i}")); - })) - .ToArray(); - - foreach (var t in threads) t.Start(); - ready.Set(); // release all threads simultaneously - foreach (var t in threads) t.Join(); - - var all = registry.All(); - all.Count.ShouldBe(parallelism); - for (var i = 0; i < parallelism; i++) - registry.TryGet($"Driver-{i}").ShouldNotBeNull( - $"Driver-{i} was lost to a race in concurrent Register"); - } - - /// - /// Core.Abstractions-004: concurrent calls for - /// the SAME driver type must result in exactly one successful registration and exactly - /// (parallelism - 1) throws — not a silent - /// last-writer-wins replacement. - /// - [Fact] - public void Register_ConcurrentDuplicateType_ExactlyOneWins() - { - var registry = new DriverTypeRegistry(); - const int parallelism = 16; - var ready = new ManualResetEventSlim(false); - var successes = 0; - var failures = 0; - - var threads = Enumerable.Range(0, parallelism) - .Select(_ => new Thread(() => - { - ready.Wait(); - try - { - registry.Register(SampleMetadata("Contended")); - Interlocked.Increment(ref successes); - } - catch (InvalidOperationException) - { - Interlocked.Increment(ref failures); - } - })) - .ToArray(); - - foreach (var t in threads) t.Start(); - ready.Set(); - foreach (var t in threads) t.Join(); - - successes.ShouldBe(1); - failures.ShouldBe(parallelism - 1); - registry.All().Count.ShouldBe(1); - } -} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs index 7031ba0d..67895cd3 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs @@ -143,13 +143,13 @@ public sealed class DriverResilienceOptionsParserTests public void PropertyNames_AreCaseInsensitive() { var json = """ - { "RECYCLEINTERVALSECONDS": 3600 } + { "CAPABILITYPOLICIES": { "Read": { "retryCount": 7 } } } """; var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, json, out var diag); diag.ShouldBeNull(); - options.RecycleIntervalSeconds.ShouldBe(3600); + options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(7); } /// Verifies that capability name is case insensitive. @@ -182,51 +182,31 @@ public sealed class DriverResilienceOptionsParserTests options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]); } - /// Verifies that RecycleIntervalSeconds on Tier C with positive value parses and surfaces. - [Fact] - public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces() - { - var options = DriverResilienceOptionsParser.ParseOrDefaults( - DriverTier.C, "{\"recycleIntervalSeconds\":3600}", out var diag); - - diag.ShouldBeNull(); - options.RecycleIntervalSeconds.ShouldBe(3600); - } - - /// Verifies that RecycleIntervalSeconds when null defaults to null. - [Fact] - public void RecycleIntervalSeconds_Null_DefaultsToNull() - { - var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, "{}", out _); - options.RecycleIntervalSeconds.ShouldBeNull(); - } - - /// Verifies that RecycleIntervalSeconds on Tier A or B is rejected with diagnostic. - /// The driver tier to test. + /// + /// Backward-compatibility guard for the #522 deletion. The four + /// RecycleIntervalSeconds tests that lived here are gone with the knob they covered — the + /// scheduled-recycle machinery it configured was constructed only in tests, and the + /// IDriverSupervisor that would have performed the recycle had no implementations. + /// What still matters is that a deployed ResilienceConfig blob written before + /// the removal keeps parsing. The removed property must be ignored as an unknown key, NOT rejected + /// — a driver whose stored config suddenly failed to parse would fall back to tier defaults and + /// silently discard the operator's real timeout/retry overrides alongside the dead one. + /// [Theory] [InlineData(DriverTier.A)] - [InlineData(DriverTier.B)] - public void RecycleIntervalSeconds_OnTierAorB_Rejected_With_Diagnostic(DriverTier tier) + [InlineData(DriverTier.C)] + public void A_legacy_blob_carrying_the_removed_recycle_key_still_parses_cleanly(DriverTier tier) { - // Decision #74 — in-process drivers must not scheduled-recycle because it would - // tear down every OPC UA session. The parser surfaces a diagnostic rather than - // silently honouring the value. - var options = DriverResilienceOptionsParser.ParseOrDefaults( - tier, "{\"recycleIntervalSeconds\":3600}", out var diag); + var json = """ + { "recycleIntervalSeconds": 3600, "capabilityPolicies": { "Read": { "retryCount": 5 } } } + """; - options.RecycleIntervalSeconds.ShouldBeNull(); - diag.ShouldContain("Tier C only"); - } + var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, json, out var diag); - /// Verifies that RecycleIntervalSeconds with non-positive value is rejected with diagnostic. - [Fact] - public void RecycleIntervalSeconds_NonPositive_Rejected_With_Diagnostic() - { - var options = DriverResilienceOptionsParser.ParseOrDefaults( - DriverTier.C, "{\"recycleIntervalSeconds\":0}", out var diag); - - options.RecycleIntervalSeconds.ShouldBeNull(); - diag.ShouldContain("must be positive"); + diag.ShouldBeNull("an unknown key must be ignored silently, not reported as a misconfiguration"); + options.Tier.ShouldBe(tier); + options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(5, + "the operator's real overrides must survive alongside the dead key"); } // ---- 01/S-6: range clamps (clamp + diagnostic, never brick) ---- diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs index 17920df5..b2a4a827 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsTests.cs @@ -15,14 +15,18 @@ public sealed class DriverResilienceOptionsTests /// knob (the bulkhead genre this pass deleted) is inertness the interface-forwarding and /// unwrapped-dispatch guards can't catch. To add a knob: wire it in DriverResiliencePipelineBuilder, /// add a behavior test that proves it engages, THEN add it to the expected set below. - /// (RecycleIntervalSeconds is itself Tier-C-dormant per 01/U-2 — a documented open question, - /// explicitly out of this pass's scope; it stays in the expected list as the recycle scheduler, not - /// the Polly pipeline, is its consumer.) + /// RecycleIntervalSeconds used to sit in the expected set under an explicit carve-out — + /// Tier-C-dormant per 01/U-2, "a documented open question, out of this pass's scope", justified by + /// the recycle scheduler rather than the Polly pipeline being its consumer. Gitea #522 closed that + /// question by deleting the scheduler (it was constructed only in tests, and the + /// IDriverSupervisor that would perform the recycle had no implementations), so the knob went + /// with it and the carve-out is gone. The expected set below is now literally what the name of this + /// test claims. /// [Fact] public void Options_properties_are_exactly_the_pipeline_wired_set() { - var expected = new[] { "Tier", "CapabilityPolicies", "RecycleIntervalSeconds" }; + var expected = new[] { "Tier", "CapabilityPolicies" }; var actual = typeof(DriverResilienceOptions) .GetProperties() diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/MemoryRecycleTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/MemoryRecycleTests.cs deleted file mode 100644 index 6f17ea9c..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/MemoryRecycleTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Stability; - -namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability; - -[Trait("Category", "Unit")] -public sealed class MemoryRecycleTests -{ - /// Verifies that Tier C hard memory breach requests supervisor recycle. - [Fact] - public async Task TierC_HardBreach_RequestsSupervisorRecycle() - { - var supervisor = new FakeSupervisor(); - var recycle = new MemoryRecycle(DriverTier.C, supervisor, NullLogger.Instance); - - var requested = await recycle.HandleAsync(MemoryTrackingAction.HardBreach, 2_000_000_000, CancellationToken.None); - - requested.ShouldBeTrue(); - supervisor.RecycleCount.ShouldBe(1); - supervisor.LastReason.ShouldContain("hard-breach"); - } - - /// Verifies that Tier A and B hard memory breach never request recycle. - /// The driver tier to test. - [Theory] - [InlineData(DriverTier.A)] - [InlineData(DriverTier.B)] - public async Task InProcessTier_HardBreach_NeverRequestsRecycle(DriverTier tier) - { - var supervisor = new FakeSupervisor(); - var recycle = new MemoryRecycle(tier, supervisor, NullLogger.Instance); - - var requested = await recycle.HandleAsync(MemoryTrackingAction.HardBreach, 2_000_000_000, CancellationToken.None); - - requested.ShouldBeFalse("Tier A/B hard-breach logs a promotion recommendation only (decisions #74, #145)"); - supervisor.RecycleCount.ShouldBe(0); - } - - /// Verifies that Tier C without supervisor hard breach is a no-op. - [Fact] - public async Task TierC_WithoutSupervisor_HardBreach_NoOp() - { - var recycle = new MemoryRecycle(DriverTier.C, supervisor: null, NullLogger.Instance); - - var requested = await recycle.HandleAsync(MemoryTrackingAction.HardBreach, 2_000_000_000, CancellationToken.None); - - requested.ShouldBeFalse("no supervisor → no recycle path; action logged only"); - } - - /// Verifies that soft memory breach never requests recycle at any tier. - /// The driver tier to test. - [Theory] - [InlineData(DriverTier.A)] - [InlineData(DriverTier.B)] - [InlineData(DriverTier.C)] - public async Task SoftBreach_NeverRequestsRecycle(DriverTier tier) - { - var supervisor = new FakeSupervisor(); - var recycle = new MemoryRecycle(tier, supervisor, NullLogger.Instance); - - var requested = await recycle.HandleAsync(MemoryTrackingAction.SoftBreach, 1_000_000_000, CancellationToken.None); - - requested.ShouldBeFalse("soft-breach is surface-only at every tier"); - supervisor.RecycleCount.ShouldBe(0); - } - - /// Verifies that non-breach memory actions are no-ops. - /// The non-breach memory tracking action to test. - [Theory] - [InlineData(MemoryTrackingAction.None)] - [InlineData(MemoryTrackingAction.Warming)] - public async Task NonBreachActions_NoOp(MemoryTrackingAction action) - { - var supervisor = new FakeSupervisor(); - var recycle = new MemoryRecycle(DriverTier.C, supervisor, NullLogger.Instance); - - var requested = await recycle.HandleAsync(action, 100_000_000, CancellationToken.None); - - requested.ShouldBeFalse(); - supervisor.RecycleCount.ShouldBe(0); - } - - private sealed class FakeSupervisor : IDriverSupervisor - { - /// Gets the driver instance identifier. - public string DriverInstanceId => "fake-tier-c"; - /// Gets the count of recycle operations. - public int RecycleCount { get; private set; } - /// Gets the reason from the last recycle operation. - public string? LastReason { get; private set; } - - /// Recycles the driver asynchronously. - /// The reason for recycling. - /// The cancellation token. - public Task RecycleAsync(string reason, CancellationToken cancellationToken) - { - RecycleCount++; - LastReason = reason; - return Task.CompletedTask; - } - } -} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/MemoryTrackingTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/MemoryTrackingTests.cs deleted file mode 100644 index e080eaee..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/MemoryTrackingTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Stability; - -namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability; - -[Trait("Category", "Unit")] -public sealed class MemoryTrackingTests -{ - private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc); - - /// Verifies that warming phase returns Warming until the time window elapses. - [Fact] - public void WarmingUp_Returns_Warming_UntilWindowElapses() - { - var tracker = new MemoryTracking(DriverTier.A, TimeSpan.FromMinutes(5)); - - tracker.Sample(100_000_000, T0).ShouldBe(MemoryTrackingAction.Warming); - tracker.Sample(105_000_000, T0.AddMinutes(1)).ShouldBe(MemoryTrackingAction.Warming); - tracker.Sample(102_000_000, T0.AddMinutes(4.9)).ShouldBe(MemoryTrackingAction.Warming); - - tracker.Phase.ShouldBe(TrackingPhase.WarmingUp); - tracker.BaselineBytes.ShouldBe(0); - } - - /// Verifies that when the window elapses, baseline is captured as median and phase transitions to steady. - [Fact] - public void WindowElapsed_CapturesBaselineAsMedian_AndTransitionsToSteady() - { - var tracker = new MemoryTracking(DriverTier.A, TimeSpan.FromMinutes(5)); - - tracker.Sample(100_000_000, T0); - tracker.Sample(200_000_000, T0.AddMinutes(1)); - tracker.Sample(150_000_000, T0.AddMinutes(2)); - var first = tracker.Sample(150_000_000, T0.AddMinutes(5)); - - tracker.Phase.ShouldBe(TrackingPhase.Steady); - tracker.BaselineBytes.ShouldBe(150_000_000L, "median of 4 samples [100, 200, 150, 150] = (150+150)/2 = 150"); - first.ShouldBe(MemoryTrackingAction.None, "150 MB is the baseline itself, well under soft threshold"); - } - - /// Verifies that tier constants match Decision 146 specification. - /// The driver tier to test. - /// Expected growth multiplier. - /// Expected floor in megabytes. - [Theory] - [InlineData(DriverTier.A, 3, 50)] - [InlineData(DriverTier.B, 3, 100)] - [InlineData(DriverTier.C, 2, 500)] - public void GetTierConstants_MatchesDecision146(DriverTier tier, int expectedMultiplier, long expectedFloorMB) - { - var (multiplier, floor) = MemoryTracking.GetTierConstants(tier); - multiplier.ShouldBe(expectedMultiplier); - floor.ShouldBe(expectedFloorMB * 1024 * 1024); - } - - /// Verifies that soft threshold uses the maximum of multiplier and floor for small baselines. - [Fact] - public void SoftThreshold_UsesMax_OfMultiplierAndFloor_SmallBaseline() - { - // Tier A: mult=3, floor=50 MB. Baseline 10 MB → 3×10=30 MB < 10+50=60 MB → floor wins. - var tracker = WarmupWithBaseline(DriverTier.A, 10L * 1024 * 1024); - tracker.SoftThresholdBytes.ShouldBe(60L * 1024 * 1024); - } - - /// Verifies that soft threshold uses the maximum of multiplier and floor for large baselines. - [Fact] - public void SoftThreshold_UsesMax_OfMultiplierAndFloor_LargeBaseline() - { - // Tier A: mult=3, floor=50 MB. Baseline 200 MB → 3×200=600 MB > 200+50=250 MB → multiplier wins. - var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024); - tracker.SoftThresholdBytes.ShouldBe(600L * 1024 * 1024); - } - - /// Verifies that hard threshold is twice the soft threshold. - [Fact] - public void HardThreshold_IsTwiceSoft() - { - var tracker = WarmupWithBaseline(DriverTier.B, 200L * 1024 * 1024); - tracker.HardThresholdBytes.ShouldBe(tracker.SoftThresholdBytes * 2); - } - - /// Verifies that samples below soft threshold return None. - [Fact] - public void Sample_Below_Soft_Returns_None() - { - var tracker = WarmupWithBaseline(DriverTier.A, 100L * 1024 * 1024); - - tracker.Sample(200L * 1024 * 1024, T0.AddMinutes(10)).ShouldBe(MemoryTrackingAction.None); - } - - /// Verifies that samples at soft threshold return SoftBreach. - [Fact] - public void Sample_AtSoft_Returns_SoftBreach() - { - // Tier A, baseline 200 MB → soft = 600 MB. Sample exactly at soft. - var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024); - - tracker.Sample(tracker.SoftThresholdBytes, T0.AddMinutes(10)) - .ShouldBe(MemoryTrackingAction.SoftBreach); - } - - /// Verifies that samples at hard threshold return HardBreach. - [Fact] - public void Sample_AtHard_Returns_HardBreach() - { - var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024); - - tracker.Sample(tracker.HardThresholdBytes, T0.AddMinutes(10)) - .ShouldBe(MemoryTrackingAction.HardBreach); - } - - /// Verifies that samples above hard threshold return HardBreach. - [Fact] - public void Sample_AboveHard_Returns_HardBreach() - { - var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024); - - tracker.Sample(tracker.HardThresholdBytes + 100_000_000, T0.AddMinutes(10)) - .ShouldBe(MemoryTrackingAction.HardBreach); - } - - private static MemoryTracking WarmupWithBaseline(DriverTier tier, long baseline) - { - var tracker = new MemoryTracking(tier, TimeSpan.FromMinutes(5)); - tracker.Sample(baseline, T0); - tracker.Sample(baseline, T0.AddMinutes(5)); - tracker.BaselineBytes.ShouldBe(baseline); - return tracker; - } -} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/ScheduledRecycleSchedulerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/ScheduledRecycleSchedulerTests.cs deleted file mode 100644 index 0e4f3f83..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Stability/ScheduledRecycleSchedulerTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Stability; - -namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability; - -[Trait("Category", "Unit")] -public sealed class ScheduledRecycleSchedulerTests -{ - private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc); - private static readonly TimeSpan Weekly = TimeSpan.FromDays(7); - - /// Verifies constructor throws for Tier A or B. - /// The driver tier to test. - [Theory] - [InlineData(DriverTier.A)] - [InlineData(DriverTier.B)] - public void TierAOrB_Ctor_Throws(DriverTier tier) - { - var supervisor = new FakeSupervisor(); - Should.Throw(() => new ScheduledRecycleScheduler( - tier, Weekly, T0, supervisor, NullLogger.Instance)); - } - - /// Verifies constructor throws for zero or negative intervals. - [Fact] - public void ZeroOrNegativeInterval_Throws() - { - var supervisor = new FakeSupervisor(); - Should.Throw(() => new ScheduledRecycleScheduler( - DriverTier.C, TimeSpan.Zero, T0, supervisor, NullLogger.Instance)); - Should.Throw(() => new ScheduledRecycleScheduler( - DriverTier.C, TimeSpan.FromSeconds(-1), T0, supervisor, NullLogger.Instance)); - } - - /// Verifies Tick before the next recycle time is a no-op. - [Fact] - public async Task Tick_BeforeNextRecycle_NoOp() - { - var supervisor = new FakeSupervisor(); - var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger.Instance); - - var fired = await sch.TickAsync(T0 + TimeSpan.FromDays(6), CancellationToken.None); - - fired.ShouldBeFalse(); - supervisor.RecycleCount.ShouldBe(0); - } - - /// Verifies Tick at or after the next recycle time fires once and advances. - [Fact] - public async Task Tick_AtOrAfterNextRecycle_FiresOnce_AndAdvances() - { - var supervisor = new FakeSupervisor(); - var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger.Instance); - - var fired = await sch.TickAsync(T0 + Weekly + TimeSpan.FromMinutes(1), CancellationToken.None); - - fired.ShouldBeTrue(); - supervisor.RecycleCount.ShouldBe(1); - sch.NextRecycleUtc.ShouldBe(T0 + Weekly + Weekly); - } - - /// Verifies RequestRecycleNow fires immediately without advancing the schedule. - [Fact] - public async Task RequestRecycleNow_Fires_Immediately_WithoutAdvancingSchedule() - { - var supervisor = new FakeSupervisor(); - var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger.Instance); - var nextBefore = sch.NextRecycleUtc; - - await sch.RequestRecycleNowAsync("memory hard-breach", CancellationToken.None); - - supervisor.RecycleCount.ShouldBe(1); - supervisor.LastReason.ShouldBe("memory hard-breach"); - sch.NextRecycleUtc.ShouldBe(nextBefore, "ad-hoc recycle doesn't shift the cron schedule"); - } - - /// Verifies multiple ticks across the recycle interval each advance by one interval. - [Fact] - public async Task MultipleFires_AcrossTicks_AdvanceOneIntervalEach() - { - var supervisor = new FakeSupervisor(); - var sch = new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromDays(1), T0, supervisor, NullLogger.Instance); - - await sch.TickAsync(T0 + TimeSpan.FromDays(1) + TimeSpan.FromHours(1), CancellationToken.None); - await sch.TickAsync(T0 + TimeSpan.FromDays(2) + TimeSpan.FromHours(1), CancellationToken.None); - await sch.TickAsync(T0 + TimeSpan.FromDays(3) + TimeSpan.FromHours(1), CancellationToken.None); - - supervisor.RecycleCount.ShouldBe(3); - sch.NextRecycleUtc.ShouldBe(T0 + TimeSpan.FromDays(4)); - } - - /// Fake driver supervisor for testing. - private sealed class FakeSupervisor : IDriverSupervisor - { - /// Gets the driver instance ID. - public string DriverInstanceId => "tier-c-fake"; - - /// Gets the number of times RecycleAsync was called. - public int RecycleCount { get; private set; } - - /// Gets the reason from the most recent recycle call. - public string? LastReason { get; private set; } - - /// Simulates a driver recycle operation. - /// The reason for the recycle. - /// Cancellation token. - /// A completed task. - public Task RecycleAsync(string reason, CancellationToken cancellationToken) - { - RecycleCount++; - LastReason = reason; - return Task.CompletedTask; - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs index e72c6bf5..acc38c31 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs @@ -2,6 +2,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts; @@ -154,4 +155,62 @@ public sealed class HostsDriverViewTests groups.Select(g => g.ClusterId).ShouldBe(new[] { "Alpha", "Beta", "zeta" }); } + + /// + /// Per-host connectivity flows through to the row (Gitea #521), and DegradedHosts picks out + /// exactly the hosts an operator needs to look at. This is the case the driver-level Status chip + /// cannot express: the driver is Healthy and one of its devices is not. + /// + [Fact] + public void Build_carries_host_statuses_and_flags_only_the_degraded_ones() + { + var snapshot = Snap("MAIN", "drv-a") with + { + HostStatuses = + [ + new HostConnectivityStatus("plc-a", HostState.Running, When), + new HostConnectivityStatus("plc-b", HostState.Stopped, When), + new HostConnectivityStatus("plc-c", HostState.Faulted, When), + ], + }; + + var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single(); + + row.State.ShouldBe("Healthy"); + row.HostStatuses!.Count.ShouldBe(3); + row.DegradedHosts.Select(h => h.HostName).ShouldBe(["plc-b", "plc-c"]); + } + + /// + /// counts as degraded. A probe that has not completed its first tick + /// — or one a driver failed to start at all, which AbCip logs explicitly — reports Unknown, and + /// rendering that as healthy is how an unstarted probe stays invisible. + /// + [Fact] + public void Unknown_host_state_counts_as_degraded() + { + var snapshot = Snap("MAIN", "drv-a") with + { + HostStatuses = [new HostConnectivityStatus("plc-a", HostState.Unknown, When)], + }; + + var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single(); + + row.DegradedHosts.ShouldHaveSingleItem().HostName.ShouldBe("plc-a"); + } + + /// + /// A driver with no probe keeps a null host list — distinct from a probe reporting zero hosts. The + /// /hosts column renders "—" for the former and "0 hosts" for the latter, and collapsing them would + /// claim every probe-less driver's devices are fine. + /// + [Fact] + public void A_driver_without_host_statuses_keeps_null_and_reports_no_degraded_hosts() + { + var row = HostsDriverView.Build([Snap("MAIN", "drv-a")], nodes: null, instances: null) + .Single().Drivers.Single(); + + row.HostStatuses.ShouldBeNull(); + row.DegradedHosts.ShouldBeEmpty(); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs index fd4a8765..c08abc1a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs @@ -67,7 +67,7 @@ public class ResilienceFormModelTests [Fact] public void Partial_override_round_trips() { - var m = new ResilienceFormModel { RecycleIntervalSeconds = 3600 }; + var m = new ResilienceFormModel(); m.Policies["Read"].TimeoutSeconds = 5; m.Policies["Read"].RetryCount = 5; @@ -75,16 +75,37 @@ public class ResilienceFormModelTests json.ShouldNotBeNull(); var back = ResilienceFormModel.FromJson(json); - back.RecycleIntervalSeconds.ShouldBe(3600); back.Policies["Read"].TimeoutSeconds.ShouldBe(5); back.Policies["Write"].IsEmpty.ShouldBeTrue(); } + /// + /// The removed recycleIntervalSeconds field (Gitea #522) must be PRESERVED in a stored blob + /// rather than stripped, via the model's preserve-unknown-keys contract. Opening a driver's + /// resilience form and saving an unrelated change must not silently rewrite the operator's stored + /// config — dropping the key is a migration decision, not a side effect of a page visit. + /// + [Fact] + public void The_removed_recycle_key_survives_a_load_save_as_an_unknown_key() + { + const string stored = """ + { "recycleIntervalSeconds": 3600, "capabilityPolicies": { "Read": { "retryCount": 5 } } } + """; + + var m = ResilienceFormModel.FromJson(stored); + m.Policies["Read"].TimeoutSeconds = 9; // an unrelated edit + + var json = m.ToJson(); + + json.ShouldNotBeNull(); + json.ShouldContain("recycleIntervalSeconds"); + json.ShouldContain("3600"); + } + [Fact] public void Malformed_json_yields_empty_model() { var m = ResilienceFormModel.FromJson("{ not valid json"); - m.RecycleIntervalSeconds.ShouldBeNull(); m.Policies["Read"].IsEmpty.ShouldBeTrue(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs index f5602483..1b4c9c62 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/TelemetryProtoMapCentralTests.cs @@ -3,6 +3,9 @@ using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.Protos; using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry; +// Aliased, not imported wholesale: Core.Abstractions also declares a DriverHealth, which collides with +// the proto DriverHealth these tests construct. +using HostState = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostState; using Xunit; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry; @@ -168,6 +171,77 @@ public sealed class TelemetryProtoMapCentralTests e.PublishedUtc.Kind.ShouldBe(DateTimeKind.Utc); } + /// + /// Per-host connectivity (Gitea #521) survives the wire with its tri-state intact: + /// null (driver has no probe) must stay distinguishable from an empty list (it has one + /// that knows no hosts). proto3 cannot tell an absent repeated field from an empty one, which is why + /// has_host_statuses exists — without it a driver with no probe would arrive looking like one + /// whose devices are all fine, and the /hosts column would render "0 hosts" for every driver in the + /// fleet. + /// + [Fact] + public void ToHealth_host_statuses_round_trip_with_the_null_vs_empty_distinction_intact() + { + var populated = new DriverHealth + { + ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy", + PublishedUtc = Timestamp.FromDateTime(SampleUtc), + HasHostStatuses = true, + HostStatuses = + { + new HostConnectivity { HostName = "plc-a", State = "Running", LastChangedUtc = Timestamp.FromDateTime(OtherUtc) }, + new HostConnectivity { HostName = "plc-b", State = "Stopped", LastChangedUtc = Timestamp.FromDateTime(SampleUtc) }, + }, + }; + + var mapped = TelemetryProtoMapCentral.ToHealth(populated).HostStatuses; + mapped.ShouldNotBeNull(); + mapped!.Count.ShouldBe(2); + mapped[0].HostName.ShouldBe("plc-a"); + mapped[0].State.ShouldBe(HostState.Running); + mapped[0].LastChangedUtc.ShouldBe(OtherUtc); + mapped[1].State.ShouldBe(HostState.Stopped); + + // A probe that currently knows no hosts: empty, NOT null. + var empty = new DriverHealth + { + ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy", + PublishedUtc = Timestamp.FromDateTime(SampleUtc), + HasHostStatuses = true, + }; + TelemetryProtoMapCentral.ToHealth(empty).HostStatuses.ShouldBeEmpty(); + + // No probe at all: null, NOT empty. + var absent = new DriverHealth + { + ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy", + PublishedUtc = Timestamp.FromDateTime(SampleUtc), + }; + TelemetryProtoMapCentral.ToHealth(absent).HostStatuses.ShouldBeNull(); + } + + /// + /// An unparseable host state degrades to rather than throwing. A node + /// running a newer build that added an enum member must not be able to kill central's telemetry + /// stream — this is observability, and it has no business failing closed. + /// + [Fact] + public void ToHealth_unknown_host_state_string_degrades_instead_of_throwing() + { + var proto = new DriverHealth + { + ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy", + PublishedUtc = Timestamp.FromDateTime(SampleUtc), + HasHostStatuses = true, + HostStatuses = { new HostConnectivity { HostName = "plc-a", State = "Quiescing" } }, + }; + + var mapped = TelemetryProtoMapCentral.ToHealth(proto).HostStatuses; + + mapped.ShouldNotBeNull(); + mapped![0].State.ShouldBe(HostState.Unknown); + } + [Fact] public void ToHealth_absent_nullable_timestamp_and_optional_string_map_to_null() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Grpc/TelemetryStreamGrpcServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Grpc/TelemetryStreamGrpcServiceTests.cs index b3b60e21..1ebd3cb3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Grpc/TelemetryStreamGrpcServiceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Grpc/TelemetryStreamGrpcServiceTests.cs @@ -8,6 +8,10 @@ using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; using ZB.MOM.WW.OtOpcUa.Host.Grpc; using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry; +// Aliased, not imported wholesale: Core.Abstractions also declares a DriverHealth, which would collide +// with the proto DriverHealth these tests assert on. +using HostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostConnectivityStatus; +using HostState = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostState; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Grpc; @@ -210,6 +214,40 @@ public sealed class TelemetryStreamGrpcServiceTests evt.DriverHealth.LastSuccessfulReadUtc.ToDateTime().ShouldBe(expectedUtc); } + /// + /// Node side of the #521 host-status carry: the presence flag must be written, so the tri-state + /// (no probe / probe with no hosts / probe with hosts) survives a wire proto3 cannot express on the + /// repeated field alone. Paired with the central-side decode in TelemetryProtoMapCentralTests. + /// + [Fact] + public void ToProto_health_writes_the_host_status_presence_flag_and_entries() + { + var changedAt = new DateTime(2026, 7, 30, 11, 0, 0, DateTimeKind.Utc); + var withHosts = new DriverHealthChanged( + "cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow, + HostStatuses: [new HostConnectivityStatus("plc-a", HostState.Stopped, changedAt)]); + + var evt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(withHosts), "c"); + + evt.DriverHealth.HasHostStatuses.ShouldBeTrue(); + evt.DriverHealth.HostStatuses.Count.ShouldBe(1); + evt.DriverHealth.HostStatuses[0].HostName.ShouldBe("plc-a"); + evt.DriverHealth.HostStatuses[0].State.ShouldBe("Stopped"); + evt.DriverHealth.HostStatuses[0].LastChangedUtc.ToDateTime().ShouldBe(changedAt); + + // A probe reporting zero hosts still sets the flag — that is the whole point of having one. + var emptyProbe = new DriverHealthChanged( + "cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow, HostStatuses: []); + var emptyEvt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(emptyProbe), "c"); + emptyEvt.DriverHealth.HasHostStatuses.ShouldBeTrue(); + emptyEvt.DriverHealth.HostStatuses.ShouldBeEmpty(); + + // No probe: flag clear. + var noProbe = new DriverHealthChanged("cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow); + TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(noProbe), "c") + .DriverHealth.HasHostStatuses.ShouldBeFalse(); + } + [Fact] public async Task Client_disconnect_mid_stream_ends_cleanly_without_leaking_a_slot() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs new file mode 100644 index 00000000..31d0da91 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs @@ -0,0 +1,298 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Covers the consumer (Gitea #521). Eleven drivers implement the +/// capability; before this wiring GetHostStatuses() had ZERO production call sites and +/// OnHostStatusChanged had no subscriber outside the Galaxy driver's own aggregator, so per-host +/// connectivity was computed by every driver and read by nobody. +/// Why it does not go to the DB. The DriverHostStatus table's doc-comment described a +/// publisher hosted service that upserted rows from each driver node. It was never built, and +/// per-cluster mesh Phase 4 made it unbuildable as described — AddOtOpcUaConfigDb is gated on the +/// admin role, so a driver-only node has no ConfigDb connection. The table was dropped; the data +/// rides the driver-health snapshot instead. +/// +[Trait("Category", "Unit")] +public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase +{ + /// The base case: a probe driver's hosts reach the health publisher. + [Fact] + public void Host_statuses_reach_the_health_publisher() + { + var driver = new ProbeStubDriver(); + driver.SetHost("plc-a", HostState.Running); + driver.SetHost("plc-b", HostState.Running); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnDriverActor(driver, publisher); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + AwaitAssert( + () => + { + var latest = publisher.Published.LastOrDefault(); + latest.ShouldNotBeNull(); + latest!.HostStatuses.ShouldNotBeNull(); + latest.HostStatuses!.Select(h => h.HostName).OrderBy(n => n, StringComparer.Ordinal) + .ShouldBe(["plc-a", "plc-b"]); + }, + TimeSpan.FromSeconds(3)); + } + + /// + /// The load-bearing case, and the entire reason this channel exists. A multi-device driver + /// stays aggregate-Healthy when ONE of its devices drops — the driver-level state chip cannot + /// express it. So the transition must reach the operator through the per-host detail. + /// This is also the dedup trap that already bit the rediscovery signal once. + /// PublishHealthSnapshot suppresses a publish whose fingerprint repeats, and on this + /// transition (state, lastSuccessfulRead, lastError, errorCount) are ALL unchanged. Unless the + /// host-status digest is part of the fingerprint, the dedup swallows exactly the publish that + /// carries the news. + /// Falsifiability: the assertion is that the publish count STRICTLY INCREASES across the + /// transition — an "eventually shows Stopped" assertion would be satisfied by the warm-up publish + /// plus a later 30 s heartbeat and would prove nothing. Drop HostStatusDigest from the + /// fingerprint tuple in DriverInstanceActor and this test must go red. Verified by doing so. + /// + [Fact] + public void A_single_host_going_down_is_not_swallowed_by_the_unchanged_health_dedup() + { + var driver = new ProbeStubDriver(); + driver.SetHost("plc-a", HostState.Running); + driver.SetHost("plc-b", HostState.Running); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnDriverActor(driver, publisher); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + // Settle, so the baseline is a quiet actor: from here only the host state changes. The driver's + // OWN health stays Healthy throughout — that is the point. + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + var before = publisher.Published.Count; + + driver.SetHost("plc-b", HostState.Stopped, raise: true); + + AwaitAssert( + () => publisher.Published.Count.ShouldBeGreaterThan(before), + TimeSpan.FromSeconds(3)); + + var latest = publisher.Published[^1]; + latest.Health.State.ShouldBe(DriverState.Healthy, "the driver itself never faulted — only one of its devices did"); + latest.HostStatuses.ShouldNotBeNull(); + latest.HostStatuses!.Single(h => h.HostName == "plc-b").State.ShouldBe(HostState.Stopped); + latest.HostStatuses.Single(h => h.HostName == "plc-a").State.ShouldBe(HostState.Running); + } + + /// + /// The other half of the dedup contract: when nothing changes, the digest must NOT churn. A digest + /// built over an IReadOnlyList by reference, or one sensitive to the order a driver happens to + /// enumerate its hosts in, would differ on every call and re-publish on every 30 s heartbeat forever + /// — turning the dedup off without anyone noticing. + /// + [Fact] + public void Unchanged_hosts_do_not_defeat_the_dedup() + { + var driver = new ProbeStubDriver(); + driver.SetHost("plc-a", HostState.Running); + driver.SetHost("plc-b", HostState.Running); + var publisher = new RecordingHealthPublisher(); + var actor = SpawnDriverActor(driver, publisher); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + var before = publisher.Published.Count; + + // The driver re-shuffles its host order without changing any state. A real driver builds this list + // from a Dictionary, so enumeration order is not guaranteed stable between calls. + driver.ReverseHostOrder(); + // Poke the actor into re-publishing without changing anything material. + driver.RaiseHostStatusChanged(); + + ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + publisher.Published.Count.ShouldBe(before, "an order flip with no state change must be deduped, not re-published"); + } + + /// + /// Leak guard. The can OUTLIVE the actor — the host respawns a child + /// around the same driver object — so a missing -= in PostStop accumulates one handler + /// per respawn, each holding a dead Self. + /// + [Fact] + public void Stopping_the_actor_detaches_the_host_status_handler() + { + var driver = new ProbeStubDriver(); + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => driver.SubscriberCount.ShouldBe(1), TimeSpan.FromSeconds(3)); + + Watch(actor); + actor.Tell(PoisonPill.Instance); + ExpectTerminated(actor, TimeSpan.FromSeconds(3)); + + driver.SubscriberCount.ShouldBe(0); + } + + /// + /// A driver with no probe publishes null host statuses — NOT an empty list. The two mean different + /// things at the UI ("no per-host detail available" vs "a probe that currently knows no hosts") and + /// collapsing them would render a driver with no probe as one whose devices are all fine. + /// + [Fact] + public void A_driver_without_a_probe_publishes_null_host_statuses() + { + var publisher = new RecordingHealthPublisher(); + var actor = SpawnDriverActor(new StubDriver(), publisher); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + + publisher.Published.ShouldAllBe(p => p.HostStatuses == null); + } + + /// + /// A probe that throws must not take the health publish down with it. GetHostStatuses() is + /// documented as a pure in-memory snapshot (which is why the capability analyzer exempts it from + /// the guarded-call rule), but a driver is free to violate that, and losing the whole health channel + /// for one misbehaving probe would be a much worse outcome than losing the host detail. + /// + [Fact] + public void A_throwing_probe_degrades_to_null_without_killing_the_health_publish() + { + var driver = new ProbeStubDriver { ThrowOnGetHostStatuses = true }; + var publisher = new RecordingHealthPublisher(); + var actor = SpawnDriverActor(driver, publisher); + + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); + publisher.Published[^1].Health.State.ShouldBe(DriverState.Healthy); + publisher.Published[^1].HostStatuses.ShouldBeNull(); + } + + private IActorRef SpawnDriverActor(IDriver driver, IDriverHealthPublisher publisher) + { + var parent = CreateTestProbe(); + parent.IgnoreMessages(_ => true); + return parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); + } + + /// Captures every health publish so a test can assert on the host-status field. + private sealed record HealthPublish( + DriverHealth Health, + IReadOnlyList? HostStatuses); + + private sealed class RecordingHealthPublisher : IDriverHealthPublisher + { + private readonly List _published = []; + + /// Thread-safe snapshot — Publish runs on the actor thread while the test asserts + /// from its own. + public IReadOnlyList Published + { + get { lock (_published) return _published.ToArray(); } + } + + /// + public void Publish( + string clusterId, + string driverInstanceId, + DriverHealth health, + int errorCount5Min, + DateTime? rediscoveryNeededUtc = null, + string? rediscoveryReason = null, + IReadOnlyList? hostStatuses = null) + { + lock (_published) _published.Add(new HealthPublish(health, hostStatuses)); + } + } + + /// + /// A stub driver exposing . + /// Its own health is deliberately STABLE (a fixed last-read timestamp), for the same + /// reason RediscoverableStubDriver is: the shared StubDriver returns + /// DateTime.UtcNow from GetHealth(), so its fingerprint differs on every call, the + /// dedup never engages, and any test built on it passes vacuously whether or not the fix is + /// present. + /// + private sealed class ProbeStubDriver : IDriver, IHostConnectivityProbe + { + private static readonly DateTime FixedLastRead = new(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime FixedChangedAt = new(2026, 7, 30, 11, 0, 0, DateTimeKind.Utc); + + private readonly List _hosts = []; + + /// When set, throws — the misbehaving-probe case. + public bool ThrowOnGetHostStatuses { get; init; } + + /// + public event EventHandler? OnHostStatusChanged; + + /// + public string DriverInstanceId => "probe-stub-1"; + + /// + public string DriverType => "Stub"; + + /// Number of live subscribers on . + public int SubscriberCount => OnHostStatusChanged?.GetInvocationList().Length ?? 0; + + /// Adds or updates a host, optionally raising the transition event as a real probe loop does. + public void SetHost(string hostName, HostState state, bool raise = false) + { + lock (_hosts) + { + var index = _hosts.FindIndex(h => h.HostName == hostName); + var entry = new HostConnectivityStatus(hostName, state, FixedChangedAt); + if (index >= 0) _hosts[index] = entry; + else _hosts.Add(entry); + } + + if (raise) RaiseHostStatusChanged(); + } + + /// Flips enumeration order without changing any host's state. + public void ReverseHostOrder() + { + lock (_hosts) _hosts.Reverse(); + } + + /// Raises the event exactly as a real probe loop does. + public void RaiseHostStatusChanged() + => OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs("plc-b", HostState.Running, HostState.Stopped)); + + /// + public IReadOnlyList GetHostStatuses() + { + if (ThrowOnGetHostStatuses) throw new InvalidOperationException("probe is broken"); + lock (_hosts) return _hosts.ToArray(); + } + + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null); + + /// + public long GetMemoryFootprint() => 0; + + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs index 4027bf68..36b2651d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs @@ -185,7 +185,8 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest DriverHealth health, int errorCount5Min, DateTime? rediscoveryNeededUtc = null, - string? rediscoveryReason = null) + string? rediscoveryReason = null, + IReadOnlyList? hostStatuses = null) { lock (_published) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs index d331ed3a..568e672e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorSubscriptionReconcileTests.cs @@ -122,7 +122,8 @@ public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActor DriverHealth health, int errorCount5Min, DateTime? rediscoveryNeededUtc = null, - string? rediscoveryReason = null) + string? rediscoveryReason = null, + IReadOnlyList? hostStatuses = null) => Interlocked.Increment(ref _count); } }