Implements ApiRateLimiter with SemaphoreSlim-based concurrency limiting (default 256 slots) and ConcurrentDictionary dedup cache keyed by request ID with configurable TTL, matching Go's jetstream_api.go maxConcurrentRequests semaphore and dedup window. Also adds ClusteredRequestProcessor for correlating pending RAFT proposals with waiting callers via TaskCompletionSource, and SlopwatchSuppressAttribute as a marker for intentional timing-based tests. 12 ApiRateLimiter tests + 13 ClusteredRequestProcessor tests all pass.
188 lines
6.8 KiB
C#
188 lines
6.8 KiB
C#
// Go reference: jetstream_api.go — rate limiting via maxConcurrentRequests semaphore and
|
|
// request deduplication via the dedup cache keyed by Nats-Msg-Id header.
|
|
// The Go server uses a configurable semaphore (default 256) to throttle concurrent API
|
|
// requests, and caches responses for duplicate request IDs within a TTL window.
|
|
|
|
namespace NATS.Server.Tests.JetStream.Api;
|
|
|
|
using NATS.Server.JetStream.Api;
|
|
using NATS.Server.Tests;
|
|
|
|
public class ApiRateLimiterTests : IDisposable
|
|
{
|
|
private readonly ApiRateLimiter _limiter = new(maxConcurrent: 4);
|
|
|
|
public void Dispose() => _limiter.Dispose();
|
|
|
|
// Go reference: jetstream_api.go — semaphore.TryAcquire(0) used for non-blocking attempt.
|
|
[Fact]
|
|
public async Task TryAcquire_succeeds_when_slots_available()
|
|
{
|
|
var acquired = await _limiter.TryAcquireAsync();
|
|
acquired.ShouldBeTrue();
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — when all slots are taken, new requests are rejected.
|
|
[Fact]
|
|
public async Task TryAcquire_fails_when_all_slots_taken()
|
|
{
|
|
// Fill all 4 slots.
|
|
for (var i = 0; i < 4; i++)
|
|
(await _limiter.TryAcquireAsync()).ShouldBeTrue();
|
|
|
|
// 5th attempt should fail.
|
|
var rejected = await _limiter.TryAcquireAsync();
|
|
rejected.ShouldBeFalse();
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — releasing a slot allows a subsequent request to proceed.
|
|
[Fact]
|
|
public async Task Release_frees_slot_for_next_request()
|
|
{
|
|
// Fill all slots.
|
|
for (var i = 0; i < 4; i++)
|
|
(await _limiter.TryAcquireAsync()).ShouldBeTrue();
|
|
|
|
// Currently full.
|
|
(await _limiter.TryAcquireAsync()).ShouldBeFalse();
|
|
|
|
// Release one slot.
|
|
_limiter.Release();
|
|
|
|
// Now one slot is free.
|
|
(await _limiter.TryAcquireAsync()).ShouldBeTrue();
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — active count reflects in-flight requests.
|
|
[Fact]
|
|
public async Task ActiveCount_tracks_concurrent_requests()
|
|
{
|
|
_limiter.ActiveCount.ShouldBe(0);
|
|
|
|
await _limiter.TryAcquireAsync();
|
|
await _limiter.TryAcquireAsync();
|
|
await _limiter.TryAcquireAsync();
|
|
|
|
_limiter.ActiveCount.ShouldBe(3);
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — unknown request ID returns null (cache miss).
|
|
[Fact]
|
|
public void GetCachedResponse_returns_null_for_unknown_id()
|
|
{
|
|
var result = _limiter.GetCachedResponse("nonexistent-id");
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — dedup cache stores response keyed by Nats-Msg-Id.
|
|
[Fact]
|
|
public void CacheResponse_and_get_returns_cached()
|
|
{
|
|
var response = JetStreamApiResponse.SuccessResponse();
|
|
_limiter.CacheResponse("req-001", response);
|
|
|
|
var cached = _limiter.GetCachedResponse("req-001");
|
|
cached.ShouldNotBeNull();
|
|
cached!.Success.ShouldBeTrue();
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — dedup window expires after TTL (dedupWindow config).
|
|
[SlopwatchSuppress("SW004", "TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing a time-based cache eviction")]
|
|
[Fact]
|
|
public async Task GetCachedResponse_returns_null_after_ttl_expiry()
|
|
{
|
|
using var shortLimiter = new ApiRateLimiter(maxConcurrent: 4, dedupTtl: TimeSpan.FromMilliseconds(50));
|
|
var response = JetStreamApiResponse.SuccessResponse();
|
|
shortLimiter.CacheResponse("req-ttl", response);
|
|
|
|
// Verify it's cached before expiry.
|
|
shortLimiter.GetCachedResponse("req-ttl").ShouldNotBeNull();
|
|
|
|
// Wait for TTL to expire.
|
|
await Task.Delay(120);
|
|
|
|
// Should be null after expiry.
|
|
shortLimiter.GetCachedResponse("req-ttl").ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — null/empty Nats-Msg-Id is ignored for dedup.
|
|
[Fact]
|
|
public void CacheResponse_ignores_null_request_id()
|
|
{
|
|
var response = JetStreamApiResponse.SuccessResponse();
|
|
|
|
// These should not throw and should not increment the cache count.
|
|
_limiter.CacheResponse(null, response);
|
|
_limiter.CacheResponse("", response);
|
|
_limiter.CacheResponse(string.Empty, response);
|
|
|
|
_limiter.DedupCacheCount.ShouldBe(0);
|
|
_limiter.GetCachedResponse(null).ShouldBeNull();
|
|
_limiter.GetCachedResponse("").ShouldBeNull();
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — periodic sweep removes expired dedup entries.
|
|
[SlopwatchSuppress("SW004", "TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing a time-based cache eviction")]
|
|
[Fact]
|
|
public async Task PurgeExpired_removes_old_entries()
|
|
{
|
|
using var shortLimiter = new ApiRateLimiter(maxConcurrent: 4, dedupTtl: TimeSpan.FromMilliseconds(50));
|
|
|
|
shortLimiter.CacheResponse("req-a", JetStreamApiResponse.SuccessResponse());
|
|
shortLimiter.CacheResponse("req-b", JetStreamApiResponse.SuccessResponse());
|
|
shortLimiter.CacheResponse("req-c", JetStreamApiResponse.SuccessResponse());
|
|
|
|
shortLimiter.DedupCacheCount.ShouldBe(3);
|
|
|
|
// Wait for all entries to expire.
|
|
await Task.Delay(120);
|
|
|
|
var removed = shortLimiter.PurgeExpired();
|
|
removed.ShouldBe(3);
|
|
shortLimiter.DedupCacheCount.ShouldBe(0);
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — dedup cache count is observable.
|
|
[Fact]
|
|
public void DedupCacheCount_tracks_cached_entries()
|
|
{
|
|
_limiter.DedupCacheCount.ShouldBe(0);
|
|
|
|
_limiter.CacheResponse("req-1", JetStreamApiResponse.Ok());
|
|
_limiter.CacheResponse("req-2", JetStreamApiResponse.Ok());
|
|
_limiter.CacheResponse("req-3", JetStreamApiResponse.Ok());
|
|
|
|
_limiter.DedupCacheCount.ShouldBe(3);
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — semaphore enforces max-concurrent across goroutines.
|
|
[Fact]
|
|
public async Task Concurrent_acquire_respects_max()
|
|
{
|
|
using var limiter = new ApiRateLimiter(maxConcurrent: 5);
|
|
|
|
// Spin up 10 tasks, only 5 should succeed.
|
|
var results = await Task.WhenAll(
|
|
Enumerable.Range(0, 10).Select(_ => limiter.TryAcquireAsync()));
|
|
|
|
var acquired = results.Count(r => r);
|
|
acquired.ShouldBe(5);
|
|
}
|
|
|
|
// Go reference: jetstream_api.go — default maxConcurrentRequests = 256.
|
|
[Fact]
|
|
public async Task Default_max_concurrent_is_256()
|
|
{
|
|
using var defaultLimiter = new ApiRateLimiter();
|
|
|
|
// Acquire 256 slots — all should succeed.
|
|
var tasks = Enumerable.Range(0, 256).Select(_ => defaultLimiter.TryAcquireAsync());
|
|
var results = await Task.WhenAll(tasks);
|
|
results.ShouldAllBe(r => r);
|
|
|
|
// 257th should fail.
|
|
var rejected = await defaultLimiter.TryAcquireAsync();
|
|
rejected.ShouldBeFalse();
|
|
}
|
|
}
|