using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; namespace ZB.MOM.WW.ScadaBridge.Transport.Import; /// /// Register row 52: backed by the central MS SQL /// BundleUnlockAttempt table — the HA store both central nodes already share, so /// the fleet-wide cap needs no new infrastructure, no new port and no consensus protocol. /// /// Registered as a singleton (the limiter that consumes it is one) and therefore resolves /// a scoped from its own /// scope per call, the same way the central purge /// singletons reach the database. /// /// /// Accuracy is deliberately approximate. The count-then-insert pair is not atomic /// across the two central nodes, so genuinely simultaneous attempts can overshoot the cap /// by up to the number in flight. A conditional single-statement insert would close that, /// at the cost of provider-specific SQL for a best-effort rate cap layered over an /// already-authenticated, role-gated surface. Overshoot is bounded and self-corrects /// within the window; it is not worth raw SQL. /// /// public sealed class SqlBundleUnlockAttemptWindow : IBundleUnlockAttemptWindow { /// /// Minimum wall-clock gap between opportunistic prunes. The prune runs on the unlock /// path (Transport owns no central scheduler, and the design constraint was "no new /// background service"), so it is claimed at most this often per process — the same /// rate-limited opportunistic-sweep shape DeploymentService uses for terminal /// deployment records. /// internal static readonly TimeSpan PruneMinInterval = TimeSpan.FromMinutes(15); /// /// Upper bound on rows deleted by one opportunistic prune pass. Bounded so a sweep on /// a request path can never become a long-running delete, even if the table grew while /// prunes were failing. /// internal const int PruneBatchSize = 1000; /// /// Multiple of the trailing window that rows are retained for before becoming /// prunable. Kept above 1 so a prune racing a count can never delete a row the count /// still needs. /// internal const int PruneRetentionWindows = 2; private readonly IServiceScopeFactory _scopeFactory; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; /// Ticks of the last claimed prune, as a CAS-guarded process-local claim. private long _lastPruneTicks; /// /// Initializes a new . /// /// Scope factory used to resolve the scoped attempt repository per call. /// Clock used for the window boundary and the prune cadence. /// Optional logger for prune diagnostics. public SqlBundleUnlockAttemptWindow( IServiceScopeFactory scopeFactory, TimeProvider timeProvider, ILogger? logger = null) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? NullLogger.Instance; // Seed the prune claim to "now" so a freshly started node does not prune on its // very first unlock — the table is almost certainly already tidy, and a node that // restarts under load would otherwise prune on every restart. _lastPruneTicks = _timeProvider.GetUtcNow().UtcTicks; } /// public async Task TryRegisterAttemptAsync( string clientKey, int maxAttemptsPerWindow, TimeSpan window, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(clientKey); if (maxAttemptsPerWindow < 1) { throw new ArgumentOutOfRangeException( nameof(maxAttemptsPerWindow), "Limit must be at least 1."); } if (window <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive."); } var now = _timeProvider.GetUtcNow().UtcDateTime; var key = clientKey.Trim(); using var scope = _scopeFactory.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); // Two round trips rather than one conditional insert — see the class remarks on // why the resulting overshoot is accepted. Exceptions propagate: the fail-open // decision lives in BundleUnlockRateLimiter. var used = await repo.CountAttemptsSinceAsync(key, now - window, ct).ConfigureAwait(false); if (used >= maxAttemptsPerWindow) { // Rejected attempts are NOT recorded — a denial burns no budget, so a client // hammering the endpoint cannot roll its own window forward indefinitely. return false; } await repo.RecordAttemptAsync(key, now, ct).ConfigureAwait(false); await TryPruneAsync(repo, now, window, ct).ConfigureAwait(false); return true; } /// /// Opportunistic, rate-limited, bounded prune of aged-out attempt rows. Best-effort: /// any failure is logged and swallowed, because retention maintenance must never fail /// the operator's actual unlock. /// /// Repository resolved for the current call's scope. /// Current UTC instant. /// The trailing window; rows older than a multiple of it are prunable. /// Cancellation token. /// A task that completes when the prune attempt has finished (or been skipped). private async Task TryPruneAsync( IBundleUnlockAttemptRepository repo, DateTime nowUtc, TimeSpan window, CancellationToken ct) { var previous = Interlocked.Read(ref _lastPruneTicks); if (nowUtc.Ticks - previous < PruneMinInterval.Ticks) { return; } // Claim the sweep so concurrent unlocks on this node don't all prune at once. if (Interlocked.CompareExchange(ref _lastPruneTicks, nowUtc.Ticks, previous) != previous) { return; } try { var cutoff = nowUtc - (window * PruneRetentionWindows); var deleted = await repo .PruneAttemptsOlderThanAsync(cutoff, PruneBatchSize, ct) .ConfigureAwait(false); if (deleted > 0) { _logger.LogDebug( "Pruned {Deleted} bundle-unlock attempt row(s) older than {Cutoff:o}.", deleted, cutoff); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { // Never fail the unlock for a retention sweep. _logger.LogWarning(ex, "Opportunistic bundle-unlock attempt prune failed; will retry on a later attempt."); } } }