diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index c5e5701..0e60f7d 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -36,9 +36,10 @@ public sealed class ConceptVoteOptions public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1); - // The most partitions each budget holds at once. At the cap a partition not yet held is refused with a 429 until - // the oldest window ends; held partitions are never evicted, so a rotating caller cannot escape a spent budget. - // 100000 partitions is a few tens of MB; a real audience is far below it. + // The most partitions each budget table holds at once (cast by account, cast by network prefix, mint). At the cap a + // partition not yet held evicts the least recently used one that still has permits left; a partition that has + // spent its permits is never evicted, so rotating sources can at most win back a forgotten partition's partial + // budget. 100000 partitions is a few tens of MB; a real audience is far below it. public int MaxTrackedKeys { get; init; } = 100_000; // The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs index d662b8a..fcfe361 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs @@ -6,23 +6,24 @@ using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.ConceptVotes; // The one owner of the vote budgets. Cast is spent by every cast and retract; mint is spent additionally by a cast that -// is about to mint a device cookie. A denial returns how long to wait and writes nothing. The partition is the account -// when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the forwarded-headers -// middleware left on the connection. No header and no client input ever becomes a key. +// is about to mint a device cookie. A denial says why and how long to wait, and writes nothing. The partition is the +// account when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the +// forwarded-headers middleware left on the connection. No header and no client input ever becomes a key. Accounts +// have a table of their own, so a flood of anonymous sources cannot crowd a signed-in voter out of it. public sealed class ConceptVoteRateLimits(IOptions options, TimeProvider clock) { private readonly ConceptVoteOptions opts = options.Value; - private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); + private readonly FixedWindowBudget castAccounts = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); + private readonly FixedWindowBudget castNetworks = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, options.Value.MaxTrackedKeys, clock); - public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) => - cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait) - ? null - : wait; + public BudgetDenial? DenyCast(IPAddress? client, VoterIdentity? known) => + known?.UserAccountId is { } account + ? castAccounts.Spend(account.ToString("N")) + : castNetworks.Spend(NetKey(client)); - public TimeSpan? DenyMint(IPAddress? client) => - mint.TryAcquire(NetKey(client), out var wait) ? null : wait; + public BudgetDenial? DenyMint(IPAddress? client) => mint.Spend(NetKey(client)); private string NetKey(IPAddress? client) => - "net:" + (client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength)); + client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength); } diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs index c002f03..b6c9083 100644 --- a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -1,11 +1,18 @@ namespace Bifrost.Web.Features.Shared; +// Why a permit was refused and how long until the answer can change. TableFull is not the caller's own spending: the +// table holds only spent windows and has no room for a key it does not know. +public readonly record struct BudgetDenial(TimeSpan RetryAfter, bool TableFull); + // The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected // clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a -// window. Windows all have one length, so they end in the order they began: a queue of window starts expires them from -// the front and the table never holds a key past its window. The table holds at most maxKeys keys; a new key arriving -// at the cap is denied until the oldest window ends, with the true wait, so a rotating caller cannot evict a spent key -// to escape its budget and the refusal is a 429 that says when to retry, never a silent drop. +// window. Windows all have one length, so they end in the order they began: a list of entries in start order expires +// them from the front and the table never holds a key past its window. +// The table holds at most maxKeys keys. A new key arriving at the cap evicts the least recently used key that still +// has permits left (nginx limit_req_zone does the same); a key that has spent all its permits is never evicted, so a +// rotating caller can at most win back a forgotten key's partial budget and never a spent one, and nobody fresh is +// refused because the table is full. Only when every held key is spent is a new key refused, with the true wait. +// Every step is O(1): two linked lists, one by window start and one by last use among keys with permits left. // System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a // test cannot move. public sealed class FixedWindowBudget @@ -16,7 +23,8 @@ public sealed class FixedWindowBudget private readonly TimeProvider clock; private readonly long origin; private readonly Dictionary entries = new(StringComparer.Ordinal); - private readonly Queue byStart = new(); + private readonly LinkedList byStart = new(); + private readonly LinkedList byUse = new(); private readonly object gate = new(); public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock) @@ -43,48 +51,81 @@ public sealed class FixedWindowBudget } } - // Takes one permit for the key. When none is left, or the table is full and the key is new, returns false and how - // long until a window ends and the answer can change. - public bool TryAcquire(string key, out TimeSpan retryAfter) + // Takes one permit for the key and returns null, or returns why it could not. + public BudgetDenial? Spend(string key) { lock (gate) { var now = clock.GetElapsedTime(origin); - while (byStart.TryPeek(out var oldest) && entries[oldest].Start + window <= now) + while (byStart.First is { Value: var oldest } && oldest.Start + window <= now) { - entries.Remove(byStart.Dequeue()); + Remove(oldest); } if (entries.TryGetValue(key, out var entry)) { if (entry.Count >= permits) { - retryAfter = entry.Start + window - now; - return false; + return new BudgetDenial(entry.Start + window - now, false); } entry.Count++; - retryAfter = TimeSpan.Zero; - return true; + if (entry.Count >= permits) + { + byUse.Remove(entry.UseNode!); + entry.UseNode = null; + } + else + { + byUse.Remove(entry.UseNode!); + byUse.AddLast(entry.UseNode!); + } + + return null; } if (entries.Count >= maxKeys) { - retryAfter = entries[byStart.Peek()].Start + window - now; - return false; + if (byUse.First is not { Value: var forgotten }) + { + return new BudgetDenial(byStart.First!.Value.Start + window - now, true); + } + + Remove(forgotten); } - entries[key] = new Entry(now); - byStart.Enqueue(key); - retryAfter = TimeSpan.Zero; - return true; + var added = new Entry(key, now); + entries[key] = added; + added.StartNode = byStart.AddLast(added); + if (added.Count < permits) + { + added.UseNode = byUse.AddLast(added); + } + + return null; } } - private sealed class Entry(TimeSpan start) + private void Remove(Entry entry) { + entries.Remove(entry.Key); + byStart.Remove(entry.StartNode!); + if (entry.UseNode is not null) + { + byUse.Remove(entry.UseNode); + } + } + + private sealed class Entry(string key, TimeSpan start) + { + public string Key { get; } = key; + public TimeSpan Start { get; } = start; public int Count { get; set; } = 1; + + public LinkedListNode? StartNode { get; set; } + + public LinkedListNode? UseNode { get; set; } } } diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 2e11b1f..f843f0a 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -77,16 +77,16 @@ public sealed class VoteModel( { var actor = await actorAccessor.GetAsync(cancellationToken); var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) - { - return throttled; - } - if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) + { + return throttled; + } + await appraisals.RetractAsync(collection, key, voter, cancellationToken); return RedirectToPage(new { collection, key }); } @@ -100,16 +100,16 @@ public sealed class VoteModel( var actor = await actorAccessor.GetAsync(cancellationToken); var client = HttpContext.Connection.RemoteIpAddress; var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) - { - return throttled; - } - if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } + if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) + { + return throttled; + } + if (voter is null) { if (Throttled(limits.DenyMint(client), MintRefusal) is { } mintThrottled) @@ -133,20 +133,25 @@ public sealed class VoteModel( VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); // A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and - // repeats nothing of the request. A refused new device is told it can sign in, which has a budget of its own. + // repeats nothing of the request. The subject is checked before any budget is spent, so a request for a concept that + // does not exist costs the sender nothing and the table nothing. A refused new device is told it can sign in, which + // has a budget of its own. A table full of spent windows is not the visitor's doing and says so. private const string CastRefusal = "You are voting too fast. Please wait a little before voting again."; private const string MintRefusal = "Too many new voters have voted from your network recently. You can sign in to vote now, or try again later."; - private IActionResult? Throttled(TimeSpan? retryAfter, string refusal) + private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while."; + + private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal) { - if (retryAfter is not { } wait) + if (denial is not { } refused) { return null; } - Response.Headers.RetryAfter = ((int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + var refusal = refused.TableFull ? BusyRefusal : spentRefusal; + Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); return new ContentResult { StatusCode = StatusCodes.Status429TooManyRequests, diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index 4feac7c..30ab093 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -1,6 +1,7 @@ using System.Net; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -270,18 +271,144 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture string.Join(",", h.Value)))); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); + } + + [Fact] + public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device() + { + var collection = await NewSubject(); + var sender = new Client(_factory, "203.0.113.140"); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + for (var i = 0; i < 4; i++) + { + using var missing = await sender.Post("c" + Guid.NewGuid().ToString("N"), handler); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + Assert.Null(SetCookie(missing)); + } + } + + for (var i = 0; i < 3; i++) + { + using var ok = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); } [Fact] @@ -313,6 +440,13 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture Capped(int maxTrackedKeys) => + _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:MaxTrackedKeys"] = maxTrackedKeys.ToString(System.Globalization.CultureInfo.InvariantCulture) + }))); + private static string? RetryAfter(HttpResponseMessage response) => response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null; diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs index 5d1c3b7..e2fedb2 100644 --- a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -13,13 +13,13 @@ public sealed class FixedWindowBudgetTests var clock = new Clock(); var budget = new FixedWindowBudget(2, Window, Cap, clock); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); clock.Advance(TimeSpan.FromSeconds(10)); - Assert.True(budget.TryAcquire("a", out _)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(TimeSpan.FromSeconds(50), wait); - Assert.True(budget.TryAcquire("b", out _)); + Assert.True(Try(budget, "b", out _)); } [Fact] @@ -27,15 +27,15 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); clock.Advance(Window - TimeSpan.FromTicks(1)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(TimeSpan.FromTicks(1), wait); clock.Advance(TimeSpan.FromTicks(1)); - Assert.True(budget.TryAcquire("a", out _)); - Assert.False(budget.TryAcquire("a", out wait)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out wait)); Assert.Equal(Window, wait); } @@ -44,15 +44,15 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); for (var i = 0; i < 5; i++) { clock.Advance(TimeSpan.FromSeconds(10)); - Assert.False(budget.TryAcquire("a", out _)); + Assert.False(Try(budget, "a", out _)); } clock.Advance(TimeSpan.FromSeconds(10)); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); } [Fact] @@ -60,21 +60,21 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); clock.Advance(TimeSpan.FromSeconds(30)); - budget.TryAcquire("b", out _); + Try(budget, "b", out _); clock.Advance(TimeSpan.FromSeconds(30)); // At 60 s a has ended and b has 30 s left. - budget.TryAcquire("c", out _); + Try(budget, "c", out _); Assert.Equal(2, budget.TrackedKeys); - Assert.False(budget.TryAcquire("b", out var wait)); + Assert.False(Try(budget, "b", out var wait)); Assert.Equal(TimeSpan.FromSeconds(30), wait); clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1)); - Assert.False(budget.TryAcquire("b", out _)); + Assert.False(Try(budget, "b", out _)); clock.Advance(TimeSpan.FromTicks(1)); - Assert.True(budget.TryAcquire("b", out _)); + Assert.True(Try(budget, "b", out _)); } [Fact] @@ -82,14 +82,14 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); clock.Advance(TimeSpan.FromSeconds(10)); clock.StepWall(-TimeSpan.FromHours(1)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(TimeSpan.FromSeconds(50), wait); clock.Advance(TimeSpan.FromSeconds(50)); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); Assert.Equal(1, budget.TrackedKeys); } @@ -98,52 +98,141 @@ public sealed class FixedWindowBudgetTests { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, Cap, clock); - budget.TryAcquire("a", out _); + Try(budget, "a", out _); clock.StepWall(TimeSpan.FromHours(1)); - Assert.False(budget.TryAcquire("a", out var wait)); + Assert.False(Try(budget, "a", out var wait)); Assert.Equal(Window, wait); } [Fact] - public void A_new_key_at_the_cap_is_denied_until_the_oldest_window_ends_and_a_held_key_is_never_evicted() + public void A_new_key_at_the_cap_evicts_the_least_recently_used_key_with_permits_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(3, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "a", out _)); + + // b was used least recently, though a began first: c takes b's place and a keeps its count. + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void A_forgotten_key_regains_only_what_it_had_not_spent_and_a_spent_key_regains_nothing() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "partial", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + // Rotating callers evict partial, never spent. + for (var i = 0; i < 10; i++) + { + Assert.True(Try(budget, "rot" + i, out _)); + } + + Assert.False(Try(budget, "spent", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.Equal(2, budget.TrackedKeys); + + // partial came back whole (2 permits), which is all a rotating caller can win: its own forgotten budget. + Assert.True(Try(budget, "partial", out _)); + } + + [Fact] + public void A_key_that_spends_its_last_permit_leaves_the_evictable_set() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + + // a is spent and b is not: the newcomer evicts b although a was used less recently. + Assert.True(Try(budget, "c", out _)); + Assert.False(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_table_full_of_spent_keys_refuses_a_new_key_as_table_full_with_the_wait_for_the_oldest_window() { var clock = new Clock(); var budget = new FixedWindowBudget(1, Window, 2, clock); - budget.TryAcquire("a", out _); + Assert.True(Try(budget, "a", out _)); clock.Advance(TimeSpan.FromSeconds(20)); - budget.TryAcquire("b", out _); + Assert.True(Try(budget, "b", out _)); clock.Advance(TimeSpan.FromSeconds(10)); - Assert.False(budget.TryAcquire("c", out var wait)); - Assert.Equal(TimeSpan.FromSeconds(30), wait); + var denial = budget.Spend("c"); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), true), denial); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), false), budget.Spend("a")); Assert.Equal(2, budget.TrackedKeys); - // Rotating new keys cannot push a spent key out to get its budget back. - for (var i = 0; i < 10; i++) - { - Assert.False(budget.TryAcquire("rot" + i, out _)); - } - - Assert.False(budget.TryAcquire("a", out wait)); - Assert.Equal(TimeSpan.FromSeconds(30), wait); - clock.Advance(TimeSpan.FromSeconds(30)); - Assert.True(budget.TryAcquire("c", out _)); + Assert.True(Try(budget, "c", out _)); Assert.Equal(2, budget.TrackedKeys); - Assert.False(budget.TryAcquire("b", out wait)); + Assert.False(Try(budget, "b", out var wait)); Assert.Equal(TimeSpan.FromSeconds(20), wait); } + [Fact] + public void An_evicted_key_leaves_the_expiry_list_so_its_old_window_end_cannot_remove_the_key_that_took_its_name() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "c", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "a", out _); + + // The first a began at 0 and ended at 60; the a that returned at 3 is still inside its window. + clock.Advance(TimeSpan.FromSeconds(57)); + Try(budget, "c", out _); + + Assert.Equal(2, budget.TrackedKeys); + } + [Fact] public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full() { var clock = new Clock(); var budget = new FixedWindowBudget(2, Window, 1, clock); - Assert.True(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); - Assert.True(budget.TryAcquire("a", out _)); - Assert.False(budget.TryAcquire("a", out _)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void Eviction_work_does_not_scan_the_table() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 50_000, clock); + for (var i = 0; i < 50_000; i++) + { + budget.Spend("k" + i); + } + + var started = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < 200_000; i++) + { + budget.Spend("n" + i); + } + + Assert.Equal(50_000, budget.TrackedKeys); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"200000 evicting inserts took {started.Elapsed}"); } [Fact] @@ -153,12 +242,12 @@ public sealed class FixedWindowBudgetTests var budget = new FixedWindowBudget(1, Window, Cap, clock); for (var i = 0; i < 100; i++) { - budget.TryAcquire("k" + i, out _); + Try(budget, "k" + i, out _); } Assert.Equal(100, budget.TrackedKeys); clock.Advance(Window); - budget.TryAcquire("fresh", out _); + Try(budget, "fresh", out _); Assert.Equal(1, budget.TrackedKeys); } @@ -180,6 +269,13 @@ public sealed class FixedWindowBudgetTests Assert.Throws(() => new FixedWindowBudget(1, Window, 0, new Clock())); } + private static bool Try(FixedWindowBudget budget, string key, out TimeSpan wait) + { + var denial = budget.Spend(key); + wait = denial?.RetryAfter ?? TimeSpan.Zero; + return denial is null; + } + // Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would. private sealed class Clock : TimeProvider {