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.");
}
}
}