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.md — soft = 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 deployedResilienceConfig 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();
}