cbb882293b
The tier system was documented, operator-authorable, and inert. Deleted rather than activated,
because its premise is gone rather than merely unused.
"Tier C" meant a driver running out-of-process behind an IDriverSupervisor that could restart its
Host without tearing down the OPC UA session. No such process exists 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. Consistently, IDriverSupervisor had ZERO implementations and there was nothing
for one to implement against.
The issue understated the inertness. It says the Tier-C-only protections never engaged, implying
the Tier A/B parts did. They did not: nothing constructs MemoryTracking, MemoryRecycle or
ScheduledRecycleScheduler outside their own unit tests, so the whole Core/Stability recycle layer
was dead — meaning option (a), "pass real tiers", was never a flag flip. It would have meant
writing the wiring that never existed AND arming it.
Deleted: MemoryTracking, MemoryRecycle, ScheduledRecycleScheduler, IDriverSupervisor, the
vestigial DriverTypeRegistry (referenced only by its own tests), and the RecycleIntervalSeconds
knob — which the AdminUI let an operator author and the parser validated while it configured
nothing.
DriverTier itself SURVIVES and is load-bearing: DriverResilienceOptions.GetTierDefaults supplies
the real per-capability timeout/retry/breaker policies via DriverFactoryRegistry.GetTier and
DriverCapabilityInvokerFactory. Only the isolation-and-recycle layer above it is gone.
Deliberately NOT deleted:
- WedgeDetector came along in the same directory and is equally dead in production, but it is
tier-agnostic and is not recycle machinery — it only shares the folder. Restored rather than
swept up in a decision that was not about it.
- IDriver.GetMemoryFootprint() and FlushOptionalCachesAsync() lose their only consumer here.
Removing them touches all 12 drivers and every test stub, so they are documented as
consumerless and filed as #525 instead of buried in this diff.
Compatibility: a deployed ResilienceConfig blob still carrying "recycleIntervalSeconds" parses
cleanly (unknown keys are ignored — guarded by a new test, because a blob that suddenly failed to
parse would fall back to tier defaults and silently discard the operator's real overrides), and
the AdminUI's preserve-unknown-keys bag keeps the key rather than rewriting stored config on an
unrelated edit.
The 01/U-6 knob-inertness guard carried an explicit carve-out admitting RecycleIntervalSeconds was
dormant and out of scope; that carve-out is now gone, so the expected set is literally what the
test's name claims.
Note: Host.IntegrationTests has 2 failures (DriverProbeRegistrationTests.is_idempotent,
PrimaryGateFailoverTests) — verified pre-existing by reproducing both on clean master dc9d947b.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
361 lines
15 KiB
C#
361 lines
15 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DriverResilienceOptionsParserTests
|
|
{
|
|
/// <summary>Verifies that null JSON returns pure tier defaults.</summary>
|
|
[Fact]
|
|
public void NullJson_ReturnsPureTierDefaults()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, null, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Tier.ShouldBe(DriverTier.A);
|
|
options.Resolve(DriverCapability.Read).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
|
}
|
|
|
|
/// <summary>Verifies that whitespace JSON returns defaults.</summary>
|
|
[Fact]
|
|
public void WhitespaceJson_ReturnsDefaults()
|
|
{
|
|
DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, " ", out var diag);
|
|
diag.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that malformed JSON falls back with diagnostic.</summary>
|
|
[Fact]
|
|
public void MalformedJson_FallsBack_WithDiagnostic()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{not json", out var diag);
|
|
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("malformed");
|
|
options.Tier.ShouldBe(DriverTier.A);
|
|
options.Resolve(DriverCapability.Read).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
|
}
|
|
|
|
/// <summary>Verifies that empty object returns defaults.</summary>
|
|
[Fact]
|
|
public void EmptyObject_ReturnsDefaults()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{}", out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Resolve(DriverCapability.Write).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
|
}
|
|
|
|
/// <summary>Verifies that Read override is merged into tier defaults.</summary>
|
|
[Fact]
|
|
public void ReadOverride_MergedIntoTierDefaults()
|
|
{
|
|
var json = """
|
|
{
|
|
"capabilityPolicies": {
|
|
"Read": { "timeoutSeconds": 5, "retryCount": 7, "breakerFailureThreshold": 2 }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
var read = options.Resolve(DriverCapability.Read);
|
|
read.TimeoutSeconds.ShouldBe(5);
|
|
read.RetryCount.ShouldBe(7);
|
|
read.BreakerFailureThreshold.ShouldBe(2);
|
|
|
|
// Other capabilities untouched
|
|
options.Resolve(DriverCapability.Write).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
|
}
|
|
|
|
/// <summary>Verifies that partial policy fills missing fields from tier default.</summary>
|
|
[Fact]
|
|
public void PartialPolicy_FillsMissingFieldsFromTierDefault()
|
|
{
|
|
var json = """
|
|
{
|
|
"capabilityPolicies": {
|
|
"Read": { "retryCount": 10 }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
|
|
|
|
var read = options.Resolve(DriverCapability.Read);
|
|
var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read];
|
|
read.RetryCount.ShouldBe(10);
|
|
read.TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds, "partial override; timeout falls back to tier default");
|
|
read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 01/U-6: the deleted bulkhead knob's keys in stored JSON become ignored unknown keys — a
|
|
/// config carrying them parses clean with no diagnostic (migration-free contract).
|
|
/// </summary>
|
|
[Fact]
|
|
public void BulkheadKeys_InStoredJson_AreIgnored()
|
|
{
|
|
var json = """
|
|
{ "bulkheadMaxConcurrent": 100, "bulkheadMaxQueue": 500 }
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, json, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
// Known capabilities untouched — the stale bulkhead keys are simply ignored.
|
|
options.Resolve(DriverCapability.Read).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.B)[DriverCapability.Read]);
|
|
}
|
|
|
|
/// <summary>Verifies that unknown capability surfaces in diagnostic but does not fail.</summary>
|
|
[Fact]
|
|
public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
|
|
{
|
|
var json = """
|
|
{
|
|
"capabilityPolicies": {
|
|
"InventedCapability": { "timeoutSeconds": 99 }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
|
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("InventedCapability");
|
|
// Known capabilities untouched.
|
|
options.Resolve(DriverCapability.Read).ShouldBe(
|
|
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
|
}
|
|
|
|
/// <summary>Verifies that top-level property names are case insensitive.</summary>
|
|
[Fact]
|
|
public void PropertyNames_AreCaseInsensitive()
|
|
{
|
|
var json = """
|
|
{ "CAPABILITYPOLICIES": { "Read": { "retryCount": 7 } } }
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, json, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(7);
|
|
}
|
|
|
|
/// <summary>Verifies that capability name is case insensitive.</summary>
|
|
[Fact]
|
|
public void CapabilityName_IsCaseInsensitive()
|
|
{
|
|
var json = """
|
|
{ "capabilityPolicies": { "read": { "retryCount": 99 } } }
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
|
|
}
|
|
|
|
/// <summary>Verifies that every tier with empty JSON round-trips its defaults.</summary>
|
|
/// <param name="tier">The driver tier to test.</param>
|
|
[Theory]
|
|
[InlineData(DriverTier.A)]
|
|
[InlineData(DriverTier.B)]
|
|
[InlineData(DriverTier.C)]
|
|
public void EveryTier_WithEmptyJson_RoundTrips_Its_Defaults(DriverTier tier)
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, "{}", out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
options.Tier.ShouldBe(tier);
|
|
foreach (var cap in Enum.GetValues<DriverCapability>())
|
|
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Backward-compatibility guard for the #522 deletion.</b> The four
|
|
/// <c>RecycleIntervalSeconds</c> tests that lived here are gone with the knob they covered — the
|
|
/// scheduled-recycle machinery it configured was constructed only in tests, and the
|
|
/// <c>IDriverSupervisor</c> that would have performed the recycle had no implementations.
|
|
/// <para>What still matters is that a <b>deployed</b> <c>ResilienceConfig</c> 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.</para>
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(DriverTier.A)]
|
|
[InlineData(DriverTier.C)]
|
|
public void A_legacy_blob_carrying_the_removed_recycle_key_still_parses_cleanly(DriverTier tier)
|
|
{
|
|
var json = """
|
|
{ "recycleIntervalSeconds": 3600, "capabilityPolicies": { "Read": { "retryCount": 5 } } }
|
|
""";
|
|
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, json, out var diag);
|
|
|
|
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) ----
|
|
|
|
/// <summary>A non-positive timeout is nonsense; it snaps to the capability's tier default with a diagnostic.</summary>
|
|
[Fact]
|
|
public void TimeoutSeconds_Zero_FallsBackToTierDefault_WithDiagnostic()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":0}}}", out var diag);
|
|
|
|
var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read];
|
|
options.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("timeoutSeconds");
|
|
}
|
|
|
|
/// <summary>A timeout above Polly's ceiling clamps to the max with a diagnostic.</summary>
|
|
[Fact]
|
|
public void TimeoutSeconds_AbovePollyMax_Clamped()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":999999}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(ResiliencePolicyRanges.MaxTimeoutSeconds);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("timeoutSeconds");
|
|
}
|
|
|
|
/// <summary>A negative retry count clamps to zero with a diagnostic.</summary>
|
|
[Fact]
|
|
public void RetryCount_Negative_ClampedToZero()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":-1}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(0);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("retryCount");
|
|
}
|
|
|
|
/// <summary>An absurd retry count clamps to the operational cap with a diagnostic.</summary>
|
|
[Fact]
|
|
public void RetryCount_AboveCap_ClampedTo100()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":2000000000}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(ResiliencePolicyRanges.MaxRetryCount);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("retryCount");
|
|
}
|
|
|
|
/// <summary>A breaker threshold of 1 (below Polly's MinimumThroughput floor) raises to 2 with a diagnostic.</summary>
|
|
[Fact]
|
|
public void BreakerThreshold_One_ClampedToTwo()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"breakerFailureThreshold\":1}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.Read).BreakerFailureThreshold.ShouldBe(
|
|
ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("breakerFailureThreshold");
|
|
}
|
|
|
|
/// <summary>A negative breaker threshold disables the breaker (0) with a diagnostic.</summary>
|
|
[Fact]
|
|
public void BreakerThreshold_Negative_DisablesBreaker()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"breakerFailureThreshold\":-5}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.Read).BreakerFailureThreshold.ShouldBe(0);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("breakerFailureThreshold");
|
|
}
|
|
|
|
/// <summary>Multiple clamps in one policy all surface in the (appended) diagnostic string.</summary>
|
|
[Fact]
|
|
public void MultipleClamps_AllSurfaceInDiagnostic()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A,
|
|
"{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":0,\"retryCount\":-1,\"breakerFailureThreshold\":1}}}",
|
|
out var diag);
|
|
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("timeoutSeconds");
|
|
diag.ShouldContain("retryCount");
|
|
diag.ShouldContain("breakerFailureThreshold");
|
|
|
|
var read = options.Resolve(DriverCapability.Read);
|
|
read.TimeoutSeconds.ShouldBe(DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read].TimeoutSeconds);
|
|
read.RetryCount.ShouldBe(0);
|
|
read.BreakerFailureThreshold.ShouldBe(ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold);
|
|
}
|
|
|
|
// ---- 01/S-8 = 03/S12: write-shaped capabilities never retry ----
|
|
|
|
/// <summary>A Write retryCount override is forced to 0 with a diagnostic (writes are non-idempotent).</summary>
|
|
[Fact]
|
|
public void WriteRetryOverride_IsForcedToZero_WithDiagnostic()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Write\":{\"retryCount\":5}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("Write never retries");
|
|
}
|
|
|
|
/// <summary>An AlarmAcknowledge retryCount override is forced to 0 with a diagnostic.</summary>
|
|
[Fact]
|
|
public void AlarmAcknowledgeRetryOverride_IsForcedToZero_WithDiagnostic()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"AlarmAcknowledge\":{\"retryCount\":3}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.AlarmAcknowledge).RetryCount.ShouldBe(0);
|
|
diag.ShouldNotBeNull();
|
|
diag.ShouldContain("AlarmAcknowledge never retries");
|
|
}
|
|
|
|
/// <summary>A Read (idempotent) retryCount override is still honored — the invariant is write-shaped only.</summary>
|
|
[Fact]
|
|
public void ReadRetryOverride_StillHonored()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"retryCount\":5}}}", out var diag);
|
|
|
|
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(5);
|
|
diag.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>In-range overrides pass through untouched with no diagnostic.</summary>
|
|
[Fact]
|
|
public void InRangeOverrides_PassThrough_NoDiagnostic()
|
|
{
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
|
DriverTier.A,
|
|
"{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":5,\"retryCount\":3,\"breakerFailureThreshold\":4}}}",
|
|
out var diag);
|
|
|
|
diag.ShouldBeNull();
|
|
var read = options.Resolve(DriverCapability.Read);
|
|
read.TimeoutSeconds.ShouldBe(5);
|
|
read.RetryCount.ShouldBe(3);
|
|
read.BreakerFailureThreshold.ShouldBe(4);
|
|
}
|
|
}
|