Vote budgets: at the cap a new key evicts the least recently used key with permits left

Ruling budget-table-evict-oldest. FixedWindowBudget keeps two linked lists (window start, last use among keys
with permits left) so eviction and expiry are O(1); a spent key is never evicted, and a table full of spent keys
refuses a new key as TableFull with the true wait. Accounts get their own cast table. The vote page checks the
subject before spending any key, and the refusal names its reason: cast budget, mint budget, or a busy table.
Not verified by a build yet at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 19:16:30 +02:00
commit 436a02864f
6 changed files with 374 additions and 96 deletions

View file

@ -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<ConceptVoteRateLim
}
[Fact]
public void Both_budgets_hold_at_most_the_configured_number_of_partitions_and_refuse_a_new_one_at_the_cap()
public void Every_table_holds_at_most_the_configured_keys_and_accounts_have_a_table_of_their_own()
{
var clock = new TestClock();
var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits(
Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1 }),
clock);
Microsoft.Extensions.Options.Options.Create(
new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1, CastPermits = 1, MintPermits = 1 }),
new TestClock());
var account = new VoterIdentity("account:test", VoterClass.Account, Guid.NewGuid());
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null));
Assert.NotNull(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null));
Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null)!.Value.TableFull);
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.2"), account));
Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1")));
Assert.NotNull(limits.DenyMint(IPAddress.Parse("198.51.100.2")));
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null));
Assert.True(limits.DenyMint(IPAddress.Parse("198.51.100.2"))!.Value.TableFull);
}
[Fact]
public async Task Filling_the_table_with_unspent_keys_locks_out_neither_a_fresh_visitor_nor_a_fresh_account()
{
var factory = Capped(4);
var collection = await NewSubject();
for (var i = 0; i < 4; i++)
{
using var filler = await new Client(factory, $"203.0.113.{80 + i}").Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, filler.StatusCode);
}
using var visitor = await new Client(factory, "203.0.113.90").Post(collection, "Up");
using var account = await new Client(factory, "203.0.113.91") { Account = ("7101", "fresh-account") }
.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, visitor.StatusCode);
Assert.NotNull(SetCookie(visitor));
Assert.Equal(HttpStatusCode.Redirect, account.StatusCode);
}
[Fact]
public async Task A_spent_key_survives_eviction_pressure_and_stays_refused_while_rotating_callers_succeed()
{
var factory = Capped(4);
var collection = await NewSubject();
var spender = new Client(factory, "203.0.113.100");
for (var i = 0; i < 3; i++)
{
using var ok = await spender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
for (var i = 0; i < 12; i++)
{
using var rotating = await new Client(factory, $"203.0.114.{i + 1}").Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, rotating.StatusCode);
}
using var denied = await spender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
Assert.Contains("too fast", await denied.Content.ReadAsStringAsync());
}
[Fact]
public async Task A_rotating_caller_wins_back_only_a_forgotten_keys_unspent_budget()
{
var factory = Capped(2);
var collection = await NewSubject();
var forgotten = new Client(factory, "203.0.113.110");
await forgotten.Post(collection, "Up");
await forgotten.Post(collection, "Up");
var recent = new Client(factory, "203.0.113.111");
await recent.Post(collection, "Up");
await new Client(factory, "203.0.113.112").Post(collection, "Up");
// forgotten was least recently used and had one permit left: it was evicted, so it has all three again, no more.
for (var i = 0; i < 3; i++)
{
using var ok = await forgotten.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
using var denied = await forgotten.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
}
[Fact]
public async Task A_table_full_of_spent_keys_refuses_a_new_visitor_with_its_true_reason_and_leaves_accounts_alone()
{
var factory = Capped(4);
var collection = await NewSubject();
for (var i = 0; i < 4; i++)
{
var spender = new Client(factory, $"203.0.113.{120 + i}");
for (var cast = 0; cast < 3; cast++)
{
using var ok = await spender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
}
var rowsBefore = (await Rows(collection)).Count;
using var visitor = await new Client(factory, "203.0.113.130").Post(collection, "Up");
using var account = await new Client(factory, "203.0.113.131") { Account = ("7102", "spared-account") }
.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, visitor.StatusCode);
Assert.NotNull(RetryAfter(visitor));
Assert.Null(SetCookie(visitor));
var body = await visitor.Content.ReadAsStringAsync();
Assert.Contains("busy", body);
Assert.DoesNotContain("too fast", body);
Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("203.0.113", body + string.Join("
", visitor.Headers.Select(h => 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<ConceptVoteRateLim
Assert.Equal(HttpStatusCode.OK, export.StatusCode);
}
private WebApplicationFactory<Program> Capped(int maxTrackedKeys) =>
_factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["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;

View file

@ -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<ArgumentOutOfRangeException>(() => 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
{