Bifrost/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs
Meta Vens 436a02864f 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>
2026-10-11 19:16:30 +02:00

299 lines
10 KiB
C#

using Bifrost.Web.Features.Shared;
namespace Bifrost.Web.Tests;
public sealed class FixedWindowBudgetTests
{
private static readonly TimeSpan Window = TimeSpan.FromSeconds(60);
private const int Cap = 1000;
[Fact]
public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left()
{
var clock = new Clock();
var budget = new FixedWindowBudget(2, Window, Cap, clock);
Assert.True(Try(budget, "a", out _));
clock.Advance(TimeSpan.FromSeconds(10));
Assert.True(Try(budget, "a", out _));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(TimeSpan.FromSeconds(50), wait);
Assert.True(Try(budget, "b", out _));
}
[Fact]
public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Assert.True(Try(budget, "a", out _));
clock.Advance(Window - TimeSpan.FromTicks(1));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(TimeSpan.FromTicks(1), wait);
clock.Advance(TimeSpan.FromTicks(1));
Assert.True(Try(budget, "a", out _));
Assert.False(Try(budget, "a", out wait));
Assert.Equal(Window, wait);
}
[Fact]
public void A_denied_attempt_does_not_extend_the_window()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
for (var i = 0; i < 5; i++)
{
clock.Advance(TimeSpan.FromSeconds(10));
Assert.False(Try(budget, "a", out _));
}
clock.Advance(TimeSpan.FromSeconds(10));
Assert.True(Try(budget, "a", out _));
}
[Fact]
public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
clock.Advance(TimeSpan.FromSeconds(30));
Try(budget, "b", out _);
clock.Advance(TimeSpan.FromSeconds(30));
// At 60 s a has ended and b has 30 s left.
Try(budget, "c", out _);
Assert.Equal(2, budget.TrackedKeys);
Assert.False(Try(budget, "b", out var wait));
Assert.Equal(TimeSpan.FromSeconds(30), wait);
clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1));
Assert.False(Try(budget, "b", out _));
clock.Advance(TimeSpan.FromTicks(1));
Assert.True(Try(budget, "b", out _));
}
[Fact]
public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
clock.Advance(TimeSpan.FromSeconds(10));
clock.StepWall(-TimeSpan.FromHours(1));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(TimeSpan.FromSeconds(50), wait);
clock.Advance(TimeSpan.FromSeconds(50));
Assert.True(Try(budget, "a", out _));
Assert.Equal(1, budget.TrackedKeys);
}
[Fact]
public void A_wall_clock_step_forward_does_not_reset_a_window()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
clock.StepWall(TimeSpan.FromHours(1));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(Window, wait);
}
[Fact]
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);
Assert.True(Try(budget, "a", out _));
clock.Advance(TimeSpan.FromSeconds(20));
Assert.True(Try(budget, "b", out _));
clock.Advance(TimeSpan.FromSeconds(10));
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);
clock.Advance(TimeSpan.FromSeconds(30));
Assert.True(Try(budget, "c", out _));
Assert.Equal(2, budget.TrackedKeys);
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(Try(budget, "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]
public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
for (var i = 0; i < 100; i++)
{
Try(budget, "k" + i, out _);
}
Assert.Equal(100, budget.TrackedKeys);
clock.Advance(Window);
Try(budget, "fresh", out _);
Assert.Equal(1, budget.TrackedKeys);
}
[Theory]
[InlineData(0, 60)]
[InlineData(-1, 60)]
[InlineData(1, 0)]
[InlineData(1, -5)]
public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds)
{
Assert.ThrowsAny<ArgumentException>(
() => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock()));
}
[Fact]
public void A_cap_below_one_is_refused()
{
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
{
private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
private long _ticks = 5_000_000;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override DateTimeOffset GetUtcNow() => _wall;
public override long GetTimestamp() => _ticks;
public void Advance(TimeSpan by)
{
_wall += by;
_ticks += by.Ticks;
}
public void StepWall(TimeSpan by) => _wall += by;
}
}