using System.Collections.Concurrent; using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Interfaces; using Microsoft.Extensions.Logging; namespace CMSMicroservice.Infrastructure.Services; /// /// In-process keyed semaphore lock for payment operations. /// Safe for multi-threaded gRPC/MediatR handlers within a single CMS instance. /// public sealed class UserPaymentLockService : IUserPaymentLock, IDisposable { private static readonly TimeSpan VerifyWaitTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan StaleEntryAge = TimeSpan.FromMinutes(30); private static readonly TimeSpan CleanupInterval = TimeSpan.FromMinutes(5); private readonly ConcurrentDictionary _entries = new(); private readonly ILogger _logger; private readonly Timer _cleanupTimer; private int _disposed; public UserPaymentLockService(ILogger logger) { _logger = logger; _cleanupTimer = new Timer(_ => CleanupStaleEntries(), null, CleanupInterval, CleanupInterval); } public Task ExecuteAsync( string scope, PaymentLockStrategy strategy, Func> action, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(scope); ArgumentNullException.ThrowIfNull(action); return ExecuteCoreAsync(scope, strategy, action, cancellationToken); } public async Task ExecuteAsync( string scope, PaymentLockStrategy strategy, Func action, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(scope); ArgumentNullException.ThrowIfNull(action); await ExecuteCoreAsync( scope, strategy, async ct => { await action(ct); return true; }, cancellationToken); } private async Task ExecuteCoreAsync( string scope, PaymentLockStrategy strategy, Func> action, CancellationToken cancellationToken) { var entry = _entries.GetOrAdd(scope, static _ => new LockEntry()); entry.Touch(); var waitTimeout = strategy == PaymentLockStrategy.FailFast ? TimeSpan.Zero : VerifyWaitTimeout; Interlocked.Increment(ref entry.WaiterCount); var acquired = false; try { acquired = await entry.Semaphore.WaitAsync(waitTimeout, cancellationToken); if (!acquired) { _logger.LogWarning( "Payment lock busy. Scope={Scope}, Strategy={Strategy}", scope, strategy); throw new PaymentInProgressException( strategy == PaymentLockStrategy.FailFast ? "درخواست پرداخت دیگری در حال پردازش است. لطفاً صبر کنید." : "تأیید پرداخت در حال انجام است. لطفاً چند لحظه صبر کنید."); } entry.Touch(); return await action(cancellationToken); } finally { if (acquired) entry.Semaphore.Release(); if (Interlocked.Decrement(ref entry.WaiterCount) == 0 && entry.CanRemove) _entries.TryRemove(scope, out _); } } private void CleanupStaleEntries() { if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return; var cutoff = DateTime.UtcNow - StaleEntryAge; foreach (var (scope, entry) in _entries) { if (entry.LastUsedUtc < cutoff && entry.CanRemove) _entries.TryRemove(scope, out _); } } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; _cleanupTimer.Dispose(); foreach (var entry in _entries.Values) entry.Semaphore.Dispose(); _entries.Clear(); } private sealed class LockEntry { public SemaphoreSlim Semaphore { get; } = new(1, 1); public int WaiterCount; public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; public void Touch() => LastUsedUtc = DateTime.UtcNow; public bool CanRemove => Volatile.Read(ref WaiterCount) == 0 && Semaphore.CurrentCount == 1; } }