Skip to content

Channel-based async rate gate for .NET — zero-loss backpressure, precise pacing, cancellation safety, Redis coordination, observability, benchmarks, and Aspire orchestration

License

Notifications You must be signed in to change notification settings

Alexanderbtw/throttle-channel

Repository files navigation

ThrottleChannel

TL;DR

Channel-driven rate gate with capacity tokens, zero-loss cancellation, deterministic shutdown, and optional Redis-backed distributed coordination.

Features

  • Channel-based backpressure: unbounded data channel with separate capacity counter prevents accidental drops.
  • Precise pacing: monotonic Stopwatch scheduling guarantees ≤ configured RPS without timer drift.
  • Immediate cancellation: canceled waiters free capacity instantly and never clog the queue.
  • Deterministic shutdown: DisposeAsync drains the channel, flags waiters, and surfaces OperationCanceledException.
  • Observability baked in: queue depth, cancellations, and wait durations exposed through RateGateMetricsSnapshot.
  • Redis adapter: ThrottleChannel.Redis ships a fixed-window distributed permit acquirer using standard ConnectionMultiplexer patterns.

Installation

dotnet add package ThrottleChannel
# optional distributed integration
dotnet add package ThrottleChannel.Redis

Target frameworks: net8.0, netstandard2.1.

Quick Start

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}");

ASP.NET Core with Redis coordination

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.

Observability & Aspire

  • Pass a Meter instance (or configure FixedRpsGateFactory with one) to enable automatic publishing through RateGateMetricsPublisher. Metrics are emitted on the ThrottleChannel.RateGate meter.

  • The tests/ThrottleChannel.ServiceDefaults project wires OpenTelemetry exporters. When OTEL_EXPORTER_OTLP_ENDPOINT is set (for example, http://localhost:4317 exposed 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.

Cancellation semantics

  • WaitAsync throws OperationCanceledException when 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.

Why Channel + capacity tokens

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

Benchmarks

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 Release

Benchmarks require restoring NuGet packages. In restricted CI environments provide an offline cache or enable network access.

Redis-backed tests

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.sln

CI/CD

  • ci.yml — restore, build, test (with coverage), pack artifacts on every push/PR.
  • release.yml — publish signed packages to NuGet when tagging v*.*.* (requires NUGET_API_KEY).

Repository layout

  • 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.

Roadmap:

token bucket, per-key limits, sliding window, concurrency caps, telemetry hooks, NativeAOT.

About

Channel-based async rate gate for .NET — zero-loss backpressure, precise pacing, cancellation safety, Redis coordination, observability, benchmarks, and Aspire orchestration

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages