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 c4cf50c32..39c6666c1 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,7 +8,16 @@ Current package versions: ## Unreleased -- (none) +- 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)) + +## 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 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/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/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/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/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/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/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/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/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/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/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/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/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/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 d474fc98d..56a9437ba 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 @@ -481,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 @@ -777,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 @@ -1021,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! @@ -1710,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 @@ -1967,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[]! @@ -2271,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/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/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/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..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(); @@ -867,6 +870,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 +882,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 +945,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 +1037,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 +1060,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 78fe4b88a..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 { @@ -324,6 +329,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); @@ -1132,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/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/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/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/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/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/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/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/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/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 31a8155fe..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); @@ -23,17 +25,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 +70,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 +117,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 @@ -121,10 +125,10 @@ public EndPoint AddEmptyNode() 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) { - 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 +491,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 +616,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; @@ -1100,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); @@ -1140,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() @@ -1246,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)] 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); }