Channel-driven rate gate with capacity tokens, zero-loss cancellation, deterministic shutdown, and optional Redis-backed distributed coordination.
- Channel-based backpressure: unbounded data channel with separate capacity counter prevents accidental drops.
- Precise pacing: monotonic
Stopwatchscheduling guarantees ≤ configured RPS without timer drift. - Immediate cancellation: canceled waiters free capacity instantly and never clog the queue.
- Deterministic shutdown:
DisposeAsyncdrains the channel, flags waiters, and surfacesOperationCanceledException. - Observability baked in: queue depth, cancellations, and wait durations exposed through
RateGateMetricsSnapshot. - Redis adapter:
ThrottleChannel.Redisships a fixed-window distributed permit acquirer using standardConnectionMultiplexerpatterns.
dotnet add package ThrottleChannel
# optional distributed integration
dotnet add package ThrottleChannel.RedisTarget frameworks: net8.0, netstandard2.1.
using System.Diagnostics.Metrics;
using ThrottleChannel.RateGate;
using var meter = new Meter("ThrottleChannel.RateGate", "1.0.0");
await using var gate = new FixedRpsGate(
rps: 1,
capacity: 1024,
meter: meter);
foreach (var request in requests)
{
await gate.WaitAsync(request.CancellationToken);
await ProcessAsync(request);
}
var metrics = gate.Metrics;
Console.WriteLine($"Granted: {metrics.TotalGranted}, cancelled: {metrics.TotalCancelled}");using Microsoft.Extensions.DependencyInjection;
using ThrottleChannel.Distributed;
using ThrottleChannel.RateGate;
using ThrottleChannel.Redis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddThrottleChannelRedis(
configuration: builder.Configuration.GetConnectionString("redis")!,
configure: options =>
{
options.Key = "throttle:orders";
options.PermitsPerWindow = 1;
});
builder.Services.AddSingleton<FixedRpsGate>(sp =>
{
var acquirer = sp.GetRequiredService<IDistributedPermitAcquirer>();
return new FixedRpsGate(rps: 1, capacity: 1024, distributedAcquirer: acquirer);
});
builder.Services.AddSingleton<IRateGate>(sp => sp.GetRequiredService<FixedRpsGate>());
var app = builder.Build();
app.MapPost("/checkout", async (IRateGate gate, CancellationToken ct) =>
{
await gate.WaitAsync(ct);
return Results.Ok();
});
app.Run();FixedRpsGate is registered as a singleton, so the DI container disposes it gracefully on shutdown.
-
Pass a
Meterinstance (or configureFixedRpsGateFactorywith one) to enable automatic publishing throughRateGateMetricsPublisher. Metrics are emitted on theThrottleChannel.RateGatemeter. -
The
tests/ThrottleChannel.ServiceDefaultsproject wires OpenTelemetry exporters. WhenOTEL_EXPORTER_OTLP_ENDPOINTis set (for example,http://localhost:4317exposed by the Aspire dashboard), metrics and traces flow to that endpoint. -
Run the local Aspire orchestrator to see live telemetry and distributed coordination:
dotnet run --project tests/ThrottleChannel.AppHost/ThrottleChannel.AppHost.csproj
The orchestrator provisions Redis, boots the integration worker, and links the OpenTelemetry pipeline to the Aspire dashboard.
WaitAsyncthrowsOperationCanceledExceptionwhen the caller supplies a canceled token.- Tokens canceled while queued release capacity immediately and complete with
OperationCanceledException. - Shutdown (
DisposeAsync) cancels every pending waiter, keeping channel and semaphore in a consistent state.
| Concern | Queue + lock | Channel + capacity |
|---|---|---|
| Backpressure | Full queue drops items or blocks producers | SemaphoreSlim gates new writers without touching the channel |
| Cancellation | Requires manual removal and lock convoy | Token callback flips the TCS and releases capacity instantly |
| Throughput pacing | Timer drift accumulates | Monotonic Stopwatch timestamps keep ≤ configured RPS |
| Shutdown | Requires walk over queue under lock | Reader naturally drains remaining work with deterministic cancel |
Scenario: 1k permit acquisitions per run, 1 or 10 rps configuration, averaged over 5 iterations. Baseline is legacy linked-list queue + locks (simulated in benchmarks project).
| RPS | Legacy gate (mean µs) | Channel gate (mean µs) | Δ |
|---|---|---|---|
| 1 | (collect after running dotnet run -c Release in benchmarks) |
(collect after running benchmarks) | (pending) |
| 10 | (collect after running dotnet run -c Release in benchmarks) |
(collect after running benchmarks) | (pending) |
Run locally:
cd benchmarks/ThrottleChannel.Benchmarks
dotnet run -c ReleaseBenchmarks require restoring NuGet packages. In restricted CI environments provide an offline cache or enable network access.
Redis integration suites rely on Docker via Testcontainers; the tests spin up a disposable Redis container and skip only if the local Docker daemon is unavailable. Make sure Docker Desktop/Colima/Podman is running before executing:
dotnet test ThrottleChannel.slnci.yml— restore, build, test (with coverage), pack artifacts on every push/PR.release.yml— publish signed packages to NuGet when taggingv*.*.*(requiresNUGET_API_KEY).
src/ThrottleChannel— core channel-based gate.src/ThrottleChannel.Redis— Redis permit acquirer + DI helpers.tests/ThrottleChannel.Tests— xUnit coverage for cancellation, capacity, pacing, shutdown.tests/ThrottleChannel.TestSupport— Redis Testcontainers helpers and conditional attributes.tests/ThrottleChannel.ServiceDefaults— shared resilience + OpenTelemetry defaults for Aspire-integrated services.benchmarks/ThrottleChannel.Benchmarks— BenchmarkDotNet comparisons.samples/BasicConsole— console demo with multiple producers under 1 RPS gate.tests/ThrottleChannel.AppHost— Aspire orchestrator for integration scenarios.tests/ThrottleChannel.IntegrationWorker— background load generator used in Aspire orchestration.
token bucket, per-key limits, sliding window, concurrency caps, telemetry hooks, NativeAOT.