refactor(deployment): injectable terminal-purge rate limiter; test the swallowed failure (ScadaBridge#90)

DeploymentService.TryPurgeTerminalDeploymentRecordsAsync rate-limited its
opportunistic retention sweep with a process-wide static tick field, so only
one test in the assembly could drive the sweep and its swallow-on-failure path
was untested.

The limit now lives in TerminalPurgeRateLimiter: a TimeProvider-driven
compare-and-swap claim (MinInterval 6 h) registered by AddDeploymentManager as
a DI singleton, so every scoped DeploymentService still shares one limit per
node. DeploymentService takes it as an optional trailing constructor parameter;
a hand-built instance gets its own limiter. The sweep's cutoff now comes from
the same clock. Behaviour is otherwise unchanged.

Evidence (TerminalPurgeRateLimiterTests):
- claim once per interval on an injected clock; 16 concurrent claimants ->
  exactly one wins;
- two services with their own limiters both sweep, two sharing one sweep once;
- cutoff = injected now - retention;
- a failing artifact batch is logged as a warning with the exception and
  swallowed, the already-purged terminal rows are still reported, the next
  call inside the interval does not retry and the first call after it does;
  a first-batch failure returns 0 and skips the artifact purge;
- AddDeploymentManager registers the limiter as a singleton shared across scopes.
Mutation-checked: a process-wide static limiter fails 5 tests (incl. the
existing artifact-purge test); narrowing the catch fails both failure tests.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 13:20:46 -05:00
parent 6070cc2b9d
commit 51d2f6c906
6 changed files with 311 additions and 23 deletions
@@ -270,7 +270,12 @@ window the table grows for the life of the system and every unfiltered deploymen
query degrades with age rather than with page size.
`TerminalDeploymentRecordRetention` (default **365 days**, deliberately generous
because deployment history is operator forensics) bounds it, swept opportunistically
and rate-limited on the deployment-list read path.
and rate-limited on the deployment-list read path: at most one sweep per 6 hours per
central node, enforced by the DI-singleton `TerminalPurgeRateLimiter` (a
`TimeProvider`-driven compare-and-swap claim shared by every scoped
`DeploymentService`; Gitea #90 — formerly a process-wide static). A failed sweep is
logged as a warning and swallowed — it never fails the operator's query — and still
consumes the interval, so the next attempt waits the full 6 hours.
Only **terminal** rows (`Success` / `Failed`) are eligible. An `InProgress` row is
never purged regardless of age: it is precisely the row the query-before-redeploy
@@ -81,17 +81,11 @@ public class DeploymentService
/// <summary>Batches deleted per opportunistic sweep, bounding one caller's latency cost.</summary>
private const int TerminalPurgeBatchesPerSweep = 4;
/// <summary>Minimum interval between opportunistic terminal-record sweeps.</summary>
private static readonly TimeSpan TerminalPurgeMinInterval = TimeSpan.FromHours(6);
/// <summary>
/// Ticks of the last opportunistic terminal-record sweep, process-wide.
/// <see cref="DeploymentService"/> is scoped, so this must be static to
/// rate-limit across requests; <see cref="Interlocked"/> makes the
/// claim-the-sweep step a compare-and-swap so concurrent readers do not all
/// sweep at once.
/// Rate limiter for the opportunistic terminal-record sweep. A DI singleton shared by every
/// scoped instance (Gitea #90 — it was a process-wide static).
/// </summary>
private static long _lastTerminalPurgeTicks;
private readonly TerminalPurgeRateLimiter _purgeRateLimiter;
/// <summary>
/// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies.
@@ -136,7 +130,8 @@ public class DeploymentService
IDeploymentStatusNotifier statusNotifier,
IOptions<DeploymentManagerOptions> options,
IOptions<CommunicationOptions> communicationOptions,
ILogger<DeploymentService> logger)
ILogger<DeploymentService> logger,
TerminalPurgeRateLimiter? purgeRateLimiter = null)
{
_repository = repository;
_siteRepository = siteRepository;
@@ -151,6 +146,9 @@ public class DeploymentService
_options = options.Value;
_commOptions = communicationOptions.Value;
_logger = logger;
// AddDeploymentManager registers the shared singleton; a hand-built instance
// (tests, tools) gets its own limiter.
_purgeRateLimiter = purgeRateLimiter ?? new TerminalPurgeRateLimiter();
}
/// <summary>
@@ -1530,7 +1528,8 @@ public class DeploymentService
/// <summary>
/// Opportunistic retention sweep for TERMINAL deployment records and system
/// artifact deployment records, rate-limited
/// to once every <see cref="TerminalPurgeMinInterval"/> per process and bounded
/// to once every <see cref="TerminalPurgeRateLimiter.MinInterval"/> per process (via the
/// singleton <see cref="TerminalPurgeRateLimiter"/>) and bounded
/// to <see cref="TerminalPurgeBatchesPerSweep"/> batches per call.
///
/// <para>
@@ -1554,14 +1553,9 @@ public class DeploymentService
if (retention <= TimeSpan.Zero)
return 0;
var now = DateTimeOffset.UtcNow;
var last = Interlocked.Read(ref _lastTerminalPurgeTicks);
if (last != 0 && now.UtcTicks - last < TerminalPurgeMinInterval.Ticks)
return 0;
// Claim the sweep. A loser of this CAS returns immediately rather than
// running a redundant second pass.
if (Interlocked.CompareExchange(ref _lastTerminalPurgeTicks, now.UtcTicks, last) != last)
// Claim the sweep. Within the interval, or a loser of a concurrent claim,
// returns immediately rather than running a redundant pass.
if (!_purgeRateLimiter.TryClaim(out var now))
return 0;
var cutoff = now - retention;
@@ -1604,7 +1598,7 @@ public class DeploymentService
{
_logger.LogWarning(ex,
"Terminal deployment-record retention sweep failed; retrying after {Interval}.",
TerminalPurgeMinInterval);
TerminalPurgeRateLimiter.MinInterval);
}
return purged;
@@ -55,6 +55,11 @@ public static class ServiceCollectionExtensions
// it instead of polling the database every 10 seconds.
services.AddSingleton<IDeploymentStatusNotifier, DeploymentStatusNotifier>();
// Rate limiter for DeploymentService's opportunistic terminal-record retention
// sweep. Singleton because DeploymentService is scoped and the limit must hold
// across requests (Gitea #90 — formerly a process-wide static field).
services.TryAddSingleton<TerminalPurgeRateLimiter>();
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
services.AddScoped<DeploymentService>();
services.AddScoped<ArtifactDeploymentService>();
@@ -0,0 +1,55 @@
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Rate limiter for <see cref="DeploymentService.TryPurgeTerminalDeploymentRecordsAsync"/>'s
/// opportunistic retention sweep: at most one sweep per <see cref="MinInterval"/>.
///
/// <para>
/// <see cref="DeploymentService"/> is scoped (one per request), so the limiter must outlive it
/// to rate-limit across requests. It used to be a process-wide <c>static</c> field, which made
/// the sweep untestable beyond a single test per assembly (Gitea #90); it is now a DI singleton
/// registered by <see cref="ServiceCollectionExtensions.AddDeploymentManager"/>, driven by an
/// injectable <see cref="TimeProvider"/>, so each test owns its own limiter and clock.
/// </para>
/// </summary>
public sealed class TerminalPurgeRateLimiter
{
/// <summary>Minimum interval between opportunistic terminal-record sweeps.</summary>
public static readonly TimeSpan MinInterval = TimeSpan.FromHours(6);
private readonly TimeProvider _timeProvider;
/// <summary>Ticks of the last claimed sweep; 0 = never swept.</summary>
private long _lastSweepTicks;
/// <summary>Initializes a limiter on the system clock.</summary>
public TerminalPurgeRateLimiter()
: this(TimeProvider.System)
{
}
/// <summary>Initializes a limiter on the given clock.</summary>
/// <param name="timeProvider">The clock the interval is measured on.</param>
public TerminalPurgeRateLimiter(TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(timeProvider);
_timeProvider = timeProvider;
}
/// <summary>
/// Claims the next sweep if <see cref="MinInterval"/> has elapsed since the last claimed one.
/// The claim is a compare-and-swap, so of several concurrent callers exactly one wins and the
/// rest return <see langword="false"/> rather than running a redundant pass. A claimed sweep
/// that then fails still counts: the next attempt waits a full interval.
/// </summary>
/// <param name="now">The clock reading the claim was made at (the sweep's cutoff basis).</param>
/// <returns><see langword="true"/> when the caller should sweep now.</returns>
public bool TryClaim(out DateTimeOffset now)
{
now = _timeProvider.GetUtcNow();
var last = Interlocked.Read(ref _lastSweepTicks);
if (last != 0 && now.UtcTicks - last < MinInterval.Ticks)
return false;
return Interlocked.CompareExchange(ref _lastSweepTicks, now.UtcTicks, last) == last;
}
}
@@ -90,8 +90,8 @@ public class DeploymentServiceTests : TestKit
/// <summary>
/// The retention sweep also purges system artifact deployment records — one per Deploy
/// Artifacts run, never read back — with the same cutoff as terminal deployment records.
/// NOTE: the sweep is rate-limited by a process-wide static, so this is the only test in
/// the assembly that may drive <see cref="DeploymentService.TryPurgeTerminalDeploymentRecordsAsync"/>.
/// The rate limiter is per service unless injected (Gitea #90); its behaviour and the sweep's
/// swallowed-failure path are covered by <c>TerminalPurgeRateLimiterTests</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -0,0 +1,229 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// Gitea #90: the terminal-record purge rate limiter is an injectable, clock-driven singleton
/// instead of a process-wide static, so the sweep's rate limit and its swallow-on-failure path
/// can be tested independently of every other test in the assembly.
/// </summary>
public class TerminalPurgeRateLimiterTests
{
private static readonly DateTimeOffset Start = new(2026, 11, 9, 12, 0, 0, TimeSpan.Zero);
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class CapturingLogger : ILogger<DeploymentService>
{
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, exception, formatter(state, exception)));
}
}
private static DeploymentService CreateService(
IDeploymentManagerRepository repo,
TerminalPurgeRateLimiter? limiter,
ILogger<DeploymentService>? logger = null) =>
new(
repo,
Substitute.For<ISiteRepository>(),
Substitute.For<ITemplateEngineRepository>(),
Substitute.For<IFlatteningPipeline>(),
new CommunicationService(
Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance),
new OperationLockManager(),
Substitute.For<IAuditService>(),
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions()),
Options.Create(new CommunicationOptions()),
logger ?? NullLogger<DeploymentService>.Instance,
limiter);
private static IDeploymentManagerRepository PurgingRepo(int terminal = 1, int artifact = 3)
{
var repo = Substitute.For<IDeploymentManagerRepository>();
repo.PurgeTerminalDeploymentRecordsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(terminal);
repo.PurgeSystemArtifactDeploymentRecordsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(artifact);
return repo;
}
/// <summary>The first claim wins; a second inside the interval loses; after it, a claim wins again.</summary>
[Fact]
public void TryClaim_OncePerInterval_OnTheInjectedClock()
{
var clock = new ManualClock(Start);
var limiter = new TerminalPurgeRateLimiter(clock);
Assert.True(limiter.TryClaim(out var first));
Assert.Equal(Start, first);
clock.Now = Start + TerminalPurgeRateLimiter.MinInterval - TimeSpan.FromSeconds(1);
Assert.False(limiter.TryClaim(out _));
clock.Now = Start + TerminalPurgeRateLimiter.MinInterval;
Assert.True(limiter.TryClaim(out var later));
Assert.Equal(clock.Now, later);
Assert.False(limiter.TryClaim(out _));
}
/// <summary>Of many concurrent claimants exactly one wins.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryClaim_ConcurrentCallers_ExactlyOneWins()
{
var limiter = new TerminalPurgeRateLimiter(new ManualClock(Start));
using var gate = new Barrier(16);
var wins = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() =>
{
gate.SignalAndWait();
return limiter.TryClaim(out var _);
})));
Assert.Equal(1, wins.Count(w => w));
}
/// <summary>
/// The limiter is per instance, not process-wide: two services with their own limiters both
/// sweep, and two sharing one (as the DI singleton makes them) sweep once.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_RateLimitIsPerLimiter_NotProcessWide()
{
var clock = new ManualClock(Start);
Assert.Equal(4, await CreateService(PurgingRepo(), new TerminalPurgeRateLimiter(clock))
.TryPurgeTerminalDeploymentRecordsAsync());
Assert.Equal(4, await CreateService(PurgingRepo(), new TerminalPurgeRateLimiter(clock))
.TryPurgeTerminalDeploymentRecordsAsync());
var shared = new TerminalPurgeRateLimiter(clock);
var secondRepo = PurgingRepo();
Assert.Equal(4, await CreateService(PurgingRepo(), shared).TryPurgeTerminalDeploymentRecordsAsync());
Assert.Equal(0, await CreateService(secondRepo, shared).TryPurgeTerminalDeploymentRecordsAsync());
await secondRepo.DidNotReceiveWithAnyArgs().PurgeTerminalDeploymentRecordsAsync(default, default, default);
}
/// <summary>
/// The sweep's cutoff is taken from the injected clock: now minus the retention window.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_CutoffIsInjectedClockMinusRetention()
{
var repo = PurgingRepo();
await CreateService(repo, new TerminalPurgeRateLimiter(new ManualClock(Start)))
.TryPurgeTerminalDeploymentRecordsAsync();
var cutoff = Start - new DeploymentManagerOptions().TerminalDeploymentRecordRetention;
await repo.Received(1).PurgeTerminalDeploymentRecordsAsync(cutoff, Arg.Any<int>(), Arg.Any<CancellationToken>());
await repo.Received(1).PurgeSystemArtifactDeploymentRecordsAsync(cutoff, Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>
/// A failing sweep never fails the caller: the exception is logged as a warning and swallowed,
/// the rows already purged are still reported, and the failed sweep still consumes the
/// interval — the next call inside it does not retry, the first call after it does.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_Failure_IsLoggedAndSwallowed_AndWaitsOutTheInterval()
{
var clock = new ManualClock(Start);
var limiter = new TerminalPurgeRateLimiter(clock);
var logger = new CapturingLogger();
var boom = new InvalidOperationException("deadlock victim");
var repo = Substitute.For<IDeploymentManagerRepository>();
repo.PurgeTerminalDeploymentRecordsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(2);
repo.PurgeSystemArtifactDeploymentRecordsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.ThrowsAsync(boom);
var service = CreateService(repo, limiter, logger);
var purged = await service.TryPurgeTerminalDeploymentRecordsAsync();
Assert.Equal(2, purged); // the terminal batch that landed before the failure
var warning = Assert.Single(logger.Entries, e => e.Level == LogLevel.Warning);
Assert.Same(boom, warning.Exception);
Assert.Contains("retention sweep failed", warning.Message, StringComparison.Ordinal);
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
// Inside the interval: rate-limited, no retry.
repo.ClearReceivedCalls();
clock.Now = Start + TimeSpan.FromHours(1);
Assert.Equal(0, await service.TryPurgeTerminalDeploymentRecordsAsync());
await repo.DidNotReceiveWithAnyArgs().PurgeTerminalDeploymentRecordsAsync(default, default, default);
// After the interval: the sweep runs again (and fails again, still swallowed).
clock.Now = Start + TerminalPurgeRateLimiter.MinInterval;
Assert.Equal(2, await service.TryPurgeTerminalDeploymentRecordsAsync());
await repo.ReceivedWithAnyArgs(1).PurgeTerminalDeploymentRecordsAsync(default, default, default);
Assert.Equal(2, logger.Entries.Count(e => e.Level == LogLevel.Warning));
}
/// <summary>A failure on the very first batch reports zero and is swallowed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_FirstBatchFailure_ReturnsZero_DoesNotThrow()
{
var repo = Substitute.For<IDeploymentManagerRepository>();
repo.PurgeTerminalDeploymentRecordsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new TimeoutException("SQL timeout"));
var logger = new CapturingLogger();
var purged = await CreateService(repo, new TerminalPurgeRateLimiter(new ManualClock(Start)), logger)
.TryPurgeTerminalDeploymentRecordsAsync();
Assert.Equal(0, purged);
Assert.IsType<TimeoutException>(Assert.Single(logger.Entries, e => e.Level == LogLevel.Warning).Exception);
await repo.DidNotReceiveWithAnyArgs().PurgeSystemArtifactDeploymentRecordsAsync(default, default, default);
}
/// <summary>
/// AddDeploymentManager registers the limiter as a singleton, so every scoped DeploymentService
/// shares one rate limit.
/// </summary>
[Fact]
public void AddDeploymentManager_RegistersLimiterAsSingleton()
{
var services = new ServiceCollection();
services.AddDeploymentManager();
var descriptor = Assert.Single(services, d => d.ServiceType == typeof(TerminalPurgeRateLimiter));
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
using var provider = services.BuildServiceProvider();
using var a = provider.CreateScope();
using var b = provider.CreateScope();
Assert.Same(
a.ServiceProvider.GetRequiredService<TerminalPurgeRateLimiter>(),
b.ServiceProvider.GetRequiredService<TerminalPurgeRateLimiter>());
}
}