Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
185affb
Track per-partition last-access tick so NoopLimiter is evicted by Hea…
Copilot Apr 29, 2026
4b31962
Address review: remove redundant timestamp write, use Interlocked for…
Copilot Apr 29, 2026
8d250d5
Clarify comment explaining why first-access has no eviction window
Copilot Apr 29, 2026
c7e37cd
Use Volatile.Read/Write instead of Interlocked for LastAccessTimestamp
Copilot Apr 29, 2026
647bd0c
Add RateLimiterHelper.GetElapsedTime(long) overload to avoid GetValue…
Copilot Apr 29, 2026
2d2f3ed
Restrict idle eviction fallback to NoopLimiter
Copilot Jun 12, 2026
762a5ac
Apply remaining changes
Copilot Jun 12, 2026
9e0efe9
Merge main + fix build after Utils.RunTimerFunc signature change
Copilot Jun 12, 2026
a6443a9
Remove unrelated scratch files accidentally committed earlier
Copilot Jun 12, 2026
0119d3d
Potential fix for pull request finding
VSadov Jun 14, 2026
cc19731
Potential fix for pull request finding
VSadov Jun 14, 2026
3dec367
Encapsulate limiter entry timestamp
Copilot Jun 14, 2026
65a161d
Centralize reflected type names in rate limiting tests
Copilot Jun 14, 2026
c621259
Add Assert.True(limiterType.IsInstanceOfType(limiter)) before GetValu…
Copilot Jun 14, 2026
56eadb6
Add explicit null check for nullable TimeSpan comparison in Heartbeat
Copilot Jun 14, 2026
97da373
Clarify RateLimiting test comment
Copilot Jun 14, 2026
72f0a07
Use is-pattern for nullable TimeSpan comparison in Heartbeat eviction
Copilot Jun 14, 2026
7e6354d
Potential fix for pull request finding
VSadov Jun 17, 2026
ff41bbc
Potential fix for pull request finding
VSadov Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ internal sealed class DefaultPartitionedRateLimiter<TResource, TKey> : Partition
private static readonly TimeSpan s_idleTimeLimit = TimeSpan.FromSeconds(10);

// TODO: Look at ConcurrentDictionary to try and avoid a global lock
private readonly Dictionary<TKey, Lazy<RateLimiter>> _limiters;
private readonly Dictionary<TKey, Lazy<LimiterEntry>> _limiters;
private bool _disposed;
private readonly TaskCompletionSource<object?> _disposeComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);

// Used by the Timer to call TryRelenish on ReplenishingRateLimiters
// We use a separate list to avoid running TryReplenish (which might be user code) inside our lock
// And we cache the list to amortize the allocation cost to as close to 0 as we can get
private readonly List<KeyValuePair<TKey, Lazy<RateLimiter>>> _cachedLimiters = new();
private readonly List<KeyValuePair<TKey, Lazy<LimiterEntry>>> _cachedLimiters = new();
private bool _cacheInvalid;
private readonly List<RateLimiter> _limitersToDispose = new();
private readonly TimerAwaitable _timer;
Expand All @@ -42,7 +42,7 @@ public DefaultPartitionedRateLimiter(Func<TResource, RateLimitPartition<TKey>> p
private DefaultPartitionedRateLimiter(Func<TResource, RateLimitPartition<TKey>> partitioner,
IEqualityComparer<TKey>? equalityComparer, TimeSpan timerInterval)
{
_limiters = new Dictionary<TKey, Lazy<RateLimiter>>(equalityComparer);
_limiters = new Dictionary<TKey, Lazy<LimiterEntry>>(equalityComparer);
_partitioner = partitioner;

_timer = new TimerAwaitable(timerInterval, timerInterval);
Expand Down Expand Up @@ -87,20 +87,36 @@ protected override ValueTask<RateLimitLease> AcquireAsyncCore(TResource resource
private RateLimiter GetRateLimiter(TResource resource)
{
RateLimitPartition<TKey> partition = _partitioner(resource);
Lazy<RateLimiter>? limiter;
Lazy<LimiterEntry>? entry;
lock (Lock)
{
ThrowIfDisposed();
if (!_limiters.TryGetValue(partition.PartitionKey, out limiter))
if (!_limiters.TryGetValue(partition.PartitionKey, out entry))
{
// Using Lazy avoids calling user code (partition.Factory) inside the lock
limiter = new Lazy<RateLimiter>(() => partition.Factory(partition.PartitionKey));
_limiters.Add(partition.PartitionKey, limiter);
// Using Lazy avoids calling user code (partition.Factory) inside the lock.
// The LimiterEntry constructor initializes LastAccessTimestamp to Stopwatch.GetTimestamp()
// when the factory runs (on the first access of entry.Value below). Until then,
// Lazy.IsValueCreated is false and Heartbeat skips this entry, so there is no window
// in which the entry could be observed without a timestamp.
entry = new Lazy<LimiterEntry>(() => new LimiterEntry(partition.Factory(partition.PartitionKey)));
_limiters.Add(partition.PartitionKey, entry);
// Cache is invalid now
_cacheInvalid = true;
}
else if (entry.IsValueCreated)
{
LimiterEntry limiterEntry = entry.Value;

if (limiterEntry.Limiter is NoopLimiter)
{
// Refresh the timestamp under the lock so Heartbeat won't evict a limiter that's actively being used.
// Use Volatile.Write so the write is atomic on 32-bit platforms where the outside-lock read in Heartbeat may otherwise tear.
Volatile.Write(ref limiterEntry.LastAccessTimestamp, Stopwatch.GetTimestamp());
}
}
}
return limiter.Value;

return entry.Value.Limiter;
}

protected override void Dispose(bool disposing)
Expand All @@ -125,11 +141,11 @@ protected override void Dispose(bool disposing)

// Safe to access _limiters outside the lock
// The timer is no longer running and _disposed is set so anyone trying to access fields will be checking that first
foreach (KeyValuePair<TKey, Lazy<RateLimiter>> limiter in _limiters)
foreach (KeyValuePair<TKey, Lazy<LimiterEntry>> limiter in _limiters)
{
try
{
limiter.Value.Value.Dispose();
limiter.Value.Value.Limiter.Dispose();
}
catch (Exception ex)
{
Expand Down Expand Up @@ -160,11 +176,11 @@ protected override async ValueTask DisposeAsyncCore()
}

List<Exception>? exceptions = null;
foreach (KeyValuePair<TKey, Lazy<RateLimiter>> limiter in _limiters)
foreach (KeyValuePair<TKey, Lazy<LimiterEntry>> limiter in _limiters)
{
try
{
await limiter.Value.Value.DisposeAsync().ConfigureAwait(false);
await limiter.Value.Value.Limiter.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -225,18 +241,19 @@ private async Task Heartbeat()
List<Exception>? aggregateExceptions = null;

// cachedLimiters is safe to use outside the lock because it is only updated by the Timer
foreach (KeyValuePair<TKey, Lazy<RateLimiter>> rateLimiter in _cachedLimiters)
foreach (KeyValuePair<TKey, Lazy<LimiterEntry>> rateLimiter in _cachedLimiters)
{
if (!rateLimiter.Value.IsValueCreated)
{
continue;
}
if (rateLimiter.Value.Value.IdleDuration is TimeSpan idleDuration && idleDuration > s_idleTimeLimit)
LimiterEntry limiterEntry = rateLimiter.Value.Value;
if (GetIdleDuration(limiterEntry) is TimeSpan idleDuration && idleDuration > s_idleTimeLimit)
{
lock (Lock)
Comment thread
VSadov marked this conversation as resolved.
{
// Check time again under lock to make sure no one calls Acquire or WaitAsync after checking the time and removing the limiter
idleDuration = rateLimiter.Value.Value.IdleDuration ?? TimeSpan.Zero;
idleDuration = GetIdleDuration(limiterEntry) ?? TimeSpan.Zero;
if (idleDuration > s_idleTimeLimit)
{
// Remove limiter from the lookup table and mark cache as invalid
Expand All @@ -246,12 +263,12 @@ private async Task Heartbeat()
_limiters.Remove(rateLimiter.Key);

// We don't want to dispose inside the lock so we need to defer it
_limitersToDispose.Add(rateLimiter.Value.Value);
_limitersToDispose.Add(limiterEntry.Limiter);
}
}
}
// We know the limiter can be replenished so let's attempt to replenish tokens
else if (rateLimiter.Value.Value is ReplenishingRateLimiter replenishingRateLimiter)
else if (limiterEntry.Limiter is ReplenishingRateLimiter replenishingRateLimiter)
{
try
{
Expand Down Expand Up @@ -284,5 +301,31 @@ private async Task Heartbeat()
throw new AggregateException(aggregateExceptions);
}
}

private static TimeSpan? GetIdleDuration(LimiterEntry limiterEntry)
{
// NoopLimiter always reports IdleDuration == null, but it is also always safe to evict.
// Fall back to our internally tracked last-access timestamp only for that known case.
// Use Volatile.Read so the value is read atomically on 32-bit platforms where 64-bit
// reads are not guaranteed to be atomic.
return limiterEntry.Limiter.IdleDuration ?? (limiterEntry.Limiter is NoopLimiter
? RateLimiterHelper.GetElapsedTime(Volatile.Read(ref limiterEntry.LastAccessTimestamp))
: null);
}

// Wraps a RateLimiter with a timestamp of when it was last accessed by the partitioned limiter.
// The timestamp is used by the Heartbeat to evict NoopLimiter partitions, whose IdleDuration
// is always null and would otherwise never be cleaned up.
private sealed class LimiterEntry
{
public LimiterEntry(RateLimiter limiter)
{
Limiter = limiter;
LastAccessTimestamp = Stopwatch.GetTimestamp();
}

public RateLimiter Limiter { get; }
internal long LastAccessTimestamp;
}
Comment thread
VSadov marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ internal static class RateLimiterHelper
return Stopwatch.GetElapsedTime(startTimestamp.Value);
}

public static TimeSpan GetElapsedTime(long startTimestamp)
{
return Stopwatch.GetElapsedTime(startTimestamp);
}

Comment thread
VSadov marked this conversation as resolved.
public static TimeSpan GetElapsedTime(long startTimestamp, long endTimestamp)
{
return Stopwatch.GetElapsedTime(startTimestamp, endTimestamp);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ namespace System.Threading.RateLimiting.Tests
{
public class PartitionedRateLimiterTests
{
private const string DefaultPartitionedRateLimiterTypeName = "System.Threading.RateLimiting.DefaultPartitionedRateLimiter`2, System.Threading.RateLimiting";
private const string DefaultPartitionedRateLimiterEntryTypeName = "System.Threading.RateLimiting.DefaultPartitionedRateLimiter`2+LimiterEntry, System.Threading.RateLimiting";

Comment thread
VSadov marked this conversation as resolved.
[Fact]
public void ThrowsWhenAcquiringLessThanZero()
{
Expand Down Expand Up @@ -529,6 +532,113 @@ public async Task IdleLimiterIsCleanedUp()
Assert.Equal(2, factoryCallCount);
}

[Fact]
public async Task NoopLimiterPartitionIsCleanedUp()
{
using var limiter = Utils.CreatePartitionedLimiterWithoutTimer<string, int>(resource =>
{
return RateLimitPartition.GetNoLimiter(1);
});

var lease = limiter.AttemptAcquire("");
Assert.True(lease.IsAcquired);
Assert.NotNull(GetLazyLimiterEntry<string, int>(limiter, key: 1));

// Backdate the internally-tracked last-access timestamp so the heartbeat treats the
// partition as idle even though NoopLimiter reports IdleDuration == null.
BackdateLastAccessTimestamp<string, int>(limiter, key: 1);

await Utils.RunTimerFunc<string, int>(limiter);
Assert.Null(GetLazyLimiterEntry<string, int>(limiter, key: 1));

lease = limiter.AttemptAcquire("");
Assert.True(lease.IsAcquired);
Assert.NotNull(GetLazyLimiterEntry<string, int>(limiter, key: 1));
}

[Fact]
public async Task LimiterWithNullIdleDurationIsNotCleanedUp()
{
var factoryCallCount = 0;
using var limiter = Utils.CreatePartitionedLimiterWithoutTimer<string, int>(resource =>
{
return RateLimitPartition.Get(1, _ =>
{
factoryCallCount++;
return new CustomizableLimiter
{
IdleDurationImpl = () => null
};
});
});

var lease = limiter.AttemptAcquire("");
Assert.True(lease.IsAcquired);
Assert.Equal(1, factoryCallCount);

BackdateLastAccessTimestamp<string, int>(limiter, key: 1);

await Utils.RunTimerFunc<string, int>(limiter);

lease = limiter.AttemptAcquire("");
Assert.True(lease.IsAcquired);
Assert.Equal(1, factoryCallCount);
}

// Reaches into the internal _limiters dictionary of DefaultPartitionedRateLimiter and
// backdates the LastAccessTimestamp on the LimiterEntry for the given key so the test
// doesn't have to wait the real idle window before the heartbeat evicts the limiter.
private static void BackdateLastAccessTimestamp<TResource, TKey>(PartitionedRateLimiter<TResource> limiter, TKey key)
{
// Use Type.GetType with literal strings so trimming can see what types we're reflecting on.
Comment thread
VSadov marked this conversation as resolved.
var limiterEntryTypeDef = Type.GetType(DefaultPartitionedRateLimiterEntryTypeName);
Assert.NotNull(limiterEntryTypeDef);
var limiterEntryType = limiterEntryTypeDef.MakeGenericType(typeof(TResource), typeof(TKey));

// LastAccessTimestamp is a non-public mutable field on the internal LimiterEntry type.
var lastAccessField = limiterEntryType.GetField("LastAccessTimestamp", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(lastAccessField);

var entry = GetLimiterEntry<TResource, TKey>(limiter, key);
long backdatedTimestamp = System.Diagnostics.Stopwatch.GetTimestamp() - (System.Diagnostics.Stopwatch.Frequency * 60);
lastAccessField.SetValue(entry, backdatedTimestamp);
}

private static object GetLimiterEntry<TResource, TKey>(PartitionedRateLimiter<TResource> limiter, TKey key)
{
var lazyEntry = GetLazyLimiterEntry<TResource, TKey>(limiter, key);
Assert.NotNull(lazyEntry);

// Force creation of the Lazy<LimiterEntry>.Value. Use Type.GetType with a literal string
// so trimming can see the LimiterEntry type, then construct the closed Lazy<LimiterEntry>.
Comment thread
VSadov marked this conversation as resolved.
var limiterEntryTypeDef = Type.GetType(DefaultPartitionedRateLimiterEntryTypeName);
Assert.NotNull(limiterEntryTypeDef);
var limiterEntryType = limiterEntryTypeDef.MakeGenericType(typeof(TResource), typeof(TKey));
var lazyType = typeof(Lazy<>).MakeGenericType(limiterEntryType);
var valueProperty = lazyType.GetProperty("Value");
Assert.NotNull(valueProperty);
var entry = valueProperty.GetValue(lazyEntry);
Assert.NotNull(entry);
return entry;
Comment thread
VSadov marked this conversation as resolved.
}

private static object GetLazyLimiterEntry<TResource, TKey>(PartitionedRateLimiter<TResource> limiter, TKey key)
{
// Use Type.GetType so trimming can see what type we're reflecting on, then construct the
// closed DefaultPartitionedRateLimiter<TResource, TKey> type for the requested arguments.
Comment thread
VSadov marked this conversation as resolved.
var limiterTypeDef = Type.GetType(DefaultPartitionedRateLimiterTypeName);
Assert.NotNull(limiterTypeDef);
var limiterType = limiterTypeDef.MakeGenericType(typeof(TResource), typeof(TKey));

var limitersField = limiterType.GetField("_limiters", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(limitersField);
Assert.True(limiterType.IsInstanceOfType(limiter));
var limitersDict = (System.Collections.IDictionary)limitersField.GetValue(limiter);
Assert.NotNull(limitersDict);

return limitersDict[key];
}

[Fact]
public async Task AllIdleLimitersCleanedUp_DisposeThrows()
{
Expand Down
Loading