Skip to content

Commit b86557f

Browse files
committed
Implement GRCA (redis/redis#14826)
1 parent 600119e commit b86557f

22 files changed

Lines changed: 762 additions & 17 deletions

File tree

src/RESPite/Messages/RespReader.cs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,30 +1852,54 @@ public readonly decimal ReadDecimal()
18521852
}
18531853

18541854
/// <summary>
1855-
/// Read the current element as a <see cref="bool"/> value.
1855+
/// Try to read the current element as a <see cref="bool"/> value.
18561856
/// </summary>
1857-
public readonly bool ReadBoolean()
1857+
/// <param name="value">The parsed boolean value if successful.</param>
1858+
/// <returns>True if the value was successfully parsed; false otherwise.</returns>
1859+
public readonly bool TryReadBoolean(out bool value)
18581860
{
18591861
var span = Buffer(stackalloc byte[2]);
18601862
switch (span.Length)
18611863
{
18621864
case 1:
18631865
switch (span[0])
18641866
{
1865-
case (byte)'0' when Prefix == RespPrefix.Integer: return false;
1866-
case (byte)'1' when Prefix == RespPrefix.Integer: return true;
1867-
case (byte)'f' when Prefix == RespPrefix.Boolean: return false;
1868-
case (byte)'t' when Prefix == RespPrefix.Boolean: return true;
1867+
case (byte)'0' when Prefix == RespPrefix.Integer:
1868+
value = false;
1869+
return true;
1870+
case (byte)'1' when Prefix == RespPrefix.Integer:
1871+
value = true;
1872+
return true;
1873+
case (byte)'f' when Prefix == RespPrefix.Boolean:
1874+
value = false;
1875+
return true;
1876+
case (byte)'t' when Prefix == RespPrefix.Boolean:
1877+
value = true;
1878+
return true;
18691879
}
18701880

18711881
break;
1872-
case 2 when Prefix == RespPrefix.SimpleString && IsOK(): return true;
1882+
case 2 when Prefix == RespPrefix.SimpleString && IsOK():
1883+
value = true;
1884+
return true;
18731885
}
18741886

1875-
ThrowFormatException();
1887+
value = false;
18761888
return false;
18771889
}
18781890

1891+
/// <summary>
1892+
/// Read the current element as a <see cref="bool"/> value.
1893+
/// </summary>
1894+
public readonly bool ReadBoolean()
1895+
{
1896+
if (!TryReadBoolean(out var value))
1897+
{
1898+
ThrowFormatException();
1899+
}
1900+
return value;
1901+
}
1902+
18791903
/// <summary>
18801904
/// Parse a scalar value as an enum of type <typeparamref name="T"/>.
18811905
/// </summary>

src/RESPite/PublicAPI/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
[SER004]RESPite.Messages.RespReader.ProtocolBytesRemaining.get -> long
156156
[SER004]RESPite.Messages.RespReader.ReadArray<TResult>(RESPite.Messages.RespReader.Projection<TResult>! projection, bool scalar = false) -> TResult[]?
157157
[SER004]RESPite.Messages.RespReader.ReadBoolean() -> bool
158+
[SER004]RESPite.Messages.RespReader.TryReadBoolean(out bool value) -> bool
158159
[SER004]RESPite.Messages.RespReader.ReadByteArray() -> byte[]?
159160
[SER004]RESPite.Messages.RespReader.ReadDecimal() -> decimal
160161
[SER004]RESPite.Messages.RespReader.ReadDouble() -> double

src/StackExchange.Redis/Enums/RedisCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ internal enum RedisCommand
6060
GEOSEARCH,
6161
GEOSEARCHSTORE,
6262

63+
GCRA,
6364
GET,
6465
GETBIT,
6566
GETDEL,
@@ -323,6 +324,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
323324
case RedisCommand.EXPIREAT:
324325
case RedisCommand.FLUSHALL:
325326
case RedisCommand.FLUSHDB:
327+
case RedisCommand.GCRA:
326328
case RedisCommand.GEOSEARCHSTORE:
327329
case RedisCommand.GETDEL:
328330
case RedisCommand.GETEX:

src/StackExchange.Redis/ExtensionMethods.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Security.Authentication;
88
using System.Security.Cryptography.X509Certificates;
99
using System.Text;
10+
using System.Threading;
1011
using System.Threading.Tasks;
1112
using Pipelines.Sockets.Unofficial.Arenas;
1213

@@ -337,5 +338,69 @@ internal static int VectorSafeIndexOfCRLF(this ReadOnlySpan<byte> span)
337338
[MethodImpl(MethodImplOptions.AggressiveInlining)]
338339
internal static TTo[]? ToArray<TTo, TState>(in this RawResult result, Projection<RawResult, TState, TTo> selector, in TState state)
339340
=> result.IsNull ? null : result.GetItems().ToArray(selector, in state);
341+
342+
/// <summary>
343+
/// Attempts to acquire a GCRA rate limit token, retrying with delays if rate limited.
344+
/// </summary>
345+
/// <param name="database">The database instance.</param>
346+
/// <param name="key">The key for the rate limiter.</param>
347+
/// <param name="maxBurst">The maximum burst size.</param>
348+
/// <param name="requestsPerPeriod">The number of requests allowed per period.</param>
349+
/// <param name="allow">The maximum time to wait for a successful acquisition.</param>
350+
/// <param name="periodSeconds">The period in seconds (default: 1.0).</param>
351+
/// <param name="count">The number of tokens to acquire (default: 1).</param>
352+
/// <param name="flags">The command flags to use.</param>
353+
/// <param name="cancellationToken">The cancellation token.</param>
354+
/// <returns>True if the token was acquired within the allowed time; false otherwise.</returns>
355+
public static async ValueTask<bool> TryAcquireGcraAsync(
356+
this IDatabaseAsync database,
357+
RedisKey key,
358+
int maxBurst,
359+
int requestsPerPeriod,
360+
TimeSpan allow,
361+
double periodSeconds = 1.0,
362+
int count = 1,
363+
CommandFlags flags = CommandFlags.None,
364+
CancellationToken cancellationToken = default)
365+
{
366+
cancellationToken.ThrowIfCancellationRequested();
367+
368+
var startTime = DateTime.UtcNow;
369+
var allowMilliseconds = allow.TotalMilliseconds;
370+
371+
while (true)
372+
{
373+
var result = await database.StringGcraRateLimitAsync(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags).ConfigureAwait(false);
374+
375+
if (!result.Limited)
376+
{
377+
return true;
378+
}
379+
380+
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
381+
var remaining = allowMilliseconds - elapsed;
382+
383+
if (remaining <= 0)
384+
{
385+
return false;
386+
}
387+
388+
var delaySeconds = result.RetryAfterSeconds;
389+
if (delaySeconds <= 0)
390+
{
391+
// Shouldn't happen when Limited is true, but handle defensively
392+
return false;
393+
}
394+
395+
var delayMilliseconds = delaySeconds * 1000.0;
396+
if (delayMilliseconds > remaining)
397+
{
398+
// Not enough time left to wait for retry
399+
return false;
400+
}
401+
402+
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken).ConfigureAwait(false);
403+
}
404+
}
340405
}
341406
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
namespace StackExchange.Redis;
2+
3+
internal partial class RedisDatabase
4+
{
5+
internal sealed class GcraMessage(
6+
int database,
7+
CommandFlags flags,
8+
RedisKey key,
9+
int maxBurst,
10+
int requestsPerPeriod,
11+
double periodSeconds,
12+
int count) : Message(database, flags, RedisCommand.GCRA)
13+
{
14+
protected override void WriteImpl(PhysicalConnection connection)
15+
{
16+
// GCRA key max_burst requests_per_period period [NUM_REQUESTS count]
17+
connection.WriteHeader(Command, ArgCount);
18+
connection.WriteBulkString(key);
19+
connection.WriteBulkString(maxBurst);
20+
connection.WriteBulkString(requestsPerPeriod);
21+
connection.WriteBulkString(periodSeconds);
22+
23+
if (count != 1)
24+
{
25+
connection.WriteBulkString("NUM_REQUESTS"u8);
26+
connection.WriteBulkString(count);
27+
}
28+
}
29+
30+
public override int ArgCount
31+
{
32+
get
33+
{
34+
int argCount = 4; // key, max_burst, requests_per_period, period
35+
if (count != 1) argCount += 2; // NUM_REQUESTS, count
36+
return argCount;
37+
}
38+
}
39+
}
40+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
namespace StackExchange.Redis;
2+
3+
/// <summary>
4+
/// Represents the result of a GCRA (Generic Cell Rate Algorithm) rate limit check.
5+
/// </summary>
6+
public readonly partial struct GcraRateLimitResult
7+
{
8+
/// <summary>
9+
/// Indicates whether the request was rate limited (true) or allowed (false).
10+
/// </summary>
11+
public bool Limited { get; }
12+
13+
/// <summary>
14+
/// The maximum number of requests allowed. Always equal to max_burst + 1.
15+
/// </summary>
16+
public int MaxRequests { get; }
17+
18+
/// <summary>
19+
/// The number of requests available immediately without being rate limited.
20+
/// </summary>
21+
public int AvailableRequests { get; }
22+
23+
/// <summary>
24+
/// The number of seconds after which the caller should retry.
25+
/// Returns -1 if the request is not limited.
26+
/// </summary>
27+
public int RetryAfterSeconds { get; }
28+
29+
/// <summary>
30+
/// The number of seconds after which a full burst will be allowed.
31+
/// </summary>
32+
public int FullBurstAfterSeconds { get; }
33+
34+
/// <summary>
35+
/// Initializes a new instance of the <see cref="GcraRateLimitResult"/> struct.
36+
/// </summary>
37+
public GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds)
38+
{
39+
Limited = limited;
40+
MaxRequests = maxRequests;
41+
AvailableRequests = availableRequests;
42+
RetryAfterSeconds = retryAfterSeconds;
43+
FullBurstAfterSeconds = fullBurstAfterSeconds;
44+
}
45+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
namespace StackExchange.Redis;
2+
3+
public readonly partial struct GcraRateLimitResult
4+
{
5+
internal static readonly ResultProcessor<GcraRateLimitResult> Processor = new GcraRateLimitResultProcessor();
6+
7+
private sealed class GcraRateLimitResultProcessor : ResultProcessor<GcraRateLimitResult>
8+
{
9+
protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result)
10+
{
11+
// GCRA returns an array with 5 elements:
12+
// 1) <limited> # 0 or 1
13+
// 2) <max-req-num> # max number of request. Always equal to max_burst+1
14+
// 3) <num-avail-req> # number of requests available immediately
15+
// 4) <reply-after> # number of seconds after which caller should retry. Always returns -1 if request isn't limited.
16+
// 5) <full-burst-after> # number of seconds after which a full burst will be allowed
17+
if (result.Resp2TypeArray == ResultType.Array && result.ItemsCount >= 5)
18+
{
19+
var items = result.GetItems();
20+
bool limited = items[0].GetBoolean();
21+
if (items[1].TryGetInt64(out long maxRequests)
22+
&& items[2].TryGetInt64(out long availableRequests)
23+
&& items[3].TryGetInt64(out long retryAfterSeconds)
24+
&& items[4].TryGetInt64(out long fullBurstAfterSeconds))
25+
{
26+
var grca = new GcraRateLimitResult(
27+
limited: limited,
28+
maxRequests: (int)maxRequests,
29+
availableRequests: (int)availableRequests,
30+
retryAfterSeconds: (int)retryAfterSeconds,
31+
fullBurstAfterSeconds: (int)fullBurstAfterSeconds);
32+
SetResult(message, grca);
33+
return true;
34+
}
35+
}
36+
37+
return false;
38+
}
39+
40+
/* for v3, already done (due to branch choice)
41+
protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader)
42+
{
43+
// GCRA returns an array with 5 elements:
44+
// 1) <limited> # 0 or 1
45+
// 2) <max-req-num> # max number of request. Always equal to max_burst+1
46+
// 3) <num-avail-req> # number of requests available immediately
47+
// 4) <reply-after> # number of seconds after which caller should retry. Always returns -1 if request isn't limited.
48+
// 5) <full-burst-after> # number of seconds after which a full burst will be allowed
49+
if (reader.IsAggregate
50+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadBoolean(out bool limited)
51+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long maxRequests)
52+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long availableRequests)
53+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long retryAfterSeconds)
54+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long fullBurstAfterSeconds))
55+
{
56+
var result = new GcraRateLimitResult(
57+
limited: limited,
58+
maxRequests: (int)maxRequests,
59+
availableRequests: (int)availableRequests,
60+
retryAfterSeconds: (int)retryAfterSeconds,
61+
fullBurstAfterSeconds: (int)fullBurstAfterSeconds);
62+
SetResult(message, result);
63+
return true;
64+
}
65+
66+
return false;
67+
}
68+
*/
69+
}
70+
}

src/StackExchange.Redis/Interfaces/IDatabase.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3254,6 +3254,19 @@ IEnumerable<SortedSetEntry> SortedSetScan(
32543254
[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)]
32553255
ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None);
32563256

3257+
/// <summary>
3258+
/// Performs a GCRA (Generic Cell Rate Algorithm) rate limit check on the specified key.
3259+
/// </summary>
3260+
/// <param name="key">The key to rate limit.</param>
3261+
/// <param name="maxBurst">The maximum burst size.</param>
3262+
/// <param name="requestsPerPeriod">The number of requests allowed per period.</param>
3263+
/// <param name="periodSeconds">The period duration in seconds. Default is 1.0.</param>
3264+
/// <param name="count">The number of requests to consume. Default is 1.</param>
3265+
/// <param name="flags">The flags to use for this operation.</param>
3266+
/// <returns>A <see cref="GcraRateLimitResult"/> containing the rate limit decision and metadata.</returns>
3267+
/// <remarks><seealso href="https://redis.io/commands/gcra"/></remarks>
3268+
GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None);
3269+
32573270
/// <summary>
32583271
/// Get the value of key. If the key does not exist the special value <see cref="RedisValue.Null"/> is returned.
32593272
/// An error is returned if the value stored at key is not a string, because GET only handles string values.

src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,9 @@ IAsyncEnumerable<SortedSetEntry> SortedSetScanAsync(
796796
[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)]
797797
Task<ValueCondition?> StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None);
798798

799+
/// <inheritdoc cref="IDatabase.StringGcraRateLimit(RedisKey, int, int, double, int, CommandFlags)"/>
800+
Task<GcraRateLimitResult> StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None);
801+
799802
/// <inheritdoc cref="IDatabase.StringGet(RedisKey, CommandFlags)"/>
800803
Task<RedisValue> StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None);
801804

src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,9 @@ public Task<long> StringIncrementAsync(RedisKey key, long value = 1, CommandFlag
792792
public Task<long> StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) =>
793793
Inner.StringLengthAsync(ToInner(key), flags);
794794

795+
public Task<GcraRateLimitResult> StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double period = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) =>
796+
Inner.StringGcraRateLimitAsync(ToInner(key), maxBurst, requestsPerPeriod, period, count, flags);
797+
795798
public Task<bool> StringSetAsync(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None)
796799
=> Inner.StringSetAsync(ToInner(key), value, expiry, when, flags);
797800

0 commit comments

Comments
 (0)