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/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.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/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/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/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/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(); }