diff --git a/src/StackExchange.Redis/Enums/ProductVariant.cs b/src/StackExchange.Redis/Enums/ProductVariant.cs index fd3a17086..ca85ee8c3 100644 --- a/src/StackExchange.Redis/Enums/ProductVariant.cs +++ b/src/StackExchange.Redis/Enums/ProductVariant.cs @@ -1,48 +1,71 @@ -// ReSharper disable once CheckNamespace +using System; +using RESPite; + +// 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. +/// is not guaranteed, and is based on the server's self-reporting (via `INFO` and `HELLO`), which may be incomplete or misleading. /// public enum ProductVariant { /// /// The original Redis server. This is also the default value if the variant is unknown. /// + [AsciiHash("redis")] Redis, /// /// Valkey is a fork of open-source Redis associated with AWS. /// + [AsciiHash("valkey")] Valkey, /// /// Garnet is a Redis-compatible server from Microsoft. /// + [AsciiHash("garnet")] Garnet, /// /// Dragonfly /// + [AsciiHash("dragonfly")] Dragonfly, /// /// Memurai /// + [AsciiHash("memurai")] Memurai, /// /// Redict /// + [AsciiHash("redict")] Redict, /// /// KeyDB /// + [AsciiHash("keydb")] KeyDB, // if you want to add another variant here, please open an issue with the details (variant name, INFO output, etc.) } + +/// +/// Metadata and parsing methods for . +/// +internal static partial class ProductVariantMetadata +{ + /// + /// Parses the server value reported by HELLO (redis, valkey, etc); this is the vendor's + /// name rather than a protocol token, so the match is case-insensitive. + /// + [AsciiHash(CaseSensitive = false)] + internal static partial bool TryParse(ReadOnlySpan value, out ProductVariant variant); +} diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index a57bea0cd..8907182df 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -623,6 +623,51 @@ internal static bool TryParseVersion(ReadOnlySpan input, [NotNullWhen(true #endif } + /// + /// Parses a UTF-8 version (7.4.3, say) directly from the wire. This must give exactly the same result as + /// the char overload for the same text (including 's -1 for unspecified parts, and + /// its tolerance of whitespace and signs), because callers used to go via that overload: net10.0 fast-paths + /// the built-in UTF-8 Version.TryParse; everything else transcodes and defers to the char overload + /// (which, on .NET Framework, allocates a string: that's fine). + /// + internal static bool TryParseVersion(ReadOnlySpan input, [NotNullWhen(true)] out Version? version) + { +#if NET10_0_OR_GREATER + if (Version.TryParse(input, out version)) return true; + // anything else (including our major-only extension) defers to the char overload, so the edge cases + // (whitespace, signs, etc) resolve identically +#endif + if (input.IsEmpty) + { + version = null; + return false; + } + char[]? lease = null; + int maxChars = Encoding.UTF8.GetMaxCharCount(input.Length); + Span chars = maxChars <= 64 ? stackalloc char[64] : (lease = ArrayPool.Shared.Rent(maxChars)); + try + { + int len; +#if NET + len = Encoding.UTF8.GetChars(input, chars); +#else + unsafe + { + fixed (byte* bPtr = &MemoryMarshal.GetReference(input)) + fixed (char* cPtr = &MemoryMarshal.GetReference(chars)) + { + len = Encoding.UTF8.GetChars(bPtr, input.Length, cPtr, chars.Length); + } + } +#endif + return TryParseVersion(chars.Slice(0, len), out version); + } + finally + { + if (lease is not null) ArrayPool.Shared.Return(lease); + } + } + internal static bool TryParseVersion(string? input, [NotNullWhen(true)] out Version? version) { if (input is not null) diff --git a/src/StackExchange.Redis/HelloField.cs b/src/StackExchange.Redis/HelloField.cs index e5d730ce4..a7a5c37e8 100644 --- a/src/StackExchange.Redis/HelloField.cs +++ b/src/StackExchange.Redis/HelloField.cs @@ -1,4 +1,4 @@ -using System; +using System; using RESPite; namespace StackExchange.Redis; @@ -15,11 +15,48 @@ internal enum HelloField Unknown = 0, /// - /// Redis server version. + /// The server product: redis for Redis and for servers that present as Redis (Garnet); Valkey reports valkey. + /// + [AsciiHash("server")] + Server, + + /// + /// Server version. This is only the Redis API version when is redis; Valkey reports its own + /// version here (9.1.2, say, while INFO still gives redis_version:7.2.4). /// [AsciiHash("version")] Version, + /// + /// Garnet's own version; Garnet reports server as redis and as the Redis API version. + /// + [AsciiHash("garnet_version")] + GarnetVersion, + + /// + /// Valkey's own version, if reported alongside . + /// + [AsciiHash("valkey_version")] + ValkeyVersion, + + /// + /// Dragonfly's own version, if reported. + /// + [AsciiHash("dragonfly_version")] + DragonflyVersion, + + /// + /// Memurai's own version, if reported. + /// + [AsciiHash("memurai_version")] + MemuraiVersion, + + /// + /// Redict's own version, if reported. + /// + [AsciiHash("redict_version")] + RedictVersion, + /// /// Protocol version (2 or 3). /// diff --git a/src/StackExchange.Redis/LoggerExtensions.cs b/src/StackExchange.Redis/LoggerExtensions.cs index 917d71ed6..df661e32e 100644 --- a/src/StackExchange.Redis/LoggerExtensions.cs +++ b/src/StackExchange.Redis/LoggerExtensions.cs @@ -642,6 +642,18 @@ internal static void LogWithThreadPoolStats(this ILogger? log, string message) Message = "{Server}: Auto-configured (HELLO) role: {Role}")] internal static partial void LogInformationAutoConfiguredHelloRole(this ILogger logger, ServerEndPointLogValue server, string role); + [LoggerMessage( + Level = LogLevel.Information, + EventId = 131, + Message = "{Server}: Auto-configured (HELLO) product: {Variant} {Version}")] + internal static partial void LogInformationAutoConfiguredHelloProductVariant(this ILogger logger, ServerEndPointLogValue server, ProductVariant variant, string version); + + [LoggerMessage( + Level = LogLevel.Information, + EventId = 132, + Message = "{Server}: Auto-configured (HELLO) unrecognized server '{ServerName}'; ignoring its version")] + internal static partial void LogInformationAutoConfiguredHelloUnknownServer(this ILogger logger, ServerEndPointLogValue server, string serverName); + // PhysicalBridge logging methods [LoggerMessage( Level = LogLevel.Information, diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 6e8d17fee..cd6992312 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -1152,6 +1152,17 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes // Handle HELLO command (returns array/map of key-value pairs) if (message?.Command == RedisCommand.HELLO && reader.IsAggregate) { + // "version" means different things depending on "server": Redis (and Garnet, which presents as + // "redis" and adds "garnet_version") report the Redis API version, but Valkey reports *its own* + // version ("server" => "valkey", "version" => "9.1.2", while INFO still says redis_version:7.2.4), + // so we can only decide what to do with "version" once the whole map has been read (#3278). + bool serverIsRedis = true; // absent means redis (older servers) + ProductVariant? helloVariant = null, vendorVariant = null; + string? vendorVersion = null; + // a snapshot of the "version" value (IsScalar when present), so we can read it as a Version or a + // string once we know which it is + RespReader helloVersion = default; + var iter = reader.AggregateChildren(); while (iter.MoveNext()) { @@ -1167,9 +1178,40 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes switch (field) { - case HelloField.Version when iter.Value.TryParseScalar(Format.TryParseVersion!, out Version version): - server.Version = version; - Log?.LogInformationAutoConfiguredHelloServerVersion(new(server), version); + case HelloField.Server: + if (iter.Value.TryParseScalar(&ProductVariantMetadata.TryParse, out ProductVariant parsedVariant)) + { + helloVariant = parsedVariant; + serverIsRedis = parsedVariant is ProductVariant.Redis; + } + else + { + serverIsRedis = false; + Log?.LogInformationAutoConfiguredHelloUnknownServer(new(server), iter.Value.ReadString() ?? ""); + } + break; + case HelloField.Version: + helloVersion = iter.Value; + break; + case HelloField.GarnetVersion: + vendorVariant = ProductVariant.Garnet; + vendorVersion = iter.Value.ReadString(); + break; + case HelloField.ValkeyVersion: + vendorVariant = ProductVariant.Valkey; + vendorVersion = iter.Value.ReadString(); + break; + case HelloField.DragonflyVersion: + vendorVariant = ProductVariant.Dragonfly; + vendorVersion = iter.Value.ReadString(); + break; + case HelloField.MemuraiVersion: + vendorVariant = ProductVariant.Memurai; + vendorVersion = iter.Value.ReadString(); + break; + case HelloField.RedictVersion: + vendorVariant = ProductVariant.Redict; + vendorVersion = iter.Value.ReadString(); break; case HelloField.Proto when iter.Value.TryReadInt64(out var i64): connection.SetProtocol(i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); @@ -1204,6 +1246,39 @@ when server.ServerType.SupportsAutoConfigure() } } + if (serverIsRedis) + { + // "version" is the Redis API version; a "{vendor}_version" field, if any, identifies the product + Version? version; + unsafe + { + // (the null test is for the compiler: the function-pointer form loses the NotNullWhen annotation) + if (helloVersion.IsScalar && helloVersion.TryParseScalar(&Format.TryParseVersion, out version) && version is not null) + { + server.Version = version; + Log?.LogInformationAutoConfiguredHelloServerVersion(new(server), version); + } + } + if (vendorVariant is { } fromField && !string.IsNullOrWhiteSpace(vendorVersion)) + { + server.SetProductVariant(fromField, vendorVersion!); + Log?.LogInformationAutoConfiguredHelloProductVariant(new(server), fromField, vendorVersion!); + } + } + else if (helloVariant is { } fromServer) + { + // a known non-Redis server: "version" is the vendor's own version, and the Redis API version + // (if the server reports one at all) comes from INFO's redis_version + var productVersion = vendorVariant == fromServer && !string.IsNullOrWhiteSpace(vendorVersion) + ? vendorVersion! : helloVersion.IsScalar ? helloVersion.ReadString() : null; + if (!string.IsNullOrWhiteSpace(productVersion)) + { + server.SetProductVariant(fromServer, productVersion!); + Log?.LogInformationAutoConfiguredHelloProductVariant(new(server), fromServer, productVersion!); + } + } + + // (an unrecognized server falls through: we can't know what its "version" means, so we leave it alone) SetResult(message, true); return true; } diff --git a/tests/StackExchange.Redis.Tests/FormatTests.cs b/tests/StackExchange.Redis.Tests/FormatTests.cs index 1e41dcecf..886ea794e 100644 --- a/tests/StackExchange.Redis.Tests/FormatTests.cs +++ b/tests/StackExchange.Redis.Tests/FormatTests.cs @@ -8,6 +8,76 @@ namespace StackExchange.Redis.Tests; public class FormatTests(ITestOutputHelper output) : TestBase(output) { + // System.Version is awkward: unspecified parts are -1, so "7.4" is (7, 4, -1, -1) and "7.4.0" is (7, 4, 0, -1), + // and the inbuilt operators order them differently; the UTF-8 overload (what the HELLO handler uses) must + // produce exactly the same raw parts as the char/string overloads, and the version gates must then treat the + // unspecified parts as zero (see VersionExtensions.IsAtLeast). + [Theory] + [InlineData("7", 7, 0, -1, -1)] // major-only is our extension; System.Version rejects it + [InlineData("7.4", 7, 4, -1, -1)] + [InlineData("7.4.0", 7, 4, 0, -1)] + [InlineData("7.4.3", 7, 4, 3, -1)] + [InlineData("7.4.3.0", 7, 4, 3, 0)] + [InlineData("9.1.2", 9, 1, 2, -1)] + [InlineData("7.4.3.1", 7, 4, 3, 1)] + [InlineData("255.255.65535.65535", 255, 255, 65535, 65535)] + [InlineData("0.0", 0, 0, -1, -1)] + [InlineData("007.040", 7, 40, -1, -1)] + // System.Version uses int.TryParse per part, which tolerates whitespace and a sign; we inherit that, not fix it + [InlineData(" 7.4", 7, 4, -1, -1)] + [InlineData("7.4 ", 7, 4, -1, -1)] + [InlineData("7. 4", 7, 4, -1, -1)] + [InlineData("+7.4", 7, 4, -1, -1)] + [InlineData("7.+4", 7, 4, -1, -1)] + [InlineData(" 7 ", 7, 0, -1, -1)] + [InlineData("", null, null, null, null)] + [InlineData(".", null, null, null, null)] + [InlineData("7.", null, null, null, null)] + [InlineData(".7", null, null, null, null)] + [InlineData("7..4", null, null, null, null)] + [InlineData("1.2.3.4.5", null, null, null, null)] + [InlineData("9.1.2-rc1", null, null, null, null)] + [InlineData("-1.2", null, null, null, null)] + [InlineData("a.b", null, null, null, null)] + [InlineData("1.2.3-preview4", null, null, null, null)] + public void TryParseVersionUtf8MatchesChars(string input, int? major, int? minor, int? build, int? revision) + { + bool success = Format.TryParseVersion(Encoding.UTF8.GetBytes(input), out var fromBytes); + Assert.Equal(major is not null, success); + Assert.Equal(success, Format.TryParseVersion(input.AsSpan(), out var fromChars)); + Assert.Equal(success, Format.TryParseVersion(input, out var fromString)); + + if (success) + { + Assert.NotNull(fromBytes); + Assert.Equal((major, minor, build, revision), (fromBytes.Major, fromBytes.Minor, fromBytes.Build, fromBytes.Revision)); + Assert.Equal(fromBytes, fromChars); // Version.Equals compares all four raw parts, -1 included + Assert.Equal(fromBytes, fromString); + } + else + { + Assert.Null(fromBytes); + Assert.Null(fromChars); + Assert.Null(fromString); + } + } + + [Theory] + [InlineData("7.4", "7.4.0", true, true)] // the unspecified parts count as zero for the gates + [InlineData("7.4.0", "7.4", true, true)] + [InlineData("7.4", "7.4.0.0", true, true)] + [InlineData("7.4.3", "7.4", true, false)] + [InlineData("7.4", "7.4.3", false, false)] + [InlineData("8", "7.4.3", true, false)] + [InlineData("7.4.3", "8", false, false)] + public void ParsedVersionGatesIgnoreUnspecifiedParts(string server, string required, bool atLeast, bool equal) + { + Assert.True(Format.TryParseVersion(Encoding.UTF8.GetBytes(server), out var x)); + Assert.True(Format.TryParseVersion(Encoding.UTF8.GetBytes(required), out var y)); + Assert.Equal(atLeast, x.IsAtLeast(y)); + Assert.Equal(equal, x.IsEqual(y)); + } + public static IEnumerable EndpointData() { // note: the 3rd arg is for formatting; null means "expect the original string" diff --git a/tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs b/tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs index 828df3435..bd4a6e54d 100644 --- a/tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ProductVariantUnitTests.cs @@ -1,10 +1,13 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; namespace StackExchange.Redis.Tests; +[RunPerProtocol] public class ProductVariantUnitTests(ITestOutputHelper log) { [Theory] @@ -14,19 +17,76 @@ public class ProductVariantUnitTests(ITestOutputHelper log) public async Task DetectProductVariant(ProductVariant variant) { using var serverObj = new ProductServer(variant, log); - using var conn = await serverObj.ConnectAsync(withPubSub: false); + // with pub/sub, both connections issue HELLO, in either order relative to the interactive INFO + using var conn = await serverObj.ConnectAsync(withPubSub: true); 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}"); + log.WriteLine($"Detected {reportedProduct} version: {reportedVersion}; redis API version: {serverApi.Version}"); if (variant == ProductVariant.Redis) { Assert.Equal(serverObj.VersionString, reportedVersion); } else { - Assert.Equal("1.2.3-preview4", reportedVersion); + Assert.Equal(ProductServer.VendorVersion, reportedVersion); + } + // the Redis API version must not be confused with the vendor's own version (#3278) + Assert.Equal(serverObj.RedisVersion, serverApi.Version); + } + + [Theory] + [InlineData(ProductVariant.Redis)] + [InlineData(ProductVariant.Valkey)] + [InlineData(ProductVariant.Garnet)] + public async Task DetectProductVariantFromHelloAlone(ProductVariant variant) + { + // with INFO disabled, HELLO is the only source of version data, so this pins down what we take from it: + // "version" is the Redis API version only when "server" is redis (Redis, Garnet); Valkey repurposes it (#3278) + using var serverObj = new ProductServer(variant, log); + var config = serverObj.GetClientConfig(withPubSub: false); + var commands = serverObj.GetCommands(); + commands.Remove(nameof(RedisCommand.INFO)); + config.CommandMap = CommandMap.Create(commands); + var fallbackVersion = new Version(6, 0, 0); // HELLO exists from 6.0, and the fake server reports 7.0.0 + config.DefaultVersion = fallbackVersion; + + await using var conn = await ConnectionMultiplexer.ConnectAsync(config); + var serverApi = conn.GetServer(conn.GetEndPoints().First()); + await serverApi.PingAsync(); + Assert.Equal(variant, serverApi.GetProductVariant(out var reportedVersion)); + log.WriteLine($"Detected {variant} version: {reportedVersion}; redis API version: {serverApi.Version}"); + switch (variant) + { + case ProductVariant.Redis: + Assert.Equal(serverObj.RedisVersion, serverApi.Version); + break; + case ProductVariant.Garnet: + Assert.Equal(serverObj.RedisVersion, serverApi.Version); + Assert.Equal(ProductServer.VendorVersion, reportedVersion); + break; + case ProductVariant.Valkey: + Assert.Equal(fallbackVersion, serverApi.Version); // HELLO told us nothing about the Redis API version + Assert.Equal(ProductServer.VendorVersion, reportedVersion); + break; + } + } + + [Fact] + public async Task ValkeyVersionIsStableAcrossConnections() + { + // #3278: Valkey reports its own version as HELLO "version", so the detected Redis API version used to + // depend on whether the (subscription) HELLO or the (interactive) INFO reply was processed last + using var serverObj = new ProductServer(ProductVariant.Valkey, log); + for (int i = 0; i < 10; i++) + { + await using var conn = await serverObj.ConnectAsync(withPubSub: true); + var serverApi = conn.GetServer(conn.GetEndPoints().First()); + await serverApi.PingAsync(); + Assert.Equal(serverObj.RedisVersion, serverApi.Version); + Assert.Equal(ProductVariant.Valkey, serverApi.GetProductVariant(out var reportedVersion)); + Assert.Equal(ProductServer.VendorVersion, reportedVersion); } } @@ -73,6 +133,8 @@ public async Task MultiDbSupportMatchesProductVariantAndServerType(ProductVarian private sealed class ProductServer : InProcessTestServer { + public const string VendorVersion = "9.1.2"; // must parse as a Version, or the HELLO-version confusion goes unnoticed + private readonly ProductVariant _variant; public ProductServer(ProductVariant variant, ITestOutputHelper log, ServerType serverType = ServerType.Standalone) @@ -90,16 +152,38 @@ protected override void Info(StringBuilder sb, string section, Server.RedisClien switch (_variant) { case ProductVariant.Garnet: - sb.AppendLine("garnet_version:1.2.3-preview4"); + sb.AppendLine("garnet_version:" + VendorVersion) + .AppendLine("server_name:garnet"); break; case ProductVariant.Valkey: - sb.AppendLine("valkey_version:1.2.3-preview4") + sb.AppendLine("valkey_version:" + VendorVersion) .AppendLine("server_name:valkey"); break; } } } + // HELLO shapes as measured against real servers (#3278): + // Garnet 2.2.2: server => redis, version => 7.4.3 (the Redis API version), garnet_version => 2.2.2 + // Valkey 9.1.2: server => valkey, version => 9.1.2 (Valkey's own version; INFO has redis_version:7.2.4) + protected override string HelloServerName => _variant switch + { + ProductVariant.Valkey => "valkey", + _ => "redis", + }; + + protected override string HelloVersionString => _variant switch + { + ProductVariant.Valkey => VendorVersion, + _ => VersionString, + }; + + protected override IReadOnlyList> HelloExtraFields => _variant switch + { + ProductVariant.Garnet => [new("garnet_version", VendorVersion)], + _ => [], + }; + protected override bool SupportMultiDb(out string err) { switch (_variant) diff --git a/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs b/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs index d9a704a04..0ec3fc0c4 100644 --- a/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ValkeyUnitTests.cs @@ -4,6 +4,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] public class ValkeyUnitTests(ITestOutputHelper log) { [Theory] @@ -16,7 +17,8 @@ public async Task IdentifyValkeyCluster(ServerType type) var serverApi = client.GetServer(server.DefaultEndPoint); Assert.Equal(type, serverApi.ServerType); Assert.Equal(ProductVariant.Valkey, serverApi.GetProductVariant(out var version)); - Assert.Equal("8.1", version); + Assert.Equal("8.1", version); + Assert.Equal(server.RedisVersion, serverApi.Version); // not the valkey version from HELLO (#3278) } private sealed class InProcessValkeyLikeServer(ITestOutputHelper log) : InProcessTestServer(log) @@ -24,6 +26,10 @@ private sealed class InProcessValkeyLikeServer(ITestOutputHelper log) : InProces // see https://github.com/StackExchange/StackExchange.Redis/pull/3050 protected override string ServerModeKey => "server_mode"; + // Valkey reports its own version as HELLO "version" (see #3278); the name match is case-insensitive + protected override string HelloServerName => "Valkey"; + protected override string HelloVersionString => "8.1"; + protected override void Info(StringBuilder sb, string section, Server.RedisClient? client = null) { base.Info(sb, section, client); diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index 7fe8c46a9..beb4aea65 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -625,24 +625,47 @@ protected virtual TypedRedisValue Hello(RedisClient client, in RedisRequest requ client.IsAuthenticated = isAuthed; client.Name = name; - var reply = TypedRedisValue.Rent(14, out var span, RespPrefix.Map); + var extras = HelloExtraFields; + var reply = TypedRedisValue.Rent(14 + (2 * extras.Count), out var span, RespPrefix.Map); span[0] = TypedRedisValue.BulkString("server"); - span[1] = TypedRedisValue.BulkString("redis"); + span[1] = TypedRedisValue.BulkString(HelloServerName); span[2] = TypedRedisValue.BulkString("version"); - span[3] = TypedRedisValue.BulkString(VersionString); - span[4] = TypedRedisValue.BulkString("proto"); - span[5] = TypedRedisValue.Integer(proto32); - span[6] = TypedRedisValue.BulkString("id"); - span[7] = TypedRedisValue.Integer(client.Id); - span[8] = TypedRedisValue.BulkString("mode"); - span[9] = TypedRedisValue.BulkString(ServerModeValue); - span[10] = TypedRedisValue.BulkString("role"); - span[11] = TypedRedisValue.BulkString(GetPrimaryOf(client?.Node) is null ? "master" : "replica"); - span[12] = TypedRedisValue.BulkString("modules"); - span[13] = TypedRedisValue.EmptyArray(RespPrefix.Array); + span[3] = TypedRedisValue.BulkString(HelloVersionString); + int index = 4; + foreach (var pair in extras) + { + span[index++] = TypedRedisValue.BulkString(pair.Key); + span[index++] = TypedRedisValue.BulkString(pair.Value); + } + span[index++] = TypedRedisValue.BulkString("proto"); + span[index++] = TypedRedisValue.Integer(proto32); + span[index++] = TypedRedisValue.BulkString("id"); + span[index++] = TypedRedisValue.Integer(client.Id); + span[index++] = TypedRedisValue.BulkString("mode"); + span[index++] = TypedRedisValue.BulkString(ServerModeValue); + span[index++] = TypedRedisValue.BulkString("role"); + span[index++] = TypedRedisValue.BulkString(GetPrimaryOf(client?.Node) is null ? "master" : "replica"); + span[index++] = TypedRedisValue.BulkString("modules"); + span[index] = TypedRedisValue.EmptyArray(RespPrefix.Array); return reply; } + /// + /// The server reported by HELLO; Redis (and Garnet) report redis, Valkey reports valkey. + /// + protected virtual string HelloServerName => "redis"; + + /// + /// The version reported by HELLO; this is the Redis API version when + /// is redis, but Valkey reports its own version here. + /// + protected virtual string HelloVersionString => VersionString; + + /// + /// Additional fields reported by HELLO (Garnet adds garnet_version, for example). + /// + protected virtual IReadOnlyList> HelloExtraFields => []; + [RedisCommand(-3)] protected virtual TypedRedisValue Sadd(RedisClient client, in RedisRequest request) {