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:
parent
8f57dc89f0
commit
436a02864f
6 changed files with 374 additions and 96 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ConceptVoteOptions> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Entry> entries = new(StringComparer.Ordinal);
|
||||
private readonly Queue<string> byStart = new();
|
||||
private readonly LinkedList<Entry> byStart = new();
|
||||
private readonly LinkedList<Entry> 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<Entry>? StartNode { get; set; }
|
||||
|
||||
public LinkedListNode<Entry>? UseNode { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue