feat: add API rate limiting and request deduplication (Gap 7.3)
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.
This commit is contained in:
108
src/NATS.Server/JetStream/Api/ApiRateLimiter.cs
Normal file
108
src/NATS.Server/JetStream/Api/ApiRateLimiter.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NATS.Server.JetStream.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Limits concurrent JetStream API requests and deduplicates by request ID.
|
||||
/// Go reference: jetstream_api.go rate limiting and deduplication logic.
|
||||
/// The Go server uses a semaphore (default 256 slots) to prevent request storms and a
|
||||
/// dedup cache keyed by Nats-Msg-Id to serve identical requests without reprocessing.
|
||||
/// </summary>
|
||||
public sealed class ApiRateLimiter : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
private readonly ConcurrentDictionary<string, CachedResponse> _dedupCache = new();
|
||||
private readonly TimeSpan _dedupTtl;
|
||||
private readonly int _maxConcurrent;
|
||||
|
||||
public ApiRateLimiter(int maxConcurrent = 256, TimeSpan? dedupTtl = null)
|
||||
{
|
||||
_maxConcurrent = maxConcurrent;
|
||||
_semaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
|
||||
_dedupTtl = dedupTtl ?? TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
/// <summary>Current number of in-flight requests.</summary>
|
||||
public int ActiveCount => _maxConcurrent - _semaphore.CurrentCount;
|
||||
|
||||
/// <summary>Number of cached dedup responses.</summary>
|
||||
public int DedupCacheCount => _dedupCache.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to acquire a concurrency slot. Returns false if the limit is reached.
|
||||
/// Go reference: jetstream_api.go — non-blocking semaphore acquire; request is rejected
|
||||
/// immediately if no slots are available rather than queuing indefinitely.
|
||||
/// </summary>
|
||||
public async Task<bool> TryAcquireAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _semaphore.WaitAsync(0, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases a concurrency slot.
|
||||
/// </summary>
|
||||
public void Release()
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a request with the given ID has a cached response.
|
||||
/// Returns the cached response if found and not expired, null otherwise.
|
||||
/// Go reference: jetstream_api.go — dedup cache is keyed by Nats-Msg-Id header value.
|
||||
/// </summary>
|
||||
public JetStreamApiResponse? GetCachedResponse(string? requestId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(requestId))
|
||||
return null;
|
||||
|
||||
if (_dedupCache.TryGetValue(requestId, out var cached))
|
||||
{
|
||||
if (DateTime.UtcNow - cached.CachedAt < _dedupTtl)
|
||||
return cached.Response;
|
||||
|
||||
// Expired — remove and return null.
|
||||
_dedupCache.TryRemove(requestId, out _);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches a response for deduplication.
|
||||
/// Go reference: jetstream_api.go — response is stored with a timestamp so that
|
||||
/// subsequent requests with the same Nats-Msg-Id within the TTL window get the same result.
|
||||
/// </summary>
|
||||
public void CacheResponse(string? requestId, JetStreamApiResponse response)
|
||||
{
|
||||
if (string.IsNullOrEmpty(requestId))
|
||||
return;
|
||||
|
||||
_dedupCache[requestId] = new CachedResponse(response, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes expired entries from the dedup cache.
|
||||
/// Call periodically to prevent unbounded growth.
|
||||
/// Go reference: jetstream_api.go — Go's dedup window uses a sliding expiry; entries older
|
||||
/// than dedupWindow are dropped on the next sweep.
|
||||
/// </summary>
|
||||
public int PurgeExpired()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - _dedupTtl;
|
||||
var removed = 0;
|
||||
foreach (var (key, value) in _dedupCache)
|
||||
{
|
||||
if (value.CachedAt < cutoff && _dedupCache.TryRemove(key, out _))
|
||||
removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_semaphore.Dispose();
|
||||
}
|
||||
|
||||
private sealed record CachedResponse(JetStreamApiResponse Response, DateTime CachedAt);
|
||||
}
|
||||
105
src/NATS.Server/JetStream/Api/ClusteredRequestProcessor.cs
Normal file
105
src/NATS.Server/JetStream/Api/ClusteredRequestProcessor.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NATS.Server.JetStream.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks pending clustered JetStream API requests, correlates RAFT apply callbacks with
|
||||
/// waiting callers, and enforces per-request timeouts.
|
||||
/// Go reference: jetstream_cluster.go:7620-7701 — jsClusteredStreamRequest proposes an entry
|
||||
/// to the meta RAFT group and waits for the leader to apply it; the result is delivered via
|
||||
/// a per-request channel. This class models that channel-per-request pattern using
|
||||
/// TaskCompletionSource.
|
||||
/// </summary>
|
||||
public sealed class ClusteredRequestProcessor
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<JetStreamApiResponse>> _pending = new();
|
||||
private readonly TimeSpan _timeout;
|
||||
private int _pendingCount;
|
||||
|
||||
public ClusteredRequestProcessor(TimeSpan? timeout = null)
|
||||
{
|
||||
_timeout = timeout ?? DefaultTimeout;
|
||||
}
|
||||
|
||||
/// <summary>Current number of in-flight pending requests.</summary>
|
||||
public int PendingCount => _pendingCount;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new pending request and returns a unique correlation ID.
|
||||
/// Go reference: jetstream_cluster.go:7620 — each clustered request gets a unique ID
|
||||
/// used to correlate the RAFT apply callback with the waiting caller.
|
||||
/// </summary>
|
||||
public string RegisterPending()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<JetStreamApiResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = tcs;
|
||||
Interlocked.Increment(ref _pendingCount);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a result to be delivered for the given request ID.
|
||||
/// Returns a timeout error if no result is delivered within the configured timeout,
|
||||
/// or a 500 error if the ID was never registered.
|
||||
/// Go reference: jetstream_cluster.go:7620 — the goroutine waits on a per-request channel
|
||||
/// with a context deadline derived from the cluster's JSApiTimeout option.
|
||||
/// </summary>
|
||||
public async Task<JetStreamApiResponse> WaitForResultAsync(string requestId, CancellationToken ct = default)
|
||||
{
|
||||
if (!_pending.TryGetValue(requestId, out var tcs))
|
||||
{
|
||||
return JetStreamApiResponse.ErrorResponse(500, "request id not found");
|
||||
}
|
||||
|
||||
using var timeoutCts = new CancellationTokenSource(_timeout);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, ct);
|
||||
|
||||
try
|
||||
{
|
||||
await using var reg = linked.Token.Register(() => tcs.TrySetCanceled(linked.Token));
|
||||
return await tcs.Task.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_pending.TryRemove(requestId, out _);
|
||||
Interlocked.Decrement(ref _pendingCount);
|
||||
return JetStreamApiResponse.ErrorResponse(408, "timeout waiting for cluster response");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers a result for a pending request. Returns true if the request was found and
|
||||
/// the result was accepted; false if the ID is unknown or already completed.
|
||||
/// Go reference: jetstream_cluster.go:7620 — the RAFT apply callback resolves the pending
|
||||
/// request channel so the waiting goroutine can return the response to the caller.
|
||||
/// </summary>
|
||||
public bool DeliverResult(string requestId, JetStreamApiResponse response)
|
||||
{
|
||||
if (!_pending.TryRemove(requestId, out var tcs))
|
||||
return false;
|
||||
|
||||
Interlocked.Decrement(ref _pendingCount);
|
||||
return tcs.TrySetResult(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all pending requests with a 503 error, typically called when this node loses
|
||||
/// RAFT leadership so callers do not hang indefinitely.
|
||||
/// Go reference: jetstream_cluster.go — when RAFT leadership changes, all in-flight
|
||||
/// proposals must be failed with a "not leader" or "cancelled" error.
|
||||
/// </summary>
|
||||
public void CancelAll(string reason = "leadership changed")
|
||||
{
|
||||
foreach (var (key, tcs) in _pending)
|
||||
{
|
||||
if (_pending.TryRemove(key, out _))
|
||||
{
|
||||
Interlocked.Decrement(ref _pendingCount);
|
||||
tcs.TrySetResult(JetStreamApiResponse.ErrorResponse(503, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user