Load, Replicas & Resilience · Lesson 3

Bounded queues and backpressure

Reject excess admission instead of concealing unbounded waiting.

External .NET10 SDK10.0.403, real Kestrel and HTTP driver. Experiments are limited to owned loopback processes. SQLite10.0.14 is a local-file classroom ledger, not a network production database.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

Waiting slots and active owners are separate quantities

A queue capacity limits waiting items, while worker count limits operations already owned by workers. Two active workers and three waiting slots can therefore own five accepted operations, not three and not an unbounded number. Backpressure can mean waiting at an explicitly owned producer boundary or rejecting admission so the caller can decide what to do. This testbed chooses immediate rejection when its real bounded Channel cannot accept another item. The portable ordinary queue isolates the smaller admission rule, while actual workers, request lifetimes and HTTP429 belong to the complete external project.

Two workers execute A and B while three queue slots contain Q1, Q2 and Q3. Five operations are admitted. Incoming C is not admitted and must wait or be rejected.View full size ↗
Queue capacity and worker concurrency are separate budgets. These limits apply to one replica.
Read the diagram

At the pictured instant, two workers are busy and three queue slots are occupied. That means five operations have been admitted: two running plus three waiting. Request C is outside the queue and has not been accepted.

For a bounded channel configured to wait when full, WriteAsync waits until space is available and must receive the caller cancellation token. TryWrite returns false when it cannot accept the item, allowing the caller to reject the request. Waiting callers are also a resource and need a bounded request budget.

The lesson uses gated workers to reproduce this state and checks rejection of a sixth request. A capacity of three does not mean only three operations may be running or waiting in total.

The diagram shows a single replica. Two replicas with independent local queues have separate budgets. A cluster-wide limit requires deliberate coordination or admission at a shared boundary; adding replicas does not automatically create a shared queue.

Illustration created with ImageGen for SharpRune.

Read the portable two-slot example precisely

The console offers A, B and C to a queue with capacity two. A and B are accepted, C is rejected, and dequeuing returns A. One waiting item remains, producing the five-line transcript accepted, accepted, rejected, next:A and queued:1. This queue has no worker threads, clock or HTTP behavior. It is useful because every count can be checked independently. The external lesson deliberately uses another configuration, two workers plus three waiting slots, so the richer snapshot demonstrates the distinction between a queue-only model and an application with active owners in addition to waiting storage.

Make the real admission boundary observable without a race

Close the Development main gate, submit two requests and wait until stats reports two active workers. Here active means worker-owned and waiting at the gate, not busy CPU computation. Then submit three more requests and observe three queued entries. A sixth request receives429 because neither an additional worker nor a waiting slot is available. Opening the gate lets all five admitted requests finish. This staged test is more precise than sending a burst and assuming a scheduler happened to fill exactly the desired arrangement. The figure's two-plus-three snapshot corresponds to this controlled external observation.

The chosen Channel mode does not force the producer policy

The channel is bounded with FullMode Wait, but this endpoint calls TryWrite rather than awaiting WriteAsync. When capacity is unavailable, TryWrite returns false and the endpoint reports rejection. A producer using WriteAsync would have a different waiting and cancellation contract. Neither choice automatically provides durable messaging or transactional acceptance across process failure. The stats fields are concurrent observations rather than one global atomic database snapshot, so use stable gated conditions when asserting an exact arrangement. Accepted request lifetimes still own completion and cancellation even though an item has crossed into the waiting queue.

Practice: change waiting capacity independently of workers

Keep two workers and the closed gate, but reduce QueueCapacity from three to one in a separate project run. Occupy the workers first, then fill the one waiting slot and predict the next admission result. Verify active, queued and accepted counts under that stable condition. Restore the baseline and change only worker count instead. The hint is to account for both budgets separately rather than treating a larger worker count as a larger queue. Keep the request total below the driver's stated bounds and release every gate before ending the experiment.

Practice: compare rejection with owned waiting

In a separate controlled project copy, replace the immediate TryWrite decision with a cancellation-aware WriteAsync admission path. Specify where a producer now waits and which timeout owns that waiting before executing it. Compare a full queue with the original429 behavior, then cancel one waiting producer and verify it does not become unbounded abandoned work. Do not call the new path durable acceptance merely because awaiting completed. The experiment changes the caller's admission contract and latency boundary, so preserve separate expected outcomes instead of labeling both versions generically as backpressure.

Choose a failure policy for demand beyond the bound

A bounded queue prevents this owner from accumulating unlimited waiting items, but it does not remove excess demand. Callers may fail, retry within a budget or shed work according to the application's contract. Unlimited immediate retries can recreate overload outside the queue. Preserve explicit accepted, rejected, queued and completed observations when changing the mechanism. The deterministic companion and the gated real-HTTP snapshot together establish useful arithmetic and execution evidence without pretending that a local Channel is a durable broker. A production adaptation must separately define persistence, fairness, deadlines and what happens to accepted work if its process fails.

  • Count worker-owned items and waiting items separately when explaining accepted capacity.
  • An in-memory bounded queue is an admission mechanism, not a durable delivery guarantee.
External .NET10 SDK10.0.403, real Kestrel and HTTP driver. Experiments are limited to owned loopback processes. SQLite10.0.14 is a local-file classroom ledger, not a network production database.

Bounded queues and backpressure — real bounded loopback testbed

Set up the SDK project

  1. Save the complete Server and Driver directories plus global.json into one new owned folder. Install official .NET10 SDK10.0.403.
  2. Restore both projects explicitly: dotnet restore Server/Lab.Server.csproj --source https://api.nuget.org/v3/index.json ; then dotnet restore Driver/Lab.Driver.csproj --source https://api.nuget.org/v3/index.json
  3. In terminal1 start the first owned server: ASPNETCORE_ENVIRONMENT=Development dotnet run --project Server/Lab.Server.csproj --no-launch-profile --urls http://127.0.0.1:5081 --Lab:Replica=one --Lab:LedgerPath=./owned-ledger.db --Lab:Workers=2 --Lab:QueueCapacity=3 --Lab:PoolSize=2 --Lab:WorkMilliseconds=20
  4. For two-replica experiments use terminal2 with port5082, Lab:Replica=two and Lab:Partition=1. Resolve both ledger paths to the same owned absolute file only when the lesson explicitly tests shared local coordination.
  5. Run drivers only against your owned loopback ports. Maximum32 logical operations, maximum8 in flight and at most2 retry attempts keep each run below100 requests. Outputs include variable measured timings.
  6. Stop only the processes you started using Ctrl+C in their own terminals; clean up only your owned fixture directory.
  7. POST /lab/gate/main/close; hold2 concurrent /work requests until stats.main.active=2, then hold3 more until queued=3. The next /work is429. POST /lab/gate/main/open and await all five.

Download complete SDK project ↗

Project files

global.jsonDownload file ↗
{
  "sdk": {
    "version": "10.0.403",
    "rollForward": "latestPatch"
  }
}
Server/Lab.Server.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk.Web"><PropertyGroup><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.14" /></ItemGroup></Project>
Server/Program.csDownload file ↗
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Data.Sqlite;
var builder = WebApplication.CreateBuilder(args);
var settings = new LabSettings {
    Replica = builder.Configuration["Lab:Replica"] ?? "one",
    Workers = builder.Configuration.GetValue("Lab:Workers", 2),
    Partition = builder.Configuration.GetValue("Lab:Partition", 0),
    QueueCapacity = builder.Configuration.GetValue("Lab:QueueCapacity", 4),
    PoolSize = builder.Configuration.GetValue("Lab:PoolSize", 2),
    WorkMilliseconds = builder.Configuration.GetValue("Lab:WorkMilliseconds", 20),
    LedgerPath = builder.Configuration["Lab:LedgerPath"] ?? Path.Combine(Path.GetTempPath(), "sharprune-local-ledger.db")
};
settings.Validate(); builder.Services.AddSingleton(settings); builder.Services.AddSingleton<LabRuntime>(); builder.Services.AddSingleton<LocalIo>();
builder.Services.AddSingleton<IHostedService>(services => services.GetRequiredService<LabRuntime>());
builder.Services.AddSingleton<LocalLedger>(); var app = builder.Build();
app.Services.GetRequiredService<LocalLedger>().Initialize();
app.MapGet("/instance", (LabRuntime lab) => new { replica = settings.Replica, visits = lab.Visit() });
app.MapGet("/live", () => TypedResults.Text("live"));
app.MapGet("/io-delay", async (CancellationToken token) => { await Task.Delay(20, token); return TypedResults.Text("controlled loopback reply"); });
app.MapGet("/io", async (CancellationToken token, IServer server, LocalIo io) => {
    using var response = await io.Client.GetAsync(LocalAddress(server), token); response.EnsureSuccessStatusCode(); _ = await response.Content.ReadAsByteArrayAsync(token);
    return TypedResults.Ok(new { replica = settings.Replica, kind = "async-loopback-http" });
});
app.MapGet("/sync", (CancellationToken token, IServer server, LocalIo io) => {
    using var response = io.Client.GetAsync(LocalAddress(server), token).GetAwaiter().GetResult(); response.EnsureSuccessStatusCode(); _ = response.Content.ReadAsByteArrayAsync(token).GetAwaiter().GetResult();
    return TypedResults.Ok(new { replica = settings.Replica, kind = "deliberate-blocking-demo" });
});
app.MapGet("/cpu", () => { uint value = 1; for (int i = 0; i < 200000; i++) value = unchecked(value * 1664525 + 1013904223); return TypedResults.Ok(new { replica = settings.Replica, kind = "bounded-cpu", value }); });
app.MapGet("/threadpool", () => new { threads = ThreadPool.ThreadCount, pending = ThreadPool.PendingWorkItemCount, completed = ThreadPool.CompletedWorkItemCount });
app.MapGet("/ready", (LabRuntime lab) => lab.Accepting ? Results.Ok(new { ready = true }) : Results.StatusCode(503));
app.MapGet("/stats", (LabRuntime lab) => new { replica = settings.Replica, accepting = lab.Accepting, main = lab.Main.Snapshot(), batch = lab.Batch.Snapshot(), dependencyCalls = lab.Circuit.DependencyCalls });
app.MapGet("/work", (HttpContext context, LabRuntime lab, int? budgetMs) => Handle(context, lab.Main, lab, budgetMs));
app.MapGet("/batch", (HttpContext context, LabRuntime lab, int? budgetMs) => Handle(context, lab.Batch, lab, budgetMs));
app.MapGet("/unstable", (LabRuntime lab, string? key) => {
    if (key != "demo" && key != "always") return Results.BadRequest();
    int attempt = lab.Attempt(key); return key == "always" || attempt == 1 ? Results.StatusCode(503) : Results.Ok(new { replica = settings.Replica, attempt });
});
app.MapGet("/session/{name}", (LabRuntime lab, string name) => name == "demo" ? Results.Ok(new { replica = settings.Replica, value = lab.Session() }) : Results.BadRequest());
app.MapPost("/once/{key}", (string key, LedgerInput input, LocalLedger ledger) => {
    if (key != "demo" || input.Amount is < 1 or > 100) return Results.BadRequest();
    LedgerDecision result = ledger.Record(key, input.Amount); return result.Conflict ? Results.Conflict(new { error = "key amount conflict" }) : Results.Ok(result);
});
app.MapGet("/ledger", (LocalLedger ledger) => new { receipts = ledger.Count() });
app.MapGet("/guarded", (LabRuntime lab) => lab.Circuit.Invoke(lab.DependencyFailing));
app.MapGet("/partition/{key}", (string key, int? owner) => {
    if (key != "A" && key != "B") return Results.BadRequest();
    int partition = StablePartition.For(key, 2);
    return owner == partition && settings.Partition == partition ? Results.Ok(new { replica = settings.Replica, partition, key }) : Results.Conflict(new { expectedPartition = partition });
});
if (app.Environment.IsDevelopment()) {
    app.MapPost("/lab/gate/{lane}/{action}", (string lane, string action, LabRuntime lab) => {
        WorkLane? target = lane == "main" ? lab.Main : lane == "batch" ? lab.Batch : null;
        if (target is null || action != "open" && action != "close") return Results.BadRequest();
        if (action == "open") target.Gate.Open(); else target.Gate.Close(); return Results.NoContent();
    });
    app.MapPost("/lab/drain", (LabRuntime lab) => { lab.Drain(); return Results.NoContent(); });
    app.MapPost("/lab/dependency/{failing:bool}", (bool failing, LabRuntime lab) => { lab.DependencyFailing = failing; return Results.NoContent(); });
}
app.Run();
static async Task<IResult> Handle(HttpContext context, WorkLane lane, LabRuntime lab, int? budgetMs) {
    if (budgetMs is < 1 or > 1000) return Results.BadRequest();
    using var budget = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
    if (budgetMs is int milliseconds) budget.CancelAfter(milliseconds);
    var item = new WorkItem(budget.Token);
    AdmissionDecision decision = lab.Admit(lane, item);
    if (decision == AdmissionDecision.Draining) return Results.StatusCode(503);
    if (decision == AdmissionDecision.Full) return Results.StatusCode(429);
    try { await item.Completion.Task.WaitAsync(budget.Token); return Results.Ok(new { replica = lab.Settings.Replica, completed = true }); }
    catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested) { return Results.Problem(statusCode: 504, title: "Classroom work budget expired"); }
}
static Uri LocalAddress(IServer server) {
    string address = server.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
    return new UriBuilder("http", "127.0.0.1", new Uri(address).Port) { Path = "/io-delay" }.Uri;
}
public sealed class LocalIo : IDisposable { public HttpClient Client { get; } = new() { Timeout = TimeSpan.FromSeconds(5) }; public void Dispose() => Client.Dispose(); }
public sealed record LedgerInput(int Amount);
public sealed class LabSettings {
    public string Replica { get; init; } = "one"; public int Partition { get; init; } public int Workers { get; init; } public int QueueCapacity { get; init; }
    public int PoolSize { get; init; } public int WorkMilliseconds { get; init; } public string LedgerPath { get; init; } = "";
    public void Validate() {
        if (Partition is < 0 or > 1 || Replica.Length is < 1 or > 32 || Workers is < 1 or > 8 || QueueCapacity is < 1 or > 16 || PoolSize is < 1 or > 8 || WorkMilliseconds is < 1 or > 250 || string.IsNullOrWhiteSpace(LedgerPath)) throw new InvalidOperationException("Invalid bounded lab settings");
    }
}
public enum AdmissionDecision { Accepted, Full, Draining }
public sealed class LabRuntime : IHostedService, IDisposable {
    private readonly object admissionSync = new();
    private int accepting = 1, visits, sessions, demoAttempts, alwaysAttempts, failing = 1, disposed;
    public LabSettings Settings { get; } public WorkLane Main { get; } public WorkLane Batch { get; }
    public ClassroomCircuit Circuit { get; } = new();
    public LabRuntime(LabSettings settings) { Settings = settings; Main = new(settings.Workers, settings.QueueCapacity, settings.PoolSize, settings.WorkMilliseconds); Batch = new(1, 1, 1, settings.WorkMilliseconds); }
    public bool Accepting => Volatile.Read(ref accepting) == 1;
    public bool DependencyFailing { get => Volatile.Read(ref failing) == 1; set => Volatile.Write(ref failing, value ? 1 : 0); }
    public void Drain() { lock (admissionSync) Volatile.Write(ref accepting, 0); }
    public AdmissionDecision Admit(WorkLane lane, WorkItem item) {
        lock (admissionSync) {
            if (!Accepting) return AdmissionDecision.Draining;
            return lane.TrySubmit(item) ? AdmissionDecision.Accepted : AdmissionDecision.Full;
        }
    }
    public int Visit() => Interlocked.Increment(ref visits); public int Session() => Interlocked.Increment(ref sessions);
    public int Attempt(string key) => key == "demo" ? Interlocked.Increment(ref demoAttempts) : Interlocked.Increment(ref alwaysAttempts);
    public Task StartAsync(CancellationToken token) { Main.Start(); Batch.Start(); return Task.CompletedTask; }
    public Task StopAsync(CancellationToken token) => Task.WhenAll(Main.StopAsync(token), Batch.StopAsync(token));
    public void Dispose() { if (Interlocked.Exchange(ref disposed, 1) != 0) return; Main.Dispose(); Batch.Dispose(); }
}
public static class StablePartition {
    public static int For(string key, int count) { uint hash = 2166136261; foreach (byte b in System.Text.Encoding.UTF8.GetBytes(key)) hash = unchecked((hash ^ b) * 16777619); return (int)(hash % (uint)count); }
}
Server/WorkLane.csDownload file ↗
using System.Threading.Channels;
public sealed record WorkItem(CancellationToken Token) { public TaskCompletionSource<bool> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); }
public sealed class AsyncGate {
    private readonly object sync = new(); private TaskCompletionSource<bool> source = Completed();
    private static TaskCompletionSource<bool> Completed() { var value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); value.SetResult(true); return value; }
    public void Close() { lock (sync) { if (source.Task.IsCompleted) source = new(TaskCreationOptions.RunContinuationsAsynchronously); } }
    public void Open() { lock (sync) source.TrySetResult(true); }
    public Task WaitAsync(CancellationToken token) { lock (sync) return source.Task.WaitAsync(token); }
}
public sealed class WorkLane : IDisposable {
    private readonly Channel<WorkItem> queue; private readonly SemaphoreSlim pool; private readonly int workers, milliseconds;
    private readonly CancellationTokenSource stop = new(); private Task[] running = Array.Empty<Task>();
    private int active, inPool, maxActive, maxPool, accepted, rejected, completed, canceled, disposed;
    public AsyncGate Gate { get; } = new();
    public WorkLane(int workers, int capacity, int poolSize, int milliseconds) {
        this.workers = workers; this.milliseconds = milliseconds; pool = new(poolSize, poolSize);
        queue = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = false, SingleReader = workers == 1 });
    }
    public void Start() { running = Enumerable.Range(0, workers).Select(_ => RunAsync()).ToArray(); }
    public bool TrySubmit(WorkItem item) { if (queue.Writer.TryWrite(item)) { Interlocked.Increment(ref accepted); return true; } Interlocked.Increment(ref rejected); return false; }
    private async Task RunAsync() {
        await foreach (WorkItem item in queue.Reader.ReadAllAsync(stop.Token)) {
            int now = Interlocked.Increment(ref active); Maximum(ref maxActive, now); bool entered = false;
            using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(item.Token, stop.Token);
            try {
                lifetime.Token.ThrowIfCancellationRequested(); await Gate.WaitAsync(lifetime.Token);
                await pool.WaitAsync(lifetime.Token); entered = true; Maximum(ref maxPool, Interlocked.Increment(ref inPool));
                await Task.Delay(milliseconds, lifetime.Token); Interlocked.Increment(ref completed); item.Completion.TrySetResult(true);
            } catch (OperationCanceledException) { Interlocked.Increment(ref canceled); item.Completion.TrySetCanceled(lifetime.Token); }
            finally { if (entered) { Interlocked.Decrement(ref inPool); pool.Release(); } Interlocked.Decrement(ref active); }
        }
    }
    private static void Maximum(ref int field, int value) { int old; do { old = Volatile.Read(ref field); if (old >= value) return; } while (Interlocked.CompareExchange(ref field, value, old) != old); }
    public object Snapshot() => new { active = Volatile.Read(ref active), queued = queue.Reader.Count, inPool = Volatile.Read(ref inPool), maxActive = Volatile.Read(ref maxActive), maxPool = Volatile.Read(ref maxPool), accepted = Volatile.Read(ref accepted), rejected = Volatile.Read(ref rejected), completed = Volatile.Read(ref completed), canceled = Volatile.Read(ref canceled) };
    public async Task StopAsync(CancellationToken token) {
        queue.Writer.TryComplete();
        try { await Task.WhenAll(running).WaitAsync(token); }
        catch (OperationCanceledException) when (token.IsCancellationRequested) { stop.Cancel(); Gate.Open(); try { await Task.WhenAll(running); } catch (OperationCanceledException) { } }
    }
    public void Dispose() { if (Interlocked.Exchange(ref disposed, 1) != 0) return; stop.Cancel(); pool.Dispose(); stop.Dispose(); }
}
Server/LocalLedger.csDownload file ↗
using Microsoft.Data.Sqlite;
public sealed record LedgerDecision(bool Replay, bool Conflict, int Amount);
public sealed class LocalLedger {
    private readonly string connectionString;
    public LocalLedger(LabSettings settings) { connectionString = new SqliteConnectionStringBuilder { DataSource = settings.LedgerPath, DefaultTimeout = 2 }.ToString(); }
    private SqliteConnection Open() { var connection = new SqliteConnection(connectionString); connection.Open(); return connection; }
    public void Initialize() { using var connection = Open(); using var command = connection.CreateCommand(); command.CommandText = "CREATE TABLE IF NOT EXISTS receipts (request_key TEXT PRIMARY KEY, amount INTEGER NOT NULL)"; command.ExecuteNonQuery(); }
    public LedgerDecision Record(string key, int amount) {
        using var connection = Open(); using var transaction = connection.BeginTransaction();
        using var insert = connection.CreateCommand(); insert.Transaction = transaction;
        insert.CommandText = "INSERT INTO receipts(request_key, amount) VALUES ($key, $amount) ON CONFLICT(request_key) DO NOTHING";
        insert.Parameters.AddWithValue("$key", key); insert.Parameters.AddWithValue("$amount", amount); int changed = insert.ExecuteNonQuery();
        using var read = connection.CreateCommand(); read.Transaction = transaction; read.CommandText = "SELECT amount FROM receipts WHERE request_key = $key"; read.Parameters.AddWithValue("$key", key);
        int stored = Convert.ToInt32(read.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); transaction.Commit();
        return new LedgerDecision(changed == 0, stored != amount, stored);
    }
    public int Count() { using var connection = Open(); using var read = connection.CreateCommand(); read.CommandText = "SELECT COUNT(*) FROM receipts"; return Convert.ToInt32(read.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); }
}
Server/ClassroomCircuit.csDownload file ↗
public sealed class ClassroomCircuit {
    private readonly object sync = new(); private int failures, calls; private bool probe; private DateTimeOffset openUntil = DateTimeOffset.MinValue;
    public int DependencyCalls { get { lock (sync) return calls; } }
    public IResult Invoke(bool failing) {
        lock (sync) {
            DateTimeOffset now = DateTimeOffset.UtcNow;
            if (openUntil != DateTimeOffset.MinValue && now < openUntil) return Results.Json(new { state = "open", called = false }, statusCode: 503);
            if (probe) return Results.Json(new { state = "probe-busy", called = false }, statusCode: 503);
            bool halfOpen = openUntil != DateTimeOffset.MinValue; if (halfOpen) probe = true;
            calls++;
            // The dependency is an explicitly controlled in-process classroom outcome, not a real remote service.
            if (failing) {
                failures++; if (halfOpen || failures >= 2) openUntil = now.AddMilliseconds(250); probe = false;
                return Results.Json(new { state = openUntil == DateTimeOffset.MinValue ? "closed" : "open", called = true }, statusCode: 503);
            }
            failures = 0; openUntil = DateTimeOffset.MinValue; probe = false; return Results.Ok(new { state = "closed", called = true });
        }
    }
}
Driver/Lab.Driver.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup></Project>
Driver/Program.csDownload file ↗
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
if (args.Length < 2) throw new ArgumentException("Usage: closed|open|retry|shard|hot|io|cpu|sync baseUrl[,baseUrl] [count<=32] [concurrency<=8] [intervalMs]");
string mode = args[0];
if (!new[] { "closed", "open", "retry", "shard", "hot", "io", "cpu", "sync" }.Contains(mode)) throw new ArgumentException("Unknown mode");
Uri[] targets = args[1].Split(',').Select(s => new Uri(s.TrimEnd('/') + "/")).ToArray();
if (targets.Length is < 1 or > 2 || targets.Any(u => !u.IsLoopback || u.Scheme != "http" || u.AbsolutePath != "/" || !string.IsNullOrEmpty(u.Query) || !string.IsNullOrEmpty(u.UserInfo))) throw new ArgumentException("Only one or two owned HTTP loopback roots are allowed");
int count = args.Length > 2 ? int.Parse(args[2], CultureInfo.InvariantCulture) : 16;
int concurrency = args.Length > 3 ? int.Parse(args[3], CultureInfo.InvariantCulture) : 4;
int interval = args.Length > 4 ? int.Parse(args[4], CultureInfo.InvariantCulture) : 2;
if (count is < 1 or > 32 || concurrency is < 1 or > 8 || interval is < 1 or > 100 || (mode == "shard" || mode == "hot") && targets.Length != 2) throw new ArgumentException("Bounded local settings required");
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var latencies = new ConcurrentBag<double>(); var replicas = new ConcurrentDictionary<string, byte>(StringComparer.Ordinal);
int attempted = 0, successful = 0, rejected = 0, skipped = 0, logical = 0;
async Task Invoke(int index) {
    string key = mode == "hot" || index % 2 == 0 ? "A" : "B"; int partition = Partition(key);
    Uri target = targets[(mode == "shard" || mode == "hot") ? partition : index % targets.Length];
    string route = mode switch { "retry" => "unstable?key=demo", "shard" or "hot" => "partition/" + key + "?owner=" + partition, "io" => "io", "cpu" => "cpu", "sync" => "sync", _ => "work" };
    int maximumAttempts = mode == "retry" ? 2 : 1;
    for (int attempt = 1; attempt <= maximumAttempts; attempt++) {
        var begin = Stopwatch.GetTimestamp(); Interlocked.Increment(ref attempted);
        using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(target, route));
        using var response = await client.SendAsync(request); string body = await response.Content.ReadAsStringAsync();
        latencies.Add(Stopwatch.GetElapsedTime(begin).TotalMilliseconds);
        if ((int)response.StatusCode == 503 && attempt < maximumAttempts) { await Task.Delay(10 + index % 7); continue; }
        if (response.IsSuccessStatusCode) {
            Interlocked.Increment(ref successful); using var doc = JsonDocument.Parse(body);
            if (doc.RootElement.TryGetProperty("replica", out var name)) replicas.TryAdd(name.GetString()!, 0);
        } else Interlocked.Increment(ref rejected);
        break;
    }
}
var elapsed = Stopwatch.StartNew();
if (mode == "open") {
    using var slots = new SemaphoreSlim(concurrency, concurrency); var running = new List<Task>();
    for (int i = 0; i < count; i++) {
        double wait = i * interval - elapsed.Elapsed.TotalMilliseconds; if (wait > 0) await Task.Delay(TimeSpan.FromMilliseconds(wait));
        Interlocked.Increment(ref logical);
        if (!slots.Wait(0)) { Interlocked.Increment(ref skipped); continue; }
        int index = i; running.Add(Release(index));
    }
    await Task.WhenAll(running);
    async Task Release(int index) { try { await Invoke(index); } finally { slots.Release(); } }
} else {
    int next = -1;
    await Task.WhenAll(Enumerable.Range(0, concurrency).Select(async _ => {
        while (true) { int index = Interlocked.Increment(ref next); if (index >= count) return; Interlocked.Increment(ref logical); await Invoke(index); }
    }));
}
elapsed.Stop(); double[] sorted = latencies.OrderBy(x => x).ToArray();
double Percentile(double probability) => sorted.Length == 0 ? 0 : sorted[(int)Math.Ceiling(probability * sorted.Length) - 1];
Console.WriteLine(JsonSerializer.Serialize(new { mode, scheduled = logical, attempted, successful, rejected, skipped, concurrency, elapsedMs = elapsed.Elapsed.TotalMilliseconds, p50Ms = Percentile(.5), p95Ms = Percentile(.95), p99Ms = Percentile(.99), replicas = replicas.Keys.OrderBy(x => x).ToArray() }));
static int Partition(string key) { uint hash = 2166136261; foreach (byte b in System.Text.Encoding.UTF8.GetBytes(key)) hash = unchecked((hash ^ b) * 16777619); return (int)(hash % 2); }
Server/appsettings.jsonDownload file ↗
{
  "Lab": {
    "Replica": "one",
    "Workers": 2,
    "QueueCapacity": 3,
    "PoolSize": 2,
    "WorkMilliseconds": 20
  }
}

Expected result

Gated snapshot is2 active +3 queued =5 accepted; sixth admission is429. After opening the gate, accepted work completes. Gate endpoints exist only in Development.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

var waiting = new Queue<string>(); const int capacity = 2;
foreach (string item in new[] { "A", "B", "C" }) { bool accepted = waiting.Count < capacity; if (accepted) waiting.Enqueue(item); Console.WriteLine(accepted ? "accepted" : "rejected"); }
Console.WriteLine("next:" + waiting.Dequeue()); Console.WriteLine("queued:" + waiting.Count);
Open C# practice

C# practice output

accepted
accepted
rejected
next:A
queued:1
Try this
Count active work and queued work separately; a bounded queue does not make accepted work durable.

Further reading