From 62f9fd2589058c228cb8e00d1d7d017e7602bf54 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 12 Mar 2026 18:45:46 +0000 Subject: [PATCH 1/2] GRCA stab, see https://github.com/redis/redis/pull/14826 --- src/RESPite/Messages/RespReader.cs | 40 ++++- src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 1 + src/StackExchange.Redis/Enums/RedisCommand.cs | 2 + src/StackExchange.Redis/ExtensionMethods.cs | 63 +++++++ .../GcraRateLimitResult.Message.cs | 17 ++ .../GcraRateLimitResult.ResultProcessor.cs | 39 +++++ .../GcraRateLimitResult.cs | 50 ++++++ .../Interfaces/IDatabase.cs | 13 ++ .../Interfaces/IDatabaseAsync.cs | 3 + .../KeyspaceIsolation/KeyPrefixed.cs | 3 + .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 3 + src/StackExchange.Redis/PhysicalBridge.cs | 3 +- .../PublicAPI/PublicAPI.Shipped.txt | 11 ++ .../RedisDatabase.Strings.cs | 12 ++ src/StackExchange.Redis/RedisLiterals.cs | 1 + src/StackExchange.Redis/ResultProcessor.cs | 3 + .../GcraTestServer.cs | 89 ++++++++++ .../GcraUnitTests.cs | 155 ++++++++++++++++++ .../ResultProcessorUnitTests/GcraRateLimit.cs | 78 +++++++++ .../GcraRateLimitRoundTrip.cs | 68 ++++++++ 20 files changed, 645 insertions(+), 9 deletions(-) create mode 100644 src/StackExchange.Redis/GcraRateLimitResult.Message.cs create mode 100644 src/StackExchange.Redis/GcraRateLimitResult.ResultProcessor.cs create mode 100644 src/StackExchange.Redis/GcraRateLimitResult.cs create mode 100644 tests/StackExchange.Redis.Tests/GcraTestServer.cs create mode 100644 tests/StackExchange.Redis.Tests/GcraUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs create mode 100644 tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 6bc42dd14..86f566b61 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -1852,9 +1852,11 @@ public readonly decimal ReadDecimal() } /// - /// Read the current element as a value. + /// Try to read the current element as a value. /// - public readonly bool ReadBoolean() + /// The parsed boolean value if successful. + /// True if the value was successfully parsed; false otherwise. + public readonly bool TryReadBoolean(out bool value) { var span = Buffer(stackalloc byte[2]); switch (span.Length) @@ -1862,20 +1864,42 @@ public readonly bool ReadBoolean() case 1: switch (span[0]) { - case (byte)'0' when Prefix == RespPrefix.Integer: return false; - case (byte)'1' when Prefix == RespPrefix.Integer: return true; - case (byte)'f' when Prefix == RespPrefix.Boolean: return false; - case (byte)'t' when Prefix == RespPrefix.Boolean: return true; + case (byte)'0' when Prefix == RespPrefix.Integer: + value = false; + return true; + case (byte)'1' when Prefix == RespPrefix.Integer: + value = true; + return true; + case (byte)'f' when Prefix == RespPrefix.Boolean: + value = false; + return true; + case (byte)'t' when Prefix == RespPrefix.Boolean: + value = true; + return true; } break; - case 2 when Prefix == RespPrefix.SimpleString && IsOK(): return true; + case 2 when Prefix == RespPrefix.SimpleString && IsOK(): + value = true; + return true; } - ThrowFormatException(); + value = false; return false; } + /// + /// Read the current element as a value. + /// + public readonly bool ReadBoolean() + { + if (!TryReadBoolean(out var value)) + { + ThrowFormatException(); + } + return value; + } + /// /// Parse a scalar value as an enum of type . /// diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt index 9ce6685bc..3dbd5e4df 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -155,6 +155,7 @@ [SER004]RESPite.Messages.RespReader.ProtocolBytesRemaining.get -> long [SER004]RESPite.Messages.RespReader.ReadArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? [SER004]RESPite.Messages.RespReader.ReadBoolean() -> bool +[SER004]RESPite.Messages.RespReader.TryReadBoolean(out bool value) -> bool [SER004]RESPite.Messages.RespReader.ReadByteArray() -> byte[]? [SER004]RESPite.Messages.RespReader.ReadDecimal() -> decimal [SER004]RESPite.Messages.RespReader.ReadDouble() -> double diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index e9abf3167..aa0d8f1c1 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -59,6 +59,7 @@ internal enum RedisCommand GEOSEARCH, GEOSEARCHSTORE, + GCRA, GET, GETBIT, GETDEL, @@ -318,6 +319,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.EXPIREAT: case RedisCommand.FLUSHALL: case RedisCommand.FLUSHDB: + case RedisCommand.GCRA: case RedisCommand.GEOSEARCHSTORE: case RedisCommand.GETDEL: case RedisCommand.GETEX: diff --git a/src/StackExchange.Redis/ExtensionMethods.cs b/src/StackExchange.Redis/ExtensionMethods.cs index 240a8752b..1d1f276b1 100644 --- a/src/StackExchange.Redis/ExtensionMethods.cs +++ b/src/StackExchange.Redis/ExtensionMethods.cs @@ -7,6 +7,7 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace StackExchange.Redis @@ -328,5 +329,67 @@ internal static int VectorSafeIndexOfCRLF(this ReadOnlySpan span) return -1; } #endif + + /// + /// Attempts to acquire a GCRA rate limit token, retrying with delays if rate limited. + /// + /// The database instance. + /// The key for the rate limiter. + /// The maximum burst size. + /// The number of requests allowed per period. + /// The maximum time to wait for a successful acquisition. + /// The period in seconds (default: 1.0). + /// The number of tokens to acquire (default: 1). + /// The command flags to use. + /// The cancellation token. + /// True if the token was acquired within the allowed time; false otherwise. + public static async ValueTask TryAcquireGcraAsync( + this IDatabaseAsync database, + RedisKey key, + int maxBurst, + int requestsPerPeriod, + TimeSpan allow, + double periodSeconds = 1.0, + int count = 1, + CommandFlags flags = CommandFlags.None, + CancellationToken cancellationToken = default) + { + var startTime = DateTime.UtcNow; + var allowMilliseconds = allow.TotalMilliseconds; + + while (true) + { + var result = await database.StringGcraRateLimitAsync(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags).ConfigureAwait(false); + + if (!result.Limited) + { + return true; + } + + var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; + var remaining = allowMilliseconds - elapsed; + + if (remaining <= 0) + { + return false; + } + + var delaySeconds = result.RetryAfterSeconds; + if (delaySeconds <= 0) + { + // Shouldn't happen when Limited is true, but handle defensively + return false; + } + + var delayMilliseconds = delaySeconds * 1000.0; + if (delayMilliseconds > remaining) + { + // Not enough time left to wait for retry + return false; + } + + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken).ConfigureAwait(false); + } + } } } diff --git a/src/StackExchange.Redis/GcraRateLimitResult.Message.cs b/src/StackExchange.Redis/GcraRateLimitResult.Message.cs new file mode 100644 index 000000000..912bdeaae --- /dev/null +++ b/src/StackExchange.Redis/GcraRateLimitResult.Message.cs @@ -0,0 +1,17 @@ +namespace StackExchange.Redis; + +internal partial class RedisDatabase +{ + private Message GetStringGcraRateLimitMessage(in RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds, int count, CommandFlags flags) + { + // GCRA key max_burst requests_per_period period [NUM_REQUESTS count] + if (count == 1) + { + return Message.Create(Database, flags, RedisCommand.GCRA, key, maxBurst, requestsPerPeriod, periodSeconds); + } + else + { + return Message.Create(Database, flags, RedisCommand.GCRA, key, maxBurst, requestsPerPeriod, periodSeconds, RedisLiterals.NUM_REQUESTS, count); + } + } +} diff --git a/src/StackExchange.Redis/GcraRateLimitResult.ResultProcessor.cs b/src/StackExchange.Redis/GcraRateLimitResult.ResultProcessor.cs new file mode 100644 index 000000000..f024f0344 --- /dev/null +++ b/src/StackExchange.Redis/GcraRateLimitResult.ResultProcessor.cs @@ -0,0 +1,39 @@ +using RESPite.Messages; + +namespace StackExchange.Redis; + +public readonly partial struct GcraRateLimitResult +{ + internal static readonly ResultProcessor Processor = new GcraRateLimitResultProcessor(); + + private sealed class GcraRateLimitResultProcessor : ResultProcessor + { + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) + { + // GCRA returns an array with 5 elements: + // 1) # 0 or 1 + // 2) # max number of request. Always equal to max_burst+1 + // 3) # number of requests available immediately + // 4) # number of seconds after which caller should retry. Always returns -1 if request isn't limited. + // 5) # number of seconds after which a full burst will be allowed + if (reader.IsAggregate + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadBoolean(out bool limited) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long maxRequests) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long availableRequests) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long retryAfterSeconds) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long fullBurstAfterSeconds)) + { + var result = new GcraRateLimitResult( + limited: limited, + maxRequests: (int)maxRequests, + availableRequests: (int)availableRequests, + retryAfterSeconds: (int)retryAfterSeconds, + fullBurstAfterSeconds: (int)fullBurstAfterSeconds); + SetResult(message, result); + return true; + } + + return false; + } + } +} diff --git a/src/StackExchange.Redis/GcraRateLimitResult.cs b/src/StackExchange.Redis/GcraRateLimitResult.cs new file mode 100644 index 000000000..ced9b8475 --- /dev/null +++ b/src/StackExchange.Redis/GcraRateLimitResult.cs @@ -0,0 +1,50 @@ +namespace StackExchange.Redis; + +/// +/// Represents the result of a GCRA (Generic Cell Rate Algorithm) rate limit check. +/// +public readonly partial struct GcraRateLimitResult +{ + /// + /// Indicates whether the request was rate limited (true) or allowed (false). + /// + public bool Limited { get; } + + /// + /// The maximum number of requests allowed. Always equal to max_burst + 1. + /// + public int MaxRequests { get; } + + /// + /// The number of requests available immediately without being rate limited. + /// + public int AvailableRequests { get; } + + /// + /// The number of seconds after which the caller should retry. + /// Returns -1 if the request is not limited. + /// + public int RetryAfterSeconds { get; } + + /// + /// The number of seconds after which a full burst will be allowed. + /// + public int FullBurstAfterSeconds { get; } + + /// + /// Creates a new . + /// + /// Whether the request was rate limited. + /// The maximum number of requests allowed. + /// The number of requests available immediately. + /// The number of seconds after which to retry (in seconds). -1 if not limited. + /// The number of seconds after which a full burst will be allowed (in seconds). + public GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds) + { + Limited = limited; + MaxRequests = maxRequests; + AvailableRequests = availableRequests; + RetryAfterSeconds = retryAfterSeconds; + FullBurstAfterSeconds = fullBurstAfterSeconds; + } +} diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index e26154652..021a8ad5e 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -3254,6 +3254,19 @@ IEnumerable SortedSetScan( [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None); + /// + /// Performs a GCRA (Generic Cell Rate Algorithm) rate limit check on the specified key. + /// + /// The key to rate limit. + /// The maximum burst size. + /// The number of requests allowed per period. + /// The period duration in seconds. Default is 1.0. + /// The number of requests to consume. Default is 1. + /// The flags to use for this operation. + /// A containing the rate limit decision and metadata. + /// + GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None); + /// /// Get the value of key. If the key does not exist the special value is returned. /// An error is returned if the value stored at key is not a string, because GET only handles string values. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 9719861d8..f7c3d703a 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -795,6 +795,9 @@ IAsyncEnumerable SortedSetScanAsync( [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None); + /// + Task StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None); + /// Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index c7831fdb8..d3314ab7a 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -792,6 +792,9 @@ public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlag public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLengthAsync(ToInner(key), flags); + public Task StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double period = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) => + Inner.StringGcraRateLimitAsync(ToInner(key), maxBurst, requestsPerPeriod, period, count, flags); + public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) => Inner.StringSetAsync(ToInner(key), value, expiry, when, flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 498159578..7b98255a3 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -774,6 +774,9 @@ public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = C public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.StringLength(ToInner(key), flags); + public GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double period = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) => + Inner.StringGcraRateLimit(ToInner(key), maxBurst, requestsPerPeriod, period, count, flags); + public bool StringSet(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) => Inner.StringSet(ToInner(key), value, expiry, when, flags); diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 9c975d03b..be76221e5 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -1615,7 +1615,8 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne if (_nextHighIntegrityToken is not 0 && !connection.TransactionActive // validated in the UNWATCH/EXEC/DISCARD - && message.Command is not RedisCommand.AUTH or RedisCommand.HELLO) // if auth fails, ECHO may also fail; avoid confusion + && message.Command is not RedisCommand.AUTH // if auth fails, ECHO may also fail; avoid confusion + && message.Command is not RedisCommand.HELLO) { // make sure this value exists early to avoid a race condition // if the response comes back super quickly diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index b2f5cab2c..32609a8a4 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -481,6 +481,14 @@ StackExchange.Redis.GeoUnit.Feet = 3 -> StackExchange.Redis.GeoUnit StackExchange.Redis.GeoUnit.Kilometers = 1 -> StackExchange.Redis.GeoUnit StackExchange.Redis.GeoUnit.Meters = 0 -> StackExchange.Redis.GeoUnit StackExchange.Redis.GeoUnit.Miles = 2 -> StackExchange.Redis.GeoUnit +StackExchange.Redis.GcraRateLimitResult +StackExchange.Redis.GcraRateLimitResult.AvailableRequests.get -> int +StackExchange.Redis.GcraRateLimitResult.FullBurstAfterSeconds.get -> int +StackExchange.Redis.GcraRateLimitResult.GcraRateLimitResult() -> void +StackExchange.Redis.GcraRateLimitResult.GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds) -> void +StackExchange.Redis.GcraRateLimitResult.Limited.get -> bool +StackExchange.Redis.GcraRateLimitResult.MaxRequests.get -> int +StackExchange.Redis.GcraRateLimitResult.RetryAfterSeconds.get -> int StackExchange.Redis.HashEntry StackExchange.Redis.HashEntry.Equals(StackExchange.Redis.HashEntry other) -> bool StackExchange.Redis.HashEntry.HashEntry() -> void @@ -777,6 +785,7 @@ StackExchange.Redis.IDatabase.StringGetSet(StackExchange.Redis.RedisKey key, Sta StackExchange.Redis.IDatabase.StringGetSetExpiry(StackExchange.Redis.RedisKey key, System.TimeSpan? expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue StackExchange.Redis.IDatabase.StringGetSetExpiry(StackExchange.Redis.RedisKey key, System.DateTime expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue StackExchange.Redis.IDatabase.StringGetWithExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValueWithExpiry +StackExchange.Redis.IDatabase.StringGcraRateLimit(StackExchange.Redis.RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1, int count = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.GcraRateLimitResult StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> double StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long StackExchange.Redis.IDatabase.StringLength(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long @@ -1021,6 +1030,7 @@ StackExchange.Redis.IDatabaseAsync.StringGetSetAsync(StackExchange.Redis.RedisKe StackExchange.Redis.IDatabaseAsync.StringGetSetExpiryAsync(StackExchange.Redis.RedisKey key, System.TimeSpan? expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringGetSetExpiryAsync(StackExchange.Redis.RedisKey key, System.DateTime expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringGetWithExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.StringGcraRateLimitAsync(StackExchange.Redis.RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1, int count = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IDatabaseAsync.StringLengthAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! @@ -1710,6 +1720,7 @@ static StackExchange.Redis.ExtensionMethods.ToStringArray(this StackExchange.Red static StackExchange.Redis.ExtensionMethods.ToStringDictionary(this StackExchange.Redis.HashEntry[]? hash) -> System.Collections.Generic.Dictionary? static StackExchange.Redis.ExtensionMethods.ToStringDictionary(this StackExchange.Redis.SortedSetEntry[]? sortedSet) -> System.Collections.Generic.Dictionary? static StackExchange.Redis.ExtensionMethods.ToStringDictionary(this System.Collections.Generic.KeyValuePair[]? pairs) -> System.Collections.Generic.Dictionary? +static StackExchange.Redis.ExtensionMethods.TryAcquireGcraAsync(this StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key, int maxBurst, int requestsPerPeriod, System.TimeSpan allow, double periodSeconds = 1, int count = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static StackExchange.Redis.GeoEntry.operator !=(StackExchange.Redis.GeoEntry x, StackExchange.Redis.GeoEntry y) -> bool static StackExchange.Redis.GeoEntry.operator ==(StackExchange.Redis.GeoEntry x, StackExchange.Redis.GeoEntry y) -> bool static StackExchange.Redis.GeoPosition.operator !=(StackExchange.Redis.GeoPosition x, StackExchange.Redis.GeoPosition y) -> bool diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index dfe4c7259..5884de382 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -47,6 +47,18 @@ private Message GetStringDeleteMessage(in RedisKey key, in ValueCondition when, return ExecuteAsync(msg, ResultProcessor.Digest); } + public GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringGcraRateLimitMessage(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags); + return ExecuteSync(msg, ResultProcessor.GcraRateLimit); + } + + public Task StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) + { + var msg = GetStringGcraRateLimitMessage(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags); + return ExecuteAsync(msg, ResultProcessor.GcraRateLimit); + } + public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None) { var msg = GetStringSetMessage(key, value, expiry, when, flags); diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index c7773879d..8753b3239 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -78,6 +78,7 @@ public static readonly RedisValue NOSAVE = RedisValue.FromRaw("NOSAVE"u8), NOT = RedisValue.FromRaw("NOT"u8), NOVALUES = RedisValue.FromRaw("NOVALUES"u8), + NUM_REQUESTS = RedisValue.FromRaw("NUM_REQUESTS"u8), NUMPAT = RedisValue.FromRaw("NUMPAT"u8), NUMSUB = RedisValue.FromRaw("NUMSUB"u8), NX = RedisValue.FromRaw("NX"u8), diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index b476708cc..6038d65c2 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -113,6 +113,9 @@ public static readonly ResultProcessor public static readonly ResultProcessor RedisGeoPosition = new RedisValueGeoPositionProcessor(); + public static readonly ResultProcessor + GcraRateLimit = GcraRateLimitResult.Processor; + public static readonly ResultProcessor ResponseTimer = new TimingProcessor(); diff --git a/tests/StackExchange.Redis.Tests/GcraTestServer.cs b/tests/StackExchange.Redis.Tests/GcraTestServer.cs new file mode 100644 index 000000000..b6815129f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/GcraTestServer.cs @@ -0,0 +1,89 @@ +using RESPite; +using RESPite.Messages; +using StackExchange.Redis.Server; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Test Redis server that simulates GCRA rate limiting responses. +/// +public class GcraTestServer : InProcessTestServer +{ + private readonly GcraRateLimitResult _expectedResult; + private GcraRequestSnapshot? _lastRequest; + + public GcraTestServer(GcraRateLimitResult expectedResult, ITestOutputHelper? log = null) : base(log) + { + _expectedResult = expectedResult; + } + + /// + /// Snapshot of the last GCRA request received by the server. + /// + public sealed class GcraRequestSnapshot + { + public RedisKey Key { get; init; } + public int MaxBurst { get; init; } + public int RequestsPerPeriod { get; init; } + public double PeriodSeconds { get; init; } + public int Count { get; init; } + } + + /// + /// Gets the last GCRA request received by the server. + /// + public GcraRequestSnapshot? LastRequest => _lastRequest; + + /// + /// Handles GCRA commands. Returns the configured result and captures request parameters. + /// + [RedisCommand(-5, "GCRA")] + protected virtual TypedRedisValue Gcra(RedisClient client, in RedisRequest request) + { + // Parse request parameters + var key = request.GetKey(1); + var maxBurst = request.GetInt32(2); + var requestsPerPeriod = request.GetInt32(3); + // Parse period as a string and convert to double + var periodString = request.GetString(4); + var periodSeconds = double.Parse(periodString, System.Globalization.CultureInfo.InvariantCulture); + + // Optional count parameter (defaults to 1) + var count = 1; + if (request.Count >= 7 && request.GetString(5) == "NUM_REQUESTS") + { + count = request.GetInt32(6); + } + + // Capture the request + _lastRequest = new GcraRequestSnapshot + { + Key = key, + MaxBurst = maxBurst, + RequestsPerPeriod = requestsPerPeriod, + PeriodSeconds = periodSeconds, + Count = count, + }; + + // Return the configured result as a 5-element array + var elements = new[] + { + TypedRedisValue.Integer(_expectedResult.Limited ? 1 : 0), + TypedRedisValue.Integer(_expectedResult.MaxRequests), + TypedRedisValue.Integer(_expectedResult.AvailableRequests), + TypedRedisValue.Integer(_expectedResult.RetryAfterSeconds), + TypedRedisValue.Integer(_expectedResult.FullBurstAfterSeconds), + }; + return TypedRedisValue.MultiBulk(elements, RespPrefix.Array); + } + + /// + /// Resets the last request snapshot. + /// + public override void ResetCounters() + { + _lastRequest = null; + base.ResetCounters(); + } +} diff --git a/tests/StackExchange.Redis.Tests/GcraUnitTests.cs b/tests/StackExchange.Redis.Tests/GcraUnitTests.cs new file mode 100644 index 000000000..fe24939a6 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/GcraUnitTests.cs @@ -0,0 +1,155 @@ +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Unit tests for GCRA rate limiting functionality. +/// +public class GcraUnitTests(ITestOutputHelper log) +{ + private RedisKey Me([CallerMemberName] string callerName = "") => callerName; + + [Fact] + public async Task GcraRateLimit_NotLimited_ReturnsExpectedResult() + { + // Arrange + var expectedResult = new GcraRateLimitResult( + limited: false, + maxRequests: 10, + availableRequests: 9, + retryAfterSeconds: 0, + fullBurstAfterSeconds: 1); + + using var server = new GcraTestServer(expectedResult, log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + + // Act + var result = await db.StringGcraRateLimitAsync(key, maxBurst: 10, requestsPerPeriod: 10, periodSeconds: 1.0, count: 1); + + // Assert + Assert.False(result.Limited); + Assert.Equal(10, result.MaxRequests); + Assert.Equal(9, result.AvailableRequests); + Assert.Equal(0, result.RetryAfterSeconds); + Assert.Equal(1, result.FullBurstAfterSeconds); + + // Verify the request received by the server + var lastRequest = server.LastRequest; + Assert.NotNull(lastRequest); + Assert.Equal(key, lastRequest.Key); + Assert.Equal(10, lastRequest.MaxBurst); + Assert.Equal(10, lastRequest.RequestsPerPeriod); + Assert.Equal(1.0, lastRequest.PeriodSeconds); + Assert.Equal(1, lastRequest.Count); + } + + [Fact] + public async Task GcraRateLimit_Limited_ReturnsExpectedResult() + { + // Arrange + var expectedResult = new GcraRateLimitResult( + limited: true, + maxRequests: 5, + availableRequests: 0, + retryAfterSeconds: 2, + fullBurstAfterSeconds: 10); + + using var server = new GcraTestServer(expectedResult, log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + + // Act + var result = await db.StringGcraRateLimitAsync(key, maxBurst: 5, requestsPerPeriod: 5, periodSeconds: 1.0); + + // Assert + Assert.True(result.Limited); + Assert.Equal(5, result.MaxRequests); + Assert.Equal(0, result.AvailableRequests); + Assert.Equal(2, result.RetryAfterSeconds); + Assert.Equal(10, result.FullBurstAfterSeconds); + + // Verify the request received by the server + var lastRequest = server.LastRequest; + Assert.NotNull(lastRequest); + Assert.Equal(key, lastRequest.Key); + Assert.Equal(5, lastRequest.MaxBurst); + Assert.Equal(5, lastRequest.RequestsPerPeriod); + Assert.Equal(1.0, lastRequest.PeriodSeconds); + Assert.Equal(1, lastRequest.Count); + } + + [Fact] + public async Task GcraRateLimit_WithCustomCount_SendsCorrectParameters() + { + // Arrange + var expectedResult = new GcraRateLimitResult( + limited: false, + maxRequests: 100, + availableRequests: 95, + retryAfterSeconds: 0, + fullBurstAfterSeconds: 5); + + using var server = new GcraTestServer(expectedResult, log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + + // Act + var result = await db.StringGcraRateLimitAsync(key, maxBurst: 100, requestsPerPeriod: 100, periodSeconds: 60.0, count: 5); + + // Assert + Assert.False(result.Limited); + Assert.Equal(100, result.MaxRequests); + Assert.Equal(95, result.AvailableRequests); + + // Verify the request received by the server includes the count parameter + var lastRequest = server.LastRequest; + Assert.NotNull(lastRequest); + Assert.Equal(key, lastRequest.Key); + Assert.Equal(100, lastRequest.MaxBurst); + Assert.Equal(100, lastRequest.RequestsPerPeriod); + Assert.Equal(60.0, lastRequest.PeriodSeconds); + Assert.Equal(5, lastRequest.Count); + } + + [Fact] + public async Task GcraRateLimit_SyncVersion_ReturnsExpectedResult() + { + // Arrange + var expectedResult = new GcraRateLimitResult( + limited: false, + maxRequests: 20, + availableRequests: 19, + retryAfterSeconds: 0, + fullBurstAfterSeconds: 1); + + using var server = new GcraTestServer(expectedResult, log); + await using var muxer = await server.ConnectAsync(); + var db = muxer.GetDatabase(); + var key = Me(); + + // Act + var result = db.StringGcraRateLimit(key, maxBurst: 20, requestsPerPeriod: 20, periodSeconds: 1.0); + + // Assert + Assert.False(result.Limited); + Assert.Equal(20, result.MaxRequests); + Assert.Equal(19, result.AvailableRequests); + Assert.Equal(0, result.RetryAfterSeconds); + Assert.Equal(1, result.FullBurstAfterSeconds); + + // Verify the request received by the server + var lastRequest = server.LastRequest; + Assert.NotNull(lastRequest); + Assert.Equal(key, lastRequest.Key); + Assert.Equal(20, lastRequest.MaxBurst); + Assert.Equal(20, lastRequest.RequestsPerPeriod); + Assert.Equal(1.0, lastRequest.PeriodSeconds); + Assert.Equal(1, lastRequest.Count); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs new file mode 100644 index 000000000..8a05768d6 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs @@ -0,0 +1,78 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for GCRA rate limit result processor. +/// +public class GcraRateLimit(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void GcraRateLimit_NotLimited_Success() + { + // GCRA response when request is allowed: + // 1) 0 (not limited) + // 2) 11 (max requests = max_burst + 1) + // 3) 10 (available requests) + // 4) -1 (retry after - always -1 when not limited) + // 5) 5 (full burst after seconds) + var resp = "*5\r\n:0\r\n:11\r\n:10\r\n:-1\r\n:5\r\n"; + var processor = ResultProcessor.GcraRateLimit; + var result = Execute(resp, processor); + + Assert.False(result.Limited); + Assert.Equal(11, result.MaxRequests); + Assert.Equal(10, result.AvailableRequests); + Assert.Equal(-1, result.RetryAfterSeconds); + Assert.Equal(5, result.FullBurstAfterSeconds); + } + + [Fact] + public void GcraRateLimit_Limited_Success() + { + // GCRA response when request is rate limited: + // 1) 1 (limited) + // 2) 11 (max requests = max_burst + 1) + // 3) 0 (no available requests) + // 4) 2 (retry after 2 seconds) + // 5) 10 (full burst after 10 seconds) + var resp = "*5\r\n:1\r\n:11\r\n:0\r\n:2\r\n:10\r\n"; + var processor = ResultProcessor.GcraRateLimit; + var result = Execute(resp, processor); + + Assert.True(result.Limited); + Assert.Equal(11, result.MaxRequests); + Assert.Equal(0, result.AvailableRequests); + Assert.Equal(2, result.RetryAfterSeconds); + Assert.Equal(10, result.FullBurstAfterSeconds); + } + + [Fact] + public void GcraRateLimit_PartiallyAvailable_Success() + { + // GCRA response when some requests are available: + // 1) 0 (not limited) + // 2) 101 (max requests) + // 3) 50 (50 requests available) + // 4) -1 (retry after - not limited) + // 5) 100 (full burst after 100 seconds) + var resp = "*5\r\n:0\r\n:101\r\n:50\r\n:-1\r\n:100\r\n"; + var processor = ResultProcessor.GcraRateLimit; + var result = Execute(resp, processor); + + Assert.False(result.Limited); + Assert.Equal(101, result.MaxRequests); + Assert.Equal(50, result.AvailableRequests); + Assert.Equal(-1, result.RetryAfterSeconds); + Assert.Equal(100, result.FullBurstAfterSeconds); + } + + [Theory] + [InlineData("*4\r\n:0\r\n:11\r\n:10\r\n:-1\r\n")] // only 4 elements + [InlineData(":0\r\n")] // scalar instead of array + [InlineData("*5\r\n$1\r\n0\r\n:11\r\n:10\r\n:-1\r\n:5\r\n")] // first element is string + public void GcraRateLimit_InvalidResponse_Failure(string resp) + { + ExecuteUnexpected(resp, ResultProcessor.GcraRateLimit); + } +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs new file mode 100644 index 000000000..ff7f525f8 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs @@ -0,0 +1,68 @@ +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +public class GcraRateLimitRoundTrip +{ + [Theory(Timeout = 1000)] + [InlineData("mykey", 10, 100, 1.0, "*5\r\n$4\r\nGCRA\r\n$5\r\nmykey\r\n$2\r\n10\r\n$3\r\n100\r\n$1\r\n1\r\n", "*5\r\n:0\r\n:11\r\n:10\r\n:-1\r\n:5\r\n")] + public async Task GcraRateLimit_DefaultCount_RoundTrip( + string key, + int maxBurst, + int requestsPerPeriod, + double periodSeconds, + string requestResp, + string responseResp) + { + var msg = Message.Create(0, CommandFlags.None, RedisCommand.GCRA, (RedisKey)key, maxBurst, requestsPerPeriod, periodSeconds); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp); + + Assert.False(result.Limited); + Assert.Equal(11, result.MaxRequests); + Assert.Equal(10, result.AvailableRequests); + Assert.Equal(-1, result.RetryAfterSeconds); + Assert.Equal(5, result.FullBurstAfterSeconds); + } + + [Theory(Timeout = 1000)] + [InlineData("mykey", 10, 100, 1.0, 5, "*7\r\n$4\r\nGCRA\r\n$5\r\nmykey\r\n$2\r\n10\r\n$3\r\n100\r\n$1\r\n1\r\n$12\r\nNUM_REQUESTS\r\n$1\r\n5\r\n", "*5\r\n:1\r\n:11\r\n:0\r\n:2\r\n:10\r\n")] + public async Task GcraRateLimit_WithCount_RoundTrip( + string key, + int maxBurst, + int requestsPerPeriod, + double periodSeconds, + int count, + string requestResp, + string responseResp) + { + var msg = Message.Create(0, CommandFlags.None, RedisCommand.GCRA, (RedisKey)key, maxBurst, requestsPerPeriod, periodSeconds, RedisLiterals.NUM_REQUESTS, count); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp); + + Assert.True(result.Limited); + Assert.Equal(11, result.MaxRequests); + Assert.Equal(0, result.AvailableRequests); + Assert.Equal(2, result.RetryAfterSeconds); + Assert.Equal(10, result.FullBurstAfterSeconds); + } + + [Theory(Timeout = 1000)] + [InlineData("rate:api", 50, 1000, 60.0, "*5\r\n$4\r\nGCRA\r\n$8\r\nrate:api\r\n$2\r\n50\r\n$4\r\n1000\r\n$2\r\n60\r\n", "*5\r\n:0\r\n:51\r\n:25\r\n:-1\r\n:30\r\n")] + public async Task GcraRateLimit_CustomPeriod_RoundTrip( + string key, + int maxBurst, + int requestsPerPeriod, + double periodSeconds, + string requestResp, + string responseResp) + { + var msg = Message.Create(0, CommandFlags.None, RedisCommand.GCRA, (RedisKey)key, maxBurst, requestsPerPeriod, periodSeconds); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp); + + Assert.False(result.Limited); + Assert.Equal(51, result.MaxRequests); + Assert.Equal(25, result.AvailableRequests); + Assert.Equal(-1, result.RetryAfterSeconds); + Assert.Equal(30, result.FullBurstAfterSeconds); + } +} From faa41095b32961bbe8a5ca73eb320c828faf7c03 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 13 Mar 2026 09:34:35 +0000 Subject: [PATCH 2/2] PR nits --- src/StackExchange.Redis/ExtensionMethods.cs | 2 + src/StackExchange.Redis/Gcra.GcraMessage.cs | 40 +++++++++++++++++++ ...tResult.cs => Gcra.GcraRateLimitResult.cs} | 7 +--- ...ltProcessor.cs => Gcra.ResultProcessor.cs} | 0 .../GcraRateLimitResult.Message.cs | 17 -------- .../HotKeys.ResultProcessor.cs | 5 ++- .../RedisDatabase.Strings.cs | 4 +- .../GcraTestServer.cs | 16 ++++---- .../GcraRateLimitRoundTrip.cs | 6 +-- .../TypedRedisValue.cs | 15 +++++-- 10 files changed, 69 insertions(+), 43 deletions(-) create mode 100644 src/StackExchange.Redis/Gcra.GcraMessage.cs rename src/StackExchange.Redis/{GcraRateLimitResult.cs => Gcra.GcraRateLimitResult.cs} (71%) rename src/StackExchange.Redis/{GcraRateLimitResult.ResultProcessor.cs => Gcra.ResultProcessor.cs} (100%) delete mode 100644 src/StackExchange.Redis/GcraRateLimitResult.Message.cs diff --git a/src/StackExchange.Redis/ExtensionMethods.cs b/src/StackExchange.Redis/ExtensionMethods.cs index 1d1f276b1..4bac65e4b 100644 --- a/src/StackExchange.Redis/ExtensionMethods.cs +++ b/src/StackExchange.Redis/ExtensionMethods.cs @@ -354,6 +354,8 @@ public static async ValueTask TryAcquireGcraAsync( CommandFlags flags = CommandFlags.None, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var startTime = DateTime.UtcNow; var allowMilliseconds = allow.TotalMilliseconds; diff --git a/src/StackExchange.Redis/Gcra.GcraMessage.cs b/src/StackExchange.Redis/Gcra.GcraMessage.cs new file mode 100644 index 000000000..7207e6acd --- /dev/null +++ b/src/StackExchange.Redis/Gcra.GcraMessage.cs @@ -0,0 +1,40 @@ +namespace StackExchange.Redis; + +internal partial class RedisDatabase +{ + internal sealed class GcraMessage( + int database, + CommandFlags flags, + RedisKey key, + int maxBurst, + int requestsPerPeriod, + double periodSeconds, + int count) : Message(database, flags, RedisCommand.GCRA) + { + protected override void WriteImpl(in MessageWriter writer) + { + // GCRA key max_burst requests_per_period period [NUM_REQUESTS count] + writer.WriteHeader(Command, ArgCount); + writer.WriteBulkString(key); + writer.WriteBulkString(maxBurst); + writer.WriteBulkString(requestsPerPeriod); + writer.WriteBulkString(periodSeconds); + + if (count != 1) + { + writer.WriteBulkString("NUM_REQUESTS"u8); + writer.WriteBulkString(count); + } + } + + public override int ArgCount + { + get + { + int argCount = 4; // key, max_burst, requests_per_period, period + if (count != 1) argCount += 2; // NUM_REQUESTS, count + return argCount; + } + } + } +} diff --git a/src/StackExchange.Redis/GcraRateLimitResult.cs b/src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs similarity index 71% rename from src/StackExchange.Redis/GcraRateLimitResult.cs rename to src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs index ced9b8475..bea6944e1 100644 --- a/src/StackExchange.Redis/GcraRateLimitResult.cs +++ b/src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs @@ -32,13 +32,8 @@ public readonly partial struct GcraRateLimitResult public int FullBurstAfterSeconds { get; } /// - /// Creates a new . + /// Initializes a new instance of the struct. /// - /// Whether the request was rate limited. - /// The maximum number of requests allowed. - /// The number of requests available immediately. - /// The number of seconds after which to retry (in seconds). -1 if not limited. - /// The number of seconds after which a full burst will be allowed (in seconds). public GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds) { Limited = limited; diff --git a/src/StackExchange.Redis/GcraRateLimitResult.ResultProcessor.cs b/src/StackExchange.Redis/Gcra.ResultProcessor.cs similarity index 100% rename from src/StackExchange.Redis/GcraRateLimitResult.ResultProcessor.cs rename to src/StackExchange.Redis/Gcra.ResultProcessor.cs diff --git a/src/StackExchange.Redis/GcraRateLimitResult.Message.cs b/src/StackExchange.Redis/GcraRateLimitResult.Message.cs deleted file mode 100644 index 912bdeaae..000000000 --- a/src/StackExchange.Redis/GcraRateLimitResult.Message.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace StackExchange.Redis; - -internal partial class RedisDatabase -{ - private Message GetStringGcraRateLimitMessage(in RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds, int count, CommandFlags flags) - { - // GCRA key max_burst requests_per_period period [NUM_REQUESTS count] - if (count == 1) - { - return Message.Create(Database, flags, RedisCommand.GCRA, key, maxBurst, requestsPerPeriod, periodSeconds); - } - else - { - return Message.Create(Database, flags, RedisCommand.GCRA, key, maxBurst, requestsPerPeriod, periodSeconds, RedisLiterals.NUM_REQUESTS, count); - } - } -} diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 10b2d6357..dd3e1c133 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -56,10 +56,11 @@ private HotKeysResult(ref RespReader reader) if (!reader.TryMoveNext()) break; long i64; + bool b; switch (field) { - case HotKeysField.TrackingActive: - TrackingActive = reader.ReadBoolean(); + case HotKeysField.TrackingActive when reader.TryReadBoolean(out b): + TrackingActive = b; break; case HotKeysField.SampleRatio when reader.TryReadInt64(out i64): SampleRatio = i64; diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index 5884de382..6dca21915 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -49,13 +49,13 @@ private Message GetStringDeleteMessage(in RedisKey key, in ValueCondition when, public GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) { - var msg = GetStringGcraRateLimitMessage(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags); + var msg = new GcraMessage(Database, flags, key, maxBurst, requestsPerPeriod, periodSeconds, count); return ExecuteSync(msg, ResultProcessor.GcraRateLimit); } public Task StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) { - var msg = GetStringGcraRateLimitMessage(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags); + var msg = new GcraMessage(Database, flags, key, maxBurst, requestsPerPeriod, periodSeconds, count); return ExecuteAsync(msg, ResultProcessor.GcraRateLimit); } diff --git a/tests/StackExchange.Redis.Tests/GcraTestServer.cs b/tests/StackExchange.Redis.Tests/GcraTestServer.cs index b6815129f..cbccce795 100644 --- a/tests/StackExchange.Redis.Tests/GcraTestServer.cs +++ b/tests/StackExchange.Redis.Tests/GcraTestServer.cs @@ -67,15 +67,13 @@ protected virtual TypedRedisValue Gcra(RedisClient client, in RedisRequest reque }; // Return the configured result as a 5-element array - var elements = new[] - { - TypedRedisValue.Integer(_expectedResult.Limited ? 1 : 0), - TypedRedisValue.Integer(_expectedResult.MaxRequests), - TypedRedisValue.Integer(_expectedResult.AvailableRequests), - TypedRedisValue.Integer(_expectedResult.RetryAfterSeconds), - TypedRedisValue.Integer(_expectedResult.FullBurstAfterSeconds), - }; - return TypedRedisValue.MultiBulk(elements, RespPrefix.Array); + var result = TypedRedisValue.Rent(5, out var span, RespPrefix.Array); + span[0] = TypedRedisValue.Integer(_expectedResult.Limited ? 1 : 0); + span[1] = TypedRedisValue.Integer(_expectedResult.MaxRequests); + span[2] = TypedRedisValue.Integer(_expectedResult.AvailableRequests); + span[3] = TypedRedisValue.Integer(_expectedResult.RetryAfterSeconds); + span[4] = TypedRedisValue.Integer(_expectedResult.FullBurstAfterSeconds); + return result; } /// diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs index ff7f525f8..0d3aecb78 100644 --- a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs @@ -15,7 +15,7 @@ public async Task GcraRateLimit_DefaultCount_RoundTrip( string requestResp, string responseResp) { - var msg = Message.Create(0, CommandFlags.None, RedisCommand.GCRA, (RedisKey)key, maxBurst, requestsPerPeriod, periodSeconds); + var msg = new RedisDatabase.GcraMessage(0, CommandFlags.None, key, maxBurst, requestsPerPeriod, periodSeconds, 1); var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp); Assert.False(result.Limited); @@ -36,7 +36,7 @@ public async Task GcraRateLimit_WithCount_RoundTrip( string requestResp, string responseResp) { - var msg = Message.Create(0, CommandFlags.None, RedisCommand.GCRA, (RedisKey)key, maxBurst, requestsPerPeriod, periodSeconds, RedisLiterals.NUM_REQUESTS, count); + var msg = new RedisDatabase.GcraMessage(0, CommandFlags.None, key, maxBurst, requestsPerPeriod, periodSeconds, count); var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp); Assert.True(result.Limited); @@ -56,7 +56,7 @@ public async Task GcraRateLimit_CustomPeriod_RoundTrip( string requestResp, string responseResp) { - var msg = Message.Create(0, CommandFlags.None, RedisCommand.GCRA, (RedisKey)key, maxBurst, requestsPerPeriod, periodSeconds); + var msg = new RedisDatabase.GcraMessage(0, CommandFlags.None, key, maxBurst, requestsPerPeriod, periodSeconds, 1); var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp); Assert.False(result.Limited); diff --git a/toys/StackExchange.Redis.Server/TypedRedisValue.cs b/toys/StackExchange.Redis.Server/TypedRedisValue.cs index 0493399ac..2daa22611 100644 --- a/toys/StackExchange.Redis.Server/TypedRedisValue.cs +++ b/toys/StackExchange.Redis.Server/TypedRedisValue.cs @@ -10,9 +10,15 @@ namespace StackExchange.Redis /// public readonly struct TypedRedisValue { - // note: if this ever becomes exposed on the public API, it should be made so that it clears; - // can't trust external callers to clear the space, and using recycle without that is dangerous - internal static TypedRedisValue Rent(int count, out Span span, RespPrefix type) + /// + /// Rents an array from the pool and returns a that wraps it. + /// The returned span is cleared to ensure safe usage. + /// + /// The number of elements to rent. + /// The span that can be used to populate the array. + /// The RESP type of the array. + /// A that wraps the rented array. + public static TypedRedisValue Rent(int count, out Span span, RespPrefix type) { if (count == 0) { @@ -20,8 +26,9 @@ internal static TypedRedisValue Rent(int count, out Span span, return EmptyArray(type); } - var arr = ArrayPool.Shared.Rent(count); // new TypedRedisValue[count]; + var arr = ArrayPool.Shared.Rent(count); span = new Span(arr, 0, count); + span.Clear(); // Clear the span to ensure safe usage by external callers return new TypedRedisValue(arr, count, type); }