From 600119ee947f2bef5f1bf8cbba6d228962c41708 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 20 Mar 2026 11:03:23 +0000 Subject: [PATCH 1/4] release notes 2.12.4 --- docs/ReleaseNotes.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index c4cf50c32..4d2a97e9e 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -10,6 +10,12 @@ Current package versions: - (none) +## 2.12.4 + +- Fix RESP3 client handshakes on non-RESP3 servers by ([#3037 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3037)) +- Improve detection of connect/handshake failures and how that impacts the retry-policy ([#3038 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3038)) + + ## 2.12.1 - Add missing `LCS` outputs and missing `RedisType.VectorSet` ([#3028 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3028)) From 364b847a766ffdd8c999ac03138c1818c13ac8b6 Mon Sep 17 00:00:00 2001 From: Tim Lovell-Smith Date: Wed, 25 Mar 2026 05:27:03 -0700 Subject: [PATCH 2/4] Filter out 'temporary placeholder' cluster nodes with a handshake flag (#3043) * Filter out 'temporary placeholder' clsuter nodes with handshake flag that redis creates internally during cluster handshake (during initial cluster MEET, cluster MEET, cluster PONG sequence) since they are not expected to be usable members of the cluster at that time. * - move the "ignore handshake nodes" logic deeper into the client, so external callers can still fetch the value - add new IgnoreFromClient internal member for ^^^, and use - support "handshake" etc in the in-proc test server - support "only initially connect to default node" in the in-proc test server - add a unit test that handshake nodes are ignored * prove that ClusterNodesAsync still reports the node * split out the two "handshake" test parts * in-proc test server; make DefaultEndPoint reliable * release notes * put IsHandshake onto public API --------- Co-authored-by: Marc Gravell --- docs/ReleaseNotes.md | 2 +- .../ClusterConfiguration.cs | 11 ++++ .../ConnectionMultiplexer.cs | 4 +- .../PublicAPI/PublicAPI.Shipped.txt | 1 + src/StackExchange.Redis/ServerEndPoint.cs | 2 + .../ClusterHandshakeNodesUnitTests.cs | 55 +++++++++++++++++++ .../InProcessTestServer.cs | 17 ++++-- .../StackExchange.Redis.Server/RedisServer.cs | 47 ++++++++++------ 8 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 4d2a97e9e..d197547d1 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,7 +8,7 @@ Current package versions: ## Unreleased -- (none) +- Ignore cluster nodes with the `handshake` flag ([#3043 by @TimLovellSmith](https://github.com/StackExchange/StackExchange.Redis/pull/3043)) ## 2.12.4 diff --git a/src/StackExchange.Redis/ClusterConfiguration.cs b/src/StackExchange.Redis/ClusterConfiguration.cs index 084f7c639..c474c3c1b 100644 --- a/src/StackExchange.Redis/ClusterConfiguration.cs +++ b/src/StackExchange.Redis/ClusterConfiguration.cs @@ -308,6 +308,7 @@ internal ClusterNode(ClusterConfiguration configuration, string raw, EndPoint or } NodeId = parts[0]; + IsHandshake = flags.Contains("handshake"); IsFail = flags.Contains("fail"); IsPossiblyFail = flags.Contains("fail?"); IsReplica = flags.Contains("slave") || flags.Contains("replica"); @@ -377,6 +378,12 @@ public IList Children [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool IsSlave => IsReplica; + /// + /// The handshake flag is set for nodes which are currently in the process of joining the cluster. + /// They might not be fully configured, node IDs and slot ranges are placeholder information, and endpoint details 'best guess'. + /// + public bool IsHandshake { get; } + /// /// Gets whether this node is a replica. /// @@ -417,6 +424,10 @@ public IList Children /// public IList Slots { get; } + // Be resilient to "handshake" nodes, which are nodes that are in the process of joining the cluster and hence might not have all information available yet. + // These nodes will be included in the configuration once they finish the handshake process and are fully part of the cluster, so we can safely ignore them for now. + internal bool IgnoreFromClient => IsHandshake; // possibly also noaddr? + /// /// Compares the current instance with another object of the same type and returns an integer that indicates /// whether the current instance precedes, follows, or occurs in the same position in the sort order as the other object. diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index a5995046e..36f459f14 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1760,7 +1760,7 @@ public EndPoint[] GetEndPoints(bool configuredOnly = false) => { return null; } - var clusterEndpoints = new EndPointCollection(clusterConfig.Nodes.Where(node => node.EndPoint is not null).Select(node => node.EndPoint!).ToList()); + var clusterEndpoints = new EndPointCollection(clusterConfig.Nodes.Where(node => node.EndPoint is not null && !node.IgnoreFromClient).Select(node => node.EndPoint!).ToList()); // Loop through nodes in the cluster and update nodes relations to other nodes ServerEndPoint? serverEndpoint = null; foreach (EndPoint endpoint in clusterEndpoints) @@ -1933,7 +1933,7 @@ internal void UpdateClusterRange(ClusterConfiguration configuration) } foreach (var node in configuration.Nodes) { - if (node.IsReplica || node.Slots.Count == 0) continue; + if (node.IgnoreFromClient || node.IsReplica || node.Slots.Count == 0) continue; foreach (var slot in node.Slots) { if (GetServerEndPoint(node.EndPoint) is ServerEndPoint server) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index d474fc98d..85262ab81 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -165,6 +165,7 @@ StackExchange.Redis.ClusterNode.EndPoint.get -> System.Net.EndPoint? StackExchange.Redis.ClusterNode.Equals(StackExchange.Redis.ClusterNode? other) -> bool StackExchange.Redis.ClusterNode.IsConnected.get -> bool StackExchange.Redis.ClusterNode.IsFail.get -> bool +StackExchange.Redis.ClusterNode.IsHandshake.get -> bool StackExchange.Redis.ClusterNode.IsMyself.get -> bool StackExchange.Redis.ClusterNode.IsNoAddr.get -> bool StackExchange.Redis.ClusterNode.IsPossiblyFail.get -> bool diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 78fe4b88a..e14f5a803 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -324,6 +324,8 @@ public void UpdateNodeRelations(ClusterConfiguration configuration) ServerEndPoint? primary = null; foreach (var node in configuration.Nodes) { + if (node.IgnoreFromClient) continue; + if (node.NodeId == thisNode.ParentNodeId) { primary = Multiplexer.GetServerEndPoint(node.EndPoint); diff --git a/tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs b/tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs new file mode 100644 index 000000000..d52295903 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ClusterHandshakeNodesUnitTests.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +// context: https://github.com/StackExchange/StackExchange.Redis/pull/3043 +public class ClusterHandshakeNodesUnitTests(ITestOutputHelper log) +{ + [Fact] + public async Task ClusterHandshakeNodesAreIgnored() + { + using var server = new InProcessTestServer() { ServerType = ServerType.Cluster }; + var a = server.DefaultEndPoint; + var b = server.AddEmptyNode(); + var c = server.AddEmptyNode(Server.RedisServer.NodeFlags.Handshake); + await using var conn = await server.ConnectAsync(defaultOnly: true); // defaultOnly: only connect to a initially + + log.WriteLine($"a: {Format.ToString(a)}, b: {Format.ToString(b)}, c: {Format.ToString(c)}"); + var ep = conn.GetEndPoints(); + log.WriteLine("Endpoints:"); + foreach (var e in ep) + { + log.WriteLine(Format.ToString(e)); + } + Assert.Equal(2, ep.Length); + Assert.Contains(a, ep); + Assert.Contains(b, ep); + Assert.DoesNotContain(c, ep); + } + + [Fact] + public async Task ClusterHandshakeNodesAreNotIgnoredWhenFetchingDirectly() + { + using var server = new InProcessTestServer() { ServerType = ServerType.Cluster }; + var a = server.DefaultEndPoint; + var b = server.AddEmptyNode(); + var c = server.AddEmptyNode(Server.RedisServer.NodeFlags.Handshake); + await using var conn = await server.ConnectAsync(defaultOnly: true); // defaultOnly: only connect to a initially + + // check we can still *fetch* handshake nodes via the admin API + var serverApi = conn.GetServer(a); + var config = await serverApi.ClusterNodesAsync(); + Assert.NotNull(config); + Assert.Equal(3, config.Nodes.Count); + var eps = config.Nodes.Select(x => x.EndPoint).ToArray(); + Assert.Contains(a, eps); + Assert.Contains(b, eps); + Assert.Contains(c, eps); + + Assert.False(config[a]!.IsHandshake); + Assert.False(config[b]!.IsHandshake); + Assert.True(config[c]!.IsHandshake); + } +} diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 1b0ca68fc..af9f1ee44 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -27,8 +27,8 @@ public InProcessTestServer(ITestOutputHelper? log = null, EndPoint? endpoint = n Tunnel = new InProcTunnel(this); } - public Task ConnectAsync(bool withPubSub = true /*, WriteMode writeMode = WriteMode.Default */, TextWriter? log = null) - => ConnectionMultiplexer.ConnectAsync(GetClientConfig(withPubSub /*, writeMode */), log); + public Task ConnectAsync(bool withPubSub = true, bool defaultOnly = false /*, WriteMode writeMode = WriteMode.Default */, TextWriter? log = null) + => ConnectionMultiplexer.ConnectAsync(GetClientConfig(withPubSub, defaultOnly /*, writeMode */), log); // view request/response highlights in the log public override TypedRedisValue Execute(RedisClient client, in RedisRequest request) @@ -60,7 +60,7 @@ public override TypedRedisValue Execute(RedisClient client, in RedisRequest requ return result; } - public ConfigurationOptions GetClientConfig(bool withPubSub = true /*, WriteMode writeMode = WriteMode.Default */) + public ConfigurationOptions GetClientConfig(bool withPubSub = true, bool defaultOnly = false /*, WriteMode writeMode = WriteMode.Default */) { var commands = GetCommands(); if (!withPubSub) @@ -106,9 +106,16 @@ public ConfigurationOptions GetClientConfig(bool withPubSub = true /*, WriteMode #endif */ - foreach (var endpoint in GetEndPoints()) + if (defaultOnly) { - config.EndPoints.Add(endpoint); + config.EndPoints.Add(DefaultEndPoint); + } + else + { + foreach (var endpoint in GetEndPoints()) + { + config.EndPoints.Add(endpoint); + } } return config; } diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index 31a8155fe..468499a70 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -23,17 +23,7 @@ public static bool IsMatch(string pattern, string key) => public bool TryGetNode(EndPoint endpoint, out Node node) => _nodes.TryGetValue(endpoint, out node); - public EndPoint DefaultEndPoint - { - get - { - foreach (var pair in _nodes) - { - return pair.Key; - } - throw new InvalidOperationException("No endpoints"); - } - } + public EndPoint DefaultEndPoint { get; } public override Node DefaultNode { @@ -78,7 +68,19 @@ public bool Migrate(int hashSlot, EndPoint to) public bool Migrate(Span key, EndPoint to) => Migrate(ServerSelectionStrategy.GetClusterSlot(key), to); public bool Migrate(in RedisKey key, EndPoint to) => Migrate(GetHashSlot(key), to); - public EndPoint AddEmptyNode() + [Flags] + public enum NodeFlags + { + None = 0, // note: implicitly primary, since no replica flag + Replica = 1 << 0, + Handshake = 1 << 1, + Fail = 1 << 2, + PFail = 1 << 3, + NoAddress = 1 << 4, + NoFailover = 1 << 5, + } + + public EndPoint AddEmptyNode(NodeFlags flags = NodeFlags.None) { EndPoint endpoint; Node node; @@ -113,7 +115,7 @@ public EndPoint AddEmptyNode() break; } - node = new(this, endpoint); + node = new(this, endpoint, flags); node.UpdateSlots([]); // explicit empty range (rather than implicit "all nodes") } // defensive loop for concurrency @@ -123,8 +125,8 @@ public EndPoint AddEmptyNode() protected RedisServer(EndPoint endpoint = null, int databases = 16, TextWriter output = null) : base(output) { - endpoint ??= new IPEndPoint(IPAddress.Loopback, 6379); - _nodes.TryAdd(endpoint, new Node(this, endpoint)); + DefaultEndPoint = endpoint ??= new IPEndPoint(IPAddress.Loopback, 6379); + _nodes.TryAdd(endpoint, new Node(this, endpoint, NodeFlags.None)); RedisVersion = s_DefaultServerVersion; if (databases < 1) throw new ArgumentOutOfRangeException(nameof(databases)); Databases = databases; @@ -487,7 +489,16 @@ protected virtual TypedRedisValue ClusterNodes(RedisClient client, in RedisReque { sb.Append("myself,"); } - sb.Append("master - 0 0 1 connected"); + sb.Append((node.Flags & NodeFlags.Replica) == 0 ? "master" : "slave"); + if ((node.Flags & NodeFlags.Handshake) != 0) + { + sb.Append(",handshake"); + } + if ((node.Flags & NodeFlags.Fail) != 0) sb.Append(",fail"); + if ((node.Flags & NodeFlags.PFail) != 0) sb.Append(",fail?"); + if ((node.Flags & NodeFlags.NoAddress) != 0) sb.Append(",noaddr"); + if ((node.Flags & NodeFlags.NoFailover) != 0) sb.Append(",nofailover"); + sb.Append(" - 0 0 1 connected"); foreach (var range in node.Slots) { sb.Append(" ").Append(range.ToString()); @@ -603,11 +614,13 @@ public override string ToString() private readonly RedisServer _server; public RedisServer Server => _server; - public Node(RedisServer server, EndPoint endpoint) + public NodeFlags Flags { get; } + public Node(RedisServer server, EndPoint endpoint, NodeFlags flags) { Host = GetHost(endpoint, out var port); Port = port; _server = server; + Flags = flags; } public void UpdateSlots(SlotRange[] slots) => _slots = slots; From ea5a6401ff7470c4c78ea1a777f778208a949643 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 25 Mar 2026 12:31:02 +0000 Subject: [PATCH 3/4] Support multi-DB cluster usage on valkey (#3040) * - propose new API to resolve product variant; only handles Redis, Valkey and Garnet at the moment * - change the test-server to support multi-DB operations - update the client to use the product-variant info to allow multi-DB operations on Valkey - unit test with the toy-server * naming is hard * release notes --- docs/ReleaseNotes.md | 2 + .../AutoConfigureInfoField.cs | 49 ++++++ .../Enums/ProductVariant.cs | 28 ++++ src/StackExchange.Redis/Enums/ServerType.cs | 26 +++- src/StackExchange.Redis/Format.cs | 10 +- src/StackExchange.Redis/Interfaces/IServer.cs | 6 + src/StackExchange.Redis/KnownRole.cs | 55 +++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 5 + src/StackExchange.Redis/RedisServer.cs | 2 + src/StackExchange.Redis/ResultProcessor.cs | 143 ++++++++---------- src/StackExchange.Redis/ServerEndPoint.cs | 23 ++- .../AutoConfigureInfoFieldUnitTests.cs | 28 ++++ .../InProcessDatabaseUnitTests.cs | 40 +++++ .../KnownRoleMetadataUnitTests.cs | 24 +++ .../ProductVariantUnitTests.cs | 116 ++++++++++++++ .../ServerTypeMetadataUnitTests.cs | 25 +++ .../MemoryCacheRedisServer.cs | 131 ++++++++++------ .../StackExchange.Redis.Server/RedisServer.cs | 19 ++- 18 files changed, 594 insertions(+), 138 deletions(-) create mode 100644 src/StackExchange.Redis/AutoConfigureInfoField.cs create mode 100644 src/StackExchange.Redis/Enums/ProductVariant.cs create mode 100644 src/StackExchange.Redis/KnownRole.cs create mode 100644 tests/StackExchange.Redis.Tests/AutoConfigureInfoFieldUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/KnownRoleMetadataUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/ServerTypeMetadataUnitTests.cs diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index d197547d1..f927b5fe4 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,6 +8,8 @@ Current package versions: ## Unreleased +- Add `IServer.GetProductVariant` to detect the product variant and version of the connected server, and use that internally + to enable multi-DB operations on Valkey clusters ([#3040 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3040)) - Ignore cluster nodes with the `handshake` flag ([#3043 by @TimLovellSmith](https://github.com/StackExchange/StackExchange.Redis/pull/3043)) ## 2.12.4 diff --git a/src/StackExchange.Redis/AutoConfigureInfoField.cs b/src/StackExchange.Redis/AutoConfigureInfoField.cs new file mode 100644 index 000000000..9d30b3ae5 --- /dev/null +++ b/src/StackExchange.Redis/AutoConfigureInfoField.cs @@ -0,0 +1,49 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in INFO output during auto-configuration. +/// +internal enum AutoConfigureInfoField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + [AsciiHash("role")] + Role, + + [AsciiHash("master_host")] + MasterHost, + + [AsciiHash("master_port")] + MasterPort, + + [AsciiHash("redis_version")] + RedisVersion, + + [AsciiHash("redis_mode")] + RedisMode, + + [AsciiHash("run_id")] + RunId, + + [AsciiHash("garnet_version")] + GarnetVersion, + + [AsciiHash("valkey_version")] + ValkeyVersion, +} + +/// +/// Metadata and parsing methods for . +/// +internal static partial class AutoConfigureInfoFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out AutoConfigureInfoField field); +} diff --git a/src/StackExchange.Redis/Enums/ProductVariant.cs b/src/StackExchange.Redis/Enums/ProductVariant.cs new file mode 100644 index 000000000..9355af073 --- /dev/null +++ b/src/StackExchange.Redis/Enums/ProductVariant.cs @@ -0,0 +1,28 @@ +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +/// +/// Indicates the flavor of RESP-server being used, if known. Unknown variants will be reported as . +/// Inclusion (or omission) in this list does not imply support for any given variant; nor does it indicate any specific +/// relationship with the vendor or rights to use the name. It is provided solely for informational purposes. Identification +/// is not guaranteed, and is based on the server's self-reporting (typically via `INFO`), which may be incomplete or misleading. +/// +public enum ProductVariant +{ + /// + /// The original Redis server. This is also the default value if the variant is unknown. + /// + Redis, + + /// + /// Valkey is a fork of open-source Redis associated with AWS. + /// + Valkey, + + /// + /// Garnet is a Redis-compatible server from Microsoft. + /// + Garnet, + + // if you want to add another variant here, please open an issue with the details (variant name, INFO output, etc.) +} diff --git a/src/StackExchange.Redis/Enums/ServerType.cs b/src/StackExchange.Redis/Enums/ServerType.cs index ef49a8449..e37d38b08 100644 --- a/src/StackExchange.Redis/Enums/ServerType.cs +++ b/src/StackExchange.Redis/Enums/ServerType.cs @@ -1,4 +1,7 @@ -namespace StackExchange.Redis +using System; +using RESPite; + +namespace StackExchange.Redis { /// /// Indicates the flavor of a particular redis server. @@ -8,29 +11,50 @@ public enum ServerType /// /// Classic redis-server server. /// + [AsciiHash("standalone")] Standalone, /// /// Monitoring/configuration redis-sentinel server. /// + [AsciiHash("sentinel")] Sentinel, /// /// Distributed redis-cluster server. /// + [AsciiHash("cluster")] Cluster, /// /// Distributed redis installation via twemproxy. /// + [AsciiHash("")] Twemproxy, /// /// Redis cluster via envoyproxy. /// + [AsciiHash("")] Envoyproxy, } + /// + /// Metadata and parsing methods for . + /// + internal static partial class ServerTypeMetadata + { + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out ServerType serverType); + + internal static bool TryParse(string? val, out ServerType serverType) + { + if (val is not null) return TryParse(val.AsSpan().Trim(), out serverType); + serverType = default; + return false; + } + } + internal static class ServerTypeExtensions { /// diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index 9279bb0f5..c7dc1e0a7 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -583,14 +583,8 @@ internal static bool TryParseVersion(ReadOnlySpan input, [NotNullWhen(true version = null; return false; } - unsafe - { - fixed (char* ptr = input) - { - string s = new(ptr, 0, input.Length); - return TryParseVersion(s, out version); - } - } + string s = input.ToString(); + return TryParseVersion(s, out version); #endif } diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index fdd3d6872..faeb20e93 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -72,6 +72,12 @@ public partial interface IServer : IRedis /// Version Version { get; } + /// + /// Attempt to identify the specific Redis product variant and version. + /// + /// Note that it is explicitly not assumed that the version will conform to the format. + ProductVariant GetProductVariant(out string version); + /// /// The number of databases supported on this server. /// diff --git a/src/StackExchange.Redis/KnownRole.cs b/src/StackExchange.Redis/KnownRole.cs new file mode 100644 index 000000000..6823d7a7c --- /dev/null +++ b/src/StackExchange.Redis/KnownRole.cs @@ -0,0 +1,55 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Known role values used during auto-configuration parsing. +/// +internal enum KnownRole +{ + /// + /// Unknown or unrecognized role. + /// + [AsciiHash("")] + None = 0, + + [AsciiHash("primary")] + Primary, + + [AsciiHash("master")] + Master, + + [AsciiHash("replica")] + Replica, + + [AsciiHash("slave")] + Slave, +} + +/// +/// Metadata and parsing methods for . +/// +internal static partial class KnownRoleMetadata +{ + [AsciiHash] + private static partial bool TryParseCore(ReadOnlySpan value, out KnownRole role); + + internal static bool TryParse(ReadOnlySpan value, out bool isReplica) + { + if (!TryParseCore(value.Trim(), out var role)) + { + isReplica = false; + return false; + } + + isReplica = role is KnownRole.Replica or KnownRole.Slave; + return true; + } + internal static bool TryParse(string? val, out bool isReplica) + { + if (val is not null) return TryParse(val.AsSpan(), out isReplica); + isReplica = false; + return false; + } +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..a53d95ff7 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,6 @@ #nullable enable +StackExchange.Redis.IServer.GetProductVariant(out string! version) -> StackExchange.Redis.ProductVariant +StackExchange.Redis.ProductVariant +StackExchange.Redis.ProductVariant.Garnet = 2 -> StackExchange.Redis.ProductVariant +StackExchange.Redis.ProductVariant.Redis = 0 -> StackExchange.Redis.ProductVariant +StackExchange.Redis.ProductVariant.Valkey = 1 -> StackExchange.Redis.ProductVariant diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 2d7e184ad..de7c03385 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -49,6 +49,8 @@ public bool AllowReplicaWrites public ServerType ServerType => server.ServerType; + public ProductVariant GetProductVariant(out string version) => server.GetProductVariant(out version); + public Version Version => server.Version; public void ClientKill(EndPoint endpoint, CommandFlags flags = CommandFlags.None) diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index f7f6047b1..9443af35c 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -867,6 +867,9 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } string? primaryHost = null, primaryPort = null; bool roleSeen = false; + ProductVariant productVariant = ProductVariant.Redis; + string productVersion = ""; + using (var reader = new StringReader(info)) { while (reader.ReadLine() is string line) @@ -876,43 +879,61 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes continue; } - string? val; - if ((val = Extract(line, "role:")) != null) - { - roleSeen = true; - if (TryParseRole(val, out bool isReplica)) - { - server.IsReplica = isReplica; - Log?.LogInformationAutoConfiguredInfoRole(new(server), isReplica ? "replica" : "primary"); - } - } - else if ((val = Extract(line, "master_host:")) != null) - { - primaryHost = val; - } - else if ((val = Extract(line, "master_port:")) != null) - { - primaryPort = val; - } - else if ((val = Extract(line, "redis_version:")) != null) - { - if (Format.TryParseVersion(val, out Version? version)) - { - server.Version = version; - Log?.LogInformationAutoConfiguredInfoVersion(new(server), version); - } - } - else if ((val = Extract(line, "redis_mode:")) != null) + var idx = line.IndexOf(':'); + if (idx < 0) continue; + + if (!AutoConfigureInfoFieldMetadata.TryParse(line.AsSpan(0, idx), out AutoConfigureInfoField field)) { - if (TryParseServerType(val, out var serverType)) - { - server.ServerType = serverType; - Log?.LogInformationAutoConfiguredInfoServerType(new(server), serverType); - } + continue; } - else if ((val = Extract(line, "run_id:")) != null) + var valSpan = line.AsSpan(idx + 1).Trim(); + + switch (field) { - server.RunId = val; + case AutoConfigureInfoField.Role: + roleSeen = true; + if (KnownRoleMetadata.TryParse(valSpan, out bool isReplica)) + { + server.IsReplica = isReplica; + Log?.LogInformationAutoConfiguredInfoRole(new(server), isReplica ? "replica" : "primary"); + } + break; + case AutoConfigureInfoField.MasterHost: + primaryHost = valSpan.ToString(); + break; + case AutoConfigureInfoField.MasterPort: + primaryPort = valSpan.ToString(); + break; + case AutoConfigureInfoField.RedisVersion: + if (Format.TryParseVersion(valSpan, out Version? version)) + { + server.Version = version; + Log?.LogInformationAutoConfiguredInfoVersion(new(server), version); + } + if (productVariant is ProductVariant.Redis) + { + // if we haven't already decided this is Garnet/Valkey, etc: capture the version string. + productVersion = valSpan.ToString(); + } + break; + case AutoConfigureInfoField.RedisMode: + if (ServerTypeMetadata.TryParse(valSpan, out var serverType)) + { + server.ServerType = serverType; + Log?.LogInformationAutoConfiguredInfoServerType(new(server), serverType); + } + break; + case AutoConfigureInfoField.RunId: + server.RunId = valSpan.ToString(); + break; + case AutoConfigureInfoField.GarnetVersion: + productVariant = ProductVariant.Garnet; + productVersion = valSpan.ToString(); + break; + case AutoConfigureInfoField.ValkeyVersion: + productVariant = ProductVariant.Valkey; + productVersion = valSpan.ToString(); + break; } } if (roleSeen && Format.TryParseEndPoint(primaryHost!, primaryPort, out var sep)) @@ -921,6 +942,13 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes server.PrimaryEndPoint = sep; } } + + // Set the product variant and version (this is deferred because there can be + // both redis_version:6.1.2 and whatever_version:12.3.4, in any order). + if (!string.IsNullOrWhiteSpace(productVersion)) + { + server.SetProductVariant(productVariant, productVersion); + } } else if (message?.Command == RedisCommand.SENTINEL) { @@ -1006,12 +1034,12 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes connection.ConnectionId = i64; Log?.LogInformationAutoConfiguredHelloConnectionId(new(server), i64); } - else if (key.IsEqual(CommonReplies.mode) && TryParseServerType(val.GetString(), out var serverType)) + else if (key.IsEqual(CommonReplies.mode) && ServerTypeMetadata.TryParse(val.GetString(), out var serverType)) { server.ServerType = serverType; Log?.LogInformationAutoConfiguredHelloServerType(new(server), serverType); } - else if (key.IsEqual(CommonReplies.role) && TryParseRole(val.GetString(), out bool isReplica)) + else if (key.IsEqual(CommonReplies.role) && KnownRoleMetadata.TryParse(val.GetString(), out bool isReplica)) { server.IsReplica = isReplica; Log?.LogInformationAutoConfiguredHelloRole(new(server), isReplica ? "replica" : "primary"); @@ -1029,49 +1057,6 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return false; } - private static string? Extract(string line, string prefix) - { - if (line.StartsWith(prefix)) return line.Substring(prefix.Length).Trim(); - return null; - } - - private static bool TryParseServerType(string? val, out ServerType serverType) - { - switch (val) - { - case "standalone": - serverType = ServerType.Standalone; - return true; - case "cluster": - serverType = ServerType.Cluster; - return true; - case "sentinel": - serverType = ServerType.Sentinel; - return true; - default: - serverType = default; - return false; - } - } - - private static bool TryParseRole(string? val, out bool isReplica) - { - switch (val) - { - case "primary": - case "master": - isReplica = false; - return true; - case "replica": - case "slave": - isReplica = true; - return true; - default: - isReplica = default; - return false; - } - } - internal static ResultProcessor Create(ILogger? log) => log is null ? AutoConfigure : new AutoConfigureProcessor(log); } diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index e14f5a803..0da791d3c 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -91,7 +91,12 @@ public RedisServer GetRedisServer(object? asyncState) /// This is memoized because it's accessed on hot paths inside the write lock. /// public bool SupportsDatabases => - supportsDatabases ??= serverType == ServerType.Standalone && Multiplexer.CommandMap.IsAvailable(RedisCommand.SELECT); + supportsDatabases ??= serverType switch + { + ServerType.Standalone => true, + ServerType.Cluster => _productVariant is ProductVariant.Valkey, + _ => false, + } && Multiplexer.CommandMap.IsAvailable(RedisCommand.SELECT); public int Databases { @@ -1134,5 +1139,21 @@ internal bool HasPendingCallerFacingItems() if (interactive?.HasPendingCallerFacingItems() == true) return true; return subscription?.HasPendingCallerFacingItems() ?? false; } + + private ProductVariant _productVariant = ProductVariant.Redis; + private string _productVersion = ""; + + internal void SetProductVariant(ProductVariant variant, string productVersion) + { + _productVariant = variant; + _productVersion = productVersion; + ClearMemoized(); // variant impacts multi-DB rules for cluster + } + + internal ProductVariant GetProductVariant(out string productVersion) + { + productVersion = _productVersion; + return _productVariant; + } } } diff --git a/tests/StackExchange.Redis.Tests/AutoConfigureInfoFieldUnitTests.cs b/tests/StackExchange.Redis.Tests/AutoConfigureInfoFieldUnitTests.cs new file mode 100644 index 000000000..ce4ce853b --- /dev/null +++ b/tests/StackExchange.Redis.Tests/AutoConfigureInfoFieldUnitTests.cs @@ -0,0 +1,28 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class AutoConfigureInfoFieldUnitTests +{ + [Theory] + [InlineData("role", (int)AutoConfigureInfoField.Role)] + [InlineData("master_host", (int)AutoConfigureInfoField.MasterHost)] + [InlineData("master_port", (int)AutoConfigureInfoField.MasterPort)] + [InlineData("redis_version", (int)AutoConfigureInfoField.RedisVersion)] + [InlineData("redis_mode", (int)AutoConfigureInfoField.RedisMode)] + [InlineData("run_id", (int)AutoConfigureInfoField.RunId)] + [InlineData("garnet_version", (int)AutoConfigureInfoField.GarnetVersion)] + [InlineData("valkey_version", (int)AutoConfigureInfoField.ValkeyVersion)] + public void TryParse_CharSpan_KnownFields(string value, int expected) + { + Assert.True(AutoConfigureInfoFieldMetadata.TryParse(value.AsSpan(), out var actual)); + Assert.Equal(expected, (int)actual); + } + + [Fact] + public void TryParse_CharSpan_UnknownField() + { + Assert.False(AutoConfigureInfoFieldMetadata.TryParse("server_name".AsSpan(), out _)); + } +} diff --git a/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs b/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs new file mode 100644 index 000000000..941e1eb15 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +[RunPerProtocol] +public class InProcessDatabaseUnitTests(ITestOutputHelper output) +{ + [Fact] + public async Task DatabasesAreIsolatedAndCanBeFlushed() + { + using var server = new InProcessTestServer(output); + await using var conn = await server.ConnectAsync(); + + var admin = conn.GetServer(conn.GetEndPoints()[0]); + var key = (RedisKey)Guid.NewGuid().ToString("n"); + var db0 = conn.GetDatabase(0); + var db1 = conn.GetDatabase(1); + + db0.KeyDelete(key, CommandFlags.FireAndForget); + db1.KeyDelete(key, CommandFlags.FireAndForget); + db0.StringSet(key, "a"); + db1.StringSet(key, "b"); + + Assert.Equal("a", db0.StringGet(key)); + Assert.Equal("b", db1.StringGet(key)); + Assert.Equal(1, admin.DatabaseSize(0)); + Assert.Equal(1, admin.DatabaseSize(1)); + + admin.FlushDatabase(0); + Assert.True(db0.StringGet(key).IsNull); + Assert.Equal("b", db1.StringGet(key)); + + admin.FlushAllDatabases(); + Assert.True(db1.StringGet(key).IsNull); + Assert.Equal(0, admin.DatabaseSize(0)); + Assert.Equal(0, admin.DatabaseSize(1)); + } +} diff --git a/tests/StackExchange.Redis.Tests/KnownRoleMetadataUnitTests.cs b/tests/StackExchange.Redis.Tests/KnownRoleMetadataUnitTests.cs new file mode 100644 index 000000000..817370667 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/KnownRoleMetadataUnitTests.cs @@ -0,0 +1,24 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class KnownRoleMetadataUnitTests +{ + [Theory] + [InlineData("primary", false)] + [InlineData("master", false)] + [InlineData("replica", true)] + [InlineData("slave", true)] + public void TryParse_CharSpan_KnownRoles(string value, bool expected) + { + Assert.True(KnownRoleMetadata.TryParse(value.AsSpan(), out var actual)); + Assert.Equal(expected, actual); + } + + [Fact] + public void TryParse_CharSpan_UnknownRole() + { + Assert.False(KnownRoleMetadata.TryParse("sentinel".AsSpan(), out _)); + } +} diff --git a/tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs b/tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs new file mode 100644 index 000000000..4082c9de5 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs @@ -0,0 +1,116 @@ +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class ProductVariantUnitTests(ITestOutputHelper log) +{ + [Theory] + [InlineData(ProductVariant.Redis)] + [InlineData(ProductVariant.Valkey)] + [InlineData(ProductVariant.Garnet)] + public async Task DetectProductVariant(ProductVariant variant) + { + using var serverObj = new ProductServer(variant, log); + using var conn = await serverObj.ConnectAsync(withPubSub: false); + var serverApi = conn.GetServer(conn.GetEndPoints().First()); + serverApi.Ping(); + var reportedProduct = serverApi.GetProductVariant(out var reportedVersion); + Assert.Equal(variant, reportedProduct); + log.WriteLine($"Detected {reportedProduct} version: {reportedVersion}"); + if (variant == ProductVariant.Redis) + { + Assert.Equal(serverObj.VersionString, reportedVersion); + } + else + { + Assert.Equal("1.2.3-preview4", reportedVersion); + } + } + + [Theory] + [InlineData(ProductVariant.Redis, ServerType.Standalone, true)] + [InlineData(ProductVariant.Redis, ServerType.Cluster, false)] + [InlineData(ProductVariant.Garnet, ServerType.Standalone, true)] + [InlineData(ProductVariant.Garnet, ServerType.Cluster, false)] + [InlineData(ProductVariant.Valkey, ServerType.Standalone, true)] + [InlineData(ProductVariant.Valkey, ServerType.Cluster, true)] + public async Task MultiDbSupportMatchesProductVariantAndServerType(ProductVariant variant, ServerType serverType, bool supportsMultiDb) + { + using var serverObj = new ProductServer(variant, log, serverType); + await using var conn = await serverObj.ConnectAsync(withPubSub: false); + + var serverApi = conn.GetServer(conn.GetEndPoints().First()); + await serverApi.PingAsync(); + Assert.Equal(serverType, serverApi.ServerType); + Assert.Equal(variant, serverApi.GetProductVariant(out _)); + + RedisKey key = $"multidb:{variant}:{serverType}"; + const string db0Value = "db0"; + const string db1Value = "db1"; + var db0 = conn.GetDatabase(0); + + var db1 = conn.GetDatabase(1); + + await db0.StringSetAsync(key, db0Value); + + if (supportsMultiDb) + { + await db1.StringSetAsync(key, db1Value); + Assert.Equal(db0Value, (string?)await db0.StringGetAsync(key)); + Assert.Equal(db1Value, (string?)await db1.StringGetAsync(key)); + } + else + { + var ex = await Assert.ThrowsAsync(() => db1.StringSetAsync(key, db1Value)); + var inner = Assert.IsType(ex.InnerException); + Assert.Contains("cannot switch to database: 1", inner.Message); + Assert.Equal(db0Value, (string?)await db0.StringGetAsync(key)); + } + } + + private sealed class ProductServer : InProcessTestServer + { + private readonly ProductVariant _variant; + + public ProductServer(ProductVariant variant, ITestOutputHelper log, ServerType serverType = ServerType.Standalone) + : base(log) + { + _variant = variant; + ServerType = serverType; + } + + protected override void Info(StringBuilder sb, string section) + { + base.Info(sb, section); + if (section is "Server") + { + switch (_variant) + { + case ProductVariant.Garnet: + sb.AppendLine("garnet_version:1.2.3-preview4"); + break; + case ProductVariant.Valkey: + sb.AppendLine("valkey_version:1.2.3-preview4") + .AppendLine("server_name:valkey"); + break; + } + } + } + + protected override bool SupportMultiDb(out string err) + { + switch (_variant) + { + case ProductVariant.Valkey: + // support multiple databases even on cluster + err = ""; + return true; + default: + return base.SupportMultiDb(out err); + } + } + } +} diff --git a/tests/StackExchange.Redis.Tests/ServerTypeMetadataUnitTests.cs b/tests/StackExchange.Redis.Tests/ServerTypeMetadataUnitTests.cs new file mode 100644 index 000000000..8203e3207 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ServerTypeMetadataUnitTests.cs @@ -0,0 +1,25 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class ServerTypeMetadataUnitTests +{ + [Theory] + [InlineData("standalone", (int)ServerType.Standalone)] + [InlineData("cluster", (int)ServerType.Cluster)] + [InlineData("sentinel", (int)ServerType.Sentinel)] + public void TryParse_CharSpan_KnownServerTypes(string value, int expected) + { + Assert.True(ServerTypeMetadata.TryParse(value.AsSpan(), out var actual)); + Assert.Equal(expected, (int)actual); + } + + [Theory] + [InlineData("twemproxy")] + [InlineData("envoyproxy")] + public void TryParse_CharSpan_IgnoresNonAutoConfiguredTypes(string value) + { + Assert.False(ServerTypeMetadata.TryParse(value.AsSpan(), out _)); + } +} diff --git a/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs b/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs index e9bcb5a5f..a8c0c22d1 100644 --- a/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs +++ b/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs @@ -1,34 +1,61 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Runtime.Caching; using System.Runtime.CompilerServices; +using System.Threading; namespace StackExchange.Redis.Server { public class MemoryCacheRedisServer : RedisServer { - public MemoryCacheRedisServer(EndPoint endpoint = null, TextWriter output = null) : base(endpoint, 1, output) - => CreateNewCache(); + private readonly string _cacheNamePrefix = $"{nameof(MemoryCacheRedisServer)}.{Guid.NewGuid():N}"; + private readonly ConcurrentDictionary _databases = new(); + private int _nextCacheId; - private MemoryCache _cache2; + public MemoryCacheRedisServer(EndPoint endpoint = null, int databases = DefaultDatabaseCount, TextWriter output = null) : base(endpoint, databases, output) + { + } + + private MemoryCache CreateNewCache(int database) + => new($"{_cacheNamePrefix}.{database}.{Interlocked.Increment(ref _nextCacheId)}"); + + private MemoryCache GetDb(int database) + { + while (true) + { + if (_databases.TryGetValue(database, out var existing)) return existing; + + var created = CreateNewCache(database); + if (_databases.TryAdd(database, created)) return created; + + created.Dispose(); + } + } - private void CreateNewCache() + private void FlushDbCore(int database) { - var old = _cache2; - _cache2 = new MemoryCache(GetType().Name); - old?.Dispose(); + if (_databases.TryRemove(database, out var cache)) cache.Dispose(); + } + + private void FlushAllCore() + { + foreach (var pair in _databases) + { + if (_databases.TryRemove(pair.Key, out var cache)) cache.Dispose(); + } } protected override void Dispose(bool disposing) { - if (disposing) _cache2.Dispose(); + if (disposing) FlushAllCore(); base.Dispose(disposing); } - protected override long Dbsize(int database) => _cache2.GetCount(); + protected override long Dbsize(int database) => GetDb(database).GetCount(); private readonly struct ExpiringValue(object value, DateTime absoluteExpiration) { @@ -43,9 +70,10 @@ private enum ExpectedType Set, List, } - private object Get(in RedisKey key, ExpectedType expectedType) + private object Get(int database, in RedisKey key, ExpectedType expectedType) { - var val = _cache2[key]; + var db = GetDb(database); + var val = db[key]; switch (val) { case null: @@ -53,7 +81,7 @@ private object Get(in RedisKey key, ExpectedType expectedType) case ExpiringValue ev: if (ev.AbsoluteExpiration <= Time()) { - _cache2.Remove(key); + db.Remove(key); return null; } return Validate(ev.Value, expectedType); @@ -78,7 +106,8 @@ static object Validate(object value, ExpectedType expectedType) } protected override TimeSpan? Ttl(int database, in RedisKey key) { - var val = _cache2[key]; + var db = GetDb(database); + var val = db[key]; switch (val) { case null: @@ -87,7 +116,7 @@ static object Validate(object value, ExpectedType expectedType) var delta = ev.AbsoluteExpiration - Time(); if (delta <= TimeSpan.Zero) { - _cache2.Remove(key); + db.Remove(key); return null; } return delta; @@ -99,10 +128,11 @@ static object Validate(object value, ExpectedType expectedType) protected override bool Expire(int database, in RedisKey key, TimeSpan timeout) { if (timeout <= TimeSpan.Zero) return Del(database, key); - var val = Get(key, ExpectedType.Any); + var db = GetDb(database); + var val = Get(database, key, ExpectedType.Any); if (val is not null) { - _cache2[key] = new ExpiringValue(val, Time() + timeout); + db[key] = new ExpiringValue(val, Time() + timeout); return true; } @@ -111,107 +141,115 @@ protected override bool Expire(int database, in RedisKey key, TimeSpan timeout) protected override RedisValue Get(int database, in RedisKey key) { - var val = Get(key, ExpectedType.Stack); + var val = Get(database, key, ExpectedType.Stack); return RedisValue.Unbox(val); } protected override void Set(int database, in RedisKey key, in RedisValue value) - => _cache2[key] = value.Box(); + => GetDb(database)[key] = value.Box(); protected override void SetEx(int database, in RedisKey key, TimeSpan expiration, in RedisValue value) { + var db = GetDb(database); var now = Time(); var absolute = now + expiration; - if (absolute <= now) _cache2.Remove(key); - else _cache2[key] = new ExpiringValue(value.Box(), absolute); + if (absolute <= now) db.Remove(key); + else db[key] = new ExpiringValue(value.Box(), absolute); } protected override bool Del(int database, in RedisKey key) - => _cache2.Remove(key) != null; + => GetDb(database).Remove(key) != null; protected override void Flushdb(int database) - => CreateNewCache(); + => FlushDbCore(database); - protected override bool Exists(int database, in RedisKey key) + protected override TypedRedisValue Flushall(RedisClient client, in RedisRequest request) { - var val = Get(key, ExpectedType.Any); - return val != null && !(val is ExpiringValue ev && ev.AbsoluteExpiration <= Time()); + FlushAllCore(); + return TypedRedisValue.OK; } - protected override IEnumerable Keys(int database, in RedisKey pattern) => GetKeysCore(pattern); - private IEnumerable GetKeysCore(RedisKey pattern) + protected override bool Exists(int database, in RedisKey key) + => Get(database, key, ExpectedType.Any) is not null; + + protected override IEnumerable Keys(int database, in RedisKey pattern) => GetKeysCore(database, pattern); + private IEnumerable GetKeysCore(int database, RedisKey pattern) { - foreach (var pair in _cache2) + foreach (var pair in GetDb(database)) { if (pair.Value is ExpiringValue ev && ev.AbsoluteExpiration <= Time()) continue; if (IsMatch(pattern, pair.Key)) yield return pair.Key; } } protected override bool Sadd(int database, in RedisKey key, in RedisValue value) - => GetSet(key, true).Add(value); + => GetSet(database, key, true).Add(value); protected override bool Sismember(int database, in RedisKey key, in RedisValue value) - => GetSet(key, false)?.Contains(value) ?? false; + => GetSet(database, key, false)?.Contains(value) ?? false; protected override bool Srem(int database, in RedisKey key, in RedisValue value) { - var set = GetSet(key, false); + var db = GetDb(database); + var set = GetSet(database, key, false); if (set != null && set.Remove(value)) { - if (set.Count == 0) _cache2.Remove(key); + if (set.Count == 0) db.Remove(key); return true; } return false; } protected override long Scard(int database, in RedisKey key) - => GetSet(key, false)?.Count ?? 0; + => GetSet(database, key, false)?.Count ?? 0; - private HashSet GetSet(RedisKey key, bool create) + private HashSet GetSet(int database, in RedisKey key, bool create) { - var set = (HashSet)Get(key, ExpectedType.Set); + var db = GetDb(database); + var set = (HashSet)Get(database, key, ExpectedType.Set); if (set == null && create) { set = new HashSet(); - _cache2[key] = set; + db[key] = set; } return set; } protected override RedisValue Spop(int database, in RedisKey key) { - var set = GetSet(key, false); + var db = GetDb(database); + var set = GetSet(database, key, false); if (set == null) return RedisValue.Null; var result = set.First(); set.Remove(result); - if (set.Count == 0) _cache2.Remove(key); + if (set.Count == 0) db.Remove(key); return result; } protected override long Lpush(int database, in RedisKey key, in RedisValue value) { - var stack = GetStack(key, true); + var stack = GetStack(database, key, true); stack.Push(value); return stack.Count; } protected override RedisValue Lpop(int database, in RedisKey key) { - var stack = GetStack(key, false); + var db = GetDb(database); + var stack = GetStack(database, key, false); if (stack == null) return RedisValue.Null; var val = stack.Pop(); - if (stack.Count == 0) _cache2.Remove(key); + if (stack.Count == 0) db.Remove(key); return val; } protected override long Llen(int database, in RedisKey key) - => GetStack(key, false)?.Count ?? 0; + => GetStack(database, key, false)?.Count ?? 0; [MethodImpl(MethodImplOptions.NoInlining)] private static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(); protected override void LRange(int database, in RedisKey key, long start, Span arr) { - var stack = GetStack(key, false); + var stack = GetStack(database, key, false); using (var iter = stack.GetEnumerator()) { @@ -227,13 +265,14 @@ protected override void LRange(int database, in RedisKey key, long start, Span GetStack(in RedisKey key, bool create) + private Stack GetStack(int database, in RedisKey key, bool create) { - var stack = (Stack)Get(key, ExpectedType.Stack); + var db = GetDb(database); + var stack = (Stack)Get(database, key, ExpectedType.Stack); if (stack == null && create) { stack = new Stack(); - _cache2[key] = stack; + db[key] = stack; } return stack; } diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index 468499a70..558d0ffb1 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -15,6 +15,8 @@ namespace StackExchange.Redis.Server { public abstract partial class RedisServer : RespServer { + public const int DefaultDatabaseCount = 16; + // non-trivial wildcards not implemented yet! public static bool IsMatch(string pattern, string key) => pattern == "*" || string.Equals(pattern, key, StringComparison.OrdinalIgnoreCase); @@ -123,7 +125,7 @@ public EndPoint AddEmptyNode(NodeFlags flags = NodeFlags.None) return endpoint; } - protected RedisServer(EndPoint endpoint = null, int databases = 16, TextWriter output = null) : base(output) + protected RedisServer(EndPoint endpoint = null, int databases = DefaultDatabaseCount, TextWriter output = null) : base(output) { DefaultEndPoint = endpoint ??= new IPEndPoint(IPAddress.Loopback, 6379); _nodes.TryAdd(endpoint, new Node(this, endpoint, NodeFlags.None)); @@ -1113,7 +1115,7 @@ protected virtual TypedRedisValue Keys(RedisClient client, in RedisRequest reque private static readonly Version s_DefaultServerVersion = new(1, 0, 0); private string _versionString; - private string VersionString => _versionString; + public string VersionString => _versionString; private static string FormatVersion(Version v) { var sb = new StringBuilder().Append(v.Major).Append('.').Append(v.Minor); @@ -1153,7 +1155,6 @@ StringBuilder AddHeader() switch (section) { case "Server": - var v = RedisVersion; AddHeader().Append("redis_version:").AppendLine(VersionString) .Append("redis_mode:").Append(ModeString).AppendLine() .Append("os:").Append(Environment.OSVersion).AppendLine() @@ -1259,10 +1260,22 @@ protected virtual TypedRedisValue Select(RedisClient client, in RedisRequest req var raw = request.GetValue(1); if (!raw.TryParse(out int db)) return TypedRedisValue.Error("ERR invalid DB index"); if (db < 0 || db >= Databases) return TypedRedisValue.Error("ERR DB index is out of range"); + if (db != 0 && !SupportMultiDb(out var err)) return TypedRedisValue.Error(err); client.Database = db; return TypedRedisValue.OK; } + protected virtual bool SupportMultiDb(out string err) + { + if (ServerType is ServerType.Cluster) + { + err = "ERR SELECT is not allowed in cluster mode"; + return false; + } + err = ""; + return true; + } + private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); [RedisCommand(1, LockFree = true)] From 8af991154ca93fc91b971146bf08b4f27d854c81 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 25 Mar 2026 12:31:42 +0000 Subject: [PATCH 4/4] Implement GRCA (#3041) * Implement GRCA (https://github.com/redis/redis/pull/14826) * Flag [SER006]; remove [SER001] * release notes * Update ReleaseNotes.md --- Directory.Build.props | 2 +- docs/ReleaseNotes.md | 1 + docs/exp/SER006.md | 24 +++ src/RESPite/Messages/RespReader.cs | 40 +++- src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 1 + src/RESPite/Shared/Experiments.cs | 2 +- .../ChannelMessageQueue.cs | 1 - src/StackExchange.Redis/Enums/RedisCommand.cs | 2 + src/StackExchange.Redis/ExtensionMethods.cs | 67 ++++++ src/StackExchange.Redis/Gcra.GcraMessage.cs | 40 ++++ .../Gcra.GcraRateLimitResult.cs | 49 +++++ .../Gcra.ResultProcessor.cs | 70 +++++++ .../HotKeys.StartMessage.cs | 1 - .../Interfaces/IDatabase.VectorSets.cs | 20 +- .../Interfaces/IDatabase.cs | 14 ++ .../Interfaces/IDatabaseAsync.VectorSets.cs | 17 -- .../Interfaces/IDatabaseAsync.cs | 5 +- src/StackExchange.Redis/KeyNotification.cs | 1 - .../KeyPrefixed.VectorSets.cs | 6 +- .../KeyspaceIsolation/KeyPrefixed.cs | 3 + .../KeyPrefixedDatabase.VectorSets.cs | 4 +- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 4 +- .../Message.ValueCondition.cs | 4 +- .../PublicAPI/PublicAPI.Shipped.txt | 197 +++++++++--------- src/StackExchange.Redis/RedisChannel.cs | 1 - .../RedisDatabase.Strings.cs | 15 +- src/StackExchange.Redis/ResultProcessor.cs | 3 + .../VectorSetAddMessage.cs | 1 - .../VectorSetAddRequest.cs | 2 - src/StackExchange.Redis/VectorSetInfo.cs | 5 - src/StackExchange.Redis/VectorSetLink.cs | 6 +- .../VectorSetQuantization.cs | 2 - .../VectorSetSimilaritySearchRequest.cs | 3 - .../VectorSetSimilaritySearchResult.cs | 2 - tests/RedisConfigs/.docker/Redis/Dockerfile | 2 +- .../StackExchange.Redis.Tests/ConfigTests.cs | 10 +- .../GcraIntegrationTests.cs | 48 +++++ .../GcraTestServer.cs | 87 ++++++++ .../GcraUnitTests.cs | 155 ++++++++++++++ .../ResultProcessorUnitTests/GcraRateLimit.cs | 80 +++++++ .../GcraRateLimitRoundTrip.cs | 71 +++++++ .../TypedRedisValue.cs | 15 +- 42 files changed, 896 insertions(+), 187 deletions(-) create mode 100644 docs/exp/SER006.md create mode 100644 src/StackExchange.Redis/Gcra.GcraMessage.cs create mode 100644 src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs create mode 100644 src/StackExchange.Redis/Gcra.ResultProcessor.cs create mode 100644 tests/StackExchange.Redis.Tests/GcraIntegrationTests.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/Directory.Build.props b/Directory.Build.props index 273acae25..58334de25 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,7 @@ true $(MSBuildThisFileDirectory)Shared.ruleset NETSDK1069 - $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005 + $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006 https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes https://stackexchange.github.io/StackExchange.Redis/ MIT diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index f927b5fe4..39c6666c1 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,6 +8,7 @@ Current package versions: ## Unreleased +- 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)) - Add `IServer.GetProductVariant` to detect the product variant and version of the connected server, and use that internally to enable multi-DB operations on Valkey clusters ([#3040 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3040)) - Ignore cluster nodes with the `handshake` flag ([#3043 by @TimLovellSmith](https://github.com/StackExchange/StackExchange.Redis/pull/3043)) diff --git a/docs/exp/SER006.md b/docs/exp/SER006.md new file mode 100644 index 000000000..8ccea3365 --- /dev/null +++ b/docs/exp/SER006.md @@ -0,0 +1,24 @@ +Redis 8.8 is currently in preview and may be subject to change. + +New features in Redis 8.8: + +- `GCRA` for rate-limiting + +The corresponding library feature must also be considered subject to change: + +1. Existing bindings may cease working correctly if the underlying server API changes. +2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time + or run-time breaks. + +While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress +this warning by adding the following to your `csproj` file: + +```xml +$(NoWarn);SER006 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER006 +``` diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index b2288b574..2c93185ea 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/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index b4b9fcee1..1d9a091fa 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -8,11 +8,11 @@ internal static class Experiments public const string UrlFormat = "https://stackexchange.github.io/StackExchange.Redis/exp/"; // ReSharper disable InconsistentNaming - public const string VectorSets = "SER001"; public const string Server_8_4 = "SER002"; public const string Server_8_6 = "SER003"; public const string Respite = "SER004"; public const string UnitTesting = "SER005"; + public const string Server_8_8 = "SER006"; // ReSharper restore InconsistentNaming } } diff --git a/src/StackExchange.Redis/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index f7bd9a4a2..65cd170b9 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -1,5 +1,4 @@ using System; -using System.Buffers.Text; using System.Collections.Generic; using System.Threading; using System.Threading.Channels; diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 2a1c7695e..5a9ba4c66 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -60,6 +60,7 @@ internal enum RedisCommand GEOSEARCH, GEOSEARCHSTORE, + GCRA, GET, GETBIT, GETDEL, @@ -323,6 +324,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 e5a5c4d4d..fdac424f9 100644 --- a/src/StackExchange.Redis/ExtensionMethods.cs +++ b/src/StackExchange.Redis/ExtensionMethods.cs @@ -7,8 +7,10 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading; using System.Threading.Tasks; using Pipelines.Sockets.Unofficial.Arenas; +using RESPite; namespace StackExchange.Redis { @@ -337,5 +339,70 @@ internal static int VectorSafeIndexOfCRLF(this ReadOnlySpan span) [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static TTo[]? ToArray(in this RawResult result, Projection selector, in TState state) => result.IsNull ? null : result.GetItems().ToArray(selector, in state); + + /// + /// 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. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + 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) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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/Gcra.GcraMessage.cs b/src/StackExchange.Redis/Gcra.GcraMessage.cs new file mode 100644 index 000000000..0ecf695f9 --- /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(PhysicalConnection connection) + { + // GCRA key max_burst requests_per_period period [NUM_REQUESTS count] + connection.WriteHeader(Command, ArgCount); + connection.WriteBulkString(key); + connection.WriteBulkString(maxBurst); + connection.WriteBulkString(requestsPerPeriod); + connection.WriteBulkString(periodSeconds); + + if (count != 1) + { + connection.WriteBulkString("NUM_REQUESTS"u8); + connection.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/Gcra.GcraRateLimitResult.cs b/src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs new file mode 100644 index 000000000..7725c270a --- /dev/null +++ b/src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Represents the result of a GCRA (Generic Cell Rate Algorithm) rate limit check. +/// +[Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] +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; } + + /// + /// Initializes a new instance of the struct. + /// + 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/Gcra.ResultProcessor.cs b/src/StackExchange.Redis/Gcra.ResultProcessor.cs new file mode 100644 index 000000000..1cff9ccb2 --- /dev/null +++ b/src/StackExchange.Redis/Gcra.ResultProcessor.cs @@ -0,0 +1,70 @@ +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, in RawResult result) + { + // 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 (result.Resp2TypeArray == ResultType.Array && result.ItemsCount >= 5) + { + var items = result.GetItems(); + bool limited = items[0].GetBoolean(); + if (items[1].TryGetInt64(out long maxRequests) + && items[2].TryGetInt64(out long availableRequests) + && items[3].TryGetInt64(out long retryAfterSeconds) + && items[4].TryGetInt64(out long fullBurstAfterSeconds)) + { + var grca = new GcraRateLimitResult( + limited: limited, + maxRequests: (int)maxRequests, + availableRequests: (int)availableRequests, + retryAfterSeconds: (int)retryAfterSeconds, + fullBurstAfterSeconds: (int)fullBurstAfterSeconds); + SetResult(message, grca); + return true; + } + } + + return false; + } + + /* for v3, already done (due to branch choice) + 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/HotKeys.StartMessage.cs b/src/StackExchange.Redis/HotKeys.StartMessage.cs index c9f0bc371..a869945b2 100644 --- a/src/StackExchange.Redis/HotKeys.StartMessage.cs +++ b/src/StackExchange.Redis/HotKeys.StartMessage.cs @@ -1,5 +1,4 @@ using System; -using System.Threading.Tasks; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs index 8e6444ea8..e1947d324 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs @@ -1,6 +1,4 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using RESPite; +using System.Diagnostics.CodeAnalysis; // ReSharper disable once CheckNamespace namespace StackExchange.Redis; @@ -20,7 +18,6 @@ public partial interface IDatabase /// The flags to use for this operation. /// if the element was added; if it already existed. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] bool VectorSetAdd( RedisKey key, VectorSetAddRequest request, @@ -33,7 +30,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// The cardinality of the vectorset. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] long VectorSetLength(RedisKey key, CommandFlags flags = CommandFlags.None); /// @@ -43,7 +39,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// The dimension of vectors in the vectorset. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] int VectorSetDimension(RedisKey key, CommandFlags flags = CommandFlags.None); /// @@ -54,7 +49,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// The vector as a pooled memory lease. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Lease? VectorSetGetApproximateVector( RedisKey key, RedisValue member, @@ -68,7 +62,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// The attributes as a JSON string. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] string? VectorSetGetAttributesJson(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// @@ -78,7 +71,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// Information about the vectorset. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] VectorSetInfo? VectorSetInfo(RedisKey key, CommandFlags flags = CommandFlags.None); /// @@ -89,7 +81,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// True if the member exists, false otherwise. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] bool VectorSetContains(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// @@ -100,7 +91,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// The linked members. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Lease? VectorSetGetLinks(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// @@ -111,7 +101,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// The linked members with their similarity scores. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Lease? VectorSetGetLinksWithScores( RedisKey key, RedisValue member, @@ -124,7 +113,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// A random member from the vectorset, or null if the vectorset is empty. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] RedisValue VectorSetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None); /// @@ -135,7 +123,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// Random members from the vectorset. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] RedisValue[] VectorSetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None); /// @@ -146,7 +133,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// if the member was removed; if it was not found. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] bool VectorSetRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// @@ -158,7 +144,6 @@ bool VectorSetAdd( /// The flags to use for this operation. /// True if successful. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] bool VectorSetSetAttributesJson( RedisKey key, RedisValue member, @@ -176,7 +161,6 @@ bool VectorSetSetAttributesJson( /// The flags to use for this operation. /// Similar vectors with their similarity scores. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Lease? VectorSetSimilaritySearch( RedisKey key, VectorSetSimilaritySearchRequest query, @@ -193,7 +177,6 @@ bool VectorSetSetAttributesJson( /// The flags to use for this operation. /// Members in the specified range as a pooled memory lease. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Lease VectorSetRange( RedisKey key, RedisValue start = default, @@ -213,7 +196,6 @@ Lease VectorSetRange( /// The flags to use for this operation. /// An enumerable of members in the specified range. /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] System.Collections.Generic.IEnumerable VectorSetRangeEnumerate( RedisKey key, RedisValue start = default, diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index e26154652..776741cfa 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -3254,6 +3254,20 @@ 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. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + 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.VectorSets.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs index a2d9b4058..5060390a3 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs @@ -1,7 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; -using RESPite; // ReSharper disable once CheckNamespace namespace StackExchange.Redis; @@ -14,70 +13,57 @@ public partial interface IDatabaseAsync // Vector Set operations /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetAddAsync( RedisKey key, VectorSetAddRequest request, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetDimensionAsync(RedisKey key, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task?> VectorSetGetApproximateVectorAsync( RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetGetAttributesJsonAsync( RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetInfoAsync(RedisKey key, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetContainsAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task?> VectorSetGetLinksAsync( RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task?> VectorSetGetLinksWithScoresAsync( RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task VectorSetSetAttributesJsonAsync( RedisKey key, RedisValue member, @@ -88,14 +74,12 @@ Task VectorSetSetAttributesJsonAsync( CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task?> VectorSetSimilaritySearchAsync( RedisKey key, VectorSetSimilaritySearchRequest query, CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] Task?> VectorSetRangeAsync( RedisKey key, RedisValue start = default, @@ -105,7 +89,6 @@ Task VectorSetSetAttributesJsonAsync( CommandFlags flags = CommandFlags.None); /// - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] System.Collections.Generic.IAsyncEnumerable VectorSetRangeEnumerateAsync( RedisKey key, RedisValue start = default, diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index c581470ca..b8ac17777 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Net; using System.Threading.Tasks; using RESPite; @@ -796,6 +795,10 @@ IAsyncEnumerable SortedSetScanAsync( [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None); + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + 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/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 08c157bc6..d435c9382 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -1,7 +1,6 @@ using System; using System.Buffers; using System.Buffers.Text; -using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; using RESPite; diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs index ad4efe916..06ca59acc 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs @@ -1,7 +1,4 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using RESPite; +using System.Threading.Tasks; // ReSharper disable once CheckNamespace namespace StackExchange.Redis.KeyspaceIsolation; @@ -9,7 +6,6 @@ namespace StackExchange.Redis.KeyspaceIsolation; internal partial class KeyPrefixed { // Vector Set operations - async methods - [Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] public Task VectorSetAddAsync( RedisKey key, VectorSetAddRequest request, 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.VectorSets.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.VectorSets.cs index 83fbb2f85..0d025dc6c 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.VectorSets.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.VectorSets.cs @@ -1,6 +1,4 @@ -using System; - -// ReSharper disable once CheckNamespace +// ReSharper disable once CheckNamespace namespace StackExchange.Redis.KeyspaceIsolation; internal sealed partial class KeyPrefixedDatabase diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 01fe28505..7b98255a3 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Net; namespace StackExchange.Redis.KeyspaceIsolation @@ -775,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/Message.ValueCondition.cs b/src/StackExchange.Redis/Message.ValueCondition.cs index 53ddc651b..a9672d945 100644 --- a/src/StackExchange.Redis/Message.ValueCondition.cs +++ b/src/StackExchange.Redis/Message.ValueCondition.cs @@ -1,6 +1,4 @@ -using System; - -namespace StackExchange.Redis; +namespace StackExchange.Redis; internal partial class Message { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 85262ab81..56a9437ba 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -482,6 +482,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 +[SER006]StackExchange.Redis.GcraRateLimitResult +[SER006]StackExchange.Redis.GcraRateLimitResult.AvailableRequests.get -> int +[SER006]StackExchange.Redis.GcraRateLimitResult.FullBurstAfterSeconds.get -> int +[SER006]StackExchange.Redis.GcraRateLimitResult.GcraRateLimitResult() -> void +[SER006]StackExchange.Redis.GcraRateLimitResult.GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds) -> void +[SER006]StackExchange.Redis.GcraRateLimitResult.Limited.get -> bool +[SER006]StackExchange.Redis.GcraRateLimitResult.MaxRequests.get -> int +[SER006]StackExchange.Redis.GcraRateLimitResult.RetryAfterSeconds.get -> int StackExchange.Redis.HashEntry StackExchange.Redis.HashEntry.Equals(StackExchange.Redis.HashEntry other) -> bool StackExchange.Redis.HashEntry.HashEntry() -> void @@ -778,6 +786,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 +[SER006]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 @@ -1022,6 +1031,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! +[SER006]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! @@ -1711,6 +1721,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? +[SER006]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 @@ -1968,95 +1979,95 @@ StackExchange.Redis.ConnectionMultiplexer.GetServer(StackExchange.Redis.RedisKey StackExchange.Redis.IConnectionMultiplexer.GetServer(StackExchange.Redis.RedisKey key, object? asyncState = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.IServer! StackExchange.Redis.IServer.Execute(int? database, string! command, System.Collections.Generic.ICollection! args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisResult! StackExchange.Redis.IServer.ExecuteAsync(int? database, string! command, System.Collections.Generic.ICollection! args, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]override StackExchange.Redis.VectorSetLink.ToString() -> string! -[SER001]override StackExchange.Redis.VectorSetSimilaritySearchResult.ToString() -> string! -[SER001]StackExchange.Redis.IDatabase.VectorSetAdd(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetAddRequest! request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetAddAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetAddRequest! request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.VectorSetAddRequest -[SER001]StackExchange.Redis.VectorSetAddRequest.BuildExplorationFactor.get -> int? -[SER001]StackExchange.Redis.VectorSetAddRequest.BuildExplorationFactor.set -> void -[SER001]StackExchange.Redis.VectorSetAddRequest.MaxConnections.get -> int? -[SER001]StackExchange.Redis.VectorSetAddRequest.MaxConnections.set -> void -[SER001]StackExchange.Redis.VectorSetAddRequest.Quantization.get -> StackExchange.Redis.VectorSetQuantization -[SER001]StackExchange.Redis.VectorSetAddRequest.Quantization.set -> void -[SER001]StackExchange.Redis.VectorSetAddRequest.ReducedDimensions.get -> int? -[SER001]StackExchange.Redis.VectorSetAddRequest.ReducedDimensions.set -> void -[SER001]StackExchange.Redis.VectorSetAddRequest.UseCheckAndSet.get -> bool -[SER001]StackExchange.Redis.VectorSetAddRequest.UseCheckAndSet.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.Count.get -> int? -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.Count.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.DisableThreading.get -> bool -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.DisableThreading.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.Epsilon.get -> double? -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.Epsilon.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.FilterExpression.get -> string? -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.FilterExpression.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.MaxFilteringEffort.get -> int? -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.MaxFilteringEffort.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.SearchExplorationFactor.get -> int? -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.SearchExplorationFactor.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.UseExactSearch.get -> bool -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.UseExactSearch.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.WithAttributes.get -> bool -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.WithAttributes.set -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.WithScores.get -> bool -[SER001]StackExchange.Redis.VectorSetSimilaritySearchRequest.WithScores.set -> void -[SER001]StackExchange.Redis.IDatabase.VectorSetContains(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -[SER001]StackExchange.Redis.IDatabase.VectorSetDimension(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> int -[SER001]StackExchange.Redis.IDatabase.VectorSetGetApproximateVector(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -[SER001]StackExchange.Redis.IDatabase.VectorSetGetAttributesJson(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> string? -[SER001]StackExchange.Redis.IDatabase.VectorSetGetLinks(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -[SER001]StackExchange.Redis.IDatabase.VectorSetGetLinksWithScores(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -[SER001]StackExchange.Redis.IDatabase.VectorSetInfo(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.VectorSetInfo? -[SER001]StackExchange.Redis.IDatabase.VectorSetLength(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long -[SER001]StackExchange.Redis.IDatabase.VectorSetRandomMember(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue -[SER001]StackExchange.Redis.IDatabase.VectorSetRandomMembers(StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue[]! -[SER001]StackExchange.Redis.IDatabase.VectorSetRemove(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -[SER001]StackExchange.Redis.IDatabase.VectorSetSetAttributesJson(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, string! attributesJson, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool -[SER001]StackExchange.Redis.IDatabase.VectorSetSimilaritySearch(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetSimilaritySearchRequest! query, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetContainsAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetDimensionAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetGetApproximateVectorAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetGetAttributesJsonAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetGetLinksAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetGetLinksWithScoresAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetInfoAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetLengthAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetRandomMemberAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetRandomMembersAsync(StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetRemoveAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetSetAttributesJsonAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, string! attributesJson, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetSimilaritySearchAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetSimilaritySearchRequest! query, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -[SER001]StackExchange.Redis.VectorSetInfo -[SER001]StackExchange.Redis.VectorSetInfo.Dimension.get -> int -[SER001]StackExchange.Redis.VectorSetInfo.HnswMaxNodeUid.get -> long -[SER001]StackExchange.Redis.VectorSetInfo.Length.get -> long -[SER001]StackExchange.Redis.VectorSetInfo.MaxLevel.get -> int -[SER001]StackExchange.Redis.VectorSetInfo.Quantization.get -> StackExchange.Redis.VectorSetQuantization -[SER001]StackExchange.Redis.VectorSetInfo.QuantizationRaw.get -> string? -[SER001]StackExchange.Redis.VectorSetInfo.VectorSetInfo() -> void -[SER001]StackExchange.Redis.VectorSetInfo.VectorSetInfo(StackExchange.Redis.VectorSetQuantization quantization, string? quantizationRaw, int dimension, long length, int maxLevel, long vectorSetUid, long hnswMaxNodeUid) -> void -[SER001]StackExchange.Redis.VectorSetInfo.VectorSetUid.get -> long -[SER001]StackExchange.Redis.VectorSetLink -[SER001]StackExchange.Redis.VectorSetLink.Member.get -> StackExchange.Redis.RedisValue -[SER001]StackExchange.Redis.VectorSetLink.Score.get -> double -[SER001]StackExchange.Redis.VectorSetLink.VectorSetLink() -> void -[SER001]StackExchange.Redis.VectorSetLink.VectorSetLink(StackExchange.Redis.RedisValue member, double score) -> void -[SER001]StackExchange.Redis.VectorSetQuantization -[SER001]StackExchange.Redis.VectorSetQuantization.Binary = 3 -> StackExchange.Redis.VectorSetQuantization -[SER001]StackExchange.Redis.VectorSetQuantization.Int8 = 2 -> StackExchange.Redis.VectorSetQuantization -[SER001]StackExchange.Redis.VectorSetQuantization.None = 1 -> StackExchange.Redis.VectorSetQuantization -[SER001]StackExchange.Redis.VectorSetQuantization.Unknown = 0 -> StackExchange.Redis.VectorSetQuantization -[SER001]StackExchange.Redis.VectorSetSimilaritySearchResult -[SER001]StackExchange.Redis.VectorSetSimilaritySearchResult.AttributesJson.get -> string? -[SER001]StackExchange.Redis.VectorSetSimilaritySearchResult.Member.get -> StackExchange.Redis.RedisValue -[SER001]StackExchange.Redis.VectorSetSimilaritySearchResult.Score.get -> double -[SER001]StackExchange.Redis.VectorSetSimilaritySearchResult.VectorSetSimilaritySearchResult() -> void -[SER001]StackExchange.Redis.VectorSetSimilaritySearchResult.VectorSetSimilaritySearchResult(StackExchange.Redis.RedisValue member, double score = NaN, string? attributesJson = null) -> void -[SER001]static StackExchange.Redis.VectorSetAddRequest.Member(StackExchange.Redis.RedisValue element, System.ReadOnlyMemory values, string? attributesJson = null) -> StackExchange.Redis.VectorSetAddRequest! -[SER001]static StackExchange.Redis.VectorSetSimilaritySearchRequest.ByMember(StackExchange.Redis.RedisValue member) -> StackExchange.Redis.VectorSetSimilaritySearchRequest! -[SER001]static StackExchange.Redis.VectorSetSimilaritySearchRequest.ByVector(System.ReadOnlyMemory vector) -> StackExchange.Redis.VectorSetSimilaritySearchRequest! +override StackExchange.Redis.VectorSetLink.ToString() -> string! +override StackExchange.Redis.VectorSetSimilaritySearchResult.ToString() -> string! +StackExchange.Redis.IDatabase.VectorSetAdd(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetAddRequest! request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +StackExchange.Redis.IDatabaseAsync.VectorSetAddAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetAddRequest! request, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.VectorSetAddRequest +StackExchange.Redis.VectorSetAddRequest.BuildExplorationFactor.get -> int? +StackExchange.Redis.VectorSetAddRequest.BuildExplorationFactor.set -> void +StackExchange.Redis.VectorSetAddRequest.MaxConnections.get -> int? +StackExchange.Redis.VectorSetAddRequest.MaxConnections.set -> void +StackExchange.Redis.VectorSetAddRequest.Quantization.get -> StackExchange.Redis.VectorSetQuantization +StackExchange.Redis.VectorSetAddRequest.Quantization.set -> void +StackExchange.Redis.VectorSetAddRequest.ReducedDimensions.get -> int? +StackExchange.Redis.VectorSetAddRequest.ReducedDimensions.set -> void +StackExchange.Redis.VectorSetAddRequest.UseCheckAndSet.get -> bool +StackExchange.Redis.VectorSetAddRequest.UseCheckAndSet.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest +StackExchange.Redis.VectorSetSimilaritySearchRequest.Count.get -> int? +StackExchange.Redis.VectorSetSimilaritySearchRequest.Count.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.DisableThreading.get -> bool +StackExchange.Redis.VectorSetSimilaritySearchRequest.DisableThreading.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.Epsilon.get -> double? +StackExchange.Redis.VectorSetSimilaritySearchRequest.Epsilon.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.FilterExpression.get -> string? +StackExchange.Redis.VectorSetSimilaritySearchRequest.FilterExpression.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.MaxFilteringEffort.get -> int? +StackExchange.Redis.VectorSetSimilaritySearchRequest.MaxFilteringEffort.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.SearchExplorationFactor.get -> int? +StackExchange.Redis.VectorSetSimilaritySearchRequest.SearchExplorationFactor.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.UseExactSearch.get -> bool +StackExchange.Redis.VectorSetSimilaritySearchRequest.UseExactSearch.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.WithAttributes.get -> bool +StackExchange.Redis.VectorSetSimilaritySearchRequest.WithAttributes.set -> void +StackExchange.Redis.VectorSetSimilaritySearchRequest.WithScores.get -> bool +StackExchange.Redis.VectorSetSimilaritySearchRequest.WithScores.set -> void +StackExchange.Redis.IDatabase.VectorSetContains(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +StackExchange.Redis.IDatabase.VectorSetDimension(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> int +StackExchange.Redis.IDatabase.VectorSetGetApproximateVector(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabase.VectorSetGetAttributesJson(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> string? +StackExchange.Redis.IDatabase.VectorSetGetLinks(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabase.VectorSetGetLinksWithScores(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabase.VectorSetInfo(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.VectorSetInfo? +StackExchange.Redis.IDatabase.VectorSetLength(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long +StackExchange.Redis.IDatabase.VectorSetRandomMember(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue +StackExchange.Redis.IDatabase.VectorSetRandomMembers(StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue[]! +StackExchange.Redis.IDatabase.VectorSetRemove(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +StackExchange.Redis.IDatabase.VectorSetSetAttributesJson(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, string! attributesJson, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool +StackExchange.Redis.IDatabase.VectorSetSimilaritySearch(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetSimilaritySearchRequest! query, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease? +StackExchange.Redis.IDatabaseAsync.VectorSetContainsAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetDimensionAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetGetApproximateVectorAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabaseAsync.VectorSetGetAttributesJsonAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetGetLinksAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabaseAsync.VectorSetGetLinksWithScoresAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabaseAsync.VectorSetInfoAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetLengthAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetRandomMemberAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetRandomMembersAsync(StackExchange.Redis.RedisKey key, long count, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetRemoveAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetSetAttributesJsonAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue member, string! attributesJson, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IDatabaseAsync.VectorSetSimilaritySearchAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.VectorSetSimilaritySearchRequest! query, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.VectorSetInfo +StackExchange.Redis.VectorSetInfo.Dimension.get -> int +StackExchange.Redis.VectorSetInfo.HnswMaxNodeUid.get -> long +StackExchange.Redis.VectorSetInfo.Length.get -> long +StackExchange.Redis.VectorSetInfo.MaxLevel.get -> int +StackExchange.Redis.VectorSetInfo.Quantization.get -> StackExchange.Redis.VectorSetQuantization +StackExchange.Redis.VectorSetInfo.QuantizationRaw.get -> string? +StackExchange.Redis.VectorSetInfo.VectorSetInfo() -> void +StackExchange.Redis.VectorSetInfo.VectorSetInfo(StackExchange.Redis.VectorSetQuantization quantization, string? quantizationRaw, int dimension, long length, int maxLevel, long vectorSetUid, long hnswMaxNodeUid) -> void +StackExchange.Redis.VectorSetInfo.VectorSetUid.get -> long +StackExchange.Redis.VectorSetLink +StackExchange.Redis.VectorSetLink.Member.get -> StackExchange.Redis.RedisValue +StackExchange.Redis.VectorSetLink.Score.get -> double +StackExchange.Redis.VectorSetLink.VectorSetLink() -> void +StackExchange.Redis.VectorSetLink.VectorSetLink(StackExchange.Redis.RedisValue member, double score) -> void +StackExchange.Redis.VectorSetQuantization +StackExchange.Redis.VectorSetQuantization.Binary = 3 -> StackExchange.Redis.VectorSetQuantization +StackExchange.Redis.VectorSetQuantization.Int8 = 2 -> StackExchange.Redis.VectorSetQuantization +StackExchange.Redis.VectorSetQuantization.None = 1 -> StackExchange.Redis.VectorSetQuantization +StackExchange.Redis.VectorSetQuantization.Unknown = 0 -> StackExchange.Redis.VectorSetQuantization +StackExchange.Redis.VectorSetSimilaritySearchResult +StackExchange.Redis.VectorSetSimilaritySearchResult.AttributesJson.get -> string? +StackExchange.Redis.VectorSetSimilaritySearchResult.Member.get -> StackExchange.Redis.RedisValue +StackExchange.Redis.VectorSetSimilaritySearchResult.Score.get -> double +StackExchange.Redis.VectorSetSimilaritySearchResult.VectorSetSimilaritySearchResult() -> void +StackExchange.Redis.VectorSetSimilaritySearchResult.VectorSetSimilaritySearchResult(StackExchange.Redis.RedisValue member, double score = NaN, string? attributesJson = null) -> void +static StackExchange.Redis.VectorSetAddRequest.Member(StackExchange.Redis.RedisValue element, System.ReadOnlyMemory values, string? attributesJson = null) -> StackExchange.Redis.VectorSetAddRequest! +static StackExchange.Redis.VectorSetSimilaritySearchRequest.ByMember(StackExchange.Redis.RedisValue member) -> StackExchange.Redis.VectorSetSimilaritySearchRequest! +static StackExchange.Redis.VectorSetSimilaritySearchRequest.ByVector(System.ReadOnlyMemory vector) -> StackExchange.Redis.VectorSetSimilaritySearchRequest! StackExchange.Redis.RedisChannel.WithKeyRouting() -> StackExchange.Redis.RedisChannel StackExchange.Redis.IDatabase.StreamReadGroup(StackExchange.Redis.StreamPosition[]! streamPositions, StackExchange.Redis.RedisValue groupName, StackExchange.Redis.RedisValue consumerName, int? countPerStream = null, bool noAck = false, System.TimeSpan? claimMinIdleTime = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisStream[]! StackExchange.Redis.IDatabase.StreamReadGroup(StackExchange.Redis.StreamPosition[]! streamPositions, StackExchange.Redis.RedisValue groupName, StackExchange.Redis.RedisValue consumerName, int? countPerStream, bool noAck, StackExchange.Redis.CommandFlags flags) -> StackExchange.Redis.RedisStream[]! @@ -2272,10 +2283,10 @@ StackExchange.Redis.StreamInfo.EntriesAdded.get -> long StackExchange.Redis.StreamInfo.MaxDeletedEntryId.get -> StackExchange.Redis.RedisValue StackExchange.Redis.StreamInfo.RecordedFirstEntryId.get -> StackExchange.Redis.RedisValue StackExchange.Redis.Lease.IsEmpty.get -> bool -[SER001]StackExchange.Redis.IDatabase.VectorSetRange(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = -1, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease! -[SER001]StackExchange.Redis.IDatabase.VectorSetRangeEnumerate(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = 100, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Collections.Generic.IEnumerable! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetRangeAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = -1, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! -[SER001]StackExchange.Redis.IDatabaseAsync.VectorSetRangeEnumerateAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = 100, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Collections.Generic.IAsyncEnumerable! +StackExchange.Redis.IDatabase.VectorSetRange(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = -1, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease! +StackExchange.Redis.IDatabase.VectorSetRangeEnumerate(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = 100, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Collections.Generic.IEnumerable! +StackExchange.Redis.IDatabaseAsync.VectorSetRangeAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = -1, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task?>! +StackExchange.Redis.IDatabaseAsync.VectorSetRangeEnumerateAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue start = default(StackExchange.Redis.RedisValue), StackExchange.Redis.RedisValue end = default(StackExchange.Redis.RedisValue), long count = 100, StackExchange.Redis.Exclude exclude = StackExchange.Redis.Exclude.None, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Collections.Generic.IAsyncEnumerable! override StackExchange.Redis.LCSMatchResult.LCSMatch.Equals(object? obj) -> bool override StackExchange.Redis.LCSMatchResult.LCSMatch.GetHashCode() -> int override StackExchange.Redis.LCSMatchResult.LCSMatch.ToString() -> string! diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index c3acf1493..2327d0a0c 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -1,5 +1,4 @@ using System; -using System.Buffers; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index 6fcb7dd3b..6dca21915 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -1,5 +1,4 @@ -using System; -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace StackExchange.Redis; @@ -48,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 = 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 = new GcraMessage(Database, flags, key, maxBurst, requestsPerPeriod, periodSeconds, count); + 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/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 9443af35c..62991373d 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/src/StackExchange.Redis/VectorSetAddMessage.cs b/src/StackExchange.Redis/VectorSetAddMessage.cs index 0beb65205..d01e0f8e9 100644 --- a/src/StackExchange.Redis/VectorSetAddMessage.cs +++ b/src/StackExchange.Redis/VectorSetAddMessage.cs @@ -1,6 +1,5 @@ using System; using System.Runtime.InteropServices; -using System.Threading; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/VectorSetAddRequest.cs b/src/StackExchange.Redis/VectorSetAddRequest.cs index 8262d4750..0ae5641c8 100644 --- a/src/StackExchange.Redis/VectorSetAddRequest.cs +++ b/src/StackExchange.Redis/VectorSetAddRequest.cs @@ -1,13 +1,11 @@ using System; using System.Diagnostics.CodeAnalysis; -using RESPite; namespace StackExchange.Redis; /// /// Represents the request for a vectorset add operation. /// -[Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] public abstract class VectorSetAddRequest { // polymorphism left open for future, but needs to be handled internally diff --git a/src/StackExchange.Redis/VectorSetInfo.cs b/src/StackExchange.Redis/VectorSetInfo.cs index afbc3fece..157130db0 100644 --- a/src/StackExchange.Redis/VectorSetInfo.cs +++ b/src/StackExchange.Redis/VectorSetInfo.cs @@ -1,13 +1,8 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using RESPite; - namespace StackExchange.Redis; /// /// Contains metadata information about a vectorset returned by VINFO command. /// -[Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] public readonly struct VectorSetInfo( VectorSetQuantization quantization, string? quantizationRaw, diff --git a/src/StackExchange.Redis/VectorSetLink.cs b/src/StackExchange.Redis/VectorSetLink.cs index 5d58a8d7f..b1ca9bfb4 100644 --- a/src/StackExchange.Redis/VectorSetLink.cs +++ b/src/StackExchange.Redis/VectorSetLink.cs @@ -1,13 +1,9 @@ -using System.Diagnostics.CodeAnalysis; -using RESPite; - -namespace StackExchange.Redis; +namespace StackExchange.Redis; /// /// Represents a link/connection between members in a vectorset with similarity score. /// Used by VLINKS command with WITHSCORES option. /// -[Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] public readonly struct VectorSetLink(RedisValue member, double score) { /// diff --git a/src/StackExchange.Redis/VectorSetQuantization.cs b/src/StackExchange.Redis/VectorSetQuantization.cs index c7c5bf2e7..611e8d37a 100644 --- a/src/StackExchange.Redis/VectorSetQuantization.cs +++ b/src/StackExchange.Redis/VectorSetQuantization.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using RESPite; namespace StackExchange.Redis; @@ -7,7 +6,6 @@ namespace StackExchange.Redis; /// /// Specifies the quantization type for vectors in a vectorset. /// -[Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] public enum VectorSetQuantization { /// diff --git a/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs b/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs index 1343fd3f1..e0c7933f3 100644 --- a/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs +++ b/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs @@ -1,7 +1,5 @@ using System; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using RESPite; using VsimFlags = StackExchange.Redis.VectorSetSimilaritySearchMessage.VsimFlags; namespace StackExchange.Redis; @@ -9,7 +7,6 @@ namespace StackExchange.Redis; /// /// Represents the request for a vector similarity search operation. /// -[Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] public abstract class VectorSetSimilaritySearchRequest { internal VectorSetSimilaritySearchRequest() diff --git a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs index c87e04bc1..f2f7cd592 100644 --- a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs +++ b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs @@ -1,12 +1,10 @@ using System.Diagnostics.CodeAnalysis; -using RESPite; namespace StackExchange.Redis; /// /// Represents a result from vector similarity search operations. /// -[Experimental(Experiments.VectorSets, UrlFormat = Experiments.UrlFormat)] public readonly struct VectorSetSimilaritySearchResult(RedisValue member, double score = double.NaN, string? attributesJson = null) { /// diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 363edde51..3ba90bb5b 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -FROM redislabs/client-libs-test:8.6.0 +FROM redislabs/client-libs-test:unstable-23321515778-debian COPY --from=configs ./Basic /data/Basic/ COPY --from=configs ./Failover /data/Failover/ diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 3dfa4f99a..6626eda42 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -27,10 +27,12 @@ public void ExpectedFields() { // if this test fails, check that you've updated ConfigurationOptions.Clone(), then: fix the test! // this is a simple but pragmatic "have you considered?" check - var fields = Array.ConvertAll( - typeof(ConfigurationOptions).GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), - x => Regex.Replace(x.Name, """^<(\w+)>k__BackingField$""", "$1")); - Array.Sort(fields); + var fields = ( + from field in typeof(ConfigurationOptions).GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + let name = Regex.Replace(field.Name, """^<(\w+)>k__BackingField$""", "$1") + where name is not "WriteMode" // silently ignored + orderby name + select name).ToArray(); Assert.Equal( new[] { diff --git a/tests/StackExchange.Redis.Tests/GcraIntegrationTests.cs b/tests/StackExchange.Redis.Tests/GcraIntegrationTests.cs new file mode 100644 index 000000000..acbef7e20 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/GcraIntegrationTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +[RunPerProtocol] +public class GcraIntegrationTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture) +{ + [Fact(Timeout = 5000)] + public async Task GcraRateLimit_SmokeTest() + { + await using var conn = Create(require: new Version(8, 8, 0)); + var db = conn.GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + for (int i = 0; i < 15; i++) + { + var result = await db.StringGcraRateLimitAsync(key, maxBurst: 10, requestsPerPeriod: 10, periodSeconds: 1.0); + Log($"Run {i}: Limited: {result.Limited}, Available: {result.AvailableRequests}, RetryAfter: {result.RetryAfterSeconds}, FullBurstAfter: {result.FullBurstAfterSeconds}, MaxRequests: {result.MaxRequests}"); + if (i <= 10) + { + Assert.False(result.Limited, $"run {i}"); + } + else + { + Assert.True(result.Limited, $"run {i}"); + } + Assert.Equal(11, result.MaxRequests); + Assert.Equal(Math.Max(0, 10 - i), result.AvailableRequests); + if (result.Limited) + { + Assert.True(result.RetryAfterSeconds > 0); + } + else + { + Assert.Equal(-1, result.RetryAfterSeconds); + } + Assert.True(result.FullBurstAfterSeconds > 0); + } + await db.TryAcquireGcraAsync( + key, + maxBurst: 10, + requestsPerPeriod: 10, + allow: TimeSpan.FromSeconds(5), + cancellationToken: TestContext.Current.CancellationToken); + } +} diff --git a/tests/StackExchange.Redis.Tests/GcraTestServer.cs b/tests/StackExchange.Redis.Tests/GcraTestServer.cs new file mode 100644 index 000000000..d140cc53c --- /dev/null +++ b/tests/StackExchange.Redis.Tests/GcraTestServer.cs @@ -0,0 +1,87 @@ +extern alias respite; +using respite::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 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; + } + + /// + /// 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..7bcdb36aa --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs @@ -0,0 +1,80 @@ +using Xunit; + +/* unavailable until v3 +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..bf8230d7b --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs @@ -0,0 +1,71 @@ +using System.Threading.Tasks; +using Xunit; + +/* unavailable until v3 +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 = 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); + 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 = 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); + 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 = 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); + Assert.Equal(51, result.MaxRequests); + Assert.Equal(25, result.AvailableRequests); + Assert.Equal(-1, result.RetryAfterSeconds); + Assert.Equal(30, result.FullBurstAfterSeconds); + } +} +*/ 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); }