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. query degrades with age rather than with page size.
`TerminalDeploymentRecordRetention` (default **365 days**, deliberately generous `TerminalDeploymentRecordRetention` (default **365 days**, deliberately generous
because deployment history is operator forensics) bounds it, swept opportunistically 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 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 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> /// <summary>Batches deleted per opportunistic sweep, bounding one caller's latency cost.</summary>
private const int TerminalPurgeBatchesPerSweep = 4; private const int TerminalPurgeBatchesPerSweep = 4;
/// <summary>Minimum interval between opportunistic terminal-record sweeps.</summary>
private static readonly TimeSpan TerminalPurgeMinInterval = TimeSpan.FromHours(6);
/// <summary> /// <summary>
/// Ticks of the last opportunistic terminal-record sweep, process-wide. /// Rate limiter for the opportunistic terminal-record sweep. A DI singleton shared by every
/// <see cref="DeploymentService"/> is scoped, so this must be static to /// scoped instance (Gitea #90 — it was a process-wide static).
/// 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.
/// </summary> /// </summary>
private static long _lastTerminalPurgeTicks; private readonly TerminalPurgeRateLimiter _purgeRateLimiter;
/// <summary> /// <summary>
/// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies. /// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies.
@@ -136,7 +130,8 @@ public class DeploymentService
IDeploymentStatusNotifier statusNotifier, IDeploymentStatusNotifier statusNotifier,
IOptions<DeploymentManagerOptions> options, IOptions<DeploymentManagerOptions> options,
IOptions<CommunicationOptions> communicationOptions, IOptions<CommunicationOptions> communicationOptions,
ILogger<DeploymentService> logger) ILogger<DeploymentService> logger,
TerminalPurgeRateLimiter? purgeRateLimiter = null)
{ {
_repository = repository; _repository = repository;
_siteRepository = siteRepository; _siteRepository = siteRepository;
@@ -151,6 +146,9 @@ public class DeploymentService
_options = options.Value; _options = options.Value;
_commOptions = communicationOptions.Value; _commOptions = communicationOptions.Value;
_logger = logger; _logger = logger;
// AddDeploymentManager registers the shared singleton; a hand-built instance
// (tests, tools) gets its own limiter.
_purgeRateLimiter = purgeRateLimiter ?? new TerminalPurgeRateLimiter();
} }
/// <summary> /// <summary>
@@ -1530,7 +1528,8 @@ public class DeploymentService
/// <summary> /// <summary>
/// Opportunistic retention sweep for TERMINAL deployment records and system /// Opportunistic retention sweep for TERMINAL deployment records and system
/// artifact deployment records, rate-limited /// 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. /// to <see cref="TerminalPurgeBatchesPerSweep"/> batches per call.
/// ///
/// <para> /// <para>
@@ -1554,14 +1553,9 @@ public class DeploymentService
if (retention <= TimeSpan.Zero) if (retention <= TimeSpan.Zero)
return 0; return 0;
var now = DateTimeOffset.UtcNow; // Claim the sweep. Within the interval, or a loser of a concurrent claim,
var last = Interlocked.Read(ref _lastTerminalPurgeTicks); // returns immediately rather than running a redundant pass.
if (last != 0 && now.UtcTicks - last < TerminalPurgeMinInterval.Ticks) if (!_purgeRateLimiter.TryClaim(out var now))
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)
return 0; return 0;
var cutoff = now - retention; var cutoff = now - retention;
@@ -1604,7 +1598,7 @@ public class DeploymentService
{ {
_logger.LogWarning(ex, _logger.LogWarning(ex,
"Terminal deployment-record retention sweep failed; retrying after {Interval}.", "Terminal deployment-record retention sweep failed; retrying after {Interval}.",
TerminalPurgeMinInterval); TerminalPurgeRateLimiter.MinInterval);
} }
return purged; return purged;
@@ -55,6 +55,11 @@ public static class ServiceCollectionExtensions
// it instead of polling the database every 10 seconds. // it instead of polling the database every 10 seconds.
services.AddSingleton<IDeploymentStatusNotifier, DeploymentStatusNotifier>(); 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<IFlatteningPipeline, FlatteningPipeline>();
services.AddScoped<DeploymentService>(); services.AddScoped<DeploymentService>();
services.AddScoped<ArtifactDeploymentService>(); 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> /// <summary>
/// The retention sweep also purges system artifact deployment records — one per Deploy /// 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. /// 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 rate limiter is per service unless injected (Gitea #90); its behaviour and the sweep's
/// the assembly that may drive <see cref="DeploymentService.TryPurgeTerminalDeploymentRecordsAsync"/>. /// swallowed-failure path are covered by <c>TerminalPurgeRateLimiterTests</c>.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [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>());
}
}