diff --git a/PowerKit.Tests/ThrottleLockTests.cs b/PowerKit.Tests/ThrottleLockTests.cs new file mode 100644 index 0000000..d21408f --- /dev/null +++ b/PowerKit.Tests/ThrottleLockTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using PowerKit; +using Xunit; + +namespace PowerKit.Tests; + +public class ThrottleLockTests +{ + [Fact] + public async Task WaitAsync_Test() + { + // Arrange + using var throttle = new ThrottleLock(TimeSpan.FromMilliseconds(50)); + + // Act + var stopwatch = Stopwatch.StartNew(); + await throttle.WaitAsync(); + await throttle.WaitAsync(); + await throttle.WaitAsync(); + + // Assert + stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(2 * TimeSpan.FromMilliseconds(50)); + } + + [Fact] + public async Task WaitAsync_Cancellation_Test() + { + // Arrange + using var throttle = new ThrottleLock(TimeSpan.FromSeconds(10)); + + // Act & assert + var act = async () => await throttle.WaitAsync(new CancellationToken(true)); + await act.Should().ThrowAsync(); + } +} diff --git a/PowerKit/ThrottleLock.cs b/PowerKit/ThrottleLock.cs new file mode 100644 index 0000000..68cfe78 --- /dev/null +++ b/PowerKit/ThrottleLock.cs @@ -0,0 +1,48 @@ +#if NET40_OR_GREATER || NETSTANDARD || NET +#nullable enable +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerKit; + +/// +/// Represents a lock that enforces a minimum interval between consecutive acquisitions, +/// ensuring that operations do not proceed faster than the specified rate. +/// +#if !POWERKIT_INCLUDE_COVERAGE +[ExcludeFromCodeCoverage] +#endif +internal sealed class ThrottleLock(TimeSpan interval) : IDisposable +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + private long _lastTimestamp = Stopwatch.GetTimestamp(); + + /// + /// Asynchronously waits until the throttle interval has elapsed since the last acquisition, + /// then records the current instant as the new last-request time. + /// + public async Task WaitAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var remaining = interval - Stopwatch.GetElapsedTime(_lastTimestamp); + if (remaining > TimeSpan.Zero) + await Task.Delay(remaining, cancellationToken).ConfigureAwait(false); + + _lastTimestamp = Stopwatch.GetTimestamp(); + } + finally + { + _semaphore.Release(); + } + } + + /// + public void Dispose() => _semaphore.Dispose(); +} +#endif