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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user