Skip to content

Commit 62f9fd2

Browse files
committed
GRCA stab, see redis/redis#14826
1 parent 645ff87 commit 62f9fd2

20 files changed

Lines changed: 645 additions & 9 deletions

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
@@ -59,6 +59,7 @@ internal enum RedisCommand
5959
GEOSEARCH,
6060
GEOSEARCHSTORE,
6161

62+
GCRA,
6263
GET,
6364
GETBIT,
6465
GETDEL,
@@ -318,6 +319,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
318319
case RedisCommand.EXPIREAT:
319320
case RedisCommand.FLUSHALL:
320321
case RedisCommand.FLUSHDB:
322+
case RedisCommand.GCRA:
321323
case RedisCommand.GEOSEARCHSTORE:
322324
case RedisCommand.GETDEL:
323325
case RedisCommand.GETEX:

src/StackExchange.Redis/ExtensionMethods.cs

Lines changed: 63 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

1213
namespace StackExchange.Redis
@@ -328,5 +329,67 @@ internal static int VectorSafeIndexOfCRLF(this ReadOnlySpan<byte> span)
328329
return -1;
329330
}
330331
#endif
332+
333+
/// <summary>
334+
/// Attempts to acquire a GCRA rate limit token, retrying with delays if rate limited.
335+
/// </summary>
336+
/// <param name="database">The database instance.</param>
337+
/// <param name="key">The key for the rate limiter.</param>
338+
/// <param name="maxBurst">The maximum burst size.</param>
339+
/// <param name="requestsPerPeriod">The number of requests allowed per period.</param>
340+
/// <param name="allow">The maximum time to wait for a successful acquisition.</param>
341+
/// <param name="periodSeconds">The period in seconds (default: 1.0).</param>
342+
/// <param name="count">The number of tokens to acquire (default: 1).</param>
343+
/// <param name="flags">The command flags to use.</param>
344+
/// <param name="cancellationToken">The cancellation token.</param>
345+
/// <returns>True if the token was acquired within the allowed time; false otherwise.</returns>
346+
public static async ValueTask<bool> TryAcquireGcraAsync(
347+
this IDatabaseAsync database,
348+
RedisKey key,
349+
int maxBurst,
350+
int requestsPerPeriod,
351+
TimeSpan allow,
352+
double periodSeconds = 1.0,
353+
int count = 1,
354+
CommandFlags flags = CommandFlags.None,
355+
CancellationToken cancellationToken = default)
356+
{
357+
var startTime = DateTime.UtcNow;
358+
var allowMilliseconds = allow.TotalMilliseconds;
359+
360+
while (true)
361+
{
362+
var result = await database.StringGcraRateLimitAsync(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags).ConfigureAwait(false);
363+
364+
if (!result.Limited)
365+
{
366+
return true;
367+
}
368+
369+
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
370+
var remaining = allowMilliseconds - elapsed;
371+
372+
if (remaining <= 0)
373+
{
374+
return false;
375+
}
376+
377+
var delaySeconds = result.RetryAfterSeconds;
378+
if (delaySeconds <= 0)
379+
{
380+
// Shouldn't happen when Limited is true, but handle defensively
381+
return false;
382+
}
383+
384+
var delayMilliseconds = delaySeconds * 1000.0;
385+
if (delayMilliseconds > remaining)
386+
{
387+
// Not enough time left to wait for retry
388+
return false;
389+
}
390+
391+
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken).ConfigureAwait(false);
392+
}
393+
}
331394
}
332395
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace StackExchange.Redis;
2+
3+
internal partial class RedisDatabase
4+
{
5+
private Message GetStringGcraRateLimitMessage(in RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds, int count, CommandFlags flags)
6+
{
7+
// GCRA key max_burst requests_per_period period [NUM_REQUESTS count]
8+
if (count == 1)
9+
{
10+
return Message.Create(Database, flags, RedisCommand.GCRA, key, maxBurst, requestsPerPeriod, periodSeconds);
11+
}
12+
else
13+
{
14+
return Message.Create(Database, flags, RedisCommand.GCRA, key, maxBurst, requestsPerPeriod, periodSeconds, RedisLiterals.NUM_REQUESTS, count);
15+
}
16+
}
17+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using RESPite.Messages;
2+
3+
namespace StackExchange.Redis;
4+
5+
public readonly partial struct GcraRateLimitResult
6+
{
7+
internal static readonly ResultProcessor<GcraRateLimitResult> Processor = new GcraRateLimitResultProcessor();
8+
9+
private sealed class GcraRateLimitResultProcessor : ResultProcessor<GcraRateLimitResult>
10+
{
11+
protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader)
12+
{
13+
// GCRA returns an array with 5 elements:
14+
// 1) <limited> # 0 or 1
15+
// 2) <max-req-num> # max number of request. Always equal to max_burst+1
16+
// 3) <num-avail-req> # number of requests available immediately
17+
// 4) <reply-after> # number of seconds after which caller should retry. Always returns -1 if request isn't limited.
18+
// 5) <full-burst-after> # number of seconds after which a full burst will be allowed
19+
if (reader.IsAggregate
20+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadBoolean(out bool limited)
21+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long maxRequests)
22+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long availableRequests)
23+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long retryAfterSeconds)
24+
&& reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long fullBurstAfterSeconds))
25+
{
26+
var result = new GcraRateLimitResult(
27+
limited: limited,
28+
maxRequests: (int)maxRequests,
29+
availableRequests: (int)availableRequests,
30+
retryAfterSeconds: (int)retryAfterSeconds,
31+
fullBurstAfterSeconds: (int)fullBurstAfterSeconds);
32+
SetResult(message, result);
33+
return true;
34+
}
35+
36+
return false;
37+
}
38+
}
39+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
/// Creates a new <see cref="GcraRateLimitResult"/>.
36+
/// </summary>
37+
/// <param name="limited">Whether the request was rate limited.</param>
38+
/// <param name="maxRequests">The maximum number of requests allowed.</param>
39+
/// <param name="availableRequests">The number of requests available immediately.</param>
40+
/// <param name="retryAfterSeconds">The number of seconds after which to retry (in seconds). -1 if not limited.</param>
41+
/// <param name="fullBurstAfterSeconds">The number of seconds after which a full burst will be allowed (in seconds).</param>
42+
public GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds)
43+
{
44+
Limited = limited;
45+
MaxRequests = maxRequests;
46+
AvailableRequests = availableRequests;
47+
RetryAfterSeconds = retryAfterSeconds;
48+
FullBurstAfterSeconds = fullBurstAfterSeconds;
49+
}
50+
}

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
@@ -795,6 +795,9 @@ IAsyncEnumerable<SortedSetEntry> SortedSetScanAsync(
795795
[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)]
796796
Task<ValueCondition?> StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None);
797797

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

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)