Rate Policy

This commit is contained in:
2026-07-13 17:15:00 +03:00
parent 31bda58460
commit 4f6d2d0801
46 changed files with 3142 additions and 468 deletions
@@ -0,0 +1,140 @@
using Esiur.Security.Permissions;
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
namespace Esiur.Security.RateLimiting;
/// <summary>
/// Per-connection, per-member token-bucket policy with bounded delayed reservations.
/// </summary>
public sealed class BurstRatePolicy : RatePolicy
{
sealed class Bucket
{
public readonly object Sync = new object();
public double Tokens;
public long LastTimestamp;
public int Queued;
public Bucket(double tokens, long timestamp)
{
Tokens = tokens;
LastTimestamp = timestamp;
}
}
sealed class ConnectionBuckets
{
public readonly ConcurrentDictionary<string, Bucket> Values
= new ConcurrentDictionary<string, Bucket>(StringComparer.Ordinal);
}
readonly ConditionalWeakTable<object, ConnectionBuckets> _connections
= new ConditionalWeakTable<object, ConnectionBuckets>();
/// <summary>
/// Number of permits replenished during each period.
/// </summary>
public int PermitLimit { get; set; } = 100;
/// <summary>
/// Replenishment period for <see cref="PermitLimit"/>.
/// </summary>
public TimeSpan Period { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Additional permits available for an immediate burst.
/// </summary>
public int BurstLimit { get; set; }
/// <summary>
/// Maximum number of delayed reservations per connection and member.
/// Further requests are denied until queue positions become available.
/// </summary>
public int QueueLimit { get; set; }
public BurstRatePolicy()
{
}
public BurstRatePolicy(string name) : base(name)
{
}
public override Ruling Applicable(RateControlContext context)
{
Validate();
var capacity = checked(PermitLimit + BurstLimit);
var now = Stopwatch.GetTimestamp();
var key = $"{(int)context.Action}:{context.Member.Fullname}";
var buckets = _connections.GetValue(context.Connection, _ => new ConnectionBuckets());
var bucket = buckets.Values.GetOrAdd(key, _ => new Bucket(capacity, now));
TimeSpan delay;
lock (bucket.Sync)
{
Replenish(bucket, now, capacity);
if (bucket.Tokens >= 1d)
{
bucket.Tokens -= 1d;
return Ruling.Allowed;
}
if (QueueLimit == 0 || bucket.Queued >= QueueLimit)
return Ruling.Denied;
bucket.Tokens -= 1d;
bucket.Queued++;
var seconds = -bucket.Tokens * Period.TotalSeconds / PermitLimit;
delay = TimeSpan.FromSeconds(Math.Max(0d, seconds));
context.Delay = delay;
}
_ = ReleaseQueuePositionAsync(bucket, delay);
return Ruling.Allowed;
}
void Validate()
{
if (PermitLimit <= 0)
throw new InvalidOperationException("PermitLimit must be greater than zero.");
if (Period <= TimeSpan.Zero)
throw new InvalidOperationException("Period must be greater than zero.");
if (BurstLimit < 0)
throw new InvalidOperationException("BurstLimit cannot be negative.");
if (QueueLimit < 0)
throw new InvalidOperationException("QueueLimit cannot be negative.");
}
void Replenish(Bucket bucket, long now, int capacity)
{
var elapsedTicks = now - bucket.LastTimestamp;
if (elapsedTicks <= 0)
return;
var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency;
var replenished = elapsedSeconds * PermitLimit / Period.TotalSeconds;
bucket.Tokens = Math.Min(capacity, bucket.Tokens + replenished);
bucket.LastTimestamp = now;
}
static async Task ReleaseQueuePositionAsync(Bucket bucket, TimeSpan delay)
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay).ConfigureAwait(false);
lock (bucket.Sync)
{
if (bucket.Queued > 0)
bucket.Queued--;
}
}
}
@@ -0,0 +1,72 @@
using Esiur.Data.Types;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Permissions;
using System;
namespace Esiur.Security.RateLimiting;
/// <summary>
/// Describes the request currently being evaluated by a rate policy.
/// </summary>
public sealed class RateControlContext
{
public Warehouse Warehouse { get; }
public EpConnection Connection { get; }
public Session Session { get; }
public IResource? Resource { get; }
public MemberDef Member { get; }
public ActionType Action { get; }
/// <summary>
/// Optional delay assigned by a policy to an allowed queued request.
/// </summary>
public TimeSpan Delay { get; set; }
public RateControlContext(
Warehouse warehouse,
EpConnection connection,
Session session,
IResource? resource,
MemberDef member,
ActionType action)
{
Warehouse = warehouse ?? throw new ArgumentNullException(nameof(warehouse));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
Session = session ?? throw new ArgumentNullException(nameof(session));
Resource = resource;
Member = member ?? throw new ArgumentNullException(nameof(member));
Action = action;
}
}
/// <summary>
/// Base class for named Warehouse rate-control policies.
/// </summary>
public abstract class RatePolicy
{
/// <summary>
/// Name referenced by <see cref="RateControlAttribute"/>.
/// </summary>
public string Name { get; set; } = string.Empty;
protected RatePolicy()
{
}
protected RatePolicy(string name)
{
Name = name;
}
/// <summary>
/// Evaluates a context-free policy. Override this for simple policies.
/// </summary>
public virtual Ruling Applicable() => Ruling.DontCare;
/// <summary>
/// Evaluates a request. Context-aware policies should override this overload.
/// </summary>
public virtual Ruling Applicable(RateControlContext context) => Applicable();
}