refactor(drivers): delete the inert driver-tier recycle machinery (#522)
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Register followed by Get round-trips metadata correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_ThenGet_RoundTrips()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
var metadata = SampleMetadata();
|
||||
|
||||
registry.Register(metadata);
|
||||
|
||||
registry.Get("Modbus").ShouldBe(metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Register accepts and preserves non-null tier values.
|
||||
/// </summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Get is case-insensitive when looking up driver types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Get_IsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Galaxy"));
|
||||
|
||||
registry.Get("galaxy").ShouldNotBeNull();
|
||||
registry.Get("GALAXY").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Get throws when looking up an unknown type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Get_UnknownType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<KeyNotFoundException>(() => registry.Get("UnregisteredType"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that TryGet returns null when looking up an unknown type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryGet_UnknownType_ReturnsNull()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
registry.TryGet("UnregisteredType").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Register throws when attempting to register a duplicate type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_DuplicateType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("Modbus")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that duplicate type detection is case-insensitive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_DuplicateTypeIsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("modbus")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that All returns all registered driver types.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Get rejects empty or null type names.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The type name to test (null, empty, or whitespace).</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Get_RejectsEmptyTypeName(string? typeName)
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
Should.Throw<ArgumentException>(() => registry.Get(typeName!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core.Abstractions-004: concurrent <see cref="DriverTypeRegistry.Register"/> 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core.Abstractions-004: concurrent <see cref="DriverTypeRegistry.Register"/> calls for
|
||||
/// the SAME driver type must result in exactly one successful registration and exactly
|
||||
/// (parallelism - 1) <see cref="InvalidOperationException"/> throws — not a silent
|
||||
/// last-writer-wins replacement.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
+22
-42
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that capability name is case insensitive.</summary>
|
||||
@@ -182,51 +182,31 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds on Tier C with positive value parses and surfaces.</summary>
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
||||
DriverTier.C, "{\"recycleIntervalSeconds\":3600}", out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
options.RecycleIntervalSeconds.ShouldBe(3600);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds when null defaults to null.</summary>
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_Null_DefaultsToNull()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, "{}", out _);
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds on Tier A or B is rejected with diagnostic.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
/// <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.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);
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds with non-positive value is rejected with diagnostic.</summary>
|
||||
[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) ----
|
||||
|
||||
@@ -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 <c>DriverResiliencePipelineBuilder</c>,
|
||||
/// add a behavior test that proves it engages, THEN add it to the expected set below.
|
||||
/// (<c>RecycleIntervalSeconds</c> 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.)
|
||||
/// <para><c>RecycleIntervalSeconds</c> 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
|
||||
/// <c>IDriverSupervisor</c> 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.</para>
|
||||
/// </summary>
|
||||
[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()
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Verifies that Tier C hard memory breach requests supervisor recycle.</summary>
|
||||
[Fact]
|
||||
public async Task TierC_HardBreach_RequestsSupervisorRecycle()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var recycle = new MemoryRecycle(DriverTier.C, supervisor, NullLogger<MemoryRecycle>.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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Tier A and B hard memory breach never request recycle.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[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<MemoryRecycle>.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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Tier C without supervisor hard breach is a no-op.</summary>
|
||||
[Fact]
|
||||
public async Task TierC_WithoutSupervisor_HardBreach_NoOp()
|
||||
{
|
||||
var recycle = new MemoryRecycle(DriverTier.C, supervisor: null, NullLogger<MemoryRecycle>.Instance);
|
||||
|
||||
var requested = await recycle.HandleAsync(MemoryTrackingAction.HardBreach, 2_000_000_000, CancellationToken.None);
|
||||
|
||||
requested.ShouldBeFalse("no supervisor → no recycle path; action logged only");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that soft memory breach never requests recycle at any tier.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[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<MemoryRecycle>.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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-breach memory actions are no-ops.</summary>
|
||||
/// <param name="action">The non-breach memory tracking action to test.</param>
|
||||
[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<MemoryRecycle>.Instance);
|
||||
|
||||
var requested = await recycle.HandleAsync(action, 100_000_000, CancellationToken.None);
|
||||
|
||||
requested.ShouldBeFalse();
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
public string DriverInstanceId => "fake-tier-c";
|
||||
/// <summary>Gets the count of recycle operations.</summary>
|
||||
public int RecycleCount { get; private set; }
|
||||
/// <summary>Gets the reason from the last recycle operation.</summary>
|
||||
public string? LastReason { get; private set; }
|
||||
|
||||
/// <summary>Recycles the driver asynchronously.</summary>
|
||||
/// <param name="reason">The reason for recycling.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
LastReason = reason;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Verifies that warming phase returns Warming until the time window elapses.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the window elapses, baseline is captured as median and phase transitions to steady.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tier constants match Decision 146 specification.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
/// <param name="expectedMultiplier">Expected growth multiplier.</param>
|
||||
/// <param name="expectedFloorMB">Expected floor in megabytes.</param>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that soft threshold uses the maximum of multiplier and floor for small baselines.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that soft threshold uses the maximum of multiplier and floor for large baselines.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that hard threshold is twice the soft threshold.</summary>
|
||||
[Fact]
|
||||
public void HardThreshold_IsTwiceSoft()
|
||||
{
|
||||
var tracker = WarmupWithBaseline(DriverTier.B, 200L * 1024 * 1024);
|
||||
tracker.HardThresholdBytes.ShouldBe(tracker.SoftThresholdBytes * 2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples below soft threshold return None.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples at soft threshold return SoftBreach.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples at hard threshold return HardBreach.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples above hard threshold return HardBreach.</summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Verifies constructor throws for Tier A or B.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
public void TierAOrB_Ctor_Throws(DriverTier tier)
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
Should.Throw<ArgumentException>(() => new ScheduledRecycleScheduler(
|
||||
tier, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>Verifies constructor throws for zero or negative intervals.</summary>
|
||||
[Fact]
|
||||
public void ZeroOrNegativeInterval_Throws()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
Should.Throw<ArgumentException>(() => new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.Zero, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
Should.Throw<ArgumentException>(() => new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromSeconds(-1), T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>Verifies Tick before the next recycle time is a no-op.</summary>
|
||||
[Fact]
|
||||
public async Task Tick_BeforeNextRecycle_NoOp()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var fired = await sch.TickAsync(T0 + TimeSpan.FromDays(6), CancellationToken.None);
|
||||
|
||||
fired.ShouldBeFalse();
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies Tick at or after the next recycle time fires once and advances.</summary>
|
||||
[Fact]
|
||||
public async Task Tick_AtOrAfterNextRecycle_FiresOnce_AndAdvances()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies RequestRecycleNow fires immediately without advancing the schedule.</summary>
|
||||
[Fact]
|
||||
public async Task RequestRecycleNow_Fires_Immediately_WithoutAdvancingSchedule()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies multiple ticks across the recycle interval each advance by one interval.</summary>
|
||||
[Fact]
|
||||
public async Task MultipleFires_AcrossTicks_AdvanceOneIntervalEach()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromDays(1), T0, supervisor, NullLogger<ScheduledRecycleScheduler>.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));
|
||||
}
|
||||
|
||||
/// <summary>Fake driver supervisor for testing.</summary>
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
/// <summary>Gets the driver instance ID.</summary>
|
||||
public string DriverInstanceId => "tier-c-fake";
|
||||
|
||||
/// <summary>Gets the number of times RecycleAsync was called.</summary>
|
||||
public int RecycleCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the reason from the most recent recycle call.</summary>
|
||||
public string? LastReason { get; private set; }
|
||||
|
||||
/// <summary>Simulates a driver recycle operation.</summary>
|
||||
/// <param name="reason">The reason for the recycle.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
LastReason = reason;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The removed <c>recycleIntervalSeconds</c> 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user