Skip to content

Commit 8af9911

Browse files
authored
Implement GRCA (#3041)
* Implement GRCA (redis/redis#14826) * Flag [SER006]; remove [SER001] * release notes * Update ReleaseNotes.md
1 parent ea5a640 commit 8af9911

42 files changed

Lines changed: 896 additions & 187 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
1111
<CodeAnalysisRuleset>$(MSBuildThisFileDirectory)Shared.ruleset</CodeAnalysisRuleset>
1212
<MSBuildWarningsAsMessages>NETSDK1069</MSBuildWarningsAsMessages>
13-
<NoWarn>$(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005</NoWarn>
13+
<NoWarn>$(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006</NoWarn>
1414
<PackageReleaseNotes>https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes</PackageReleaseNotes>
1515
<PackageProjectUrl>https://stackexchange.github.io/StackExchange.Redis/</PackageProjectUrl>
1616
<PackageLicenseExpression>MIT</PackageLicenseExpression>

docs/ReleaseNotes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Current package versions:
88

99
## Unreleased
1010

11+
- Add [`GCRA`](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm) support (and remove experimental flag on `VSIM`) ([#3041 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3041))
1112
- Add `IServer.GetProductVariant` to detect the product variant and version of the connected server, and use that internally
1213
to enable multi-DB operations on Valkey clusters ([#3040 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3040))
1314
- Ignore cluster nodes with the `handshake` flag ([#3043 by @TimLovellSmith](https://github.com/StackExchange/StackExchange.Redis/pull/3043))

docs/exp/SER006.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
Redis 8.8 is currently in preview and may be subject to change.
2+
3+
New features in Redis 8.8:
4+
5+
- `GCRA` for rate-limiting
6+
7+
The corresponding library feature must also be considered subject to change:
8+
9+
1. Existing bindings may cease working correctly if the underlying server API changes.
10+
2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time
11+
or run-time breaks.
12+
13+
While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress
14+
this warning by adding the following to your `csproj` file:
15+
16+
```xml
17+
<NoWarn>$(NoWarn);SER006</NoWarn>
18+
```
19+
20+
or more granularly / locally in C#:
21+
22+
``` c#
23+
#pragma warning disable SER006
24+
```

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/RESPite/Shared/Experiments.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ internal static class Experiments
88
public const string UrlFormat = "https://stackexchange.github.io/StackExchange.Redis/exp/";
99

1010
// ReSharper disable InconsistentNaming
11-
public const string VectorSets = "SER001";
1211
public const string Server_8_4 = "SER002";
1312
public const string Server_8_6 = "SER003";
1413
public const string Respite = "SER004";
1514
public const string UnitTesting = "SER005";
15+
public const string Server_8_8 = "SER006";
1616
// ReSharper restore InconsistentNaming
1717
}
1818
}

src/StackExchange.Redis/ChannelMessageQueue.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System;
2-
using System.Buffers.Text;
32
using System.Collections.Generic;
43
using System.Threading;
54
using System.Threading.Channels;

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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
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;
13+
using RESPite;
1214

1315
namespace StackExchange.Redis
1416
{
@@ -337,5 +339,70 @@ internal static int VectorSafeIndexOfCRLF(this ReadOnlySpan<byte> span)
337339
[MethodImpl(MethodImplOptions.AggressiveInlining)]
338340
internal static TTo[]? ToArray<TTo, TState>(in this RawResult result, Projection<RawResult, TState, TTo> selector, in TState state)
339341
=> result.IsNull ? null : result.GetItems().ToArray(selector, in state);
342+
343+
/// <summary>
344+
/// Attempts to acquire a GCRA rate limit token, retrying with delays if rate limited.
345+
/// </summary>
346+
/// <param name="database">The database instance.</param>
347+
/// <param name="key">The key for the rate limiter.</param>
348+
/// <param name="maxBurst">The maximum burst size.</param>
349+
/// <param name="requestsPerPeriod">The number of requests allowed per period.</param>
350+
/// <param name="allow">The maximum time to wait for a successful acquisition.</param>
351+
/// <param name="periodSeconds">The period in seconds (default: 1.0).</param>
352+
/// <param name="count">The number of tokens to acquire (default: 1).</param>
353+
/// <param name="flags">The command flags to use.</param>
354+
/// <param name="cancellationToken">The cancellation token.</param>
355+
/// <returns>True if the token was acquired within the allowed time; false otherwise.</returns>
356+
[Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)]
357+
public static async ValueTask<bool> TryAcquireGcraAsync(
358+
this IDatabaseAsync database,
359+
RedisKey key,
360+
int maxBurst,
361+
int requestsPerPeriod,
362+
TimeSpan allow,
363+
double periodSeconds = 1.0,
364+
int count = 1,
365+
CommandFlags flags = CommandFlags.None,
366+
CancellationToken cancellationToken = default)
367+
{
368+
cancellationToken.ThrowIfCancellationRequested();
369+
370+
var startTime = DateTime.UtcNow;
371+
var allowMilliseconds = allow.TotalMilliseconds;
372+
373+
while (true)
374+
{
375+
var result = await database.StringGcraRateLimitAsync(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags).ConfigureAwait(false);
376+
377+
if (!result.Limited)
378+
{
379+
return true;
380+
}
381+
382+
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
383+
var remaining = allowMilliseconds - elapsed;
384+
385+
if (remaining <= 0)
386+
{
387+
return false;
388+
}
389+
390+
var delaySeconds = result.RetryAfterSeconds;
391+
if (delaySeconds <= 0)
392+
{
393+
// Shouldn't happen when Limited is true, but handle defensively
394+
return false;
395+
}
396+
397+
var delayMilliseconds = delaySeconds * 1000.0;
398+
if (delayMilliseconds > remaining)
399+
{
400+
// Not enough time left to wait for retry
401+
return false;
402+
}
403+
404+
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken).ConfigureAwait(false);
405+
}
406+
}
340407
}
341408
}
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+
}

0 commit comments

Comments
 (0)