From ed1b1072fd6153e7bf60f447b8d2f48ab770a3ea Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 10 Aug 2023 15:02:02 +0100 Subject: [PATCH 01/24] first stab at RESP3; no actual parse code at the moment - this is API-centric - introduce `Resp2Type` and `Resp3Type` shims (`Resp2Type` has reduced types) - mark existing `Type` as `[Obsolete]`, and proxy to `Resp2Type` for compat - deal with null handling differences - deal with `Boolean`, which works very differently (`t`/`f` instead of `1`/`0`) remove RedisResult.Type from Shipped.txt - handle RESP3 types - handle [+|-]{inf|nan} - avoid alloc when parsing doubles made the RedisResult constructor non-public (fix accidental API) remove RawResult.Type; fix broken loop format incorrect attribute check - nomenclature: MultiBulk => Array - efficiency: use bit-packing to RESP2 type conversion is a bit mask fix RawResult.HasValue fix null array return (EmptyMultiBulk is no longer helpful) add a ToString to Replica (other bits were local test setup issues) simplify switch in TryParseDouble - protocol configuration parsing - avoid inbuilt equality/comparison operations on Version - rules for when to try resp3 configuration documentation words fix ConfigurationOptions.Clone actually connect via RESP3 demand redis6 in the RESP3 connect test remove unnecessary directives Lua results more tests and tweaks to fix tests simplify handshake fallback connect move SETNAME back into HELLO message; add lots of documentation about *why* DEBUG PROTOCOL tests; some failures to look at fix protocol tests add missing "hide me" attribs add docs and release notes tyop redundant re-enable to get server-maintenance notifications - ConnectWithBrokenHello is inconclusive if not a v6 server - allow non-RESP3 tests on non-v6 servers "DEBUG PROTOCOL" tests are inconclusive on non-v6 fix TryConnect (CLIENT ID) not always available save all counting is hard expose IAsyncEnumerable on ChannelMessageQueue (#2402) * expose IAsyncEnumerable on ChannelMessageQueue fix #2400 * PR number * move ChannelMessageQueue.GetAsyncEnumerator to shipped LUA conversions version Lua RESP conversions true/false handling depends on setresp(3) revert "if" split in ResultProcessor use enum for RedisProtocol reinstate parameterless RedisResult .ctor fix resp 2/3 inversion snafu from enumification fix resp dependent connection reuse issue add failing Execute test re RESP2 vs RESP3 delta ValuePairInterleavedProcessorBase should auto-handle responses that have become jagged in RESP3 pattern match is easier to read here - move IsResp3; that is a PhysicalConnection thing, not a ServerEndPoint thing - allow RawResult to know whether it is RESP3; involved moving some flags (which removes a bit hack we were using, so: yay) - make the interleave un-jaggedify only apply on RESP3 add more RESP3 API change tests compensate for XREAD having a different shape in RESP3 disable implicit RESP3 based on target server version # Conflicts: # docs/Configuration.md # docs/ReleaseNotes.md # src/StackExchange.Redis/ConfigurationOptions.cs # src/StackExchange.Redis/ConnectionMultiplexer.cs # src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs # src/StackExchange.Redis/ServerEndPoint.cs # tests/StackExchange.Redis.Tests/PubSubTests.cs # tests/StackExchange.Redis.Tests/TestBase.cs --- StackExchange.Redis.sln | 1 + docs/Configuration.md | 45 +- docs/ReleaseNotes.md | 1 + docs/Resp3.md | 39 ++ docs/index.md | 1 + .../APITypes/LatencyHistoryEntry.cs | 2 +- .../APITypes/LatencyLatestEntry.cs | 2 +- .../ChannelMessageQueue.cs | 1 + src/StackExchange.Redis/ClientInfo.cs | 2 +- src/StackExchange.Redis/CommandTrace.cs | 4 +- src/StackExchange.Redis/Condition.cs | 10 +- .../ConfigurationOptions.cs | 76 ++- .../ConnectionMultiplexer.Compat.cs | 3 + .../ConnectionMultiplexer.cs | 12 +- src/StackExchange.Redis/Enums/CommandFlags.cs | 1 + src/StackExchange.Redis/Enums/RedisCommand.cs | 2 + src/StackExchange.Redis/Enums/ResultType.cs | 71 ++- src/StackExchange.Redis/ExceptionFactory.cs | 10 +- src/StackExchange.Redis/Format.cs | 138 +++-- .../Interfaces/IConnectionMultiplexer.cs | 4 + src/StackExchange.Redis/Interfaces/IServer.cs | 2 + src/StackExchange.Redis/Message.cs | 55 +- src/StackExchange.Redis/PhysicalBridge.cs | 6 +- src/StackExchange.Redis/PhysicalConnection.cs | 159 +++-- .../Profiling/ProfiledCommand.cs | 2 +- .../PublicAPI/PublicAPI.Shipped.txt | 1 - .../PublicAPI/PublicAPI.Unshipped.txt | 30 +- src/StackExchange.Redis/RawResult.cs | 140 ++++- src/StackExchange.Redis/RedisDatabase.cs | 9 +- src/StackExchange.Redis/RedisFeatures.cs | 143 +++-- src/StackExchange.Redis/RedisLiterals.cs | 11 +- src/StackExchange.Redis/RedisProtocol.cs | 21 + src/StackExchange.Redis/RedisResult.cs | 154 ++++- src/StackExchange.Redis/RedisServer.cs | 6 +- src/StackExchange.Redis/RedisTransaction.cs | 9 +- src/StackExchange.Redis/RedisValue.cs | 22 + src/StackExchange.Redis/ResultProcessor.cs | 561 +++++++++++------- .../ResultTypeExtensions.cs | 14 + src/StackExchange.Redis/Role.cs | 3 + .../ScriptParameterMapper.cs | 7 +- src/StackExchange.Redis/ServerEndPoint.cs | 100 +++- tests/StackExchange.Redis.Tests/BitTests.cs | 13 +- tests/StackExchange.Redis.Tests/GeoTests.cs | 14 +- tests/StackExchange.Redis.Tests/HashTests.cs | 13 +- .../Helpers/ProtocolDependentFixture.cs | 104 ++++ .../Helpers/SharedConnectionFixture.cs | 6 +- .../HyperLogLogTests.cs | 13 +- tests/StackExchange.Redis.Tests/KeyTests.cs | 13 +- tests/StackExchange.Redis.Tests/ListTests.cs | 14 +- .../StackExchange.Redis.Tests/MemoryTests.cs | 8 +- tests/StackExchange.Redis.Tests/ParseTests.cs | 2 +- .../RawResultTests.cs | 18 +- tests/StackExchange.Redis.Tests/Resp3Tests.cs | 422 +++++++++++++ tests/StackExchange.Redis.Tests/RoleTests.cs | 5 + tests/StackExchange.Redis.Tests/ScanTests.cs | 14 +- .../StackExchange.Redis.Tests/SecureTests.cs | 2 +- tests/StackExchange.Redis.Tests/SetTests.cs | 14 +- .../SortedSetTests.cs | 90 ++- .../StackExchange.Redis.Tests/StreamTests.cs | 14 +- .../StackExchange.Redis.Tests/StringTests.cs | 14 +- tests/StackExchange.Redis.Tests/TestBase.cs | 59 +- .../TransactionTests.cs | 14 +- toys/StackExchange.Redis.Server/RespServer.cs | 6 +- .../TypedRedisValue.cs | 12 +- 64 files changed, 2209 insertions(+), 555 deletions(-) create mode 100644 docs/Resp3.md create mode 100644 src/StackExchange.Redis/RedisProtocol.cs create mode 100644 src/StackExchange.Redis/ResultTypeExtensions.cs create mode 100644 tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs create mode 100644 tests/StackExchange.Redis.Tests/Resp3Tests.cs diff --git a/StackExchange.Redis.sln b/StackExchange.Redis.sln index cdd254217..1fa39f4c2 100644 --- a/StackExchange.Redis.sln +++ b/StackExchange.Redis.sln @@ -119,6 +119,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{153A10E4-E docs\Profiling_v2.md = docs\Profiling_v2.md docs\PubSubOrder.md = docs\PubSubOrder.md docs\ReleaseNotes.md = docs\ReleaseNotes.md + docs\Resp3.md = docs\Resp3.md docs\Scripting.md = docs\Scripting.md docs\Server.md = docs\Server.md docs\Testing.md = docs\Testing.md diff --git a/docs/Configuration.md b/docs/Configuration.md index 753abf83b..53269440c 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1,4 +1,4 @@ -Configuration +# Configuration === When connecting to Redis version 6 or above with an ACL configured, your ACL user needs to at least have permissions to run the ECHO command. We run this command to verify that we have a valid connection to the Redis service. @@ -15,7 +15,7 @@ The `configuration` here can be either: The latter is *basically* a tokenized form of the former. -Basic Configuration Strings +## Basic Configuration Strings - The *simplest* configuration example is just the host name: @@ -66,7 +66,7 @@ Microsoft Azure Redis example with password var conn = ConnectionMultiplexer.Connect("contoso5.redis.cache.windows.net,ssl=true,password=..."); ``` -Configuration Options +## Configuration Options --- The `ConfigurationOptions` object has a wide range of properties, all of which are fully documented in intellisense. Some of the more common options to use include: @@ -98,6 +98,7 @@ The `ConfigurationOptions` object has a wide range of properties, all of which a | version={string} | `DefaultVersion` | (`4.0` in Azure, else `2.0`) | Redis version level (useful when the server does not make this available) | | tunnel={string} | `Tunnel` | `null` | Tunnel for connections (use `http:{proxy url}` for "connect"-based proxy server) | | setlib={bool} | `SetClientLibrary` | `true` | Whether to attempt to use `CLIENT SETINFO` to set the library name/version on the connection | +| protocol={string} | `Protocol` | `null` | Redis protocol to use; see section below | Additional code-only options: - ReconnectRetryPolicy (`IReconnectRetryPolicy`) - Default: `ReconnectRetryPolicy = ExponentialRetry(ConnectTimeout / 2);` @@ -121,8 +122,9 @@ Additional code-only options: Tokens in the configuration string are comma-separated; any without an `=` sign are assumed to be redis server endpoints. Endpoints without an explicit port will use 6379 if ssl is not enabled, and 6380 if ssl is enabled. Tokens starting with `$` are taken to represent command maps, for example: `$config=cfg`. -Obsolete Configuration Options +## Obsolete Configuration Options --- + These options are parsed in connection strings for backwards compatibility (meaning they do not error as invalid), but no longer have any effect. | Configuration string | `ConfigurationOptions` | Previous Default | Previous Meaning | @@ -130,7 +132,7 @@ These options are parsed in connection strings for backwards compatibility (mean | responseTimeout={int} | `ResponseTimeout` | `SyncTimeout` | Time (ms) to decide whether the socket is unhealthy | | writeBuffer={int} | `WriteBuffer` | `4096` | Size of the output buffer | -Automatic and Manual Configuration +## Automatic and Manual Configuration --- In many common scenarios, StackExchange.Redis will automatically configure a lot of settings, including the server type and version, connection timeouts, and primary/replica relationships. Sometimes, though, the commands for this have been disabled on the redis server. In this case, it is useful to provide more information: @@ -159,7 +161,8 @@ Which is equivalent to the command string: ```config redis0:6379,redis1:6380,keepAlive=180,version=2.8.8,$CLIENT=,$CLUSTER=,$CONFIG=,$ECHO=,$INFO=,$PING= ``` -Renaming Commands + +## Renaming Commands --- A slightly unusual feature of redis is that you can disable and/or rename individual commands. As per the previous example, this is done via the `CommandMap`, but instead of passing a `HashSet` to `Create()` (to indicate the available or unavailable commands), you pass a `Dictionary`. All commands not mentioned in the dictionary are assumed to be enabled and not renamed. A `null` or blank value records that the command is disabled. For example: @@ -182,8 +185,9 @@ The above is equivalent to (in the connection string): $INFO=,$SELECT=use ``` -Redis Server Permissions +## Redis Server Permissions --- + If the user you're connecting to Redis with is limited, it still needs to have certain commands enabled for the StackExchange.Redis to succeed in connecting. The client uses: - `AUTH` to authenticate - `CLIENT` to set the client name @@ -203,7 +207,7 @@ For example, a common _very_ minimal configuration ACL on the server (non-cluste Note that if you choose to disable access to the above commands, it needs to be done via the `CommandMap` and not only the ACL on the server (otherwise we'll attempt the command and fail the handshake). Also, if any of the these commands are disabled, some functionality may be diminished or broken. -twemproxy +## twemproxy --- [twemproxy](https://github.com/twitter/twemproxy) is a tool that allows multiple redis instances to be used as though it were a single server, with inbuilt sharding and fault tolerance (much like redis cluster, but implemented separately). The feature-set available to Twemproxy is reduced. To avoid having to configure this manually, the `Proxy` option can be used: @@ -216,8 +220,9 @@ var options = new ConfigurationOptions }; ``` -envoyproxy +##envoyproxy --- + [Envoyproxy](https://github.com/envoyproxy/envoy) is a tool that allows to front a redis cluster with a set of proxies, with inbuilt discovery and fault tolerance. The feature-set available to Envoyproxy is reduced. To avoid having to configure this manually, the `Proxy` option can be used: ```csharp var options = new ConfigurationOptions+{ @@ -227,7 +232,7 @@ var options = new ConfigurationOptions+{ ``` -Tiebreakers and Configuration Change Announcements +## Tiebreakers and Configuration Change Announcements --- Normally StackExchange.Redis will resolve primary/replica nodes automatically. However, if you are not using a management tool such as redis-sentinel or redis cluster, there is a chance that occasionally you will get multiple primary nodes (for example, while resetting a node for maintenance it may reappear on the network as a primary). To help with this, StackExchange.Redis can use the notion of a *tie-breaker* - which is only used when multiple primaries are detected (not including redis cluster, where multiple primaries are *expected*). For compatibility with BookSleeve, this defaults to the key named `"__Booksleeve_TieBreak"` (always in database 0). This is used as a crude voting mechanism to help determine the *preferred* primary, so that work is routed correctly. @@ -238,8 +243,9 @@ Both options can be customized or disabled (set to `""`), via the `.Configuratio These settings are also used by the `IServer.MakeMaster()` method, which can set the tie-breaker in the database and broadcast the configuration change message. The configuration message can also be used separately to primary/replica changes simply to request all nodes to refresh their configurations, via the `ConnectionMultiplexer.PublishReconfigure` method. -ReconnectRetryPolicy +## ReconnectRetryPolicy --- + StackExchange.Redis automatically tries to reconnect in the background when the connection is lost for any reason. It keeps retrying until the connection has been restored. It would use ReconnectRetryPolicy to decide how long it should wait between the retries. ReconnectRetryPolicy can be exponential (default), linear or a custom retry policy. @@ -264,3 +270,20 @@ config.ReconnectRetryPolicy = new LinearRetry(5000); //5 5000 //6 5000 ``` + +## Redis protocol +``` + +Without any additional prompting, StackExchange.Redis will use the RESP2 protocol; this means that pub/sub requires a separatate connection to the server. RESP3 is a newer protocol +(usually, but not always, available on v6 servers and above) which allows (smong other changes) pub/sub messages to be communicated on the *same* connection - which can be very +desirable in servers with a large number of clients. The protocol handshake needs to happen very early in the connection, so *by default* the library does not attempt a RESP3 connection +unless it has reason to expect it to work: + +This can be considered, in order: + +- the `HELLO` command has been disabled: RESP2 is used +- a protocol *other than* `resp3` or `3` is specified: RESP2 is used +- a protocol of `resp3` or `3` is specified: RESP3 is attempted (with fallback if it fails) +- a version of at least 6 is specified: RESP3 is attempted (with fallback if it fails) +- in all other scenarios: RESP2 is used + diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 8d3cc613b..bbeceba48 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -8,6 +8,7 @@ Current package versions: ## Unreleased +- Adds: RESP3 support ([#2396 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2396)) - see https://stackexchange.github.io/StackExchange.Redis/Resp3 - Fix [#2507](https://github.com/StackExchange/StackExchange.Redis/issues/2507): Pub/sub with multi-item payloads should be usable ([#2508 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2508)) - Add: connection-id tracking (internal only, no public API) ([#2508 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2508)) diff --git a/docs/Resp3.md b/docs/Resp3.md new file mode 100644 index 000000000..b65777e13 --- /dev/null +++ b/docs/Resp3.md @@ -0,0 +1,39 @@ +# RESP3 and StackExchange.Redis + +RESP2 and RESP3 are evolutions of the Redis protocol; the main differences are: + +1. RESP3 can carry out-of-band / "push" messages on a single connection, where-as RESP2 requires a separate connection for these messages +2. RESP3 can (when appropriate) convey additional semantic meaning about returned payloads + +For most people, the first point is the main reason to consider RESP3, as in high-usage servers, this can halve the number of connections required. +This is particularly useful in hosted environments where the number of inbound connections to the server is capped as part of a service plan. +Alternatively, where users are currently choosing to disable the out-of-band connection to achieve this, they may now be able to re-enable this +(for example, to receive server maintenance notifications) *without* incurring any additional connection overhead. + +There are no significant other differences, i.e. security, performance, etc all perform identically under both RESP2 and RESP3. + +RESP3 requires a Redis server version 6 or above; since the library cannot automatically know the server version *before* it has successfully connected, +the library currently requires a hint to enable this mode, in particular, configuring the `ConfigurationOptions.Version` property to 6 (or above), or using +`,version=6.0` (or above) on the configuration string. + +When using StackExchange.Redis, the second point only applies to: + +- Lua scripts that are invoked via the `ScriptEvaluate[Async](...)` or related APIs, that either: + - uses the `redis.setresp(3)` API and returns a value from `redis.[p]call(...)` + - returns a value that satisfies the [LUA to RESP3 type conversion rules](https://redis.io/docs/manual/programmability/lua-api/#lua-to-resp3-type-conversion) +- ad-hoc commands (in particular: *modules*) that are invoked via the `Execute[Async](string command, ...)` API + +both which return `RedisResult`. **If you are not using these APIs, you do not need to do anything.** + +Historically, you could use the `RedisResult.Type` property to query the type of data returned (integer, string, etc). In particular: + +- two new properties are added: `RedisResult.Resp2Type` and `RedisResult.Resp3Type` + - the `Resp3Type` property exposes the new semantic data (when using RESP3), for example it can indicate that a value is a double-precision number, a boolean, a map, etc (types that did not historically exist) + - the `Resp2Type` property exposes the same value that *would* have been returned if this data had been returned over RESP2 + - the `Type` property is now marked obsolete, but functions identically to `Resp2Type`, so that pre-existing code (for example, that has a `switch` on the type) is not impacted by RESP3 +- the `ResultType.MultiBulk` is superseded by `ResultType.Array` (this is a nomenclature change only; they are the same value and function identically) + +No changes to existing code are *required*, but: + +1. to prevent build warnings, replace usage of `ResultType.MultiBulk` with `ResultType.Array`, and usage of `RedisResult.Type` with `RedisResult.Resp2Type` +2. if you wish to exploit the additional semantic data when using RESP3, use `RedisResult.Resp3Type` where appropriate \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index d1711e346..cd1c84d7e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,7 @@ Documentation - [Transactions](Transactions) - how atomic transactions work in redis - [Events](Events) - the events available for logging / information purposes - [Pub/Sub Message Order](PubSubOrder) - advice on sequential and concurrent processing +- [Using RESP3](Resp3) - information on using RESP3 - [ServerMaintenanceEvent](ServerMaintenanceEvent) - how to listen and prepare for hosted server maintenance (e.g. Azure Cache for Redis) - [Streams](Streams) - how to use the Stream data type - [Where are `KEYS` / `SCAN` / `FLUSH*`?](KeysScan) - how to use server-based commands diff --git a/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs b/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs index e07d89342..2303c6e49 100644 --- a/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs +++ b/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs @@ -13,7 +13,7 @@ private sealed class Processor : ArrayResultProcessor { protected override bool TryParse(in RawResult raw, out LatencyHistoryEntry parsed) { - if (raw.Type == ResultType.MultiBulk) + if (raw.Resp2TypeArray == ResultType.Array) { var items = raw.GetItems(); if (items.Length >= 2 diff --git a/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs b/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs index 739d1c71d..67e416dc8 100644 --- a/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs +++ b/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs @@ -13,7 +13,7 @@ private sealed class Processor : ArrayResultProcessor { protected override bool TryParse(in RawResult raw, out LatencyLatestEntry parsed) { - if (raw.Type == ResultType.MultiBulk) + if (raw.Resp2TypeArray == ResultType.Array) { var items = raw.GetItems(); if (items.Length >= 4 diff --git a/src/StackExchange.Redis/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index 3cc6c3d5a..ffb82507d 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; diff --git a/src/StackExchange.Redis/ClientInfo.cs b/src/StackExchange.Redis/ClientInfo.cs index 4fa0aa378..65bb031d0 100644 --- a/src/StackExchange.Redis/ClientInfo.cs +++ b/src/StackExchange.Redis/ClientInfo.cs @@ -280,7 +280,7 @@ private class ClientInfoProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch(result.Type) + switch(result.Resp2TypeBulkString) { case ResultType.BulkString: var raw = result.GetString(); diff --git a/src/StackExchange.Redis/CommandTrace.cs b/src/StackExchange.Redis/CommandTrace.cs index fcb9aefdf..061f252b9 100644 --- a/src/StackExchange.Redis/CommandTrace.cs +++ b/src/StackExchange.Redis/CommandTrace.cs @@ -73,9 +73,9 @@ private class CommandTraceProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch(result.Type) + switch(result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var parts = result.GetItems(); CommandTrace[] arr = new CommandTrace[parts.Length]; int i = 0; diff --git a/src/StackExchange.Redis/Condition.cs b/src/StackExchange.Redis/Condition.cs index 85d78de8c..0dcccf59c 100644 --- a/src/StackExchange.Redis/Condition.cs +++ b/src/StackExchange.Redis/Condition.cs @@ -563,7 +563,7 @@ internal override bool TryValidate(in RawResult result, out bool value) return true; default: - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.BulkString: case ResultType.SimpleString: @@ -619,7 +619,7 @@ internal sealed override IEnumerable CreateMessages(int db, IResultBox? internal override bool TryValidate(in RawResult result, out bool value) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.BulkString: case ResultType.SimpleString: @@ -692,7 +692,7 @@ internal sealed override IEnumerable CreateMessages(int db, IResultBox? internal override bool TryValidate(in RawResult result, out bool value) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.BulkString: case ResultType.SimpleString: @@ -749,7 +749,7 @@ internal sealed override IEnumerable CreateMessages(int db, IResultBox? internal override bool TryValidate(in RawResult result, out bool value) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.BulkString: case ResultType.SimpleString: @@ -806,7 +806,7 @@ internal sealed override IEnumerable CreateMessages(int db, IResultBox? internal override bool TryValidate(in RawResult result, out bool value) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: var parsedValue = result.AsRedisValue(); diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 19314c344..dd445b88f 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Security; @@ -46,8 +47,11 @@ internal static bool ParseBoolean(string key, string value) internal static Version ParseVersion(string key, string value) { - if (!System.Version.TryParse(value, out Version? tmp)) throw new ArgumentOutOfRangeException(key, $"Keyword '{key}' requires a version value; the value '{value}' is not recognised."); - return tmp; + if (Format.TryParseVersion(value, out Version? tmp)) + { + return tmp; + } + throw new ArgumentOutOfRangeException(key, $"Keyword '{key}' requires a version value; the value '{value}' is not recognised."); } internal static Proxy ParseProxy(string key, string value) @@ -66,6 +70,27 @@ internal static SslProtocols ParseSslProtocols(string key, string? value) return tmp; } + internal static RedisProtocol ParseRedisProtocol(string key, string value) + { + // accept raw integers too, but only trust them if we recognize them + // (note we need to do this before enums, because Enum.TryParse will + // accept integers as the raw value, which is not what we want here) + if (Format.TryParseInt32(value, out int i32)) + { + switch (i32) + { + case 2: return RedisProtocol.Resp2; + case 3: return RedisProtocol.Resp3; + } + } + else + { + if (Enum.TryParse(value, true, out RedisProtocol tmp)) return tmp; + } + + throw new ArgumentOutOfRangeException(key, $"Keyword '{key}' requires a RedisProtocol value or a known protocol version number; the value '{value}' is not recognised."); + } + internal static void Unknown(string key) => throw new ArgumentException($"Keyword '{key}' is not supported.", key); @@ -98,7 +123,8 @@ internal const string WriteBuffer = "writeBuffer", CheckCertificateRevocation = "checkCertificateRevocation", Tunnel = "tunnel", - SetClientLibrary = "setlib"; + SetClientLibrary = "setlib", + Protocol = "protocol"; private static readonly Dictionary normalizedOptions = new[] { @@ -127,7 +153,8 @@ internal const string TieBreaker, Version, WriteBuffer, - CheckCertificateRevocation + CheckCertificateRevocation, + Protocol, }.ToDictionary(x => x, StringComparer.OrdinalIgnoreCase); public static string TryNormalize(string value) @@ -411,6 +438,7 @@ public TimeSpan HeartbeatInterval /// If , will be used. /// [Obsolete($"This setting no longer has any effect, please use {nameof(SocketManager.SocketManagerOptions)}.{nameof(SocketManager.SocketManagerOptions.UseHighPrioritySocketThreads)} instead - this setting will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool HighPrioritySocketThreads { get => false; @@ -470,6 +498,7 @@ public string? Password /// Specifies whether asynchronous operations should be invoked in a way that guarantees their original delivery order. /// [Obsolete("Not supported; if you require ordered pub/sub, please see " + nameof(ChannelMessageQueue) + " - this will be removed in 3.0.", false)] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool PreserveAsyncOrder { get => false; @@ -517,6 +546,7 @@ public bool ResolveDns /// Specifies the time in milliseconds that the system should allow for responses before concluding that the socket is unhealthy. /// [Obsolete("This setting no longer has any effect, and should not be used - will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public int ResponseTimeout { get => 0; @@ -591,6 +621,7 @@ public string TieBreaker /// The size of the output buffer to use. /// [Obsolete("This setting no longer has any effect, and should not be used - will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public int WriteBuffer { get => 0; @@ -681,6 +712,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow Tunnel = Tunnel, setClientLibrary = setClientLibrary, LibraryName = LibraryName, + Protocol = Protocol, }; /// @@ -761,12 +793,20 @@ public string ToString(bool includePassword) Append(sb, OptionKeys.ResponseTimeout, responseTimeout); Append(sb, OptionKeys.DefaultDatabase, DefaultDatabase); Append(sb, OptionKeys.SetClientLibrary, setClientLibrary); + Append(sb, OptionKeys.Protocol, FormatProtocol(Protocol)); if (Tunnel is { IsInbuilt: true } tunnel) { Append(sb, OptionKeys.Tunnel, tunnel.ToString()); } commandMap?.AppendDeltas(sb); return sb.ToString(); + + static string? FormatProtocol(RedisProtocol? protocol) => protocol switch { + null => null, + RedisProtocol.Resp2 => "resp2", + RedisProtocol.Resp3 => "resp3", + _ => protocol.GetValueOrDefault().ToString(), + }; } private static void Append(StringBuilder sb, object value) @@ -942,6 +982,9 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown) Tunnel = Tunnel.HttpProxy(ep); } break; + case OptionKeys.Protocol: + Protocol = OptionKeys.ParseRedisProtocol(key, value); + break; // Deprecated options we ignore... case OptionKeys.HighPrioritySocketThreads: case OptionKeys.PreserveAsyncOrder: @@ -984,5 +1027,30 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown) /// Allows custom transport implementations, such as http-tunneling via a proxy. /// public Tunnel? Tunnel { get; set; } + + /// + /// Specify the redis protocol type + /// + public RedisProtocol? Protocol { get; set; } + + internal bool TryResp3() + { + // note: deliberately leaving the IsAvailable duplicated to use short-circuit + + //if (Protocol is null) + //{ + // // if not specified, lean on the server version and whether HELLO is available + // return new RedisFeatures(DefaultVersion).Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO); + //} + //else + // ^^^ left for context; originally our intention was to auto-enable RESP3 by default *if* the server version + // is >= 6; however, it turns out (see extensive conversation here https://github.com/StackExchange/StackExchange.Redis/pull/2396) + // that tangential undocumented API breaks were made at the same time; this means that even if we fix every + // edge case in the library itself, the break is still visible to external callers via Execute[Async]; with an + // abundance of caution, we are therefore making RESP3 explicit opt-in only for now; we may revisit this in a major + { + return Protocol.GetValueOrDefault() >= RedisProtocol.Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO); + } + } } } diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs index f105fe2ca..6786e87d2 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Threading.Tasks; namespace StackExchange.Redis; @@ -9,11 +10,13 @@ public partial class ConnectionMultiplexer /// No longer used. /// [Obsolete("No longer used, will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public static TaskFactory Factory { get => Task.Factory; set { } } /// /// Gets or sets whether asynchronous operations should be invoked in a way that guarantees their original delivery order. /// [Obsolete("Not supported; if you require ordered pub/sub, please see " + nameof(ChannelMessageQueue) + ", will be removed in 3.0", false)] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool PreserveAsyncOrder { get => false; set { } } } diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 68d58253a..212070536 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -68,6 +69,7 @@ pulse is null /// Should exceptions include identifiable details? (key names, additional .Data annotations) /// [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludeDetailInExceptions)} instead - this will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool IncludeDetailInExceptions { get => RawConfig.IncludeDetailInExceptions; @@ -81,6 +83,7 @@ public bool IncludeDetailInExceptions /// CPU usage, etc - note that this can be problematic on some platforms. /// [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludePerformanceCountersInExceptions)} instead - this will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool IncludePerformanceCountersInExceptions { get => RawConfig.IncludePerformanceCountersInExceptions; @@ -132,7 +135,7 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? se EndPoints.SetDefaultPorts(serverType, ssl: RawConfig.Ssl); var map = CommandMap = configuration.GetCommandMap(serverType); - if (!string.IsNullOrWhiteSpace(configuration.Password)) + if (!string.IsNullOrWhiteSpace(configuration.Password) && !configuration.TryResp3()) // RESP3 doesn't need AUTH (can issue as part of HELLO) { map.AssertAvailable(RedisCommand.AUTH); } @@ -874,6 +877,8 @@ public ServerSnapshotFiltered(ServerEndPoint[] endpoints, int count, Func GetServerEndPoint(endpoint); + [return: NotNullIfNotNull(nameof(endpoint))] internal ServerEndPoint? GetServerEndPoint(EndPoint? endpoint, LogProxy? log = null, bool activate = true) { @@ -899,7 +904,7 @@ public ServerSnapshotFiltered(ServerEndPoint[] endpoints, int count, Func [Obsolete("From 2.0, this flag is not used, this will be removed in 3.0.", false)] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] HighPriority = 1, /// /// The caller is not interested in the result; the caller will immediately receive a default-value diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 40cb5c708..884114139 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -64,6 +64,7 @@ internal enum RedisCommand GETSET, HDEL, + HELLO, HEXISTS, HGET, HGETALL, @@ -376,6 +377,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command) case RedisCommand.GET: case RedisCommand.GETBIT: case RedisCommand.GETRANGE: + case RedisCommand.HELLO: case RedisCommand.HEXISTS: case RedisCommand.HGET: case RedisCommand.HGETALL: diff --git a/src/StackExchange.Redis/Enums/ResultType.cs b/src/StackExchange.Redis/Enums/ResultType.cs index 3ea559d0a..2e3f1d8a9 100644 --- a/src/StackExchange.Redis/Enums/ResultType.cs +++ b/src/StackExchange.Redis/Enums/ResultType.cs @@ -1,4 +1,7 @@ -namespace StackExchange.Redis +using System; +using System.ComponentModel; + +namespace StackExchange.Redis { /// /// The underlying result type as defined by Redis. @@ -9,6 +12,9 @@ public enum ResultType : byte /// No value was received. /// None = 0, + + // RESP 2 + /// /// Basic strings typically represent status results such as "OK". /// @@ -25,9 +31,72 @@ public enum ResultType : byte /// Bulk strings represent typical user content values. /// BulkString = 4, + /// /// Multi-bulk replies represent complex results such as arrays. /// + Array = 5, + + /// + /// Multi-bulk replies represent complex results such as arrays. + /// + [Obsolete("Please use " + nameof(Array))] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] MultiBulk = 5, + + // RESP3: https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md + + // note: we will arrange the values such as the last 3 bits are the RESP2 equivalent, + // and then we count up from there + + /// + /// A single null value replacing RESP v2 blob and multi-bulk nulls. + /// + Null = (1 << 3) | None, + + /// + /// True or false. + /// + Boolean = (1 << 3) | Integer, + + /// + /// A floating point number. + /// + Double = (1 << 3) | SimpleString, + + /// + /// A large number non representable by the type + /// + BigInteger = (2 << 3) | SimpleString, + + /// + /// Binary safe error code and message. + /// + BlobError = (1 << 3) | Error, + + /// + /// A binary safe string that should be displayed to humans without any escaping or filtering. For instance the output of LATENCY DOCTOR in Redis. + /// + VerbatimString = (1 << 3) | BulkString, + + /// + /// An unordered collection of key-value pairs. Keys and values can be any other RESP3 type. + /// + Map = (1 << 3) | Array, + + /// + /// An unordered collection of N other types. + /// + Set = (2 << 3) | Array, + + /// + /// Like the type, but the client should keep reading the reply ignoring the attribute type, and return it to the client as additional information. + /// + Attribute = (3 << 3) | Array, + + /// + /// Out of band data. The format is like the type, but the client should just check the first string element, stating the type of the out of band data, a call a callback if there is one registered for this specific type of push information. Push types are not related to replies, since they are information that the server may push at any time in the connection, so the client should keep reading if it is reading the reply of a command. + /// + Push = (4 << 3) | Array, } } diff --git a/src/StackExchange.Redis/ExceptionFactory.cs b/src/StackExchange.Redis/ExceptionFactory.cs index 4308b4f00..11948fa32 100644 --- a/src/StackExchange.Redis/ExceptionFactory.cs +++ b/src/StackExchange.Redis/ExceptionFactory.cs @@ -241,12 +241,12 @@ internal static Exception Timeout(ConnectionMultiplexer multiplexer, string? bas if (message != null) { - sb.Append(", command=").Append(message.Command); // no key here, note + sb.Append(", command=").Append(message.CommandString); // no key here, note } } else { - sb.Append("Timeout performing ").Append(message.Command).Append(" (").Append(Format.ToString(multiplexer.TimeoutMilliseconds)).Append("ms)"); + sb.Append("Timeout performing ").Append(message.CommandString).Append(" (").Append(Format.ToString(multiplexer.TimeoutMilliseconds)).Append("ms)"); } // Add timeout data, if we have it @@ -318,8 +318,8 @@ private static void AddCommonDetail( if (message != null) { message.TryGetHeadMessages(out var now, out var next); - if (now != null) Add(data, sb, "Message-Current", "active", multiplexer.RawConfig.IncludeDetailInExceptions ? now.CommandAndKey : now.Command.ToString()); - if (next != null) Add(data, sb, "Message-Next", "next", multiplexer.RawConfig.IncludeDetailInExceptions ? next.CommandAndKey : next.Command.ToString()); + if (now != null) Add(data, sb, "Message-Current", "active", multiplexer.RawConfig.IncludeDetailInExceptions ? now.CommandAndKey : now.CommandString); + if (next != null) Add(data, sb, "Message-Next", "next", multiplexer.RawConfig.IncludeDetailInExceptions ? next.CommandAndKey : next.CommandString); } // Add server data, if we have it @@ -406,7 +406,7 @@ private static void AddExceptionDetail(Exception? exception, Message? message, S private static string GetLabel(bool includeDetail, RedisCommand command, Message? message) { - return message == null ? command.ToString() : (includeDetail ? message.CommandAndKey : message.Command.ToString()); + return message == null ? command.ToString() : (includeDetail ? message.CommandAndKey : message.CommandString); } internal static Exception UnableToConnect(ConnectionMultiplexer muxer, string? failureMessage = null) diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index 9c96ccebe..73c29a82e 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -5,6 +5,7 @@ using System.Net; using System.Text; using System.Diagnostics.CodeAnalysis; + #if UNIX_SOCKET using System.Net.Sockets; #endif @@ -139,26 +140,37 @@ internal static bool TryGetHostPort(EndPoint? endpoint, [NotNullWhen(true)] out internal static bool TryParseDouble(string? s, out double value) { - if (s.IsNullOrEmpty()) + if (s is null) { value = 0; return false; } - if (s.Length == 1 && s[0] >= '0' && s[0] <= '9') - { - value = (int)(s[0] - '0'); - return true; - } - // need to handle these - if (string.Equals("+inf", s, StringComparison.OrdinalIgnoreCase) || string.Equals("inf", s, StringComparison.OrdinalIgnoreCase)) + switch (s.Length) { - value = double.PositiveInfinity; - return true; - } - if (string.Equals("-inf", s, StringComparison.OrdinalIgnoreCase)) - { - value = double.NegativeInfinity; - return true; + case 0: + value = 0; + return false; + // single-digits + case 1 when s[0] >= '0' && s[0] <= '9': + value = s[0] - '0'; + return true; + // RESP3 spec demands inf/nan handling + case 3 when CaseInsensitiveASCIIEqual("inf", s): + value = double.PositiveInfinity; + return true; + case 3 when CaseInsensitiveASCIIEqual("nan", s): + value = double.NaN; + return true; + case 4 when CaseInsensitiveASCIIEqual("+inf", s): + value = double.PositiveInfinity; + return true; + case 4 when CaseInsensitiveASCIIEqual("-inf", s): + value = double.NegativeInfinity; + return true; + case 4 when CaseInsensitiveASCIIEqual("+nan", s): + case 4 when CaseInsensitiveASCIIEqual("-nan", s): + value = double.NaN; + return true; } return double.TryParse(s, NumberStyles.Any, NumberFormatInfo.InvariantInfo, out value); } @@ -200,30 +212,39 @@ internal static bool TryParseInt64(string s, out long value) => internal static bool TryParseDouble(ReadOnlySpan s, out double value) { - if (s.IsEmpty) + switch (s.Length) { - value = 0; - return false; - } - if (s.Length == 1 && s[0] >= '0' && s[0] <= '9') - { - value = (int)(s[0] - '0'); - return true; - } - // need to handle these - if (CaseInsensitiveASCIIEqual("+inf", s) || CaseInsensitiveASCIIEqual("inf", s)) - { - value = double.PositiveInfinity; - return true; - } - if (CaseInsensitiveASCIIEqual("-inf", s)) - { - value = double.NegativeInfinity; - return true; + case 0: + value = 0; + return false; + // single-digits + case 1 when s[0] >= '0' && s[0] <= '9': + value = s[0] - '0'; + return true; + // RESP3 spec demands inf/nan handling + case 3 when CaseInsensitiveASCIIEqual("inf", s): + value = double.PositiveInfinity; + return true; + case 3 when CaseInsensitiveASCIIEqual("nan", s): + value = double.NaN; + return true; + case 4 when CaseInsensitiveASCIIEqual("+inf", s): + value = double.PositiveInfinity; + return true; + case 4 when CaseInsensitiveASCIIEqual("-inf", s): + value = double.NegativeInfinity; + return true; + case 4 when CaseInsensitiveASCIIEqual("+nan", s): + case 4 when CaseInsensitiveASCIIEqual("-nan", s): + value = double.NaN; + return true; } return Utf8Parser.TryParse(s, out value, out int bytes) & bytes == s.Length; } + private static bool CaseInsensitiveASCIIEqual(string xLowerCase, string y) + => string.Equals(xLowerCase, y, StringComparison.OrdinalIgnoreCase); + private static bool CaseInsensitiveASCIIEqual(string xLowerCase, ReadOnlySpan y) { if (y.Length != xLowerCase.Length) return false; @@ -350,10 +371,14 @@ internal static string GetString(ReadOnlySequence buffer) internal static unsafe string GetString(ReadOnlySpan span) { if (span.IsEmpty) return ""; +#if NETCOREAPP3_1_OR_GREATER + return Encoding.UTF8.GetString(span); +#else fixed (byte* ptr = span) { return Encoding.UTF8.GetString(ptr, span.Length); } +#endif } [DoesNotReturn] @@ -427,5 +452,50 @@ internal static int FormatInt32(int value, Span destination) ThrowFormatFailed(); return len; } + + internal static bool TryParseVersion(ReadOnlySpan input, [NotNullWhen(true)] out Version? version) + { +#if NETCOREAPP3_1_OR_GREATER + if (Version.TryParse(input, out version)) return true; + // allow major-only (Version doesn't do this, because... reasons?) + if (TryParseInt32(input, out int i32)) + { + version = new(i32, 0); + return true; + } + version = null; + return false; +#else + if (input.IsEmpty) + { + version = null; + return false; + } + unsafe + { + fixed (char* ptr = input) + { + string s = new(ptr, 0, input.Length); + return TryParseVersion(s, out version); + } + } +#endif + } + + internal static bool TryParseVersion(string? input, [NotNullWhen(true)] out Version? version) + { + if (input is not null) + { + if (Version.TryParse(input, out version)) return true; + // allow major-only (Version doesn't do this, because... reasons?) + if (TryParseInt32(input, out int i32)) + { + version = new(i32, 0); + return true; + } + } + version = null; + return false; + } } } diff --git a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs index 58973df68..621f1c7e1 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs @@ -1,6 +1,7 @@ using StackExchange.Redis.Maintenance; using StackExchange.Redis.Profiling; using System; +using System.ComponentModel; using System.IO; using System.Net; using System.Threading.Tasks; @@ -14,6 +15,7 @@ internal interface IInternalConnectionMultiplexer : IConnectionMultiplexer bool IgnoreConnect { get; set; } ReadOnlySpan GetServerSnapshot(); + ServerEndPoint GetServerEndPoint(EndPoint endpoint); ConfigurationOptions RawConfig { get; } @@ -49,6 +51,7 @@ public interface IConnectionMultiplexer : IDisposable, IAsyncDisposable /// Gets or sets whether asynchronous operations should be invoked in a way that guarantees their original delivery order. /// [Obsolete("Not supported; if you require ordered pub/sub, please see " + nameof(ChannelMessageQueue), false)] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] bool PreserveAsyncOrder { get; set; } /// @@ -65,6 +68,7 @@ public interface IConnectionMultiplexer : IDisposable, IAsyncDisposable /// Should exceptions include identifiable details? (key names, additional annotations). /// [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludeDetailInExceptions)} instead - this will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] bool IncludeDetailInExceptions { get; set; } /// diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 7319f7feb..3a7d2b3ba 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -357,6 +357,7 @@ public partial interface IServer : IRedis /// [Obsolete("Please use " + nameof(MakePrimaryAsync) + ", this will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] void MakeMaster(ReplicationChangeOptions options, TextWriter? log = null); /// @@ -468,6 +469,7 @@ public partial interface IServer : IRedis /// [Obsolete("Please use " + nameof(ReplicaOfAsync) + ", this will be removed in 3.0.")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] void ReplicaOf(EndPoint master, CommandFlags flags = CommandFlags.None); /// diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index a76001756..6b2b20162 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -1,11 +1,11 @@ -using System; +using StackExchange.Redis.Profiling; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Threading; -using StackExchange.Redis.Profiling; namespace StackExchange.Redis { @@ -478,6 +478,7 @@ internal static bool RequiresDatabase(RedisCommand command) case RedisCommand.DISCARD: case RedisCommand.ECHO: case RedisCommand.FLUSHALL: + case RedisCommand.HELLO: case RedisCommand.INFO: case RedisCommand.LASTSAVE: case RedisCommand.LATENCY: @@ -636,6 +637,9 @@ internal void SetWriteTime() /// Gets if this command should be sent over the subscription bridge. /// internal bool IsForSubscriptionBridge => (Flags & DemandSubscriptionConnection) != 0; + + public virtual string CommandString => Command.ToString(); + /// /// Sends this command to the subscription connection rather than the interactive. /// @@ -705,6 +709,53 @@ internal void WriteTo(PhysicalConnection physical) } } + internal static Message CreateHello(int protocolVersion, string? username, string? password, string? clientName, CommandFlags flags) + => new HelloMessage(protocolVersion, username, password, clientName, flags); + + internal sealed class HelloMessage : Message + { + private readonly string? _username, _password, _clientName; + private readonly int _protocolVersion; + + internal HelloMessage(int protocolVersion, string? username, string? password, string? clientName, CommandFlags flags) + : base(-1, flags, RedisCommand.HELLO) + { + _protocolVersion = protocolVersion; + _username = username; + _password = password; + _clientName = clientName; + } + + public override string CommandAndKey => Command + " " + _protocolVersion; + + public override int ArgCount + { + get + { + int count = 1; // HELLO protover + if (!string.IsNullOrWhiteSpace(_password)) count += 3; // [AUTH username password] + if (!string.IsNullOrWhiteSpace(_clientName)) count += 2; // [SETNAME client] + return count; + } + } + protected override void WriteImpl(PhysicalConnection physical) + { + physical.WriteHeader(Command, ArgCount); + physical.WriteBulkString(_protocolVersion); + if (!string.IsNullOrWhiteSpace(_password)) + { + physical.WriteBulkString(RedisLiterals.AUTH); + physical.WriteBulkString(string.IsNullOrWhiteSpace(_username) ? RedisLiterals.@default : _username); + physical.WriteBulkString(_password); + } + if (!string.IsNullOrWhiteSpace(_clientName)) + { + physical.WriteBulkString(RedisLiterals.SETNAME); + physical.WriteBulkString(_clientName); + } + } + } + internal abstract class CommandChannelBase : Message { protected readonly RedisChannel Channel; diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 68ea70105..529cbf3b1 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -68,6 +68,9 @@ internal sealed class PhysicalBridge : IDisposable #endif internal string? PhysicalName => physical?.ToString(); + + internal long? ClientId => physical?.ClientId; + public DateTime? ConnectedAt { get; private set; } public PhysicalBridge(ServerEndPoint serverEndPoint, ConnectionType type, int timeoutMilliseconds) @@ -112,6 +115,7 @@ public enum State : byte internal long OperationCount => Interlocked.Read(ref operationCount); public RedisCommand LastCommand { get; private set; } + public bool IsResp3 => physical is { IsResp3: true }; public void Dispose() { @@ -1470,7 +1474,7 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne // If we are executing AUTH, it means we are still unauthenticated // Setting READONLY before AUTH always fails but we think it succeeded since // we run it as Fire and Forget. - if (cmd != RedisCommand.AUTH) + if (cmd != RedisCommand.AUTH && cmd != RedisCommand.HELLO) { var readmode = connection.GetReadModeCommand(isPrimaryOnly); if (readmode != null) diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 595371529..823326e8a 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -257,6 +257,9 @@ private enum ReadMode : byte public long SubscriptionCount { get; set; } public bool TransactionActive { get; internal set; } + internal long? ClientId { get; set; } + public bool IsResp3 { get; set; } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times")] internal void Shutdown() @@ -1549,7 +1552,7 @@ internal async ValueTask ConnectedAsync(Socket? socket, LogProxy? log, Soc private void MatchResult(in RawResult result) { // check to see if it could be an out-of-band pubsub message - if (connectionType == ConnectionType.Subscription && result.Type == ResultType.MultiBulk) + if ((connectionType == ConnectionType.Subscription && result.Resp2TypeArray == ResultType.Array) || result.Resp3Type == ResultType.Push) { var muxer = BridgeCouldBeNull?.Multiplexer; if (muxer == null) return; @@ -1653,14 +1656,14 @@ static bool TryGetPubSubPayload(in RawResult value, out RedisValue parsed, bool parsed = RedisValue.Null; return true; } - switch (value.Type) + switch (value.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: case ResultType.BulkString: parsed = value.AsRedisValue(); return true; - case ResultType.MultiBulk when allowArraySingleton && value.ItemsCount == 1: + case ResultType.Array when allowArraySingleton && value.ItemsCount == 1: return TryGetPubSubPayload(in value[0], out parsed, allowArraySingleton: false); } parsed = default; @@ -1669,7 +1672,7 @@ static bool TryGetPubSubPayload(in RawResult value, out RedisValue parsed, bool static bool TryGetMultiPubSubPayload(in RawResult value, out Sequence parsed) { - if (value.Type == ResultType.MultiBulk && value.ItemsCount != 0) + if (value.Resp2TypeArray == ResultType.Array && value.ItemsCount != 0) { parsed = value.GetItems(); return true; @@ -1806,7 +1809,7 @@ private int ProcessBuffer(ref ReadOnlySequence buffer) { _readStatus = ReadStatus.TryParseResult; var reader = new BufferReader(buffer); - var result = TryParseResult(_arena, in buffer, ref reader, IncludeDetailInExceptions, BridgeCouldBeNull?.ServerEndPoint); + var result = TryParseResult(IsResp3, _arena, in buffer, ref reader, IncludeDetailInExceptions, this); try { if (result.HasValue) @@ -1861,34 +1864,39 @@ private int ProcessBuffer(ref ReadOnlySequence buffer) // } //} - private static RawResult ReadArray(Arena arena, in ReadOnlySequence buffer, ref BufferReader reader, bool includeDetailInExceptions, ServerEndPoint? server) + private static RawResult.ResultFlags AsNull(RawResult.ResultFlags flags) => flags & ~RawResult.ResultFlags.NonNull; + + private static RawResult ReadArray(ResultType resultType, RawResult.ResultFlags flags, Arena arena, in ReadOnlySequence buffer, ref BufferReader reader, bool includeDetailInExceptions, ServerEndPoint? server) { - var itemCount = ReadLineTerminatedString(ResultType.Integer, ref reader); + var itemCount = ReadLineTerminatedString(ResultType.Integer, flags, ref reader); if (itemCount.HasValue) { - if (!itemCount.TryGetInt64(out long i64)) throw ExceptionFactory.ConnectionFailure(includeDetailInExceptions, ConnectionFailureType.ProtocolFailure, "Invalid array length", server); + if (!itemCount.TryGetInt64(out long i64)) throw ExceptionFactory.ConnectionFailure(includeDetailInExceptions, ConnectionFailureType.ProtocolFailure, + itemCount.Is('?') ? "Streamed aggregate types not yet implemented" : "Invalid array length", server); int itemCountActual = checked((int)i64); if (itemCountActual < 0) { //for null response by command like EXEC, RESP array: *-1\r\n - return RawResult.NullMultiBulk; + return new RawResult(resultType, items: default, AsNull(flags)); } else if (itemCountActual == 0) { //for zero array response by command like SCAN, Resp array: *0\r\n - return RawResult.EmptyMultiBulk; + return new RawResult(resultType, items: default, flags); } + if (resultType == ResultType.Map) itemCountActual <<= 1; // if it says "3", it means 3 pairs, i.e. 6 values + var oversized = arena.Allocate(itemCountActual); - var result = new RawResult(oversized, false); + var result = new RawResult(resultType, oversized, flags); if (oversized.IsSingleSegment) { var span = oversized.FirstSpan; - for(int i = 0; i < span.Length; i++) + for (int i = 0; i < span.Length; i++) { - if (!(span[i] = TryParseResult(arena, in buffer, ref reader, includeDetailInExceptions, server)).HasValue) + if (!(span[i] = TryParseResult(flags, arena, in buffer, ref reader, includeDetailInExceptions, server)).HasValue) { return RawResult.Nil; } @@ -1896,11 +1904,11 @@ private static RawResult ReadArray(Arena arena, in ReadOnlySequence arena, in ReadOnlySequence.Empty, true); + return new RawResult(type, ReadOnlySequence.Empty, AsNull(flags)); } if (reader.TryConsumeAsBuffer(bodySize, out var payload)) @@ -1931,7 +1943,7 @@ private static RawResult ReadBulkString(ref BufferReader reader, bool includeDet case ConsumeResult.NeedMoreData: break; // see NilResult below case ConsumeResult.Success: - return new RawResult(ResultType.BulkString, payload, false); + return new RawResult(type, payload, flags); default: throw ExceptionFactory.ConnectionFailure(includeDetailInExceptions, ConnectionFailureType.ProtocolFailure, "Invalid bulk string terminator", server); } @@ -1940,7 +1952,7 @@ private static RawResult ReadBulkString(ref BufferReader reader, bool includeDet return RawResult.Nil; } - private static RawResult ReadLineTerminatedString(ResultType type, ref BufferReader reader) + private static RawResult ReadLineTerminatedString(ResultType type, RawResult.ResultFlags flags, ref BufferReader reader) { int crlfOffsetFromCurrent = BufferReader.FindNextCrLf(reader); if (crlfOffsetFromCurrent < 0) return RawResult.Nil; @@ -1948,7 +1960,7 @@ private static RawResult ReadLineTerminatedString(ResultType type, ref BufferRea var payload = reader.ConsumeAsBuffer(crlfOffsetFromCurrent); reader.Consume(2); - return new RawResult(type, payload, false); + return new RawResult(type, payload, flags); } internal enum ReadStatus @@ -1982,36 +1994,83 @@ internal enum ReadStatus internal void StartReading() => ReadFromPipe().RedisFireAndForget(); - internal static RawResult TryParseResult(Arena arena, in ReadOnlySequence buffer, ref BufferReader reader, + internal static RawResult TryParseResult(bool isResp3, Arena arena, in ReadOnlySequence buffer, ref BufferReader reader, + bool includeDetilInExceptions, PhysicalConnection? connection, bool allowInlineProtocol = false) + { + return TryParseResult(isResp3 ? (RawResult.ResultFlags.Resp3 | RawResult.ResultFlags.NonNull) : RawResult.ResultFlags.NonNull, + arena, buffer, ref reader, includeDetilInExceptions, connection?.BridgeCouldBeNull?.ServerEndPoint, allowInlineProtocol); + } + + private static RawResult TryParseResult(RawResult.ResultFlags flags, Arena arena, in ReadOnlySequence buffer, ref BufferReader reader, bool includeDetilInExceptions, ServerEndPoint? server, bool allowInlineProtocol = false) { - var prefix = reader.PeekByte(); - if (prefix < 0) return RawResult.Nil; // EOF - switch (prefix) - { - case '+': // simple string - reader.Consume(1); - return ReadLineTerminatedString(ResultType.SimpleString, ref reader); - case '-': // error - reader.Consume(1); - return ReadLineTerminatedString(ResultType.Error, ref reader); - case ':': // integer - reader.Consume(1); - return ReadLineTerminatedString(ResultType.Integer, ref reader); - case '$': // bulk string - reader.Consume(1); - return ReadBulkString(ref reader, includeDetilInExceptions, server); - case '*': // array - reader.Consume(1); - return ReadArray(arena, in buffer, ref reader, includeDetilInExceptions, server); - default: - // string s = Format.GetString(buffer); - if (allowInlineProtocol) return ParseInlineProtocol(arena, ReadLineTerminatedString(ResultType.SimpleString, ref reader)); - throw new InvalidOperationException("Unexpected response prefix: " + (char)prefix); - } + int prefix; + do // this loop is just to allow us to parse (skip) attributes without doing a stack-dive + { + prefix = reader.PeekByte(); + if (prefix < 0) return RawResult.Nil; // EOF + switch (prefix) + { + // RESP2 + case '+': // simple string + reader.Consume(1); + return ReadLineTerminatedString(ResultType.SimpleString, flags, ref reader); + case '-': // error + reader.Consume(1); + return ReadLineTerminatedString(ResultType.Error, flags, ref reader); + case ':': // integer + reader.Consume(1); + return ReadLineTerminatedString(ResultType.Integer, flags, ref reader); + case '$': // bulk string + reader.Consume(1); + return ReadBulkString(ResultType.BulkString, flags, ref reader, includeDetilInExceptions, server); + case '*': // array + reader.Consume(1); + return ReadArray(ResultType.Array, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); + // RESP3 + case '_': // null + reader.Consume(1); + return ReadLineTerminatedString(ResultType.Null, flags, ref reader); + case ',': // double + reader.Consume(1); + return ReadLineTerminatedString(ResultType.Double, flags, ref reader); + case '#': // boolean + reader.Consume(1); + return ReadLineTerminatedString(ResultType.Boolean, flags, ref reader); + case '!': // blob error + reader.Consume(1); + return ReadBulkString(ResultType.BlobError, flags, ref reader, includeDetilInExceptions, server); + case '=': // verbatim string + reader.Consume(1); + return ReadBulkString(ResultType.VerbatimString, flags, ref reader, includeDetilInExceptions, server); + case '(': // big number + reader.Consume(1); + return ReadLineTerminatedString(ResultType.BigInteger, flags, ref reader); + case '%': // map + reader.Consume(1); + return ReadArray(ResultType.Map, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); + case '~': // set + reader.Consume(1); + return ReadArray(ResultType.Set, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); + case '|': // attribute + reader.Consume(1); + var arr = ReadArray(ResultType.Attribute, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); + if (!arr.HasValue) return RawResult.Nil; // failed to parse attribute data + + // for now, we want to just skip attribute data; so + // drop whatever we parsed on the floor and keep looking + break; // exits the SWITCH, not the DO/WHILE + case '>': // push + reader.Consume(1); + return ReadArray(ResultType.Push, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); + } + } while (prefix == '|'); + + if (allowInlineProtocol) return ParseInlineProtocol(flags, arena, ReadLineTerminatedString(ResultType.SimpleString, flags, ref reader)); + throw new InvalidOperationException("Unexpected response prefix: " + (char)prefix); } - private static RawResult ParseInlineProtocol(Arena arena, in RawResult line) + private static RawResult ParseInlineProtocol(RawResult.ResultFlags flags, Arena arena, in RawResult line) { if (!line.HasValue) return RawResult.Nil; // incomplete line @@ -2022,9 +2081,9 @@ private static RawResult ParseInlineProtocol(Arena arena, in RawResul var iter = block.GetEnumerator(); foreach (var token in line.GetInlineTokenizer()) { // this assigns *via a reference*, returned via the iterator; just... sweet - iter.GetNext() = new RawResult(line.Type, token, false); + iter.GetNext() = new RawResult(line.Resp3Type, token, flags); // spoof RESP2 from RESP1 } - return new RawResult(block, false); + return new RawResult(ResultType.Array, block, flags); // spoof RESP2 from RESP1 } internal bool HasPendingCallerFacingItems() diff --git a/src/StackExchange.Redis/Profiling/ProfiledCommand.cs b/src/StackExchange.Redis/Profiling/ProfiledCommand.cs index e4037902e..a549b2699 100644 --- a/src/StackExchange.Redis/Profiling/ProfiledCommand.cs +++ b/src/StackExchange.Redis/Profiling/ProfiledCommand.cs @@ -15,7 +15,7 @@ internal sealed class ProfiledCommand : IProfiledCommand public int Db => Message!.Db; - public string Command => Message is RedisDatabase.ExecuteMessage em ? em.Command.ToString() : Message!.Command.ToString(); + public string Command => Message!.CommandString; public CommandFlags Flags => Message!.Flags; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 84d4ce032..cf3b47077 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -1,6 +1,5 @@ #nullable enable abstract StackExchange.Redis.RedisResult.IsNull.get -> bool -abstract StackExchange.Redis.RedisResult.Type.get -> StackExchange.Redis.ResultType override StackExchange.Redis.ChannelMessage.Equals(object? obj) -> bool override StackExchange.Redis.ChannelMessage.GetHashCode() -> int override StackExchange.Redis.ChannelMessage.ToString() -> string! diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 5f282702b..31d1fccdc 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,29 @@ - \ No newline at end of file +abstract StackExchange.Redis.RedisResult.ToString(out string? type) -> string? +override sealed StackExchange.Redis.RedisResult.ToString() -> string! +override StackExchange.Redis.Role.Master.Replica.ToString() -> string! +StackExchange.Redis.ConfigurationOptions.Protocol.get -> StackExchange.Redis.RedisProtocol? +StackExchange.Redis.ConfigurationOptions.Protocol.set -> void +StackExchange.Redis.RedisFeatures.ClientId.get -> bool +StackExchange.Redis.RedisFeatures.Equals(StackExchange.Redis.RedisFeatures other) -> bool +StackExchange.Redis.RedisFeatures.Resp3.get -> bool +StackExchange.Redis.RedisProtocol +StackExchange.Redis.RedisProtocol.Resp2 = 20000 -> StackExchange.Redis.RedisProtocol +StackExchange.Redis.RedisProtocol.Resp3 = 30000 -> StackExchange.Redis.RedisProtocol +StackExchange.Redis.RedisResult.Resp2Type.get -> StackExchange.Redis.ResultType +StackExchange.Redis.RedisResult.Resp3Type.get -> StackExchange.Redis.ResultType +StackExchange.Redis.RedisResult.Type.get -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Array = 5 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Attribute = 29 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.BigInteger = 17 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.BlobError = 10 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Boolean = 11 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Double = 9 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Map = 13 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Null = 8 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Push = 37 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.Set = 21 -> StackExchange.Redis.ResultType +StackExchange.Redis.ResultType.VerbatimString = 12 -> StackExchange.Redis.ResultType +static StackExchange.Redis.RedisResult.Create(StackExchange.Redis.RedisResult![]! values, StackExchange.Redis.ResultType resultType) -> StackExchange.Redis.RedisResult! +static StackExchange.Redis.RedisResult.Create(StackExchange.Redis.RedisValue[]! values, StackExchange.Redis.ResultType resultType) -> StackExchange.Redis.RedisResult! +virtual StackExchange.Redis.RedisResult.Length.get -> int +virtual StackExchange.Redis.RedisResult.this[int index].get -> StackExchange.Redis.RedisResult! \ No newline at end of file diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index fc189f10c..ab94c2c45 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -1,8 +1,8 @@ -using System; +using Pipelines.Sockets.Unofficial.Arenas; +using System; using System.Buffers; using System.Runtime.CompilerServices; using System.Text; -using Pipelines.Sockets.Unofficial.Arenas; namespace StackExchange.Redis { @@ -13,16 +13,22 @@ internal readonly struct RawResult internal int ItemsCount => (int)_items.Length; internal ReadOnlySequence Payload { get; } - internal static readonly RawResult NullMultiBulk = new RawResult(default(Sequence), isNull: true); - internal static readonly RawResult EmptyMultiBulk = new RawResult(default(Sequence), isNull: false); internal static readonly RawResult Nil = default; // Note: can't use Memory here - struct recursion breaks runtime private readonly Sequence _items; - private readonly ResultType _type; + private readonly ResultType _resultType; + private readonly ResultFlags _flags; - private const ResultType NonNullFlag = (ResultType)128; + [Flags] + internal enum ResultFlags + { + None = 0, + HasValue = 1 << 0, // simply indicates "not the default" (always set in .ctor) + NonNull = 1 << 1, // defines explicit null; isn't "IsNull" because we want default to be null + Resp3 = 1 << 2, // was the connection in RESP3 mode? + } - public RawResult(ResultType resultType, in ReadOnlySequence payload, bool isNull) + public RawResult(ResultType resultType, in ReadOnlySequence payload, ResultFlags flags) { switch (resultType) { @@ -30,40 +36,76 @@ public RawResult(ResultType resultType, in ReadOnlySequence payload, bool case ResultType.Error: case ResultType.Integer: case ResultType.BulkString: + case ResultType.Double: + case ResultType.Boolean: + case ResultType.BlobError: + case ResultType.VerbatimString: + case ResultType.BigInteger: + break; + case ResultType.Null: + flags &= ~ResultFlags.NonNull; break; default: - throw new ArgumentOutOfRangeException(nameof(resultType)); + ThrowInvalidType(resultType); + break; } - if (!isNull) resultType |= NonNullFlag; - _type = resultType; + _resultType = resultType; + _flags = flags | ResultFlags.HasValue; Payload = payload; _items = default; } - public RawResult(Sequence items, bool isNull) + public RawResult(ResultType resultType, Sequence items, ResultFlags flags) { - _type = isNull ? ResultType.MultiBulk : (ResultType.MultiBulk | NonNullFlag); + switch (resultType) + { + case ResultType.Array: + case ResultType.Map: + case ResultType.Set: + case ResultType.Attribute: + case ResultType.Push: + break; + case ResultType.Null: + flags &= ~ResultFlags.NonNull; + break; + default: + ThrowInvalidType(resultType); + break; + } + _resultType = resultType; + _flags = flags | ResultFlags.HasValue; Payload = default; _items = items.Untyped(); } - public bool IsError => Type == ResultType.Error; + private static void ThrowInvalidType(ResultType resultType) + => throw new ArgumentOutOfRangeException(nameof(resultType), $"Invalid result-type: {resultType}"); + + public bool IsError => _resultType.IsError(); + + public ResultType Resp3Type => _resultType; + + // if null, assume string + public ResultType Resp2TypeBulkString => _resultType == ResultType.Null ? ResultType.BulkString : _resultType.ToResp2(); + // if null, assume array + public ResultType Resp2TypeArray => _resultType == ResultType.Null ? ResultType.Array : _resultType.ToResp2(); + + internal bool IsNull => (_flags & ResultFlags.NonNull) == 0; - public ResultType Type => _type & ~NonNullFlag; + public bool HasValue => (_flags & ResultFlags.HasValue) != 0; - internal bool IsNull => (_type & NonNullFlag) == 0; - public bool HasValue => Type != ResultType.None; + public bool IsResp3 => (_flags & ResultFlags.Resp3) != 0; public override string ToString() { if (IsNull) return "(null)"; - return Type switch + return _resultType.ToResp2() switch { - ResultType.SimpleString or ResultType.Integer or ResultType.Error => $"{Type}: {GetString()}", - ResultType.BulkString => $"{Type}: {Payload.Length} bytes", - ResultType.MultiBulk => $"{Type}: {ItemsCount} items", - _ => $"(unknown: {Type})", + ResultType.SimpleString or ResultType.Integer or ResultType.Error => $"{Resp3Type}: {GetString()}", + ResultType.BulkString => $"{Resp3Type}: {Payload.Length} bytes", + ResultType.Array => $"{Resp3Type}: {ItemsCount} items", + _ => $"(unknown: {Resp3Type})", }; } @@ -119,7 +161,7 @@ public bool MoveNext() } internal RedisChannel AsRedisChannel(byte[]? channelPrefix, RedisChannel.PatternMode mode) { - switch (Type) + switch (Resp2TypeBulkString) { case ResultType.SimpleString: case ResultType.BulkString: @@ -134,20 +176,31 @@ internal RedisChannel AsRedisChannel(byte[]? channelPrefix, RedisChannel.Pattern } return default; default: - throw new InvalidCastException("Cannot convert to RedisChannel: " + Type); + throw new InvalidCastException("Cannot convert to RedisChannel: " + Resp3Type); } } - internal RedisKey AsRedisKey() => Type switch + internal RedisKey AsRedisKey() { - ResultType.SimpleString or ResultType.BulkString => (RedisKey)GetBlob(), - _ => throw new InvalidCastException("Cannot convert to RedisKey: " + Type), - }; + return Resp2TypeBulkString switch + { + ResultType.SimpleString or ResultType.BulkString => (RedisKey)GetBlob(), + _ => throw new InvalidCastException("Cannot convert to RedisKey: " + Resp3Type), + }; + } internal RedisValue AsRedisValue() { if (IsNull) return RedisValue.Null; - switch (Type) + if (Resp3Type == ResultType.Boolean && Payload.Length == 1) + { + switch (Payload.First.Span[0]) + { + case (byte)'t': return (RedisValue)true; + case (byte)'f': return (RedisValue)false; + }; + } + switch (Resp2TypeBulkString) { case ResultType.Integer: long i64; @@ -157,13 +210,13 @@ internal RedisValue AsRedisValue() case ResultType.BulkString: return (RedisValue)GetBlob(); } - throw new InvalidCastException("Cannot convert to RedisValue: " + Type); + throw new InvalidCastException("Cannot convert to RedisValue: " + Resp3Type); } internal Lease? AsLease() { if (IsNull) return null; - switch (Type) + switch (Resp2TypeBulkString) { case ResultType.SimpleString: case ResultType.BulkString: @@ -172,7 +225,7 @@ internal RedisValue AsRedisValue() payload.CopyTo(lease.Span); return lease; } - throw new InvalidCastException("Cannot convert to Lease: " + Type); + throw new InvalidCastException("Cannot convert to Lease: " + Resp3Type); } internal bool IsEqual(in CommandBytes expected) @@ -242,6 +295,15 @@ internal bool StartsWith(byte[] expected) internal bool GetBoolean() { if (Payload.Length != 1) throw new InvalidCastException(); + if (Resp3Type == ResultType.Boolean) + { + return Payload.First.Span[0] switch + { + (byte)'t' => true, + (byte)'f' => false, + _ => throw new InvalidCastException(), + }; + } return Payload.First.Span[0] switch { (byte)'1' => true, @@ -369,7 +431,7 @@ private static GeoPosition AsGeoPosition(in Sequence coords) internal bool TryGetDouble(out double val) { - if (IsNull) + if (IsNull || Payload.IsEmpty) { val = 0; return false; @@ -379,6 +441,14 @@ internal bool TryGetDouble(out double val) val = i64; return true; } + + if (Payload.IsSingleSegment) return Format.TryParseDouble(Payload.First.Span, out val); + if (Payload.Length < 64) + { + Span span = stackalloc byte[(int)Payload.Length]; + Payload.CopyTo(span); + return Format.TryParseDouble(span, out val); + } return Format.TryParseDouble(GetString(), out val); } @@ -396,5 +466,11 @@ internal bool TryGetInt64(out long value) Payload.CopyTo(span); return Format.TryParseInt64(span, out value); } + + internal bool Is(char value) + { + var span = Payload.First.Span; + return span.Length == 1 && (char)span[0] == value && Payload.IsSingleSegment; + } } } diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 9df7ac742..1cc089f2d 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -4702,14 +4702,14 @@ private abstract class ScanResultProcessor : ResultProcessor.ScanResult(i64, oversized, count, true); @@ -4761,6 +4761,7 @@ protected override void WriteImpl(PhysicalConnection physical) } } + public override string CommandString => Command.ToString(); public override string CommandAndKey => Command.ToString(); public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) @@ -5048,7 +5049,7 @@ private class StringGetWithExpiryProcessor : ResultProcessor /// Provides basic information about the features available on a particular version of Redis. /// - public readonly struct RedisFeatures + public readonly struct RedisFeatures : IEquatable { internal static readonly Version v2_0_0 = new Version(2, 0, 0), v2_1_0 = new Version(2, 1, 0), @@ -56,167 +56,172 @@ public RedisFeatures(Version version) /// /// Are BITOP and BITCOUNT available? /// - public bool BitwiseOperations => Version >= v2_6_0; + public bool BitwiseOperations => Version.IsAtLeast(v2_6_0); /// /// Is CLIENT SETNAME available? /// - public bool ClientName => Version >= v2_6_9; + public bool ClientName => Version.IsAtLeast(v2_6_9); + + /// + /// Is CLIENT ID available? + /// + public bool ClientId => Version.IsAtLeast(v5_0_0); /// /// Does EXEC support EXECABORT if there are errors? /// - public bool ExecAbort => Version >= v2_6_5 && Version != v2_9_5; + public bool ExecAbort => Version.IsAtLeast(v2_6_5) && !Version.IsEqual(v2_9_5); /// /// Can EXPIRE be used to set expiration on a key that is already volatile (i.e. has an expiration)? /// - public bool ExpireOverwrite => Version >= v2_1_3; + public bool ExpireOverwrite => Version.IsAtLeast(v2_1_3); /// /// Is GETDEL available? /// - public bool GetDelete => Version >= v6_2_0; + public bool GetDelete => Version.IsAtLeast(v6_2_0); /// /// Is HSTRLEN available? /// - public bool HashStringLength => Version >= v3_2_0; + public bool HashStringLength => Version.IsAtLeast(v3_2_0); /// /// Does HDEL support variadic usage? /// - public bool HashVaradicDelete => Version >= v2_4_0; + public bool HashVaradicDelete => Version.IsAtLeast(v2_4_0); /// /// Are INCRBYFLOAT and HINCRBYFLOAT available? /// - public bool IncrementFloat => Version >= v2_6_0; + public bool IncrementFloat => Version.IsAtLeast(v2_6_0); /// /// Does INFO support sections? /// - public bool InfoSections => Version >= v2_8_0; + public bool InfoSections => Version.IsAtLeast(v2_8_0); /// /// Is LINSERT available? /// - public bool ListInsert => Version >= v2_1_1; + public bool ListInsert => Version.IsAtLeast(v2_1_1); /// /// Is MEMORY available? /// - public bool Memory => Version >= v4_0_0; + public bool Memory => Version.IsAtLeast(v4_0_0); /// /// Are PEXPIRE and PTTL available? /// - public bool MillisecondExpiry => Version >= v2_6_0; + public bool MillisecondExpiry => Version.IsAtLeast(v2_6_0); /// /// Is MODULE available? /// - public bool Module => Version >= v4_0_0; + public bool Module => Version.IsAtLeast(v4_0_0); /// /// Does SRANDMEMBER support the "count" option? /// - public bool MultipleRandom => Version >= v2_5_14; + public bool MultipleRandom => Version.IsAtLeast(v2_5_14); /// /// Is PERSIST available? /// - public bool Persist => Version >= v2_1_2; + public bool Persist => Version.IsAtLeast(v2_1_2); /// /// Are LPUSHX and RPUSHX available? /// - public bool PushIfNotExists => Version >= v2_1_1; + public bool PushIfNotExists => Version.IsAtLeast(v2_1_1); /// /// Does this support SORT_RO? /// - internal bool ReadOnlySort => Version >= v7_0_0_rc1; + internal bool ReadOnlySort => Version.IsAtLeast(v7_0_0_rc1); /// /// Is SCAN (cursor-based scanning) available? /// - public bool Scan => Version >= v2_8_0; + public bool Scan => Version.IsAtLeast(v2_8_0); /// /// Are EVAL, EVALSHA, and other script commands available? /// - public bool Scripting => Version >= v2_6_0; + public bool Scripting => Version.IsAtLeast(v2_6_0); /// /// Does SET support the GET option? /// - public bool SetAndGet => Version >= v6_2_0; + public bool SetAndGet => Version.IsAtLeast(v6_2_0); /// /// Does SET support the EX, PX, NX, and XX options? /// - public bool SetConditional => Version >= v2_6_12; + public bool SetConditional => Version.IsAtLeast(v2_6_12); /// /// Does SET have the KEEPTTL option? /// - public bool SetKeepTtl => Version >= v6_0_0; + public bool SetKeepTtl => Version.IsAtLeast(v6_0_0); /// /// Does SET allow the NX and GET options to be used together? /// - public bool SetNotExistsAndGet => Version >= v7_0_0_rc1; + public bool SetNotExistsAndGet => Version.IsAtLeast(v7_0_0_rc1); /// /// Does SADD support variadic usage? /// - public bool SetVaradicAddRemove => Version >= v2_4_0; + public bool SetVaradicAddRemove => Version.IsAtLeast(v2_4_0); /// /// Are ZPOPMIN and ZPOPMAX available? /// - public bool SortedSetPop => Version >= v5_0_0; + public bool SortedSetPop => Version.IsAtLeast(v5_0_0); /// /// Is ZRANGESTORE available? /// - public bool SortedSetRangeStore => Version >= v6_2_0; + public bool SortedSetRangeStore => Version.IsAtLeast(v6_2_0); /// /// Are Redis Streams available? /// - public bool Streams => Version >= v4_9_1; + public bool Streams => Version.IsAtLeast(v4_9_1); /// /// Is STRLEN available? /// - public bool StringLength => Version >= v2_1_2; + public bool StringLength => Version.IsAtLeast(v2_1_2); /// /// Is SETRANGE available? /// - public bool StringSetRange => Version >= v2_1_8; + public bool StringSetRange => Version.IsAtLeast(v2_1_8); /// /// Is SWAPDB available? /// - public bool SwapDB => Version >= v4_0_0; + public bool SwapDB => Version.IsAtLeast(v4_0_0); /// /// Is TIME available? /// - public bool Time => Version >= v2_6_0; + public bool Time => Version.IsAtLeast(v2_6_0); /// /// Is UNLINK available? /// - public bool Unlink => Version >= v4_0_0; + public bool Unlink => Version.IsAtLeast(v4_0_0); /// /// Are Lua changes to the calling database transparent to the calling client? /// - public bool ScriptingDatabaseSafe => Version >= v2_8_12; + public bool ScriptingDatabaseSafe => Version.IsAtLeast(v2_8_12); /// [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(HyperLogLogCountReplicaSafe) + " instead, this will be removed in 3.0.")] @@ -226,37 +231,43 @@ public RedisFeatures(Version version) /// /// Is PFCOUNT available on replicas? /// - public bool HyperLogLogCountReplicaSafe => Version >= v2_8_18; + public bool HyperLogLogCountReplicaSafe => Version.IsAtLeast(v2_8_18); /// /// Are geospatial commands available? /// - public bool Geo => Version >= v3_2_0; + public bool Geo => Version.IsAtLeast(v3_2_0); /// /// Can PING be used on a subscription connection? /// - internal bool PingOnSubscriber => Version >= v3_0_0; + internal bool PingOnSubscriber => Version.IsAtLeast(v3_0_0); /// /// Does SPOP support popping multiple items? /// - public bool SetPopMultiple => Version >= v3_2_0; + public bool SetPopMultiple => Version.IsAtLeast(v3_2_0); /// /// Is TOUCH available? /// - public bool KeyTouch => Version >= v3_2_1; + public bool KeyTouch => Version.IsAtLeast(v3_2_1); /// /// Does the server prefer 'replica' terminology - 'REPLICAOF', etc? /// - public bool ReplicaCommands => Version >= v5_0_0; + public bool ReplicaCommands => Version.IsAtLeast(v5_0_0); /// /// Do list-push commands support multiple arguments? /// - public bool PushMultiple => Version >= v4_0_0; + public bool PushMultiple => Version.IsAtLeast(v4_0_0); + + + /// + /// Is the RESP3 protocol available? + /// + public bool Resp3 => Version.IsAtLeast(v6_0_0); /// /// The Redis version of the server @@ -274,7 +285,7 @@ public override string ToString() if (v.Build >= 0) sb.Append('.').Append(v.Build); sb.AppendLine(); object boxed = this; - foreach(var prop in s_props) + foreach (var prop in s_props) { sb.Append(prop.Name).Append(": ").Append(prop.GetValue(boxed)).AppendLine(); } @@ -291,7 +302,7 @@ orderby prop.Name /// Returns the hash code for this instance. /// A 32-bit signed integer that is the hash code for this instance. - public override int GetHashCode() => Version.GetHashCode(); + public override int GetHashCode() => Version.GetNormalizedHashCode(); /// /// Indicates whether this instance and a specified object are equal. @@ -300,16 +311,56 @@ orderby prop.Name /// if and this instance are the same type and represent the same value, otherwise. /// /// The object to compare with the current instance. - public override bool Equals(object? obj) => obj is RedisFeatures f && f.Version == Version; + public override bool Equals(object? obj) => obj is RedisFeatures f && f.Version.IsEqual(Version); + + /// + /// Indicates whether this instance and a specified object are equal. + /// + /// + /// if and this instance are the same type and represent the same value, otherwise. + /// + /// The object to compare with the current instance. + public bool Equals(RedisFeatures other) => other.Version.IsEqual(Version); /// /// Checks if 2 are .Equal(). /// - public static bool operator ==(RedisFeatures left, RedisFeatures right) => left.Equals(right); + public static bool operator ==(RedisFeatures left, RedisFeatures right) => left.Version.IsEqual(right.Version); /// /// Checks if 2 are not .Equal(). /// - public static bool operator !=(RedisFeatures left, RedisFeatures right) => !left.Equals(right); + public static bool operator !=(RedisFeatures left, RedisFeatures right) => !left.Version.IsEqual(right.Version); + } +} +internal static class VersionExtensions +{ + // normalize two version parts and smash them together into a long; if either part is -ve, + // zero is used instead; this gives us consistent ordering following "long" rules + + private static long ComposeMajorMinor(Version version) // always specified + => (((long)version.Major) << 32) | (long)version.Minor; + + private static long ComposeBuildRevision(Version version) // can be -ve for "not specified" + { + int build = version.Build, revision = version.Revision; + return (((long)(build < 0 ? 0 : build)) << 32) | (long)(revision < 0 ? 0 : revision); + } + + internal static int GetNormalizedHashCode(this Version value) + => (ComposeMajorMinor(value) * ComposeBuildRevision(value)).GetHashCode(); + + internal static bool IsEqual(this Version x, Version y) + => ComposeMajorMinor(x) == ComposeMajorMinor(y) + && ComposeBuildRevision(x) == ComposeBuildRevision(y); + + internal static bool IsAtLeast(this Version x, Version y) + { + // think >=, but: without the... "unusual behaviour" in how Version's >= operator + // compares values with different part lengths, i.e. "6.0" **is not** >= "6.0.0" + // under the inbuilt operator + var delta = ComposeMajorMinor(x) - ComposeMajorMinor(y); + if (delta > 0) return true; + return delta < 0 ? false : ComposeBuildRevision(x) >= ComposeBuildRevision(y); } } diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index e926b6da4..7f786c4d7 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -35,7 +35,14 @@ public static readonly CommandBytes groups = "groups", lastGeneratedId = "last-generated-id", firstEntry = "first-entry", - lastEntry = "last-entry"; + lastEntry = "last-entry", + + // HELLO + version = "version", + proto = "proto", + role = "role", + mode = "mode", + id = "id"; } internal static class RedisLiterals { @@ -50,6 +57,7 @@ public static readonly RedisValue AND = "AND", ANY = "ANY", ASC = "ASC", + AUTH = "AUTH", BEFORE = "BEFORE", BIT = "BIT", BY = "BY", @@ -61,6 +69,7 @@ public static readonly RedisValue COPY = "COPY", COUNT = "COUNT", DB = "DB", + @default = "default", DESC = "DESC", DOCTOR = "DOCTOR", ENCODING = "ENCODING", diff --git a/src/StackExchange.Redis/RedisProtocol.cs b/src/StackExchange.Redis/RedisProtocol.cs new file mode 100644 index 000000000..2b2635388 --- /dev/null +++ b/src/StackExchange.Redis/RedisProtocol.cs @@ -0,0 +1,21 @@ +namespace StackExchange.Redis; + +/// +/// Indicates the protocol for communicating with the server +/// +public enum RedisProtocol +{ + // note: the non-binary safe protocol is not supported by the client, although the parser does support it (it is used in the toy server) + + // important: please use "major_minor_revision" numbers (two digit minor/revision), to allow for possible scenarios like + // "hey, we've added RESP 3.1; oops, we've added RESP 3.1.1" + + /// + /// The protocol used by all redis server versions since 1.2, as defined by https://github.com/redis/redis-specifications/blob/master/protocol/RESP2.md + /// + Resp2 = 2_00_00, // major__minor__revision + /// + /// Opt-in variant introduced in server version 6, as defined by https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md + /// + Resp3 = 3_00_00, // major__minor__revision +} diff --git a/src/StackExchange.Redis/RedisResult.cs b/src/StackExchange.Redis/RedisResult.cs index 9935deee7..0a63f0275 100644 --- a/src/StackExchange.Redis/RedisResult.cs +++ b/src/StackExchange.Redis/RedisResult.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; @@ -10,12 +11,21 @@ namespace StackExchange.Redis /// public abstract class RedisResult { + /// + /// Do not use + /// + [Obsolete("Please specify a result type", true)] // retained purely for binary compat + public RedisResult() : this(default) { } + + internal RedisResult(ResultType resultType) => Resp3Type = resultType; + /// /// Create a new RedisResult representing a single value. /// /// The to create a result from. /// The type of result being represented /// new . + [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "")] public static RedisResult Create(RedisValue value, ResultType? resultType = null) => new SingleRedisResult(value, resultType); /// @@ -23,9 +33,18 @@ public abstract class RedisResult /// /// The s to create a result from. /// new . - public static RedisResult Create(RedisValue[] values) => - values == null ? NullArray : values.Length == 0 ? EmptyArray : - new ArrayRedisResult(Array.ConvertAll(values, value => new SingleRedisResult(value, null))); + public static RedisResult Create(RedisValue[] values) + => Create(values, ResultType.Array); + + /// + /// Create a new RedisResult representing an array of values. + /// + /// The s to create a result from. + /// new . + /// The explicit data type + public static RedisResult Create(RedisValue[] values, ResultType resultType) => + values == null ? NullArray : values.Length == 0 ? EmptyArray(resultType) : + new ArrayRedisResult(Array.ConvertAll(values, value => new SingleRedisResult(value, null)), resultType); /// /// Create a new RedisResult representing an array of values. @@ -33,22 +52,54 @@ public static RedisResult Create(RedisValue[] values) => /// The s to create a result from. /// new . public static RedisResult Create(RedisResult[] values) - => values == null ? NullArray : values.Length == 0 ? EmptyArray : new ArrayRedisResult(values); + => Create(values, ResultType.Array); + + /// + /// Create a new RedisResult representing an array of values. + /// + /// The s to create a result from. + /// new . + /// The explicit data type + public static RedisResult Create(RedisResult[] values, ResultType resultType) + => values == null ? NullArray : values.Length == 0 ? EmptyArray(resultType) : new ArrayRedisResult(values, resultType); /// /// An empty array result. /// - internal static RedisResult EmptyArray { get; } = new ArrayRedisResult(Array.Empty()); + internal static RedisResult EmptyArray(ResultType type) => type switch + { + ResultType.Array => s_EmptyArray ??= new ArrayRedisResult(Array.Empty(), type), + ResultType.Set => s_EmptySet ??= new ArrayRedisResult(Array.Empty(), type), + ResultType.Map => s_EmptyMap ??= new ArrayRedisResult(Array.Empty(), type), + _ => new ArrayRedisResult(Array.Empty(), type), + }; + + private static RedisResult? s_EmptyArray, s_EmptySet, s_EmptyMap; /// /// A null array result. /// - internal static RedisResult NullArray { get; } = new ArrayRedisResult(null); + internal static RedisResult NullArray { get; } = new ArrayRedisResult(null, ResultType.Null); /// /// A null single result, to use as a default for invalid returns. /// - internal static RedisResult NullSingle { get; } = new SingleRedisResult(RedisValue.Null, ResultType.None); + internal static RedisResult NullSingle { get; } = new SingleRedisResult(RedisValue.Null, ResultType.Null); + + /// + /// Gets the number of elements in this item if it is a valid array, or -1 otherwise. + /// + public virtual int Length => -1; + + /// + public sealed override string ToString() => ToString(out _) ?? ""; + + /// + /// Gets the string content as per , but also obtains the declared type from verbatim strings (for example LATENCY DOCTOR) + /// + /// The type of the returned string + /// The content + public abstract string? ToString(out string? type); /// /// Internally, this is very similar to RawResult, except it is designed to be usable, @@ -58,14 +109,18 @@ internal static bool TryCreate(PhysicalConnection connection, in RawResult resul { try { - switch (result.Type) + if (result.Resp3Type == ResultType.Null) + { + Console.Write("hi"); + } + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: case ResultType.BulkString: - redisResult = new SingleRedisResult(result.AsRedisValue(), result.Type); + redisResult = new SingleRedisResult(result.AsRedisValue(), result.Resp3Type); return true; - case ResultType.MultiBulk: + case ResultType.Array: if (result.IsNull) { redisResult = NullArray; @@ -74,7 +129,7 @@ internal static bool TryCreate(PhysicalConnection connection, in RawResult resul var items = result.GetItems(); if (items.Length == 0) { - redisResult = EmptyArray; + redisResult = EmptyArray(result.Resp3Type); return true; } var arr = new RedisResult[items.Length]; @@ -91,10 +146,10 @@ internal static bool TryCreate(PhysicalConnection connection, in RawResult resul return false; } } - redisResult = new ArrayRedisResult(arr); + redisResult = new ArrayRedisResult(arr, result.Resp3Type); return true; case ResultType.Error: - redisResult = new ErrorRedisResult(result.GetString()); + redisResult = new ErrorRedisResult(result.GetString(), result.Resp3Type); return true; default: redisResult = null; @@ -110,9 +165,23 @@ internal static bool TryCreate(PhysicalConnection connection, in RawResult resul } /// - /// Indicate the type of result that was received from redis. + /// Indicate the type of result that was received from redis, in RESP2 terms. + /// + [Obsolete($"Please use either {nameof(Resp2Type)} (simplified) or {nameof(Resp3Type)} (full)")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] + public ResultType Type => Resp2Type; + + /// + /// Indicate the type of result that was received from redis, in RESP3 terms. + /// + public ResultType Resp3Type { get; } + + /// + /// Indicate the type of result that was received from redis, in RESP2 terms. /// - public abstract ResultType Type { get; } + public ResultType Resp2Type => Resp3Type == ResultType.Null ? Resp2NullType : Resp3Type.ToResp2(); + + internal virtual ResultType Resp2NullType => ResultType.BulkString; /// /// Indicates whether this result was a null result. @@ -263,6 +332,11 @@ public Dictionary ToDictionary(IEqualityComparer? c return result; } + /// + /// Get a sub-item by index + /// + public virtual RedisResult this[int index] => throw new InvalidOperationException("Indexers can only be used on array results"); + internal abstract bool AsBoolean(); internal abstract bool[]? AsBooleanArray(); internal abstract byte[]? AsByteArray(); @@ -287,18 +361,26 @@ public Dictionary ToDictionary(IEqualityComparer? c internal abstract RedisValue[]? AsRedisValueArray(); internal abstract string? AsString(); internal abstract string?[]? AsStringArray(); + private sealed class ArrayRedisResult : RedisResult { - public override bool IsNull => _value == null; + public override bool IsNull => _value is null; private readonly RedisResult[]? _value; - public override ResultType Type => ResultType.MultiBulk; - public ArrayRedisResult(RedisResult[]? value) + internal override ResultType Resp2NullType => ResultType.Array; + + public ArrayRedisResult(RedisResult[]? value, ResultType resultType) : base(value is null ? ResultType.Null : resultType) { _value = value; } - public override string ToString() => _value == null ? "(nil)" : (_value.Length + " element(s)"); + public override int Length => _value is null ? -1 : _value.Length; + + public override string? ToString(out string? type) + { + type = null; + return _value == null ? "(nil)" : (_value.Length + " element(s)"); + } internal override bool AsBoolean() { @@ -306,6 +388,8 @@ internal override bool AsBoolean() throw new InvalidCastException(); } + public override RedisResult this[int index] => _value![index]; + internal override bool[]? AsBooleanArray() => IsNull ? null : Array.ConvertAll(_value!, x => x.AsBoolean()); internal override byte[]? AsByteArray() @@ -446,14 +530,17 @@ private sealed class ErrorRedisResult : RedisResult { private readonly string value; - public override ResultType Type => ResultType.Error; - public ErrorRedisResult(string? value) + public ErrorRedisResult(string? value, ResultType type) : base(type) { this.value = value ?? throw new ArgumentNullException(nameof(value)); } public override bool IsNull => value == null; - public override string ToString() => value; + public override string? ToString(out string? type) + { + type = null; + return value; + } internal override bool AsBoolean() => throw new RedisServerException(value); internal override bool[] AsBooleanArray() => throw new RedisServerException(value); internal override byte[] AsByteArray() => throw new RedisServerException(value); @@ -483,17 +570,26 @@ public ErrorRedisResult(string? value) private sealed class SingleRedisResult : RedisResult, IConvertible { private readonly RedisValue _value; - public override ResultType Type { get; } - public SingleRedisResult(RedisValue value, ResultType? resultType) + public SingleRedisResult(RedisValue value, ResultType? resultType) : base(value.IsNull ? ResultType.Null : resultType ?? (value.IsInteger ? ResultType.Integer : ResultType.BulkString)) { _value = value; - Type = resultType ?? (value.IsInteger ? ResultType.Integer : ResultType.BulkString); } - public override bool IsNull => _value.IsNull; + public override bool IsNull => Resp3Type == ResultType.Null || _value.IsNull; + + public override string? ToString(out string? type) + { + type = null; + string? s = _value; + if (Resp3Type == ResultType.VerbatimString && s is not null && s.Length >= 4 && s[3] == ':') + { // remove the prefix + type = s.Substring(0, 3); + s = s.Substring(4); + } + return s; + } - public override string ToString() => _value.ToString(); internal override bool AsBoolean() => (bool)_value; internal override bool[] AsBooleanArray() => new[] { AsBoolean() }; internal override byte[]? AsByteArray() => (byte[]?)_value; @@ -555,7 +651,7 @@ ulong IConvertible.ToUInt64(IFormatProvider? provider) decimal IConvertible.ToDecimal(IFormatProvider? provider) { // we can do this safely *sometimes* - if (Type == ResultType.Integer) return AsInt64(); + if (Resp2Type == ResultType.Integer) return AsInt64(); // but not always ThrowNotSupported(); return default; @@ -578,7 +674,7 @@ object IConvertible.ToType(Type conversionType, IFormatProvider? provider) case TypeCode.UInt64: checked { return (ulong)AsInt64(); } case TypeCode.Single: return (float)AsDouble(); case TypeCode.Double: return AsDouble(); - case TypeCode.Decimal when Type == ResultType.Integer: return AsInt64(); + case TypeCode.Decimal when Resp2Type == ResultType.Integer: return AsInt64(); case TypeCode.String: return AsString()!; default: ThrowNotSupported(); diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 13b114da0..9a335aee4 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -923,12 +923,12 @@ private class ScanResultProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var arr = result.GetItems(); RawResult inner; - if (arr.Length == 2 && (inner = arr[1]).Type == ResultType.MultiBulk) + if (arr.Length == 2 && (inner = arr[1]).Resp2TypeArray == ResultType.Array) { var items = inner.GetItems(); RedisKey[] keys; diff --git a/src/StackExchange.Redis/RedisTransaction.cs b/src/StackExchange.Redis/RedisTransaction.cs index 32e7bfb1d..ea71e7dd1 100644 --- a/src/StackExchange.Redis/RedisTransaction.cs +++ b/src/StackExchange.Redis/RedisTransaction.cs @@ -199,7 +199,7 @@ private class QueuedProcessor : ResultProcessor protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.SimpleString && result.IsEqual(CommonReplies.QUEUED)) + if (result.Resp2TypeBulkString == ResultType.SimpleString && result.IsEqual(CommonReplies.QUEUED)) { if (message is QueuedMessage q) { @@ -270,8 +270,7 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) public IEnumerable GetMessages(PhysicalConnection connection) { IResultBox? lastBox = null; - var bridge = connection.BridgeCouldBeNull; - if (bridge == null) throw new ObjectDisposedException(connection.ToString()); + var bridge = connection.BridgeCouldBeNull ?? throw new ObjectDisposedException(connection.ToString()); bool explicitCheckForQueued = !bridge.ServerEndPoint.GetFeatures().ExecAbort; var multiplexer = bridge.Multiplexer; @@ -486,7 +485,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes if (message is TransactionMessage tran) { var wrapped = tran.InnerOperations; - switch (result.Type) + switch (result.Resp2TypeArray) { case ResultType.SimpleString: if (tran.IsAborted && result.IsEqual(CommonReplies.OK)) @@ -510,7 +509,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return true; } break; - case ResultType.MultiBulk: + case ResultType.Array: if (!tran.IsAborted) { var arr = result.GetItems(); diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index a0b045cf4..de129f868 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -329,6 +329,28 @@ internal StorageType Type } } + internal Version? TryGetVersion() + { + switch (Type) + { + case StorageType.Int64: + var value64 = OverlappedValueInt64; + return value64 >= 0 & value64 <= int.MaxValue ? new Version((int)value64, 0) : null; + case StorageType.Raw when _memory.Length < 128: +#if NETCOREAPP3_1_OR_GREATER + Span chars = stackalloc char[Encoding.UTF8.GetMaxCharCount(_memory.Length)]; + int count = Encoding.UTF8.GetChars(_memory.Span, chars); + return Format.TryParseVersion(chars.Slice(0, count), out var version) ? version : null; +#else + return Format.TryParseVersion(ToString(), out var version) ? version : null; +#endif + case StorageType.String: + return Format.TryParseVersion((string)_objectOrSentinel!, out version) ? version : null; + default: + return null; + } + } + /// /// Get the size of this value in bytes /// diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 5b43401e4..6f6294933 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -1,4 +1,5 @@ -using System; +using Pipelines.Sockets.Unofficial.Arenas; +using System; using System.Buffers; using System.Collections.Generic; using System.Diagnostics; @@ -8,7 +9,6 @@ using System.Net; using System.Text; using System.Text.RegularExpressions; -using Pipelines.Sockets.Unofficial.Arenas; namespace StackExchange.Redis { @@ -334,7 +334,7 @@ public virtual bool SetResult(PhysicalConnection connection, Message message, in private void UnexpectedResponse(Message message, in RawResult result) { ConnectionMultiplexer.TraceWithoutContext("From " + GetType().Name, "Unexpected Response"); - ConnectionFail(message, ConnectionFailureType.ProtocolFailure, "Unexpected response to " + (message?.Command.ToString() ?? "n/a") + ": " + result.ToString()); + ConnectionFail(message, ConnectionFailureType.ProtocolFailure, "Unexpected response to " + (message?.CommandString ?? "n/a") + ": " + result.ToString()); } public sealed class TimeSpanProcessor : ResultProcessor @@ -347,7 +347,7 @@ public TimeSpanProcessor(bool isMilliseconds) public bool TryParse(in RawResult result, out TimeSpan? expiry) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: long time; @@ -397,7 +397,7 @@ public static TimerMessage CreateMessage(int db, CommandFlags flags, RedisComman protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.Error) + if (result.IsError) { return false; } @@ -454,7 +454,7 @@ public sealed class TrackSubscriptionsProcessor : ResultProcessor protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.MultiBulk) + if (result.Resp2TypeArray == ResultType.Array) { var items = result.GetItems(); if (items.Length >= 3 && items[2].TryGetInt64(out long count)) @@ -480,7 +480,7 @@ internal sealed class DemandZeroOrOneProcessor : ResultProcessor { public static bool TryGet(in RawResult result, out bool value) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -548,7 +548,7 @@ static int FromHex(char c) // (is that a thing?) will be wrapped in the RedisResult protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.BulkString: var asciiHash = result.GetBlob(); @@ -573,16 +573,16 @@ internal sealed class SortedSetEntryProcessor : ResultProcessor { public static bool TryParse(in RawResult result, out SortedSetEntry? entry) { - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: - var arr = result.GetItems(); - if (result.IsNull || arr.Length < 2) + case ResultType.Array: + if (result.IsNull || result.ItemsCount < 2) { entry = null; } else { + var arr = result.GetItems(); entry = new SortedSetEntry(arr[0].AsRedisValue(), arr[1].TryGetDouble(out double val) ? val : double.NaN); } return true; @@ -605,7 +605,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class SortedSetEntryArrayProcessor : ValuePairInterleavedProcessorBase { - protected override SortedSetEntry Parse(in RawResult first, in RawResult second) => + protected override SortedSetEntry Parse(in RawResult first, in RawResult second, object? state) => new SortedSetEntry(first.AsRedisValue(), second.TryGetDouble(out double val) ? val : double.NaN); } @@ -613,7 +613,7 @@ internal sealed class SortedSetPopResultProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.MultiBulk) + if (result.Resp2TypeArray == ResultType.Array) { if (result.IsNull) { @@ -654,63 +654,126 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class HashEntryArrayProcessor : ValuePairInterleavedProcessorBase { - protected override HashEntry Parse(in RawResult first, in RawResult second) => + protected override HashEntry Parse(in RawResult first, in RawResult second, object? state) => new HashEntry(first.AsRedisValue(), second.AsRedisValue()); } internal abstract class ValuePairInterleavedProcessorBase : ResultProcessor { + // when RESP3 was added, some interleaved value/pair responses: became jagged instead; + // this isn't strictly a RESP3 thing (RESP2 supports jagged), but: it is a thing that + // happened, and we need to handle that; thus, by default, we'll detect jagged data + // and handle it automatically; this virtual is included so we can turn it off + // on a per-processor basis if needed + protected virtual bool AllowJaggedPairs => true; + public bool TryParse(in RawResult result, out T[]? pairs) => TryParse(result, out pairs, false, out _); - public bool TryParse(in RawResult result, out T[]? pairs, bool allowOversized, out int count) + public T[]? ParseArray(in RawResult result, bool allowOversized, out int count, object? state) { - count = 0; - switch (result.Type) + if (result.IsNull) { - case ResultType.MultiBulk: - var arr = result.GetItems(); - if (result.IsNull) + count = 0; + return null; + } + + var arr = result.GetItems(); + count = (int)arr.Length; + if (count == 0) + { + return Array.Empty(); + } + + bool interleaved = !(result.IsResp3 && AllowJaggedPairs && IsAllJaggedPairs(arr)); + if (interleaved) count >>= 1; // so: half of that + var pairs = allowOversized ? ArrayPool.Shared.Rent(count) : new T[count]; + + if (interleaved) + { + // linear elements i.e. {key,value,key,value,key,value} + if (arr.IsSingleSegment) + { + var span = arr.FirstSpan; + int offset = 0; + for (int i = 0; i < count; i++) { - pairs = null; + pairs[i] = Parse(span[offset++], span[offset++], state); } - else + } + else + { + var iter = arr.GetEnumerator(); // simplest way of getting successive values + for (int i = 0; i < count; i++) { - count = (int)arr.Length / 2; - if (count == 0) - { - pairs = Array.Empty(); - } - else - { - pairs = allowOversized ? ArrayPool.Shared.Rent(count) : new T[count]; - if (arr.IsSingleSegment) - { - var span = arr.FirstSpan; - int offset = 0; - for (int i = 0; i < count; i++) - { - pairs[i] = Parse(span[offset++], span[offset++]); - } - } - else - { - var iter = arr.GetEnumerator(); // simplest way of getting successive values - for (int i = 0; i < count; i++) - { - pairs[i] = Parse(iter.GetNext(), iter.GetNext()); - } - } - } + pairs[i] = Parse(iter.GetNext(), iter.GetNext(), state); + } + } + } + else + { + // jagged elements i.e. {{key,value},{key,value},{key,value}} + // to get here, we've already asserted that all elements are arrays with length 2 + if (arr.IsSingleSegment) + { + int i = 0; + foreach (var el in arr.FirstSpan) + { + var inner = el.GetItems(); + pairs[i++] = Parse(inner[0], inner[1], state); + } + } + else + { + var iter = arr.GetEnumerator(); // simplest way of getting successive values + for (int i = 0; i < count; i++) + { + var inner = iter.GetNext().GetItems(); + pairs[i] = Parse(inner[0], inner[1], state); + } + } + } + return pairs; + + static bool IsAllJaggedPairs(in Sequence arr) + { + return arr.IsSingleSegment ? CheckSpan(arr.FirstSpan) : CheckSpans(arr); + + static bool CheckSpans(in Sequence arr) + { + foreach (var chunk in arr.Spans) + { + if (!CheckSpan(chunk)) return false; } return true; + } + static bool CheckSpan(ReadOnlySpan chunk) + { + // check whether each value is actually an array of length 2 + foreach (ref readonly RawResult el in chunk) + { + if (el is not { Resp2TypeArray: ResultType.Array, ItemsCount: 2 }) return false; + } + return true; + } + } + } + + public bool TryParse(in RawResult result, out T[]? pairs, bool allowOversized, out int count) + { + switch (result.Resp2TypeArray) + { + case ResultType.Array: + pairs = ParseArray(in result, allowOversized, out count, null); + return true; default: + count = 0; pairs = null; return false; } } - protected abstract T Parse(in RawResult first, in RawResult second); + protected abstract T Parse(in RawResult first, in RawResult second, object? state); protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { if (TryParse(result, out T[]? arr)) @@ -739,6 +802,7 @@ public override bool SetResult(PhysicalConnection connection, Message message, i server.IsReplica = true; } } + return base.SetResult(connection, message, result); } @@ -746,8 +810,19 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { var server = connection.BridgeCouldBeNull?.ServerEndPoint; if (server == null) return false; - switch (result.Type) + + switch (result.Resp2TypeBulkString) { + case ResultType.Integer: + if (message?.Command == RedisCommand.CLIENT) + { + if (result.TryGetInt64(out long clientId)) + { + connection.ClientId = clientId; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (CLIENT) connection-id: {clientId}"); + } + } + break; case ResultType.BulkString: if (message?.Command == RedisCommand.INFO) { @@ -772,17 +847,10 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes if ((val = Extract(line, "role:")) != null) { roleSeen = true; - switch (val) + if (TryParseRole(val, out bool isReplica)) { - case "master": - server.IsReplica = false; - Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) role: primary"); - break; - case "replica": - case "slave": - server.IsReplica = true; - Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) role: replica"); - break; + server.IsReplica = isReplica; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) role: {(isReplica ? "replica" : "primary")}"); } } else if ((val = Extract(line, "master_host:")) != null) @@ -795,7 +863,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } else if ((val = Extract(line, "redis_version:")) != null) { - if (Version.TryParse(val, out Version? version)) + if (Format.TryParseVersion(val, out Version? version)) { server.Version = version; Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) version: " + version); @@ -803,20 +871,10 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } else if ((val = Extract(line, "redis_mode:")) != null) { - switch (val) + if (TryParseServerType(val, out var serverType)) { - case "standalone": - server.ServerType = ServerType.Standalone; - Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) server-type: standalone"); - break; - case "cluster": - server.ServerType = ServerType.Cluster; - Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) server-type: cluster"); - break; - case "sentinel": - server.ServerType = ServerType.Sentinel; - Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) server-type: sentinel"); - break; + server.ServerType = serverType; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (INFO) server-type: {serverType}"); } } else if ((val = Extract(line, "run_id:")) != null) @@ -838,7 +896,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } SetResult(message, true); return true; - case ResultType.MultiBulk: + case ResultType.Array: if (message?.Command == RedisCommand.CONFIG) { var iter = result.GetItems().GetEnumerator(); @@ -887,6 +945,42 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } } } + else if (message?.Command == RedisCommand.HELLO) + { + var iter = result.GetItems().GetEnumerator(); + while (iter.MoveNext()) + { + ref RawResult key = ref iter.Current; + if (!iter.MoveNext()) break; + ref RawResult val = ref iter.Current; + + if (key.IsEqual(CommonReplies.version) && Format.TryParseVersion(val.GetString(), out var version)) + { + server.Version = version; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) server-version: {version}"); + } + else if (key.IsEqual(CommonReplies.proto) && val.TryGetInt64(out var i64)) + { + connection.IsResp3 = i64 >= 3; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) protocol: {(connection.IsResp3 ? "RESP3" : "RESP2")}"); + } + else if (key.IsEqual(CommonReplies.id) && val.TryGetInt64(out i64)) + { + connection.ClientId = i64; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) connection-id: {i64}"); + } + else if (key.IsEqual(CommonReplies.mode) && TryParseServerType(val.GetString(), out var serverType)) + { + server.ServerType = serverType; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) server-type: {serverType}"); + } + else if (key.IsEqual(CommonReplies.role) && TryParseRole(val.GetString(), out bool isReplica)) + { + server.IsReplica = isReplica; + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) role: {(isReplica ? "replica" : "primary")}"); + } + } + } else if (message?.Command == RedisCommand.SENTINEL) { server.ServerType = ServerType.Sentinel; @@ -903,6 +997,45 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes 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(LogProxy? log) => log is null ? AutoConfigure : new AutoConfigureProcessor(log); } private sealed class BooleanProcessor : ResultProcessor @@ -914,7 +1047,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes SetResult(message, false); // lots of ops return (nil) when they mean "no" return true; } - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.SimpleString: if (result.IsEqual(CommonReplies.OK)) @@ -930,7 +1063,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes case ResultType.BulkString: SetResult(message, result.GetBoolean()); return true; - case ResultType.MultiBulk: + case ResultType.Array: var items = result.GetItems(); if (items.Length == 1) { // treat an array of 1 like a single reply (for example, SCRIPT EXISTS) @@ -947,7 +1080,7 @@ private sealed class ByteArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.BulkString: SetResult(message, result.GetBlob()); @@ -970,7 +1103,7 @@ internal static ClusterConfiguration Parse(PhysicalConnection connection, string protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.BulkString: string nodes = result.GetString()!; @@ -988,7 +1121,7 @@ private sealed class ClusterNodesRawProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1023,7 +1156,7 @@ private sealed class DateTimeProcessor : ResultProcessor protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { long unixTime; - switch (result.Type) + switch (result.Resp2TypeArray) { case ResultType.Integer: if (result.TryGetInt64(out unixTime)) @@ -1033,7 +1166,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return true; } break; - case ResultType.MultiBulk: + case ResultType.Array: var arr = result.GetItems(); switch (arr.Length) { @@ -1067,7 +1200,7 @@ public sealed class NullableDateTimeProcessor : ResultProcessor protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer when result.TryGetInt64(out var duration): DateTime? expiry = duration switch @@ -1092,7 +1225,7 @@ private sealed class DoubleProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: long i64; @@ -1142,7 +1275,7 @@ private sealed class InfoProcessor : ResultProcessor>>(); @@ -1188,7 +1321,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes SetResult(message, _defaultValue); return true; } - if (result.Type == ResultType.Integer && result.TryGetInt64(out var i64)) + if (result.Resp2TypeBulkString == ResultType.Integer && result.TryGetInt64(out var i64)) { SetResult(message, i64); return true; @@ -1201,7 +1334,7 @@ private class Int64Processor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1222,7 +1355,7 @@ private class ClientIdProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1244,7 +1377,7 @@ private class PubSubNumSubProcessor : Int64Processor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.MultiBulk) + if (result.Resp2TypeArray == ResultType.Array) { var arr = result.GetItems(); if (arr.Length == 2 && arr[1].TryGetInt64(out long val)) @@ -1261,7 +1394,7 @@ private sealed class NullableDoubleArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.MultiBulk && !result.IsNull) + if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) { var arr = result.GetItemsAsDoubles()!; SetResult(message, arr); @@ -1275,7 +1408,7 @@ private sealed class NullableDoubleProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1301,7 +1434,7 @@ private sealed class NullableInt64Processor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1343,9 +1476,9 @@ public ChannelState(byte[]? prefix, RedisChannel.PatternMode mode) } protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var final = result.ToArray( (in RawResult item, in ChannelState state) => item.AsRedisChannel(state.Prefix, state.Mode), new ChannelState(connection.ChannelPrefix, mode))!; @@ -1361,9 +1494,9 @@ private sealed class RedisKeyArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var arr = result.GetItemsAsKeys()!; SetResult(message, arr); return true; @@ -1376,7 +1509,7 @@ private sealed class RedisKeyProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1392,7 +1525,7 @@ private sealed class RedisTypeProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.SimpleString: case ResultType.BulkString: @@ -1411,7 +1544,7 @@ private sealed class RedisValueArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { // allow a single item to pass explicitly pretending to be an array; example: SPOP {key} 1 case ResultType.BulkString: @@ -1421,7 +1554,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes : new[] { result.AsRedisValue() }; SetResult(message, arr); return true; - case ResultType.MultiBulk: + case ResultType.Array: arr = result.GetItemsAsValues()!; SetResult(message, arr); return true; @@ -1434,7 +1567,7 @@ private sealed class Int64ArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.MultiBulk && !result.IsNull) + if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) { var arr = result.ToArray((in RawResult x) => (long)x.AsRedisValue())!; SetResult(message, arr); @@ -1449,9 +1582,9 @@ private sealed class NullableStringArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var arr = result.GetItemsAsStrings()!; SetResult(message, arr); @@ -1465,9 +1598,9 @@ private sealed class StringArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var arr = result.GetItemsAsStringsNotNullable()!; SetResult(message, arr); return true; @@ -1480,7 +1613,7 @@ private sealed class BooleanArrayProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.MultiBulk && !result.IsNull) + if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) { var arr = result.GetItemsAsBooleans()!; SetResult(message, arr); @@ -1494,9 +1627,9 @@ private sealed class RedisValueGeoPositionProcessor : ResultProcessor Parse(item, radiusOptions), options)!; SetResult(message, typed); @@ -1609,10 +1742,9 @@ private sealed class LongestCommonSubsequenceProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1789,7 +1921,7 @@ private sealed class LeaseProcessor : ResultProcessor> { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: @@ -1805,7 +1937,7 @@ private class ScriptResultProcessor : ResultProcessor { public override bool SetResult(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type == ResultType.Error && result.StartsWith(CommonReplies.NOSCRIPT)) + if (result.IsError && result.StartsWith(CommonReplies.NOSCRIPT)) { // scripts are not flushed individually, so assume the entire script cache is toast ("SCRIPT FLUSH") connection.BridgeCouldBeNull?.ServerEndPoint?.FlushScriptCache(); message.SetScriptUnavailable(); @@ -1848,7 +1980,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return true; } - if (result.Type != ResultType.MultiBulk) + if (result.Resp2TypeArray != ResultType.Array) { return false; } @@ -1857,23 +1989,47 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes if (skipStreamName) { - // > XREAD COUNT 2 STREAMS mystream 0 - // 1) 1) "mystream" <== Skip the stream name - // 2) 1) 1) 1519073278252 - 0 <== Index 1 contains the array of stream entries - // 2) 1) "foo" - // 2) "value_1" - // 2) 1) 1519073279157 - 0 - // 2) 1) "foo" - // 2) "value_2" - - // Retrieve the initial array. For XREAD of a single stream it will - // be an array of only 1 element in the response. - var readResult = result.GetItems(); - - // Within that single element, GetItems will return an array of - // 2 elements: the stream name and the stream entries. - // Skip the stream name (index 0) and only process the stream entries (index 1). - entries = ParseRedisStreamEntries(readResult[0].GetItems()[1]); + /* + RESP 2: array element per stream; each element is an array of a name plus payload; payload is array of name/value pairs + + 127.0.0.1:6379> XREAD COUNT 2 STREAMS temperatures:us-ny:10007 0-0 + 1) 1) "temperatures:us-ny:10007" + 2) 1) 1) "1691504774593-0" + 2) 1) "temp_f" + 2) "87.2" + 3) "pressure" + 4) "29.69" + 5) "humidity" + 6) "46" + 2) 1) "1691504856705-0" + 2) 1) "temp_f" + 2) "87.2" + 3) "pressure" + 4) "29.69" + 5) "humidity" + 6) "46" + + RESP 3: map of element names with array of name plus payload; payload is array of name/value pairs + + 127.0.0.1:6379> XREAD COUNT 2 STREAMS temperatures:us-ny:10007 0-0 + 1# "temperatures:us-ny:10007" => 1) 1) "1691504774593-0" + 2) 1) "temp_f" + 2) "87.2" + 3) "pressure" + 4) "29.69" + 5) "humidity" + 6) "46" + 2) 1) "1691504856705-0" + 2) 1) "temp_f" + 2) "87.2" + 3) "pressure" + 4) "29.69" + 5) "humidity" + 6) "46" + */ + + ref readonly RawResult readResult = ref (result.Resp3Type == ResultType.Map ? ref result[1] : ref result[0][1]); + entries = ParseRedisStreamEntries(readResult); } else { @@ -1929,26 +2085,45 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return true; } - if (result.Type != ResultType.MultiBulk) + if (result.Resp2TypeArray != ResultType.Array) { return false; } - var streams = result.GetItems().ToArray((in RawResult item, in MultiStreamProcessor obj) => + RedisStream[] streams; + if (result.Resp3Type == ResultType.Map) // see SetResultCore for the shape delta between RESP2 and RESP3 { - var details = item.GetItems(); + // root is a map of named inner-arrays + streams = RedisStreamInterleavedProcessor.Instance.ParseArray(result, false, out _, this)!; // null-checked + } + else + { + streams = result.GetItems().ToArray((in RawResult item, in MultiStreamProcessor obj) => + { + var details = item.GetItems(); - // details[0] = Name of the Stream - // details[1] = Multibulk Array of Stream Entries - return new RedisStream(key: details[0].AsRedisKey(), - entries: obj.ParseRedisStreamEntries(details[1])!); - }, this); + // details[0] = Name of the Stream + // details[1] = Multibulk Array of Stream Entries + return new RedisStream(key: details[0].AsRedisKey(), + entries: obj.ParseRedisStreamEntries(details[1])!); + }, this); + } SetResult(message, streams); return true; } } + private sealed class RedisStreamInterleavedProcessor : ValuePairInterleavedProcessorBase + { + protected override bool AllowJaggedPairs => false; // we only use this on a flattened map + + public static readonly RedisStreamInterleavedProcessor Instance = new(); + private RedisStreamInterleavedProcessor() { } + protected override RedisStream Parse(in RawResult first, in RawResult second, object? state) + => new(key: first.AsRedisKey(), entries: ((MultiStreamProcessor)state!).ParseRedisStreamEntries(second)); + } + /// /// This processor is for *without* the option. /// @@ -1958,7 +2133,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { // See https://redis.io/commands/xautoclaim for command documentation. // Note that the result should never be null, so intentionally treating it as a failure to parse here - if (result.Type == ResultType.MultiBulk && !result.IsNull) + if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) { var items = result.GetItems(); @@ -1987,7 +2162,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { // See https://redis.io/commands/xautoclaim for command documentation. // Note that the result should never be null, so intentionally treating it as a failure to parse here - if (result.Type == ResultType.MultiBulk && !result.IsNull) + if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) { var items = result.GetItems(); @@ -2135,7 +2310,7 @@ internal abstract class InterleavedStreamInfoProcessorBase : ResultProcessor< protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type != ResultType.MultiBulk) + if (result.Resp2TypeArray != ResultType.Array) { return false; } @@ -2172,7 +2347,7 @@ internal sealed class StreamInfoProcessor : StreamProcessorBase // 2) "banana" protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - if (result.Type != ResultType.MultiBulk) + if (result.Resp2TypeArray != ResultType.Array) { return false; } @@ -2248,7 +2423,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes // 5) 1) 1) "Joe" // 2) "8" - if (result.Type != ResultType.MultiBulk) + if (result.Resp2TypeArray != ResultType.Array) { return false; } @@ -2292,7 +2467,7 @@ internal sealed class StreamPendingMessagesProcessor : ResultProcessor + { + public static readonly StreamNameValueEntryProcessor Instance = new(); + private StreamNameValueEntryProcessor() { } + protected override NameValueEntry Parse(in RawResult first, in RawResult second, object? state) + => new NameValueEntry(first.AsRedisValue(), second.AsRedisValue()); + } + /// /// Handles stream responses. For formats, see . /// @@ -2319,7 +2502,7 @@ internal abstract class StreamProcessorBase : ResultProcessor { protected static StreamEntry ParseRedisStreamEntry(in RawResult item) { - if (item.IsNull || item.Type != ResultType.MultiBulk) + if (item.IsNull || item.Resp2TypeArray != ResultType.Array) { return StreamEntry.Null; } @@ -2331,7 +2514,7 @@ protected static StreamEntry ParseRedisStreamEntry(in RawResult item) return new StreamEntry(id: entryDetails[0].AsRedisValue(), values: ParseStreamEntryValues(entryDetails[1])); } - protected StreamEntry[] ParseRedisStreamEntries(in RawResult result) => + protected internal StreamEntry[] ParseRedisStreamEntries(in RawResult result) => result.GetItems().ToArray((in RawResult item, in StreamProcessorBase _) => ParseRedisStreamEntry(item), this); protected static NameValueEntry[] ParseStreamEntryValues(in RawResult result) @@ -2351,34 +2534,17 @@ protected static NameValueEntry[] ParseStreamEntryValues(in RawResult result) // 3) "temperature" // 4) "18.2" - if (result.Type != ResultType.MultiBulk || result.IsNull) + if (result.Resp2TypeArray != ResultType.Array || result.IsNull) { return Array.Empty(); } - - var arr = result.GetItems(); - - // Calculate how many name/value pairs are in the stream entry. - int count = (int)arr.Length / 2; - - if (count == 0) return Array.Empty(); - - var pairs = new NameValueEntry[count]; - - var iter = arr.GetEnumerator(); - for (int i = 0; i < pairs.Length; i++) - { - pairs[i] = new NameValueEntry(iter.GetNext().AsRedisValue(), - iter.GetNext().AsRedisValue()); - } - - return pairs; + return StreamNameValueEntryProcessor.Instance.ParseArray(result, false, out _, null)!; // ! because we checked null above } } private sealed class StringPairInterleavedProcessor : ValuePairInterleavedProcessorBase> { - protected override KeyValuePair Parse(in RawResult first, in RawResult second) => + protected override KeyValuePair Parse(in RawResult first, in RawResult second, object? state) => new KeyValuePair(first.GetString()!, second.GetString()!); } @@ -2386,14 +2552,14 @@ private sealed class StringProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.Integer: case ResultType.SimpleString: case ResultType.BulkString: SetResult(message, result.GetString()); return true; - case ResultType.MultiBulk: + case ResultType.Array: var arr = result.GetItems(); if (arr.Length == 1) { @@ -2410,7 +2576,7 @@ private sealed class TieBreakerProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeBulkString) { case ResultType.SimpleString: case ResultType.BulkString: @@ -2470,26 +2636,19 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes switch (message.Command) { case RedisCommand.ECHO: - happy = result.Type == ResultType.BulkString && (!establishConnection || result.IsEqual(connection.BridgeCouldBeNull?.Multiplexer?.UniqueId)); + happy = result.Resp2TypeBulkString == ResultType.BulkString && (!establishConnection || result.IsEqual(connection.BridgeCouldBeNull?.Multiplexer?.UniqueId)); break; case RedisCommand.PING: // there are two different PINGs; "interactive" is a +PONG or +{your message}, // but subscriber returns a bulk-array of [ "pong", {your message} ] - switch (result.Type) + switch (result.Resp2TypeArray) { case ResultType.SimpleString: happy = result.IsEqual(CommonReplies.PONG); break; - case ResultType.MultiBulk: - if (result.ItemsCount == 2) - { - var items = result.GetItems(); - happy = items[0].IsEqual(CommonReplies.PONG) && items[1].Payload.IsEmpty; - } - else - { - happy = false; - } + case ResultType.Array when result.ItemsCount == 2: + var items = result.GetItems(); + happy = items[0].IsEqual(CommonReplies.PONG) && items[1].Payload.IsEmpty; break; default: happy = false; @@ -2497,10 +2656,10 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } break; case RedisCommand.TIME: - happy = result.Type == ResultType.MultiBulk && result.GetItems().Length == 2; + happy = result.Resp2TypeArray == ResultType.Array && result.ItemsCount == 2; break; case RedisCommand.EXISTS: - happy = result.Type == ResultType.Integer; + happy = result.Resp2TypeBulkString == ResultType.Integer; break; default: happy = false; @@ -2529,9 +2688,9 @@ private sealed class SentinelGetPrimaryAddressByNameProcessor : ResultProcessor< { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var items = result.GetItems(); if (result.IsNull) { @@ -2559,9 +2718,9 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { List endPoints = new List(); - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: foreach (RawResult item in result.GetItems()) { var pairs = item.GetItems(); @@ -2593,9 +2752,9 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { List endPoints = new List(); - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: foreach (RawResult item in result.GetItems()) { var pairs = item.GetItems(); @@ -2635,9 +2794,9 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return false; } - switch (result.Type) + switch (result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var arrayOfArrays = result.GetItems(); var returnArray = result.ToArray[], StringPairInterleavedProcessor>( @@ -2677,9 +2836,9 @@ internal abstract class ArrayResultProcessor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch(result.Type) + switch(result.Resp2TypeArray) { - case ResultType.MultiBulk: + case ResultType.Array: var items = result.GetItems(); T[] arr; if (items.IsEmpty) diff --git a/src/StackExchange.Redis/ResultTypeExtensions.cs b/src/StackExchange.Redis/ResultTypeExtensions.cs new file mode 100644 index 000000000..b4578450f --- /dev/null +++ b/src/StackExchange.Redis/ResultTypeExtensions.cs @@ -0,0 +1,14 @@ +using System; +using System.Diagnostics; + +namespace StackExchange.Redis +{ + internal static class ResultTypeExtensions + { + public static bool IsError(this ResultType value) + => (value & (ResultType)0b111) == ResultType.Error; + + public static ResultType ToResp2(this ResultType value) + => value & (ResultType)0b111; // just keep the last 3 bits + } +} diff --git a/src/StackExchange.Redis/Role.cs b/src/StackExchange.Redis/Role.cs index 7f26220fb..587194026 100644 --- a/src/StackExchange.Redis/Role.cs +++ b/src/StackExchange.Redis/Role.cs @@ -62,6 +62,9 @@ internal Replica(string ip, int port, long offset) Port = port; ReplicationOffset = offset; } + + /// + public override string ToString() => $"{Ip}:{Port} - {ReplicationOffset}"; } internal Master(long offset, ICollection replicas) : base(RedisLiterals.master!) diff --git a/src/StackExchange.Redis/ScriptParameterMapper.cs b/src/StackExchange.Redis/ScriptParameterMapper.cs index 88be5d338..390db8180 100644 --- a/src/StackExchange.Redis/ScriptParameterMapper.cs +++ b/src/StackExchange.Redis/ScriptParameterMapper.cs @@ -224,12 +224,7 @@ public static bool IsValidParameterHash(Type t, LuaScript script, out string? mi for (var i = 0; i < script.Arguments.Length; i++) { var argName = script.Arguments[i]; - var member = t.GetMember(argName).SingleOrDefault(m => m is PropertyInfo || m is FieldInfo); - if (member is null) - { - throw new ArgumentException($"There was no member found for {argName}"); - } - + var member = t.GetMember(argName).SingleOrDefault(m => m is PropertyInfo || m is FieldInfo) ?? throw new ArgumentException($"There was no member found for {argName}"); var memberType = member is FieldInfo memberFieldInfo ? memberFieldInfo.FieldType : ((PropertyInfo)member).PropertyType; if (memberType == typeof(RedisKey)) diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index e661d8953..26a918078 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -198,6 +198,8 @@ public Version Version set => SetConfig(ref version, value); } + public bool IsResp3 => interactive is { IsResp3: true }; + public int WriteEverySeconds { get => writeEverySeconds; @@ -223,7 +225,7 @@ public void Dispose() if (isDisposed) return null; return type switch { - ConnectionType.Interactive => interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, log) : null), + ConnectionType.Interactive /* or _ when IsResp3 */ => interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, log) : null), ConnectionType.Subscription => subscription ?? (create ? subscription = CreateBridge(ConnectionType.Subscription, log) : null), _ => null, }; @@ -236,6 +238,7 @@ public void Dispose() // Subscription commands go to a specific bridge - so we need to set that up. // There are other commands we need to send to the right connection (e.g. subscriber PING with an explicit SetForSubscriptionBridge call), // but these always go subscriber. + switch (message.Command) { case RedisCommand.SUBSCRIBE: @@ -246,7 +249,7 @@ public void Dispose() break; } - return message.IsForSubscriptionBridge + return (message.IsForSubscriptionBridge /* && !IsResp3 */) ? subscription ??= CreateBridge(ConnectionType.Subscription, null) : interactive ??= CreateBridge(ConnectionType.Interactive, null); } @@ -254,16 +257,18 @@ public void Dispose() public PhysicalBridge? GetBridge(RedisCommand command, bool create = true) { if (isDisposed) return null; - switch (command) + //if (!IsResp3) { - case RedisCommand.SUBSCRIBE: - case RedisCommand.UNSUBSCRIBE: - case RedisCommand.PSUBSCRIBE: - case RedisCommand.PUNSUBSCRIBE: - return subscription ?? (create ? subscription = CreateBridge(ConnectionType.Subscription, null) : null); - default: - return interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, null) : null); + switch (command) + { + case RedisCommand.SUBSCRIBE: + case RedisCommand.UNSUBSCRIBE: + case RedisCommand.PSUBSCRIBE: + case RedisCommand.PUNSUBSCRIBE: + return subscription ?? (create ? subscription = CreateBridge(ConnectionType.Subscription, null) : null); + } } + return interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, null) : null); } public RedisFeatures GetFeatures() => new RedisFeatures(version); @@ -365,7 +370,7 @@ internal async Task AutoConfigureAsync(PhysicalConnection? connection, LogProxy? var features = GetFeatures(); Message msg; - var autoConfigProcessor = new ResultProcessor.AutoConfigureProcessor(log); + var autoConfigProcessor = ResultProcessor.AutoConfigureProcessor.Create(log); if (commandMap.IsAvailable(RedisCommand.CONFIG)) { @@ -629,10 +634,13 @@ static async Task OnEstablishingAsyncAwaited(PhysicalConnection connection, Task try { if (connection == null) return Task.CompletedTask; + var handshake = HandshakeAsync(connection, log); if (handshake.Status != TaskStatus.RanToCompletion) + { return OnEstablishingAsyncAwaited(connection, handshake); + } } catch (Exception ex) { @@ -694,7 +702,7 @@ internal bool CheckInfoReplication() lastInfoReplicationCheckTicks = Environment.TickCount; ResetExponentiallyReplicationCheck(); - if (version >= RedisFeatures.v2_8_0 && Multiplexer.CommandMap.IsAvailable(RedisCommand.INFO) + if (version.IsAtLeast(RedisFeatures.v2_8_0) && Multiplexer.CommandMap.IsAvailable(RedisCommand.INFO) && GetBridge(ConnectionType.Interactive, false) is PhysicalBridge bridge) { var msg = Message.Create(-1, CommandFlags.FireAndForget | CommandFlags.NoRedirect, RedisCommand.INFO, RedisLiterals.replication); @@ -901,14 +909,56 @@ private async Task HandshakeAsync(PhysicalConnection connection, LogProxy? log) var config = Multiplexer.RawConfig; string? user = config.User; string password = config.Password ?? ""; - if (!string.IsNullOrWhiteSpace(user)) + + string clientName = Multiplexer.ClientName; + if (!string.IsNullOrWhiteSpace(clientName)) + { + clientName = nameSanitizer.Replace(clientName, ""); + } + + // NOTE: + // we might send the auth and client-name *twice* in RESP3 mode; this is intentional: + // - we don't know for sure which commands are available; HELLO is not always available, + // even on v6 servers, and we don't usually even know the server version yet; likewise, + // CLIENT could be disabled/renamed + // - on an authenticated server, you MUST issue HELLO with AUTH, so we can't avoid it there + // - but if the HELLO with AUTH isn't recognized, we might still need to auth; the following is + // legal in all scenarios, and results in a consistent state: + // + // (auth enabled) + // + // HELLO 3 AUTH {user} {password} SETNAME {client} + // AUTH {user} {password} + // CLIENT SETNAME {client} + // + // (auth disabled) + // + // HELLO 3 SETNAME {client} + // CLIENT SETNAME {client} + // + // this might look a little redundant, but: we only do it once per connection, and it isn't + // many bytes different; this allows us to pipeline the entire handshake without having to + // add latency + + ResultProcessor? autoConfig = null; + if (Multiplexer.RawConfig.TryResp3()) // note this includes a availability check on HELLO + { + log?.WriteLine($"{Format.ToString(this)}: Authenticating via HELLO"); + var hello = Message.CreateHello(3, user, password, clientName, CommandFlags.None); + hello.SetInternalCall(); + await WriteDirectOrQueueFireAndForgetAsync(connection, hello, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); + } + + // note: we auth EVEN IF we have used HELLO to AUTH; because otherwise the fallback/detection path is pure hell, + // and: we're pipelined here, so... meh + if (!string.IsNullOrWhiteSpace(user) && Multiplexer.CommandMap.IsAvailable(RedisCommand.AUTH)) { log?.WriteLine($"{Format.ToString(this)}: Authenticating (user/password)"); msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.AUTH, (RedisValue)user, (RedisValue)password); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } - else if (!string.IsNullOrWhiteSpace(password)) + else if (!string.IsNullOrWhiteSpace(password) && Multiplexer.CommandMap.IsAvailable(RedisCommand.AUTH)) { log?.WriteLine($"{Format.ToString(this)}: Authenticating (password)"); msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.AUTH, (RedisValue)password); @@ -918,18 +968,14 @@ private async Task HandshakeAsync(PhysicalConnection connection, LogProxy? log) if (Multiplexer.CommandMap.IsAvailable(RedisCommand.CLIENT)) { - string name = Multiplexer.ClientName; - if (!string.IsNullOrWhiteSpace(name)) + if (!string.IsNullOrWhiteSpace(clientName)) { - name = nameSanitizer.Replace(name, ""); - if (!string.IsNullOrWhiteSpace(name)) - { - log?.WriteLine($"{Format.ToString(this)}: Setting client name: {name}"); - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.SETNAME, (RedisValue)name); - msg.SetInternalCall(); - await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); - } + log?.WriteLine($"{Format.ToString(this)}: Setting client name: {clientName}"); + msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.SETNAME, (RedisValue)clientName); + msg.SetInternalCall(); + await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } + if (config.SetClientLibrary) { // note that this is a relatively new feature, but usually we won't know the @@ -963,10 +1009,14 @@ private async Task HandshakeAsync(PhysicalConnection connection, LogProxy? log) msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.ID); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.ClientId).ForAwait(); + + msg = Message.Create(-1, default, RedisCommand.CLIENT, RedisLiterals.ID); + msg.SetInternalCall(); + await WriteDirectOrQueueFireAndForgetAsync(connection, msg, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); } var bridge = connection.BridgeCouldBeNull; - if (bridge == null) + if (bridge is null) { return; } diff --git a/tests/StackExchange.Redis.Tests/BitTests.cs b/tests/StackExchange.Redis.Tests/BitTests.cs index 5dd3d05c2..61a1b3012 100644 --- a/tests/StackExchange.Redis.Tests/BitTests.cs +++ b/tests/StackExchange.Redis.Tests/BitTests.cs @@ -3,10 +3,17 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class BitTests : TestBase +public class Resp2BitTests : BitTests { - public BitTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2BitTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3BitTests : BitTests +{ + public Resp3BitTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} +public abstract class BitTests : ProtocolFixedTestBase +{ + public BitTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base (output, fixture, resp3) { } [Fact] public void BasicOps() diff --git a/tests/StackExchange.Redis.Tests/GeoTests.cs b/tests/StackExchange.Redis.Tests/GeoTests.cs index 562bd1f5b..b94d259e2 100644 --- a/tests/StackExchange.Redis.Tests/GeoTests.cs +++ b/tests/StackExchange.Redis.Tests/GeoTests.cs @@ -5,10 +5,18 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class GeoTests : TestBase +public class Resp2GeoTests : StringTests { - public GeoTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2GeoTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3GeoTests : StringTests +{ + public Resp3GeoTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class GeoTests : ProtocolFixedTestBase +{ + public GeoTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base (output, fixture, resp3) { } private static readonly GeoEntry palermo = new GeoEntry(13.361389, 38.115556, "Palermo"), diff --git a/tests/StackExchange.Redis.Tests/HashTests.cs b/tests/StackExchange.Redis.Tests/HashTests.cs index 3022779c4..5cd87feee 100644 --- a/tests/StackExchange.Redis.Tests/HashTests.cs +++ b/tests/StackExchange.Redis.Tests/HashTests.cs @@ -8,13 +8,20 @@ namespace StackExchange.Redis.Tests; +public class Resp2HashTests : HashTests +{ + public Resp2HashTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3HashTests : HashTests +{ + public Resp3HashTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} /// /// Tests for . /// -[Collection(SharedConnectionFixture.Key)] -public class HashTests : TestBase +public abstract class HashTests : ProtocolFixedTestBase { - public HashTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public HashTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public async Task TestIncrBy() diff --git a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs new file mode 100644 index 000000000..58d66b9de --- /dev/null +++ b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Xunit; +using Xunit.Abstractions; +using static StackExchange.Redis.Tests.SharedConnectionFixture; + +namespace StackExchange.Redis.Tests; + + +public class ProtocolDependentFixture : IDisposable // without this, test perf is intolerable +{ + public const string Key = nameof(ProtocolDependentFixture); + + private NonDisposingConnection? resp2, resp3; + internal IInternalConnectionMultiplexer GetConnection(TestBase obj, bool useResp3, [CallerMemberName] string caller = "") + { + Version? require = useResp3 ? RedisFeatures.v6_0_0 : null; + lock (this) + { + if (useResp3) + { + return resp3 ??= new NonDisposingConnection(obj.Create(protocol: RedisProtocol.Resp3, require: require, caller: caller, shared: false, allowAdmin: true)); + } + else + { + return resp2 ??= new NonDisposingConnection(obj.Create(protocol: RedisProtocol.Resp2, require: require, caller: caller, shared: false, allowAdmin: true)); + } + } + } + + public void Dispose() + { + resp2?.UnderlyingConnection?.Dispose(); + resp3?.UnderlyingConnection?.Dispose(); + } +} + +[Collection(ProtocolDependentFixture.Key)] +public abstract class ProtocolDependentTestBase : TestBase // ProtocolDependentFixture has separate access to resp2/resp3 connections +{ + public ProtocolDependentTestBase(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output) + => Fixture = fixture; + + public ProtocolDependentFixture Fixture { get; } +} + + +public abstract class ProtocolFixedTestBase : ProtocolDependentTestBase // extends that cability to apply/enforce correct RESP during Create +{ + public ProtocolFixedTestBase(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture) + => Resp3 = resp3; + + public bool Resp3 { get; } + + internal new IInternalConnectionMultiplexer Create(string? clientName = null, int? syncTimeout = null, bool? allowAdmin = null, int? keepAlive = null, int? connectTimeout = null, string? password = null, string? tieBreaker = null, TextWriter? log = null, bool fail = true, string[]? disabledCommands = null, string[]? enabledCommands = null, bool checkConnect = true, string? failMessage = null, string? channelPrefix = null, Proxy? proxy = null, string? configuration = null, bool logTransactionData = true, bool shared = true, int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, Version? require = null, RedisProtocol? protocol = null, [CallerMemberName] string? caller = null) + { + if (protocol is not null) + { + Assert.True(Resp3 && protocol >= RedisProtocol.Resp3, "Test is requesting incorrect RESP"); + } + if (shared && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy, protocol)) + { + // can use the fixture's *pair* of resp clients + var conn = Fixture.GetConnection(this, Resp3, caller!); + ThrowIfBelowMinVersion(conn, require); + return conn; + } + else + { + if (Resp3 && (require is null || require < RedisFeatures.v6_0_0)) + { + require = RedisFeatures.v6_0_0; + } + return base.Create(clientName, syncTimeout, allowAdmin, keepAlive, connectTimeout, password, tieBreaker, log, fail, disabledCommands, enabledCommands, checkConnect, failMessage, channelPrefix, proxy, configuration, logTransactionData, shared, + defaultDatabase, backlogPolicy, require, protocol, caller); + } + } + + + //internal override IInternalConnectionMultiplexer Create(string? clientName = null, int? syncTimeout = null, bool? allowAdmin = null, int? keepAlive = null, int? connectTimeout = null, string? password = null, string? tieBreaker = null, TextWriter? log = null, bool fail = true, string[]? disabledCommands = null, string[]? enabledCommands = null, bool checkConnect = true, string? failMessage = null, string? channelPrefix = null, Proxy? proxy = null, string? configuration = null, bool logTransactionData = true, bool shared = true, int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, Version? require = null, RedisProtocol? protocol = null, [CallerMemberName] string? caller = null) + //{ + // var obj = base.Create(clientName, syncTimeout, allowAdmin, keepAlive, connectTimeout, password, tieBreaker, log, fail, disabledCommands, enabledCommands, checkConnect, failMessage, channelPrefix, proxy, configuration, logTransactionData, shared, defaultDatabase, backlogPolicy, require, protocol, caller); + + // var ep = obj.GetEndPoints().FirstOrDefault(); + // if (ep is not null) + // { + // var server = obj.GetServerEndPoint(ep); + // if (server is not null && server.IsResp3 != Resp3) Skip.Inconclusive("Incorrect RESP version"); + // } + // return obj; + //} +} + +/// +/// See . +/// +[CollectionDefinition(ProtocolDependentFixture.Key)] +public class ProtocolDependentCollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs index 9c557911d..a4e061c28 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs @@ -36,8 +36,10 @@ public SharedConnectionFixture() Connection = new NonDisposingConnection(_actualConnection); } - private class NonDisposingConnection : IInternalConnectionMultiplexer + internal sealed class NonDisposingConnection : IInternalConnectionMultiplexer { + public IInternalConnectionMultiplexer UnderlyingConnection => _inner; + public bool AllowConnect { get => _inner.AllowConnect; @@ -50,6 +52,8 @@ public bool IgnoreConnect set => _inner.IgnoreConnect = value; } + public ServerEndPoint GetServerEndPoint(EndPoint endpoint) => _inner.GetServerEndPoint(endpoint); + public ReadOnlySpan GetServerSnapshot() => _inner.GetServerSnapshot(); private readonly IInternalConnectionMultiplexer _inner; diff --git a/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs b/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs index d110c86b6..d78cc3764 100644 --- a/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs +++ b/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs @@ -3,10 +3,17 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class HyperLogLogTests : TestBase +public class Resp2HyperLogLogTests : HyperLogLogTests { - public HyperLogLogTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2HyperLogLogTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3HyperLogLogTests : HyperLogLogTests +{ + public Resp3HyperLogLogTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} +public abstract class HyperLogLogTests : ProtocolFixedTestBase +{ + public HyperLogLogTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public void SingleKeyLength() diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index 062f2caaa..b44f020ac 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -9,10 +9,17 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class KeyTests : TestBase +public class Resp2KeyTests : KeyTests { - public KeyTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2KeyTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3KeyTests : KeyTests +{ + public Resp3KeyTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} +public abstract class KeyTests : ProtocolFixedTestBase +{ + public KeyTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public void TestScan() diff --git a/tests/StackExchange.Redis.Tests/ListTests.cs b/tests/StackExchange.Redis.Tests/ListTests.cs index bb212db14..1a522a129 100644 --- a/tests/StackExchange.Redis.Tests/ListTests.cs +++ b/tests/StackExchange.Redis.Tests/ListTests.cs @@ -6,10 +6,18 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class ListTests : TestBase +public class Resp2ListTests : ListTests { - public ListTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public Resp2ListTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3ListTests : ListTests +{ + public Resp3ListTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} +public abstract class ListTests : ProtocolFixedTestBase +{ + public ListTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + [Fact] public void Ranges() diff --git a/tests/StackExchange.Redis.Tests/MemoryTests.cs b/tests/StackExchange.Redis.Tests/MemoryTests.cs index 21325e0f2..50812e597 100644 --- a/tests/StackExchange.Redis.Tests/MemoryTests.cs +++ b/tests/StackExchange.Redis.Tests/MemoryTests.cs @@ -58,20 +58,20 @@ public async Task GetStats() var server = conn.GetServer(conn.GetEndPoints()[0]); var stats = server.MemoryStats(); Assert.NotNull(stats); - Assert.Equal(ResultType.MultiBulk, stats.Type); + Assert.Equal(ResultType.Array, stats.Resp2Type); var parsed = stats.ToDictionary(); var alloc = parsed["total.allocated"]; - Assert.Equal(ResultType.Integer, alloc.Type); + Assert.Equal(ResultType.Integer, alloc.Resp2Type); Assert.True(alloc.AsInt64() > 0); stats = await server.MemoryStatsAsync(); Assert.NotNull(stats); - Assert.Equal(ResultType.MultiBulk, stats.Type); + Assert.Equal(ResultType.Array, stats.Resp2Type); alloc = parsed["total.allocated"]; - Assert.Equal(ResultType.Integer, alloc.Type); + Assert.Equal(ResultType.Integer, alloc.Resp2Type); Assert.True(alloc.AsInt64() > 0); } } diff --git a/tests/StackExchange.Redis.Tests/ParseTests.cs b/tests/StackExchange.Redis.Tests/ParseTests.cs index e0a46a2ce..83bab6113 100644 --- a/tests/StackExchange.Redis.Tests/ParseTests.cs +++ b/tests/StackExchange.Redis.Tests/ParseTests.cs @@ -74,7 +74,7 @@ private void ProcessMessages(Arena arena, ReadOnlySequence buff var reader = new BufferReader(buffer); RawResult result; int found = 0; - while (!(result = PhysicalConnection.TryParseResult(arena, buffer, ref reader, false, null, false)).IsNull) + while (!(result = PhysicalConnection.TryParseResult(false, arena, buffer, ref reader, false, null, false)).IsNull) { Writer.WriteLine($"{result} - {result.GetString()}"); found++; diff --git a/tests/StackExchange.Redis.Tests/RawResultTests.cs b/tests/StackExchange.Redis.Tests/RawResultTests.cs index 895ec4ec1..9cf578ee1 100644 --- a/tests/StackExchange.Redis.Tests/RawResultTests.cs +++ b/tests/StackExchange.Redis.Tests/RawResultTests.cs @@ -12,11 +12,14 @@ public void TypeLoads() Assert.Equal(nameof(RawResult), type.Name); } - [Fact] - public void NullWorks() + [Theory] + [InlineData(ResultType.BulkString)] + [InlineData(ResultType.Null)] + public void NullWorks(ResultType type) { - var result = new RawResult(ResultType.BulkString, ReadOnlySequence.Empty, true); - Assert.Equal(ResultType.BulkString, result.Type); + var result = new RawResult(type, ReadOnlySequence.Empty, RawResult.ResultFlags.None); + Assert.Equal(type, result.Resp3Type); + Assert.True(result.HasValue); Assert.True(result.IsNull); var value = result.AsRedisValue(); @@ -32,8 +35,9 @@ public void NullWorks() [Fact] public void DefaultWorks() { - var result = default(RawResult); - Assert.Equal(ResultType.None, result.Type); + var result = RawResult.Nil; + Assert.Equal(ResultType.None, result.Resp3Type); + Assert.False(result.HasValue); Assert.True(result.IsNull); var value = result.AsRedisValue(); @@ -50,7 +54,7 @@ public void DefaultWorks() public void NilWorks() { var result = RawResult.Nil; - Assert.Equal(ResultType.None, result.Type); + Assert.Equal(ResultType.None, result.Resp3Type); Assert.True(result.IsNull); var value = result.AsRedisValue(); diff --git a/tests/StackExchange.Redis.Tests/Resp3Tests.cs b/tests/StackExchange.Redis.Tests/Resp3Tests.cs new file mode 100644 index 000000000..49f0d09d7 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/Resp3Tests.cs @@ -0,0 +1,422 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace StackExchange.Redis.Tests; + +public sealed class Resp3Tests : ProtocolDependentTestBase +{ + public Resp3Tests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture) { } + + [Theory] + // specify nothing + [InlineData("someserver", false)] + // specify *just* the protocol; sure, we'll believe you + [InlineData("someserver,protocol=resp3", true)] + [InlineData("someserver,protocol=resp3,$HELLO=", false)] + [InlineData("someserver,protocol=resp3,$HELLO=BONJOUR", true)] + [InlineData("someserver,protocol=3", true, "resp3")] + [InlineData("someserver,protocol=3,$HELLO=", false, "resp3")] + [InlineData("someserver,protocol=3,$HELLO=BONJOUR", true, "resp3")] + [InlineData("someserver,protocol=2", false, "resp2")] + [InlineData("someserver,protocol=2,$HELLO=", false, "resp2")] + [InlineData("someserver,protocol=2,$HELLO=BONJOUR", false, "resp2")] + // specify a pre-6 version - only used if protocol specified + [InlineData("someserver,version=5.9", false)] + [InlineData("someserver,version=5.9,$HELLO=", false)] + [InlineData("someserver,version=5.9,$HELLO=BONJOUR", false)] + [InlineData("someserver,version=5.9,protocol=resp3", true)] + [InlineData("someserver,version=5.9,protocol=resp3,$HELLO=", false)] + [InlineData("someserver,version=5.9,protocol=resp3,$HELLO=BONJOUR", true)] + [InlineData("someserver,version=5.9,protocol=3", true, "resp3")] + [InlineData("someserver,version=5.9,protocol=3,$HELLO=", false, "resp3")] + [InlineData("someserver,version=5.9,protocol=3,$HELLO=BONJOUR", true, "resp3")] + [InlineData("someserver,version=5.9,protocol=2", false, "resp2")] + [InlineData("someserver,version=5.9,protocol=2,$HELLO=", false, "resp2")] + [InlineData("someserver,version=5.9,protocol=2,$HELLO=BONJOUR", false, "resp2")] + // specify a post-6 version; attempt by default + [InlineData("someserver,version=6.0", false)] + [InlineData("someserver,version=6.0,$HELLO=", false)] + [InlineData("someserver,version=6.0,$HELLO=BONJOUR", false)] + [InlineData("someserver,version=6.0,protocol=resp3", true)] + [InlineData("someserver,version=6.0,protocol=resp3,$HELLO=", false)] + [InlineData("someserver,version=6.0,protocol=resp3,$HELLO=BONJOUR", true)] + [InlineData("someserver,version=6.0,protocol=3", true, "resp3")] + [InlineData("someserver,version=6.0,protocol=3,$HELLO=", false, "resp3")] + [InlineData("someserver,version=6.0,protocol=3,$HELLO=BONJOUR", true, "resp3")] + [InlineData("someserver,version=6.0,protocol=2", false, "resp2")] + [InlineData("someserver,version=6.0,protocol=2,$HELLO=", false, "resp2")] + [InlineData("someserver,version=6.0,protocol=2,$HELLO=BONJOUR", false, "resp2")] + [InlineData("someserver,version=7.2", false)] + [InlineData("someserver,version=7.2,$HELLO=", false)] + [InlineData("someserver,version=7.2,$HELLO=BONJOUR", false)] + public void ParseFormatConfigOptions(string configurationString, bool tryResp3, string? formatProtocol = null) + { + var config = ConfigurationOptions.Parse(configurationString); + + string expectedConfigurationString = formatProtocol is null ? configurationString : Regex.Replace(configurationString, "(?<=protocol=)[^,]+", formatProtocol); + + Assert.Equal(expectedConfigurationString, config.ToString(true)); // check round-trip + Assert.Equal(expectedConfigurationString, config.Clone().ToString(true)); // check clone + Assert.Equal(tryResp3, config.TryResp3()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TryConnect(bool useResp3) + { + var muxer = Fixture.GetConnection(this, useResp3); + await muxer.GetDatabase().PingAsync(); + + var server = muxer.GetServerEndPoint(muxer.GetEndPoints().Single()); + if (useResp3 && !server.GetFeatures().Resp3) + { + Skip.Inconclusive("server does not support RESP3"); + } + if (useResp3) + { + Assert.True(server.IsResp3, nameof(server.IsResp3)); + } + else + { + Assert.False(server.IsResp3, nameof(server.IsResp3)); + } + var cid = server.GetBridge(RedisCommand.GET)?.ClientId; + if (server.GetFeatures().ClientId) + { + Assert.NotNull(cid); + } + else + { + Assert.Null(cid); + } + } + + [Theory] + [InlineData("HELLO", true)] + [InlineData("BONJOUR", false)] + public async Task ConnectWithBrokenHello(string command, bool isResp3) + { + var config = ConfigurationOptions.Parse(TestConfig.Current.SecureServerAndPort); + config.Password = TestConfig.Current.SecurePassword; + config.Protocol = RedisProtocol.Resp3; + config.CommandMap = CommandMap.Create(new() { ["hello"] = command }); + + using var muxer = await ConnectionMultiplexer.ConnectAsync(config, Writer); + await muxer.GetDatabase().PingAsync(); // is connected + var ep = muxer.GetServerEndPoint(muxer.GetEndPoints()[0]); + if (!ep.GetFeatures().Resp3) // this is just a v6 check + { + isResp3 = false; // then, no: it won't be + } + Assert.Equal(isResp3, ep.IsResp3); + var result = await muxer.GetDatabase().ExecuteAsync("latency", "doctor"); + Assert.Equal(isResp3 ? ResultType.VerbatimString : ResultType.BulkString, result.Resp3Type); + } + + [Theory] + [InlineData("return 42", false, ResultType.Integer, ResultType.Integer, 42)] + [InlineData("return 'abc'", false, ResultType.BulkString, ResultType.BulkString, "abc")] + [InlineData(@"return {1,2,3}", false, ResultType.Array, ResultType.Array, ARR_123)] + [InlineData("return nil", false, ResultType.BulkString, ResultType.Null, null)] + [InlineData(@"return redis.pcall('hgetall', 'key')", false, ResultType.Array, ResultType.Array, MAP_ABC)] + [InlineData(@"redis.setresp(3) +return redis.pcall('hgetall', 'key')", false, ResultType.Array, ResultType.Array, MAP_ABC)] + [InlineData("return true", false, ResultType.Integer, ResultType.Integer, 1)] + [InlineData("return false", false, ResultType.BulkString, ResultType.Null, null)] + [InlineData("redis.setresp(3) return true", false, ResultType.Integer, ResultType.Integer, 1)] + [InlineData("redis.setresp(3) return false", false, ResultType.Integer, ResultType.Integer, 0)] + + [InlineData("return { map = { a = 1, b = 2, c = 3 } }", false, ResultType.Array, ResultType.Array, MAP_ABC, 6)] + [InlineData("return { set = { a = 1, b = 2, c = 3 } }", false, ResultType.Array, ResultType.Array, SET_ABC, 6)] + [InlineData("return { double = 42 }", false, ResultType.BulkString, ResultType.BulkString, 42.0, 6)] + + [InlineData("return 42", true, ResultType.Integer, ResultType.Integer, 42)] + [InlineData("return 'abc'", true, ResultType.BulkString, ResultType.BulkString, "abc")] + [InlineData("return {1,2,3}", true, ResultType.Array, ResultType.Array, ARR_123)] + [InlineData("return nil", true, ResultType.BulkString, ResultType.Null, null)] + [InlineData(@"return redis.pcall('hgetall', 'key')", true, ResultType.Array, ResultType.Array, MAP_ABC)] + [InlineData(@"redis.setresp(3) +return redis.pcall('hgetall', 'key')", true, ResultType.Array, ResultType.Map, MAP_ABC)] + [InlineData("return true", true, ResultType.Integer, ResultType.Integer, 1)] + [InlineData("return false", true, ResultType.BulkString, ResultType.Null, null)] + [InlineData("redis.setresp(3) return true", true, ResultType.Integer, ResultType.Boolean, true)] + [InlineData("redis.setresp(3) return false", true, ResultType.Integer, ResultType.Boolean, false)] + + [InlineData("return { map = { a = 1, b = 2, c = 3 } }", true, ResultType.Array, ResultType.Map, MAP_ABC, 6)] + [InlineData("return { set = { a = 1, b = 2, c = 3 } }", true, ResultType.Array, ResultType.Set, SET_ABC, 6)] + [InlineData("return { double = 42 }", true, ResultType.SimpleString, ResultType.Double, 42.0, 6)] + public async Task CheckLuaResult(string script, bool useResp3, ResultType resp2, ResultType resp3, object expected, int serverMin = 1) + { + // note Lua does not appear to return RESP3 types in any scenarios + var muxer = Fixture.GetConnection(this, useResp3); + var ep = muxer.GetServerEndPoint(muxer.GetEndPoints().Single()); + if (serverMin > ep.Version.Major) + { + Skip.Inconclusive($"applies to v{serverMin} onwards - detected v{ep.Version.Major}"); + } + if (script.Contains("redis.setresp(3)") && !ep.GetFeatures().Resp3) /* v6 check */ + { + Skip.Inconclusive("debug protocol not available"); + } + Assert.Equal(useResp3, ep.IsResp3); + + var db = muxer.GetDatabase(); + if (expected is MAP_ABC) + { + db.KeyDelete("key"); + db.HashSet("key", "a", 1); + db.HashSet("key", "b", 2); + db.HashSet("key", "c", 3); + } + var result = await db.ScriptEvaluateAsync(script, flags: CommandFlags.NoScriptCache); + Assert.Equal(resp2, result.Resp2Type); + Assert.Equal(resp3, result.Resp3Type); + + switch (expected) + { + case null: + Assert.True(result.IsNull); + break; + case ARR_123: + Assert.Equal(3, result.Length); + for (int i = 0; i < result.Length; i++) + { + Assert.Equal(i + 1, result[i].AsInt32()); + } + break; + case MAP_ABC: + var map = result.ToDictionary(); + Assert.Equal(3, map.Count); + Assert.True(map.TryGetValue("a", out var value)); + Assert.Equal(1, value.AsInt32()); + Assert.True(map.TryGetValue("b", out value)); + Assert.Equal(2, value.AsInt32()); + Assert.True(map.TryGetValue("c", out value)); + Assert.Equal(3, value.AsInt32()); + break; + case SET_ABC: + Assert.Equal(3, result.Length); + var arr = result.AsStringArray()!; + Assert.Contains("a", arr); + Assert.Contains("b", arr); + Assert.Contains("c", arr); + break; + case string s: + Assert.Equal(s, result.AsString()); + break; + case double d: + Assert.Equal(d, result.AsDouble()); + break; + case int i: + Assert.Equal(i, result.AsInt32()); + break; + case bool b: + Assert.Equal(b, result.AsBoolean()); + break; + } + } + + + [Theory] + //[InlineData("return 42", false, ResultType.Integer, ResultType.Integer, 42)] + //[InlineData("return 'abc'", false, ResultType.BulkString, ResultType.BulkString, "abc")] + //[InlineData(@"return {1,2,3}", false, ResultType.Array, ResultType.Array, ARR_123)] + //[InlineData("return nil", false, ResultType.BulkString, ResultType.Null, null)] + //[InlineData(@"return redis.pcall('hgetall', 'key')", false, ResultType.Array, ResultType.Array, MAP_ABC)] + //[InlineData("return true", false, ResultType.Integer, ResultType.Integer, 1)] + + //[InlineData("return 42", true, ResultType.Integer, ResultType.Integer, 42)] + //[InlineData("return 'abc'", true, ResultType.BulkString, ResultType.BulkString, "abc")] + //[InlineData("return {1,2,3}", true, ResultType.Array, ResultType.Array, ARR_123)] + //[InlineData("return nil", true, ResultType.BulkString, ResultType.Null, null)] + //[InlineData(@"return redis.pcall('hgetall', 'key')", true, ResultType.Array, ResultType.Array, MAP_ABC)] + //[InlineData("return true", true, ResultType.Integer, ResultType.Integer, 1)] + + + [InlineData("incrby", false, ResultType.Integer, ResultType.Integer, 42, "ikey", 2)] + [InlineData("incrby", true, ResultType.Integer, ResultType.Integer, 42, "ikey", 2)] + [InlineData("incrby", false, ResultType.Integer, ResultType.Integer, 2, "nkey", 2)] + [InlineData("incrby", true, ResultType.Integer, ResultType.Integer, 2, "nkey", 2)] + + [InlineData("get", false, ResultType.BulkString, ResultType.BulkString, "40", "ikey")] + [InlineData("get", true, ResultType.BulkString, ResultType.BulkString, "40", "ikey")] + [InlineData("get", false, ResultType.BulkString, ResultType.Null, null, "nkey")] + [InlineData("get", true, ResultType.BulkString, ResultType.Null, null, "nkey")] + + [InlineData("smembers", false, ResultType.Array, ResultType.Array, SET_ABC, "skey")] + [InlineData("smembers", true, ResultType.Array, ResultType.Set, SET_ABC, "skey")] + [InlineData("smembers", false, ResultType.Array, ResultType.Array, EMPTY_ARR, "nkey")] + [InlineData("smembers", true, ResultType.Array, ResultType.Set, EMPTY_ARR, "nkey")] + + [InlineData("hgetall", false, ResultType.Array, ResultType.Array, MAP_ABC, "hkey")] + [InlineData("hgetall", true, ResultType.Array, ResultType.Map, MAP_ABC, "hkey")] + [InlineData("hgetall", false, ResultType.Array, ResultType.Array, EMPTY_ARR, "nkey")] + [InlineData("hgetall", true, ResultType.Array, ResultType.Map, EMPTY_ARR, "nkey")] + + [InlineData("sismember", false, ResultType.Integer, ResultType.Integer, true, "skey", "b")] + [InlineData("sismember", true, ResultType.Integer, ResultType.Integer, true, "skey", "b")] + [InlineData("sismember", false, ResultType.Integer, ResultType.Integer, false, "nkey", "b")] + [InlineData("sismember", true, ResultType.Integer, ResultType.Integer, false, "nkey", "b")] + [InlineData("sismember", false, ResultType.Integer, ResultType.Integer, false, "skey", "d")] + [InlineData("sismember", true, ResultType.Integer, ResultType.Integer, false, "skey", "d")] + + [InlineData("latency", false, ResultType.BulkString, ResultType.BulkString, STR_DAVE, "doctor")] + [InlineData("latency", true, ResultType.BulkString, ResultType.VerbatimString, STR_DAVE, "doctor")] + + [InlineData("incrbyfloat", false, ResultType.BulkString, ResultType.BulkString, 41.5, "ikey", 1.5)] + [InlineData("incrbyfloat", true, ResultType.BulkString, ResultType.BulkString, 41.5, "ikey", 1.5)] + + /* DEBUG PROTOCOL + * Reply with a test value of the specified type. can be: string, + * integer, double, bignum, null, array, set, map, attrib, push, verbatim, + * true, false., + * + * NOTE: "debug protocol" may be disabled in later default server configs; if this starts + * failing when we upgrade the test server: update the config to re-enable the command + */ + [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] + [InlineData("debug", true, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] + + [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "double")] + [InlineData("debug", true, ResultType.SimpleString, ResultType.Double, ANY, "protocol", "double")] + + [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "bignum")] + [InlineData("debug", true, ResultType.SimpleString, ResultType.BigInteger, ANY, "protocol", "bignum")] + + [InlineData("debug", false, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] + [InlineData("debug", true, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] + + [InlineData("debug", false, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] + [InlineData("debug", true, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] + + [InlineData("debug", false, ResultType.Array, ResultType.Array, ANY, "protocol", "set")] + [InlineData("debug", true, ResultType.Array, ResultType.Set, ANY, "protocol", "set")] + + [InlineData("debug", false, ResultType.Array, ResultType.Array, ANY, "protocol", "map")] + [InlineData("debug", true, ResultType.Array, ResultType.Map, ANY, "protocol", "map")] + + [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "verbatim")] + [InlineData("debug", true, ResultType.BulkString, ResultType.VerbatimString, ANY, "protocol", "verbatim")] + + [InlineData("debug", false, ResultType.Integer, ResultType.Integer, true, "protocol", "true")] + [InlineData("debug", true, ResultType.Integer, ResultType.Boolean, true, "protocol", "true")] + + [InlineData("debug", false, ResultType.Integer, ResultType.Integer, false, "protocol", "false")] + [InlineData("debug", true, ResultType.Integer, ResultType.Boolean, false, "protocol", "false")] + + public async Task CheckCommandResult(string command, bool useResp3, ResultType resp2, ResultType resp3, object expected, params object[] args) + { + var muxer = Fixture.GetConnection(this, useResp3); + var ep = muxer.GetServerEndPoint(muxer.GetEndPoints().Single()); + if (command == "debug" && args.Length > 0 && args[0] is "protocol" && !ep.GetFeatures().Resp3 /* v6 check */ ) + { + Skip.Inconclusive("debug protocol not available"); + } + Assert.Equal(useResp3, ep.IsResp3); + + var db = muxer.GetDatabase(); + if (args.Length > 0) + { + await db.KeyDeleteAsync((string)args[0]); + switch (args[0]) + { + case "ikey": + await db.StringSetAsync("ikey", "40"); + break; + case "skey": + await db.SetAddAsync("skey", new RedisValue[] { "a", "b", "c" }); + break; + case "hkey": + await db.HashSetAsync("hkey", new HashEntry[] { new("a", 1), new("b", 2), new("c",3) }); + break; + } + } + var result = await db.ExecuteAsync(command, args); + Assert.Equal(resp2, result.Resp2Type); + Assert.Equal(resp3, result.Resp3Type); + + switch (expected) + { + case null: + Assert.True(result.IsNull); + break; + case ANY: + // not checked beyond type + break; + case EMPTY_ARR: + Assert.Equal(0, result.Length); + break; + case ARR_123: + Assert.Equal(3, result.Length); + for (int i = 0; i < result.Length; i++) + { + Assert.Equal(i + 1, result[i].AsInt32()); + } + break; + case STR_DAVE: + var scontent = result.ToString(); + LogNoTime(scontent); + Assert.NotNull(scontent); + var isExpectedContent = scontent.StartsWith("Dave, ") || scontent.StartsWith("I'm sorry, Dave"); + Assert.True(isExpectedContent); + LogNoTime(scontent); + + scontent = result.ToString(out var type); + Assert.NotNull(scontent); + isExpectedContent = scontent.StartsWith("Dave, ") || scontent.StartsWith("I'm sorry, Dave"); + Assert.True(isExpectedContent); + LogNoTime(scontent); + if (useResp3) + { + Assert.Equal("txt", type); + } + else + { + Assert.Null(type); + } + break; + case SET_ABC: + Assert.Equal(3, result.Length); + var arr = result.AsStringArray()!; + Assert.Contains("a", arr); + Assert.Contains("b", arr); + Assert.Contains("c", arr); + break; + case MAP_ABC: + var map = result.ToDictionary(); + Assert.Equal(3, map.Count); + Assert.True(map.TryGetValue("a", out var value)); + Assert.Equal(1, value.AsInt32()); + Assert.True(map.TryGetValue("b", out value)); + Assert.Equal(2, value.AsInt32()); + Assert.True(map.TryGetValue("c", out value)); + Assert.Equal(3, value.AsInt32()); + break; + case string s: + Assert.Equal(s, result.AsString()); + break; + case int i: + Assert.Equal(i, result.AsInt32()); + break; + case bool b: + Assert.Equal(b, result.AsBoolean()); + Assert.Equal(b ? 1 : 0, result.AsInt32()); + Assert.Equal(b ? 1 : 0, result.AsInt64()); + break; + } + + + } + + private const string SET_ABC = nameof(SET_ABC); + private const string ARR_123 = nameof(ARR_123); + private const string MAP_ABC = nameof(MAP_ABC); + private const string EMPTY_ARR = nameof(EMPTY_ARR); + private const string STR_DAVE = nameof(STR_DAVE); + private const string ANY = nameof(ANY); +} diff --git a/tests/StackExchange.Redis.Tests/RoleTests.cs b/tests/StackExchange.Redis.Tests/RoleTests.cs index 09e88ce77..be5561115 100644 --- a/tests/StackExchange.Redis.Tests/RoleTests.cs +++ b/tests/StackExchange.Redis.Tests/RoleTests.cs @@ -22,6 +22,11 @@ public void PrimaryRole(bool allowAdmin) // should work with or without admin no var primary = role as Role.Master; Assert.NotNull(primary); Assert.NotNull(primary.Replicas); + Assert.NotEmpty(primary.Replicas); + foreach (var replica in primary.Replicas) + { + Log(replica.ToString()); + } Assert.Contains(primary.Replicas, r => r.Ip == TestConfig.Current.ReplicaServer && r.Port == TestConfig.Current.ReplicaPort); diff --git a/tests/StackExchange.Redis.Tests/ScanTests.cs b/tests/StackExchange.Redis.Tests/ScanTests.cs index b90d37592..acfe67f9c 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -7,10 +7,18 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class ScanTests : TestBase +public class Resp2ScanTests : StringTests { - public ScanTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2ScanTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3ScanTests : StringTests +{ + public Resp3ScanTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class ScanTests : ProtocolFixedTestBase +{ + public ScanTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Theory] [InlineData(true)] diff --git a/tests/StackExchange.Redis.Tests/SecureTests.cs b/tests/StackExchange.Redis.Tests/SecureTests.cs index 454dedc68..9763cc15f 100644 --- a/tests/StackExchange.Redis.Tests/SecureTests.cs +++ b/tests/StackExchange.Redis.Tests/SecureTests.cs @@ -81,7 +81,7 @@ public async Task ConnectWithWrongPassword(string password, string exepctedMessa Assert.StartsWith("It was not possible to connect to the redis server(s). There was an authentication failure; check that passwords (or client certificates) are configured correctly: (RedisServerException) ", ex.Message); // This changed in some version...not sure which. For our purposes, splitting on v3 vs v6+ - if (checkServer.Version >= RedisFeatures.v6_0_0) + if (checkServer.Version.IsAtLeast(RedisFeatures.v6_0_0)) { Assert.EndsWith(exepctedMessage, ex.Message); } diff --git a/tests/StackExchange.Redis.Tests/SetTests.cs b/tests/StackExchange.Redis.Tests/SetTests.cs index ea7043cf8..ce0560e62 100644 --- a/tests/StackExchange.Redis.Tests/SetTests.cs +++ b/tests/StackExchange.Redis.Tests/SetTests.cs @@ -6,10 +6,18 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class SetTests : TestBase +public class Resp2SetTests : SetTests { - public SetTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2SetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3SetTests : SetTests +{ + public Resp3SetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class SetTests : ProtocolFixedTestBase +{ + public SetTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base (output, fixture, resp3) { } [Fact] public void SetContains() diff --git a/tests/StackExchange.Redis.Tests/SortedSetTests.cs b/tests/StackExchange.Redis.Tests/SortedSetTests.cs index 3b99478ce..ce91f110a 100644 --- a/tests/StackExchange.Redis.Tests/SortedSetTests.cs +++ b/tests/StackExchange.Redis.Tests/SortedSetTests.cs @@ -5,10 +5,19 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class SortedSetTests : TestBase + +public class Resp2SortedSetTests : SortedSetTests +{ + public Resp2SortedSetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3SortedSetTests : SortedSetTests +{ + public Resp3SortedSetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class SortedSetTests : ProtocolFixedTestBase { - public SortedSetTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public SortedSetTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } private static readonly SortedSetEntry[] entries = new SortedSetEntry[] { @@ -327,6 +336,81 @@ public async Task SortedSetIntersectionLengthAsync() Assert.Equal(3, inter); } + [Fact] + public void SortedSetRangeViaScript() + { + using var conn = Create(require: RedisFeatures.v5_0_0); + var db = conn.GetDatabase(); + var key = Me(); + + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SortedSetAdd(key, entries, CommandFlags.FireAndForget); + + var result = db.ScriptEvaluate("return redis.call('ZRANGE', KEYS[1], 0, -1, 'WITHSCORES')", new RedisKey[] { key }); + AssertFlatArrayEntries(result); + } + + [Fact] + public void SortedSetRangeViaExecute() + { + using var conn = Create(require: RedisFeatures.v5_0_0); + var db = conn.GetDatabase(); + var key = Me(); + + db.KeyDelete(key, CommandFlags.FireAndForget); + db.SortedSetAdd(key, entries, CommandFlags.FireAndForget); + + var result = db.Execute("ZRANGE", new object[] { key, 0, -1, "WITHSCORES" }); + + if (Resp3) + { + AssertJaggedArrayEntries(result); + } + else + { + AssertFlatArrayEntries(result); + } + } + + private void AssertFlatArrayEntries(RedisResult result) + { + Assert.Equal(ResultType.Array, result.Resp2Type); + Assert.Equal(entries.Length * 2, (int)result.Length); + int index = 0; + foreach (var entry in entries) + { + var e = result[index++]; + Assert.Equal(ResultType.BulkString, e.Resp2Type); + Assert.Equal(entry.Element, e.AsRedisValue()); + + e = result[index++]; + Assert.Equal(ResultType.BulkString, e.Resp2Type); + Assert.Equal(entry.Score, e.AsDouble()); + } + } + + private void AssertJaggedArrayEntries(RedisResult result) + { + Assert.Equal(ResultType.Array, result.Resp2Type); + Assert.Equal(entries.Length, (int)result.Length); + int index = 0; + foreach (var entry in entries) + { + var arr = result[index++]; + Assert.Equal(ResultType.Array, arr.Resp2Type); + Assert.Equal(2, arr.Length); + + var e = arr[0]; + Assert.Equal(ResultType.BulkString, e.Resp2Type); + Assert.Equal(entry.Element, e.AsRedisValue()); + + e = arr[1]; + Assert.Equal(ResultType.SimpleString, e.Resp2Type); + Assert.Equal(ResultType.Double, e.Resp3Type); + Assert.Equal(entry.Score, e.AsDouble()); + } + } + [Fact] public void SortedSetPopMulti_Multi() { diff --git a/tests/StackExchange.Redis.Tests/StreamTests.cs b/tests/StackExchange.Redis.Tests/StreamTests.cs index b6568d525..e2f3e17f7 100644 --- a/tests/StackExchange.Redis.Tests/StreamTests.cs +++ b/tests/StackExchange.Redis.Tests/StreamTests.cs @@ -7,10 +7,18 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class StreamTests : TestBase +public class Resp2StreamTests : StreamTests { - public StreamTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public Resp2StreamTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3StreamTests : StreamTests +{ + public Resp3StreamTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class StreamTests : ProtocolFixedTestBase +{ + public StreamTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public void IsStreamType() diff --git a/tests/StackExchange.Redis.Tests/StringTests.cs b/tests/StackExchange.Redis.Tests/StringTests.cs index 9ba3c73bd..298a21990 100644 --- a/tests/StackExchange.Redis.Tests/StringTests.cs +++ b/tests/StackExchange.Redis.Tests/StringTests.cs @@ -8,13 +8,21 @@ namespace StackExchange.Redis.Tests; +public class Resp2StringTests : StringTests +{ + public Resp2StringTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3StringTests : StringTests +{ + public Resp3StringTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + /// /// Tests for . /// -[Collection(SharedConnectionFixture.Key)] -public class StringTests : TestBase +public abstract class StringTests : ProtocolFixedTestBase { - public StringTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public StringTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public async Task Append() diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 4ba21d4f5..19bfca330 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -1,4 +1,6 @@ -using System; +using StackExchange.Redis.Profiling; +using StackExchange.Redis.Tests.Helpers; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -7,8 +9,6 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using StackExchange.Redis.Profiling; -using StackExchange.Redis.Tests.Helpers; using Xunit; using Xunit.Abstractions; @@ -263,6 +263,7 @@ internal virtual IInternalConnectionMultiplexer Create( int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, Version? require = null, + RedisProtocol? protocol = null, [CallerMemberName] string? caller = null) { if (Output == null) @@ -271,20 +272,9 @@ internal virtual IInternalConnectionMultiplexer Create( } // Share a connection if instructed to and we can - many specifics mean no sharing - if (shared + if (shared && expectedFailCount == 0 && _fixture != null && _fixture.IsEnabled - && enabledCommands == null - && disabledCommands == null - && fail - && channelPrefix == null - && proxy == null - && configuration == null - && password == null - && tieBreaker == null - && defaultDatabase == null - && (allowAdmin == null || allowAdmin == true) - && expectedFailCount == 0 - && backlogPolicy == null) + && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy, protocol)) { configuration = GetConfiguration(); // Only return if we match @@ -304,7 +294,7 @@ internal virtual IInternalConnectionMultiplexer Create( checkConnect, failMessage, channelPrefix, proxy, logTransactionData, defaultDatabase, - backlogPolicy, + backlogPolicy, protocol, caller); ThrowIfBelowMinVersion(conn, require); @@ -315,15 +305,42 @@ internal virtual IInternalConnectionMultiplexer Create( return conn; } - protected void ThrowIfBelowMinVersion(IConnectionMultiplexer conn, Version? requiredVersion) + internal static bool CanShare( + bool? allowAdmin, + string? password, + string? tieBreaker, + bool fail, + string[]? disabledCommands, + string[]? enabledCommands, + string? channelPrefix, + Proxy? proxy, + string? configuration, + int? defaultDatabase, + BacklogPolicy? backlogPolicy, + RedisProtocol? protocol + ) + => enabledCommands == null + && disabledCommands == null + && fail + && channelPrefix == null + && proxy == null + && configuration == null + && password == null + && tieBreaker == null + && defaultDatabase == null + && (allowAdmin == null || allowAdmin == true) + && backlogPolicy == null + && protocol is null; + + internal void ThrowIfBelowMinVersion(IInternalConnectionMultiplexer conn, Version? requiredVersion) { if (requiredVersion is null) { return; } - var serverVersion = conn.GetServer(conn.GetEndPoints()[0]).Version; - if (requiredVersion > serverVersion) + var serverVersion = conn.GetServerEndPoint(conn.GetEndPoints()[0]).Version; + if (!serverVersion.IsAtLeast(requiredVersion)) { throw new SkipTestException($"Requires server version {requiredVersion}, but server is only {serverVersion}.") { @@ -353,6 +370,7 @@ public static ConnectionMultiplexer CreateDefault( bool logTransactionData = true, int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, + RedisProtocol? protocol = null, [CallerMemberName] string? caller = null) { StringWriter? localLog = null; @@ -389,6 +407,7 @@ public static ConnectionMultiplexer CreateDefault( if (proxy != null) config.Proxy = proxy.Value; if (defaultDatabase != null) config.DefaultDatabase = defaultDatabase.Value; if (backlogPolicy != null) config.BacklogPolicy = backlogPolicy; + if (protocol is not null) config.Protocol = protocol; var watch = Stopwatch.StartNew(); var task = ConnectionMultiplexer.ConnectAsync(config, log); if (!task.Wait(config.ConnectTimeout >= (int.MaxValue / 2) ? int.MaxValue : config.ConnectTimeout * 2)) diff --git a/tests/StackExchange.Redis.Tests/TransactionTests.cs b/tests/StackExchange.Redis.Tests/TransactionTests.cs index d151e5985..5d48109c8 100644 --- a/tests/StackExchange.Redis.Tests/TransactionTests.cs +++ b/tests/StackExchange.Redis.Tests/TransactionTests.cs @@ -5,10 +5,18 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class TransactionTests : TestBase +public class Resp2TransactionTests : TransactionTests { - public TransactionTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public Resp2TransactionTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3TransactionTests : TransactionTests +{ + public Resp3TransactionTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class TransactionTests : ProtocolFixedTestBase +{ + public TransactionTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public void BasicEmptyTran() diff --git a/toys/StackExchange.Redis.Server/RespServer.cs b/toys/StackExchange.Redis.Server/RespServer.cs index 174eb10fc..1edd2a3a7 100644 --- a/toys/StackExchange.Redis.Server/RespServer.cs +++ b/toys/StackExchange.Redis.Server/RespServer.cs @@ -315,7 +315,7 @@ static void WritePrefix(PipeWriter ooutput, char pprefix) if (value.IsNil) return; // not actually a request (i.e. empty/whitespace request) if (client != null && client.ShouldSkipResponse()) return; // intentionally skipping the result char prefix; - switch (value.Type) + switch (value.Type.ToResp2()) { case ResultType.Integer: PhysicalConnection.WriteInteger(output, (long)value.AsRedisValue()); @@ -335,7 +335,7 @@ static void WritePrefix(PipeWriter ooutput, char pprefix) case ResultType.BulkString: PhysicalConnection.WriteBulkString(value.AsRedisValue(), output); break; - case ResultType.MultiBulk: + case ResultType.Array: if (value.IsNullArray) { PhysicalConnection.WriteMultiBulkHeader(output, -1); @@ -367,7 +367,7 @@ static void WritePrefix(PipeWriter ooutput, char pprefix) private static bool TryParseRequest(Arena arena, ref ReadOnlySequence buffer, out RedisRequest request) { var reader = new BufferReader(buffer); - var raw = PhysicalConnection.TryParseResult(arena, in buffer, ref reader, false, null, true); + var raw = PhysicalConnection.TryParseResult(false, arena, in buffer, ref reader, false, null, true); if (raw.HasValue) { buffer = reader.SliceFromCurrent(); diff --git a/toys/StackExchange.Redis.Server/TypedRedisValue.cs b/toys/StackExchange.Redis.Server/TypedRedisValue.cs index e6d27110c..b7240370b 100644 --- a/toys/StackExchange.Redis.Server/TypedRedisValue.cs +++ b/toys/StackExchange.Redis.Server/TypedRedisValue.cs @@ -35,7 +35,7 @@ internal static TypedRedisValue Rent(int count, out Span span) /// /// Returns whether this value represents a null array. /// - public bool IsNullArray => Type == ResultType.MultiBulk && _value.DirectObject == null; + public bool IsNullArray => Type == ResultType.Array && _value.DirectObject == null; private readonly RedisValue _value; @@ -85,7 +85,7 @@ public ReadOnlySpan Span { get { - if (Type != ResultType.MultiBulk) return default; + if (Type != ResultType.Array) return default; var arr = (TypedRedisValue[])_value.DirectObject; if (arr == null) return default; var length = (int)_value.DirectOverlappedBits64; @@ -96,7 +96,7 @@ public ArraySegment Segment { get { - if (Type != ResultType.MultiBulk) return default; + if (Type != ResultType.Array) return default; var arr = (TypedRedisValue[])_value.DirectObject; if (arr == null) return default; var length = (int)_value.DirectOverlappedBits64; @@ -156,7 +156,7 @@ private TypedRedisValue(TypedRedisValue[] oversizedItems, int count) if (count == 0) oversizedItems = Array.Empty(); } _value = new RedisValue(oversizedItems, count); - Type = ResultType.MultiBulk; + Type = ResultType.Array; } internal void Recycle(int limit = -1) @@ -175,7 +175,7 @@ internal void Recycle(int limit = -1) /// /// Get the underlying assuming that it is a valid type with a meaningful value. /// - internal RedisValue AsRedisValue() => Type == ResultType.MultiBulk ? default :_value; + internal RedisValue AsRedisValue() => Type == ResultType.Array ? default :_value; /// /// Obtain the value as a string. @@ -189,7 +189,7 @@ public override string ToString() case ResultType.Integer: case ResultType.Error: return $"{Type}:{_value}"; - case ResultType.MultiBulk: + case ResultType.Array: return $"{Type}:[{Span.Length}]"; default: return Type.ToString(); From 4c2cd0dab2ec6dab6ec41109e5b6309c8d8cceb2 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 10 Aug 2023 15:14:07 +0100 Subject: [PATCH 02/24] pass connection to test API to give warn rather than error if not enough DBs --- tests/StackExchange.Redis.Tests/KeyTests.cs | 2 +- tests/StackExchange.Redis.Tests/ProfilingTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index b44f020ac..48df08b53 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -26,7 +26,7 @@ public void TestScan() { using var conn = Create(allowAdmin: true); - var dbId = TestConfig.GetDedicatedDB(); + var dbId = TestConfig.GetDedicatedDB(conn); var db = conn.GetDatabase(dbId); var server = GetAnyPrimary(conn); var prefix = Me(); diff --git a/tests/StackExchange.Redis.Tests/ProfilingTests.cs b/tests/StackExchange.Redis.Tests/ProfilingTests.cs index 41aef9337..52401c55a 100644 --- a/tests/StackExchange.Redis.Tests/ProfilingTests.cs +++ b/tests/StackExchange.Redis.Tests/ProfilingTests.cs @@ -29,7 +29,7 @@ public void Simple() conn.RegisterProfiler(() => session); - var dbId = TestConfig.GetDedicatedDB(); + var dbId = TestConfig.GetDedicatedDB(conn); var db = conn.GetDatabase(dbId); db.StringSet(key, "world"); var result = db.ScriptEvaluate(script, new { key = (RedisKey)key }); From 843a84e48668bd6e664a63dbecebe313d27a3340 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 10 Aug 2023 15:54:43 +0100 Subject: [PATCH 03/24] test setup: ensure we don't false-negative due to confusion between server version and protocol --- .../Helpers/ProtocolDependentFixture.cs | 8 +++++--- tests/StackExchange.Redis.Tests/TestBase.cs | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs index 58d66b9de..45bc4d4cb 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs @@ -57,18 +57,20 @@ public ProtocolFixedTestBase(ITestOutputHelper output, ProtocolDependentFixture { if (protocol is not null) { - Assert.True(Resp3 && protocol >= RedisProtocol.Resp3, "Test is requesting incorrect RESP"); + Assert.True(Resp3 && protocol != RedisProtocol.Resp3, "Test is demanding incorrect RESP"); } - if (shared && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy, protocol)) + protocol = Resp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; + if (shared && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy, protocol: null)) // we're handling protocol manually { // can use the fixture's *pair* of resp clients var conn = Fixture.GetConnection(this, Resp3, caller!); + ThrowIfIncorrectProtocol(conn, protocol); ThrowIfBelowMinVersion(conn, require); return conn; } else { - if (Resp3 && (require is null || require < RedisFeatures.v6_0_0)) + if (Resp3 && (require is null || !require.IsAtLeast(RedisFeatures.v6_0_0))) { require = RedisFeatures.v6_0_0; } diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 19bfca330..2ab04a473 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -278,6 +278,8 @@ internal virtual IInternalConnectionMultiplexer Create( { configuration = GetConfiguration(); // Only return if we match + ThrowIfIncorrectProtocol(_fixture.Connection, protocol); + if (configuration == _fixture.Configuration) { ThrowIfBelowMinVersion(_fixture.Connection, require); @@ -297,6 +299,7 @@ internal virtual IInternalConnectionMultiplexer Create( backlogPolicy, protocol, caller); + ThrowIfIncorrectProtocol(conn, protocol); ThrowIfBelowMinVersion(conn, require); conn.InternalError += OnInternalError; @@ -332,6 +335,23 @@ internal static bool CanShare( && backlogPolicy == null && protocol is null; + internal void ThrowIfIncorrectProtocol(IInternalConnectionMultiplexer conn, RedisProtocol? requiredProtocol) + { + if (requiredProtocol is null) + { + return; + } + + var serverProtocol = conn.GetServerEndPoint(conn.GetEndPoints()[0]).IsResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; + if (serverProtocol != requiredProtocol) + { + throw new SkipTestException($"Requires protocol {requiredProtocol}, but connection is {serverProtocol}.") + { + MissingFeatures = $"Protocol {requiredProtocol}." + }; + } + } + internal void ThrowIfBelowMinVersion(IInternalConnectionMultiplexer conn, Version? requiredVersion) { if (requiredVersion is null) From a548b511f5eeb4a473c95449708e3e7661c2b874 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 10 Aug 2023 16:13:11 +0100 Subject: [PATCH 04/24] update RESP3 guidance --- docs/Resp3.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/Resp3.md b/docs/Resp3.md index b65777e13..0b99107fd 100644 --- a/docs/Resp3.md +++ b/docs/Resp3.md @@ -1,29 +1,34 @@ # RESP3 and StackExchange.Redis -RESP2 and RESP3 are evolutions of the Redis protocol; the main differences are: +RESP2 and RESP3 are evolutions of the Redis protocol, with RESP3 existing from Redis server version 6 onwards; the main differences are: 1. RESP3 can carry out-of-band / "push" messages on a single connection, where-as RESP2 requires a separate connection for these messages -2. RESP3 can (when appropriate) convey additional semantic meaning about returned payloads +2. RESP3 can (when appropriate) convey additional semantic meaning about returned payloads inside the same result structure +3. Some commands (see [this topic](https://github.com/redis/redis-doc/issues/2511)) return different result structures in RESP3 mode; for example a flat interleaved array might become a jagged array -For most people, the first point is the main reason to consider RESP3, as in high-usage servers, this can halve the number of connections required. +For most people, the bullet "1" is the main reason to consider RESP3, as in high-usage servers, this can halve the number of connections required. This is particularly useful in hosted environments where the number of inbound connections to the server is capped as part of a service plan. Alternatively, where users are currently choosing to disable the out-of-band connection to achieve this, they may now be able to re-enable this (for example, to receive server maintenance notifications) *without* incurring any additional connection overhead. -There are no significant other differences, i.e. security, performance, etc all perform identically under both RESP2 and RESP3. +Because of the significance of bullet "3" (and to avoid breaking your code), the library does not currently default to RESP3 mode; this must be enabled explicitly +via `ConfigurationOptions.Protocol` or by adding `,protocol=resp3` (or `,protocol=3`) to the configuration string. -RESP3 requires a Redis server version 6 or above; since the library cannot automatically know the server version *before* it has successfully connected, -the library currently requires a hint to enable this mode, in particular, configuring the `ConfigurationOptions.Version` property to 6 (or above), or using -`,version=6.0` (or above) on the configuration string. +--- -When using StackExchange.Redis, the second point only applies to: +Bullet "3" is a critical one; the library *should* already handle all documented commands that have revised results in RESP3, but if you're using +`Execute[Async]` to issue ad-hoc commands, you may need to update your processing code to compensate for this, ideally using detection to handle +*either* format so that the same code works in both REP2 and RESP3. Since the impacted commands are handled internally by the library, in reality +this should not usually present a difficulty. -- Lua scripts that are invoked via the `ScriptEvaluate[Async](...)` or related APIs, that either: +The minor (bullet "2") and major (bullet "3") differences to results are only visible to your code when using: + +- Lua scripts invoked via the `ScriptEvaluate[Async](...)` or related APIs, that either: - uses the `redis.setresp(3)` API and returns a value from `redis.[p]call(...)` - returns a value that satisfies the [LUA to RESP3 type conversion rules](https://redis.io/docs/manual/programmability/lua-api/#lua-to-resp3-type-conversion) - ad-hoc commands (in particular: *modules*) that are invoked via the `Execute[Async](string command, ...)` API -both which return `RedisResult`. **If you are not using these APIs, you do not need to do anything.** +both which return `RedisResult`. **If you are not using these APIs, you should not need to do anything additional.** Historically, you could use the `RedisResult.Type` property to query the type of data returned (integer, string, etc). In particular: @@ -33,7 +38,8 @@ Historically, you could use the `RedisResult.Type` property to query the type of - the `Type` property is now marked obsolete, but functions identically to `Resp2Type`, so that pre-existing code (for example, that has a `switch` on the type) is not impacted by RESP3 - the `ResultType.MultiBulk` is superseded by `ResultType.Array` (this is a nomenclature change only; they are the same value and function identically) -No changes to existing code are *required*, but: +Possible changes required due to RESP3: 1. to prevent build warnings, replace usage of `ResultType.MultiBulk` with `ResultType.Array`, and usage of `RedisResult.Type` with `RedisResult.Resp2Type` -2. if you wish to exploit the additional semantic data when using RESP3, use `RedisResult.Resp3Type` where appropriate \ No newline at end of file +2. if you wish to exploit the additional semantic data when enabling RESP3, use `RedisResult.Resp3Type` where appropriate +3. if you are enabling RESP3, you must verify whether the commands you are using can give different result shapes on RESP3 connections \ No newline at end of file From e0efe480d38f8afd7756b5876153752d924c6531 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 11 Aug 2023 11:22:24 +0100 Subject: [PATCH 05/24] - remove double CLIENT ID (retain the "main" property, ConnectionId) - add a few missing ForAwait - add onnectWithTiming which is intended to find the netfx connect stall --- src/StackExchange.Redis/PhysicalBridge.cs | 2 -- src/StackExchange.Redis/PhysicalConnection.cs | 4 +-- src/StackExchange.Redis/RedisDatabase.cs | 4 +-- src/StackExchange.Redis/ResultProcessor.cs | 29 ++----------------- src/StackExchange.Redis/ServerEndPoint.cs | 5 +--- .../StackExchange.Redis.Tests/PubSubTests.cs | 13 +++++++-- tests/StackExchange.Redis.Tests/Resp3Tests.cs | 15 ++++++++-- 7 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 529cbf3b1..1d34af547 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -69,8 +69,6 @@ internal sealed class PhysicalBridge : IDisposable internal string? PhysicalName => physical?.ToString(); - internal long? ClientId => physical?.ClientId; - public DateTime? ConnectedAt { get; private set; } public PhysicalBridge(ServerEndPoint serverEndPoint, ConnectionType type, int timeoutMilliseconds) diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 823326e8a..d2a8e2c26 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -257,7 +257,7 @@ private enum ReadMode : byte public long SubscriptionCount { get; set; } public bool TransactionActive { get; internal set; } - internal long? ClientId { get; set; } + public bool IsResp3 { get; set; } @@ -1497,7 +1497,7 @@ internal async ValueTask ConnectedAsync(Socket? socket, LogProxy? log, Soc var configOptions = config.SslClientAuthenticationOptions?.Invoke(host); if (configOptions is not null) { - await ssl.AuthenticateAsClientAsync(configOptions); + await ssl.AuthenticateAsClientAsync(configOptions).ForAwait(); } else { diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 1cc089f2d..d24a0be5f 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -1549,12 +1549,12 @@ public async Task ScriptEvaluateAsync(string script, RedisKey[]? ke try { - return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ConfigureAwait(false); + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); } catch (RedisServerException) when (msg.IsScriptUnavailable) { // could be a NOSCRIPT; for a sync call, we can re-issue that without problem - return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ConfigureAwait(false); + return await ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle).ForAwait(); } } diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 6f6294933..1cbd3ced2 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -56,8 +56,7 @@ public static readonly MultiStreamProcessor public static readonly ResultProcessor Int64 = new Int64Processor(), PubSubNumSub = new PubSubNumSubProcessor(), - Int64DefaultNegativeOne = new Int64DefaultValueProcessor(-1), - ClientId = new ClientIdProcessor(); + Int64DefaultNegativeOne = new Int64DefaultValueProcessor(-1); public static readonly ResultProcessor NullableDouble = new NullableDoubleProcessor(); @@ -818,7 +817,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { if (result.TryGetInt64(out long clientId)) { - connection.ClientId = clientId; + connection.ConnectionId = clientId; Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (CLIENT) connection-id: {clientId}"); } } @@ -966,7 +965,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } else if (key.IsEqual(CommonReplies.id) && val.TryGetInt64(out i64)) { - connection.ClientId = i64; + connection.ConnectionId = i64; Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) connection-id: {i64}"); } else if (key.IsEqual(CommonReplies.mode) && TryParseServerType(val.GetString(), out var serverType)) @@ -1351,28 +1350,6 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } } - private class ClientIdProcessor : ResultProcessor - { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) - { - switch (result.Resp2TypeBulkString) - { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - long i64; - if (result.TryGetInt64(out i64)) - { - SetResult(message, i64); - connection.ConnectionId = i64; - return true; - } - break; - } - return false; - } - } - private class PubSubNumSubProcessor : Int64Processor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 26a918078..45285b0d5 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -1006,11 +1006,8 @@ private async Task HandshakeAsync(PhysicalConnection connection, LogProxy? log) await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } } - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.ID); - msg.SetInternalCall(); - await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.ClientId).ForAwait(); - msg = Message.Create(-1, default, RedisCommand.CLIENT, RedisLiterals.ID); + msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.ID); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); } diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs index 833d888e9..d1afa52cd 100644 --- a/tests/StackExchange.Redis.Tests/PubSubTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubTests.cs @@ -12,10 +12,17 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class PubSubTests : TestBase +public class Resp2PubSubTests : PubSubTests { - public PubSubTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public Resp2PubSubTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3PubSubTests : PubSubTests +{ + public Resp3PubSubTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} +public abstract class PubSubTests : ProtocolFixedTestBase +{ + public PubSubTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public async Task ExplicitPublishMode() diff --git a/tests/StackExchange.Redis.Tests/Resp3Tests.cs b/tests/StackExchange.Redis.Tests/Resp3Tests.cs index 49f0d09d7..dc0a29ae3 100644 --- a/tests/StackExchange.Redis.Tests/Resp3Tests.cs +++ b/tests/StackExchange.Redis.Tests/Resp3Tests.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Xunit; @@ -11,6 +10,16 @@ public sealed class Resp3Tests : ProtocolDependentTestBase { public Resp3Tests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture) { } + [Theory] + [InlineData(RedisProtocol.Resp2)] + [InlineData(RedisProtocol.Resp3)] + public async Task ConnectWithTiming(RedisProtocol protocol) + { + using var conn = Create(protocol: protocol, shared: false, log: Writer); + await conn.GetDatabase().PingAsync(); + + } + [Theory] // specify nothing [InlineData("someserver", false)] @@ -85,7 +94,7 @@ public async Task TryConnect(bool useResp3) { Assert.False(server.IsResp3, nameof(server.IsResp3)); } - var cid = server.GetBridge(RedisCommand.GET)?.ClientId; + var cid = server.GetBridge(RedisCommand.GET)?.ConnectionId; if (server.GetFeatures().ClientId) { Assert.NotNull(cid); From 9f01470627b4de85ff0a0c183747f292581884d4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 16 Aug 2023 13:28:33 +0100 Subject: [PATCH 06/24] - fix initial connect pause - fix pubsub --- .../ConnectionMultiplexer.cs | 5 +- src/StackExchange.Redis/PhysicalBridge.cs | 6 +- src/StackExchange.Redis/PhysicalConnection.cs | 6 +- src/StackExchange.Redis/ResultProcessor.cs | 11 +++- src/StackExchange.Redis/ServerEndPoint.cs | 64 +++++++++++++------ .../Helpers/ProtocolDependentFixture.cs | 14 ---- .../StackExchange.Redis.Tests/PubSubTests.cs | 2 +- tests/StackExchange.Redis.Tests/Resp3Tests.cs | 10 +-- tests/StackExchange.Redis.Tests/TestBase.cs | 2 +- 9 files changed, 73 insertions(+), 47 deletions(-) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 212070536..bd787dce6 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -904,7 +904,7 @@ public ServerSnapshotFiltered(ServerEndPoint[] endpoints, int count, Func Interlocked.Read(ref operationCount); public RedisCommand LastCommand { get; private set; } - public bool IsResp3 => physical is { IsResp3: true }; + + /// + /// If we have completed handshake, report the actual protocol; if we're not sure, report null + /// + public RedisProtocol? Protocol => physical?.Protocol; public void Dispose() { diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index d2a8e2c26..626aa9e11 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -258,7 +258,9 @@ private enum ReadMode : byte public bool TransactionActive { get; internal set; } - public bool IsResp3 { get; set; } + public RedisProtocol? Protocol => _knownProtocol == 0 ? null : _knownProtocol; // advertise null if we haven't completed handshake + public void SetProtocol(RedisProtocol value) => _knownProtocol = value; + private RedisProtocol _knownProtocol; // note that this defaults to ZERO, not 2, until the handshake; just to avoid some storage; [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times")] @@ -1809,7 +1811,7 @@ private int ProcessBuffer(ref ReadOnlySequence buffer) { _readStatus = ReadStatus.TryParseResult; var reader = new BufferReader(buffer); - var result = TryParseResult(IsResp3, _arena, in buffer, ref reader, IncludeDetailInExceptions, this); + var result = TryParseResult(_knownProtocol >= RedisProtocol.Resp3, _arena, in buffer, ref reader, IncludeDetailInExceptions, this); try { if (result.HasValue) diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 1cbd3ced2..c4f9e8018 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -960,8 +960,8 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } else if (key.IsEqual(CommonReplies.proto) && val.TryGetInt64(out var i64)) { - connection.IsResp3 = i64 >= 3; - Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) protocol: {(connection.IsResp3 ? "RESP3" : "RESP2")}"); + connection.SetProtocol(i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); + Log?.WriteLine($"{Format.ToString(server)}: Auto-configured (HELLO) protocol: {connection.Protocol}"); } else if (key.IsEqual(CommonReplies.id) && val.TryGetInt64(out i64)) { @@ -2603,6 +2603,13 @@ public override bool SetResult(PhysicalConnection connection, Message message, i connection.RecordConnectionFailed(ConnectionFailureType.ProtocolFailure, new RedisServerException(result.ToString())); } } + + if (connection.Protocol is null) + { + // if we didn't get a valid response from HELLO, then we have to assume RESP2 at some point + connection.SetProtocol(RedisProtocol.Resp2); + } + return final; } diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 45285b0d5..41ba3c6a1 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Runtime.CompilerServices; @@ -93,7 +94,15 @@ public int Databases public bool IsConnecting => interactive?.IsConnecting == true; public bool IsConnected => interactive?.IsConnected == true; - public bool IsSubscriberConnected => subscription?.IsConnected == true; + public bool IsSubscriberConnected => KnowOrAssumeResp3() ? IsConnected : subscription?.IsConnected == true; + + public bool KnowOrAssumeResp3() + { + var protocol = interactive?.Protocol; + return protocol is not null + ? protocol.GetValueOrDefault() >= RedisProtocol.Resp3 // <= if we've completed handshake, use what we *know for sure* + : Multiplexer.RawConfig.TryResp3(); // otherwise, use what we *expect* + } public bool SupportsSubscriptions => Multiplexer.CommandMap.IsAvailable(RedisCommand.SUBSCRIBE); public bool SupportsPrimaryWrites => supportsPrimaryWrites ??= (!IsReplica || !ReplicaReadOnly || AllowReplicaWrites); @@ -198,7 +207,7 @@ public Version Version set => SetConfig(ref version, value); } - public bool IsResp3 => interactive is { IsResp3: true }; + public RedisProtocol? Protocol => interactive?.Protocol; public int WriteEverySeconds { @@ -223,12 +232,16 @@ public void Dispose() public PhysicalBridge? GetBridge(ConnectionType type, bool create = true, LogProxy? log = null) { if (isDisposed) return null; - return type switch + switch (type) { - ConnectionType.Interactive /* or _ when IsResp3 */ => interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, log) : null), - ConnectionType.Subscription => subscription ?? (create ? subscription = CreateBridge(ConnectionType.Subscription, log) : null), - _ => null, - }; + case ConnectionType.Interactive: + case ConnectionType.Subscription when KnowOrAssumeResp3(): + return interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, log) : null); + case ConnectionType.Subscription: + return subscription ?? (create ? subscription = CreateBridge(ConnectionType.Subscription, log) : null); + default: + return null; + } } public PhysicalBridge? GetBridge(Message message) @@ -249,7 +262,7 @@ public void Dispose() break; } - return (message.IsForSubscriptionBridge /* && !IsResp3 */) + return (message.IsForSubscriptionBridge && !KnowOrAssumeResp3()) ? subscription ??= CreateBridge(ConnectionType.Subscription, null) : interactive ??= CreateBridge(ConnectionType.Interactive, null); } @@ -257,16 +270,17 @@ public void Dispose() public PhysicalBridge? GetBridge(RedisCommand command, bool create = true) { if (isDisposed) return null; - //if (!IsResp3) + switch (command) { - switch (command) - { - case RedisCommand.SUBSCRIBE: - case RedisCommand.UNSUBSCRIBE: - case RedisCommand.PSUBSCRIBE: - case RedisCommand.PUNSUBSCRIBE: + case RedisCommand.SUBSCRIBE: + case RedisCommand.UNSUBSCRIBE: + case RedisCommand.PSUBSCRIBE: + case RedisCommand.PUNSUBSCRIBE: + if (!KnowOrAssumeResp3()) + { return subscription ?? (create ? subscription = CreateBridge(ConnectionType.Subscription, null) : null); - } + } + break; } return interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, null) : null); } @@ -666,7 +680,7 @@ internal void OnFullyEstablished(PhysicalConnection connection, string source) // Since we're issuing commands inside a SetResult path in a message, we'd create a deadlock by waiting. Multiplexer.EnsureSubscriptions(CommandFlags.FireAndForget); } - if (IsConnected && (IsSubscriberConnected || !SupportsSubscriptions)) + if (IsConnected && (IsSubscriberConnected || !SupportsSubscriptions || KnowOrAssumeResp3())) { // Only connect on the second leg - we can accomplish this by checking both // Or the first leg, if we're only making 1 connection because subscriptions aren't supported @@ -940,13 +954,27 @@ private async Task HandshakeAsync(PhysicalConnection connection, LogProxy? log) // many bytes different; this allows us to pipeline the entire handshake without having to // add latency + // note on the use of FireAndForget here; in F+F, the result processor is still invoked, which + // is what we need for things to work; what *doesn't* happen is the result-box activation etc; + // that's fine and doesn't cause a problem; if we wanted we could probably just discard (`_ =`) + // the various tasks and just `return connection.FlushAsync();` - however, since handshake is low + // volume, we can afford to optimize for a good stack-trace rather than avoiding state machines. + ResultProcessor? autoConfig = null; if (Multiplexer.RawConfig.TryResp3()) // note this includes a availability check on HELLO { log?.WriteLine($"{Format.ToString(this)}: Authenticating via HELLO"); - var hello = Message.CreateHello(3, user, password, clientName, CommandFlags.None); + var hello = Message.CreateHello(3, user, password, clientName, CommandFlags.FireAndForget); hello.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, hello, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); + + // note that we don't know the actual protocol yet; this still could be RESP2 if either HELLO isn't supported/reports an error, + // or if the server negotiation says "I understand HELLO, but we're talking RESP2" + } + else + { + // if we're not even issuing HELLO, we're RESP2 + connection.SetProtocol(RedisProtocol.Resp2); } // note: we auth EVEN IF we have used HELLO to AUTH; because otherwise the fallback/detection path is pure hell, diff --git a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs index 45bc4d4cb..ed69c7463 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs @@ -78,20 +78,6 @@ public ProtocolFixedTestBase(ITestOutputHelper output, ProtocolDependentFixture defaultDatabase, backlogPolicy, require, protocol, caller); } } - - - //internal override IInternalConnectionMultiplexer Create(string? clientName = null, int? syncTimeout = null, bool? allowAdmin = null, int? keepAlive = null, int? connectTimeout = null, string? password = null, string? tieBreaker = null, TextWriter? log = null, bool fail = true, string[]? disabledCommands = null, string[]? enabledCommands = null, bool checkConnect = true, string? failMessage = null, string? channelPrefix = null, Proxy? proxy = null, string? configuration = null, bool logTransactionData = true, bool shared = true, int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, Version? require = null, RedisProtocol? protocol = null, [CallerMemberName] string? caller = null) - //{ - // var obj = base.Create(clientName, syncTimeout, allowAdmin, keepAlive, connectTimeout, password, tieBreaker, log, fail, disabledCommands, enabledCommands, checkConnect, failMessage, channelPrefix, proxy, configuration, logTransactionData, shared, defaultDatabase, backlogPolicy, require, protocol, caller); - - // var ep = obj.GetEndPoints().FirstOrDefault(); - // if (ep is not null) - // { - // var server = obj.GetServerEndPoint(ep); - // if (server is not null && server.IsResp3 != Resp3) Skip.Inconclusive("Incorrect RESP version"); - // } - // return obj; - //} } /// diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs index d1afa52cd..482b12add 100644 --- a/tests/StackExchange.Redis.Tests/PubSubTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubTests.cs @@ -383,7 +383,7 @@ public async Task PubSubGetAllAnyOrder() const int count = 1000; var syncLock = new object(); - Assert.True(sub.IsConnected()); + Assert.True(sub.IsConnected(), nameof(sub.IsConnected)); var data = new HashSet(); await sub.SubscribeAsync(channel, (_, val) => { diff --git a/tests/StackExchange.Redis.Tests/Resp3Tests.cs b/tests/StackExchange.Redis.Tests/Resp3Tests.cs index dc0a29ae3..6d033147b 100644 --- a/tests/StackExchange.Redis.Tests/Resp3Tests.cs +++ b/tests/StackExchange.Redis.Tests/Resp3Tests.cs @@ -88,11 +88,11 @@ public async Task TryConnect(bool useResp3) } if (useResp3) { - Assert.True(server.IsResp3, nameof(server.IsResp3)); + Assert.Equal(RedisProtocol.Resp3, server.Protocol); } else { - Assert.False(server.IsResp3, nameof(server.IsResp3)); + Assert.Equal(RedisProtocol.Resp2, server.Protocol); } var cid = server.GetBridge(RedisCommand.GET)?.ConnectionId; if (server.GetFeatures().ClientId) @@ -122,7 +122,7 @@ public async Task ConnectWithBrokenHello(string command, bool isResp3) { isResp3 = false; // then, no: it won't be } - Assert.Equal(isResp3, ep.IsResp3); + Assert.Equal(isResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, ep.Protocol); var result = await muxer.GetDatabase().ExecuteAsync("latency", "doctor"); Assert.Equal(isResp3 ? ResultType.VerbatimString : ResultType.BulkString, result.Resp3Type); } @@ -172,7 +172,7 @@ public async Task CheckLuaResult(string script, bool useResp3, ResultType resp2, { Skip.Inconclusive("debug protocol not available"); } - Assert.Equal(useResp3, ep.IsResp3); + Assert.Equal(useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, ep.Protocol); var db = muxer.GetDatabase(); if (expected is MAP_ABC) @@ -326,7 +326,7 @@ public async Task CheckCommandResult(string command, bool useResp3, ResultType r { Skip.Inconclusive("debug protocol not available"); } - Assert.Equal(useResp3, ep.IsResp3); + Assert.Equal(useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, ep.Protocol); var db = muxer.GetDatabase(); if (args.Length > 0) diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 2ab04a473..6d9cc3344 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -342,7 +342,7 @@ internal void ThrowIfIncorrectProtocol(IInternalConnectionMultiplexer conn, Redi return; } - var serverProtocol = conn.GetServerEndPoint(conn.GetEndPoints()[0]).IsResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; + var serverProtocol = conn.GetServerEndPoint(conn.GetEndPoints()[0]).Protocol ?? RedisProtocol.Resp2; if (serverProtocol != requiredProtocol) { throw new SkipTestException($"Requires protocol {requiredProtocol}, but connection is {serverProtocol}.") From af46e9e13ee0a0bafcec8dbce67487ae2f0b2908 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 16 Aug 2023 13:51:15 +0100 Subject: [PATCH 07/24] - advertise RedisProtocol on IServer - fix cluster tests (false positive: we *expect* zero subscriber connections in RESP3) - fix flakey Lua test (unrelated to resp 3) - separate the Me() key in protocol dependent tests --- src/StackExchange.Redis/Interfaces/IServer.cs | 5 +++++ .../PublicAPI/PublicAPI.Unshipped.txt | 1 + src/StackExchange.Redis/RedisServer.cs | 2 ++ src/StackExchange.Redis/ServerEndPoint.cs | 3 +-- .../AggresssiveTests.cs | 4 ++-- .../StackExchange.Redis.Tests/ClusterTests.cs | 17 ++++++++++++++--- .../Helpers/ProtocolDependentFixture.cs | 3 +++ .../Issues/SO25567566Tests.cs | 2 +- .../StackExchange.Redis.Tests/LockingTests.cs | 2 +- tests/StackExchange.Redis.Tests/ScanTests.cs | 1 + .../ScriptingTests.cs | 19 ++++++++++++++----- tests/StackExchange.Redis.Tests/TestBase.cs | 4 ++-- 12 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 3a7d2b3ba..9f8f0b075 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -33,6 +33,11 @@ public partial interface IServer : IRedis /// bool IsConnected { get; } + /// + /// The protocol being used to communicate with this server (if not connected/known, then the anticipated protocol from the configuration is returned, assuming success) + /// + RedisProtocol Protocol { get; } + /// /// Gets whether the connected server is a replica. /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 31d1fccdc..a7e3db152 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -3,6 +3,7 @@ override sealed StackExchange.Redis.RedisResult.ToString() -> string! override StackExchange.Redis.Role.Master.Replica.ToString() -> string! StackExchange.Redis.ConfigurationOptions.Protocol.get -> StackExchange.Redis.RedisProtocol? StackExchange.Redis.ConfigurationOptions.Protocol.set -> void +StackExchange.Redis.IServer.Protocol.get -> StackExchange.Redis.RedisProtocol StackExchange.Redis.RedisFeatures.ClientId.get -> bool StackExchange.Redis.RedisFeatures.Equals(StackExchange.Redis.RedisFeatures other) -> bool StackExchange.Redis.RedisFeatures.Resp3.get -> bool diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 9a335aee4..172e295c0 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -34,6 +34,8 @@ internal RedisServer(ConnectionMultiplexer multiplexer, ServerEndPoint server, o bool IServer.IsSlave => IsReplica; public bool IsReplica => server.IsReplica; + public RedisProtocol Protocol => server.Protocol ?? (multiplexer.RawConfig.TryResp3() ? RedisProtocol.Resp3 : RedisProtocol.Resp2); + bool IServer.AllowSlaveWrites { get => AllowReplicaWrites; diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 41ba3c6a1..04b8e614c 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Net; using System.Runtime.CompilerServices; @@ -167,7 +166,7 @@ internal Exception? LastException } internal State InteractiveConnectionState => interactive?.ConnectionState ?? State.Disconnected; - internal State SubscriptionConnectionState => subscription?.ConnectionState ?? State.Disconnected; + internal State SubscriptionConnectionState => KnowOrAssumeResp3() ? InteractiveConnectionState : subscription?.ConnectionState ?? State.Disconnected; public long OperationCount => interactive?.OperationCount ?? 0 + subscription?.OperationCount ?? 0; diff --git a/tests/StackExchange.Redis.Tests/AggresssiveTests.cs b/tests/StackExchange.Redis.Tests/AggresssiveTests.cs index 04d472965..ec4ae7d5d 100644 --- a/tests/StackExchange.Redis.Tests/AggresssiveTests.cs +++ b/tests/StackExchange.Redis.Tests/AggresssiveTests.cs @@ -234,7 +234,7 @@ private void TranRunIntegers(IDatabase db) Writer.WriteLine($"tally: {count}"); } - private static void TranRunPings(IDatabase db) + private void TranRunPings(IDatabase db) { var key = Me(); db.KeyDelete(key); @@ -292,7 +292,7 @@ private async Task TranRunIntegersAsync(IDatabase db) Writer.WriteLine($"tally: {count}"); } - private static async Task TranRunPingsAsync(IDatabase db) + private async Task TranRunPingsAsync(IDatabase db) { var key = Me(); db.KeyDelete(key); diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index c945812a8..65e8725ed 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -11,9 +11,20 @@ namespace StackExchange.Redis.Tests; -public class ClusterTests : TestBase + +public class Resp2ClusterTests : ClusterTests +{ + public Resp2ClusterTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3ClusterTests : ClusterTests { - public ClusterTests(ITestOutputHelper output) : base (output) { } + public Resp3ClusterTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class ClusterTests : ProtocolFixedTestBase +{ + public ClusterTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; [Fact] @@ -48,7 +59,7 @@ public void ConnectUsesSingleSocket() var srv = conn.GetServer(ep); var counters = srv.GetCounters(); Assert.Equal(1, counters.Interactive.SocketCount); - Assert.Equal(1, counters.Subscription.SocketCount); + Assert.Equal(Resp3 ? 0 : 1, counters.Subscription.SocketCount); } } } diff --git a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs index ed69c7463..b6b1570c5 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs @@ -48,6 +48,9 @@ public ProtocolDependentTestBase(ITestOutputHelper output, ProtocolDependentFixt public abstract class ProtocolFixedTestBase : ProtocolDependentTestBase // extends that cability to apply/enforce correct RESP during Create { + public override string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) + => (Resp3 ? "R3:" : "R2:") + base.Me(filePath, caller); + public ProtocolFixedTestBase(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture) => Resp3 = resp3; diff --git a/tests/StackExchange.Redis.Tests/Issues/SO25567566Tests.cs b/tests/StackExchange.Redis.Tests/Issues/SO25567566Tests.cs index 18163b23c..f7bde6c4a 100644 --- a/tests/StackExchange.Redis.Tests/Issues/SO25567566Tests.cs +++ b/tests/StackExchange.Redis.Tests/Issues/SO25567566Tests.cs @@ -21,7 +21,7 @@ public async Task Execute() } } - private static async Task DoStuff(ConnectionMultiplexer conn) + private async Task DoStuff(ConnectionMultiplexer conn) { var db = conn.GetDatabase(); diff --git a/tests/StackExchange.Redis.Tests/LockingTests.cs b/tests/StackExchange.Redis.Tests/LockingTests.cs index 1d9c8742e..a2ce6986d 100644 --- a/tests/StackExchange.Redis.Tests/LockingTests.cs +++ b/tests/StackExchange.Redis.Tests/LockingTests.cs @@ -75,7 +75,7 @@ public void TestOpCountByVersionLocal_UpLevel() TestLockOpCountByVersion(conn, 1, true); } - private static void TestLockOpCountByVersion(IConnectionMultiplexer conn, int expectedOps, bool existFirst) + private void TestLockOpCountByVersion(IConnectionMultiplexer conn, int expectedOps, bool existFirst) { const int LockDuration = 30; RedisKey Key = Me(); diff --git a/tests/StackExchange.Redis.Tests/ScanTests.cs b/tests/StackExchange.Redis.Tests/ScanTests.cs index acfe67f9c..e454c35c4 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -32,6 +32,7 @@ public void KeysScan(bool supported) var db = conn.GetDatabase(dbId); var prefix = Me() + ":"; var server = GetServer(conn); + Assert.Equal(Resp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, server.Protocol); server.FlushDatabase(dbId); for (int i = 0; i < 100; i++) { diff --git a/tests/StackExchange.Redis.Tests/ScriptingTests.cs b/tests/StackExchange.Redis.Tests/ScriptingTests.cs index 00919d0c0..c3c35bfce 100644 --- a/tests/StackExchange.Redis.Tests/ScriptingTests.cs +++ b/tests/StackExchange.Redis.Tests/ScriptingTests.cs @@ -10,10 +10,19 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class ScriptingTests : TestBase +public class Resp2ScriptingTests : ScriptingTests { - public ScriptingTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2ScriptingTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3ScriptingTests : ScriptingTests +{ + public Resp3ScriptingTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + + +public abstract class ScriptingTests : ProtocolFixedTestBase +{ + public ScriptingTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } private IConnectionMultiplexer GetScriptConn(bool allowAdmin = false) { @@ -791,13 +800,13 @@ public void IDatabaseLuaScriptConvenienceMethods() var db = conn.GetDatabase(); var key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); - db.ScriptEvaluate(script, new { key = (RedisKey)key, value = "value" }, flags: CommandFlags.FireAndForget); + db.ScriptEvaluate(script, new { key = (RedisKey)key, value = "value" }); var val = db.StringGet(key); Assert.Equal("value", val); var prepared = script.Load(conn.GetServer(conn.GetEndPoints()[0])); - db.ScriptEvaluate(prepared, new { key = (RedisKey)(key + "2"), value = "value2" }, flags: CommandFlags.FireAndForget); + db.ScriptEvaluate(prepared, new { key = (RedisKey)(key + "2"), value = "value2" }); var val2 = db.StringGet(key + "2"); Assert.Equal("value2", val2); } diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 6d9cc3344..5c78c1184 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -482,10 +482,10 @@ public static ConnectionMultiplexer CreateDefault( } } - public static string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) => + public virtual string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) => Environment.Version.ToString() + Path.GetFileNameWithoutExtension(filePath) + "-" + caller; - protected static TimeSpan RunConcurrent(Action work, int threads, int timeout = 10000, [CallerMemberName] string? caller = null) + protected TimeSpan RunConcurrent(Action work, int threads, int timeout = 10000, [CallerMemberName] string? caller = null) { if (work == null) throw new ArgumentNullException(nameof(work)); if (threads < 1) throw new ArgumentOutOfRangeException(nameof(threads)); From 7efe5f8275e3248afc22c069ea582d2100664f94 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 16 Aug 2023 14:32:56 +0100 Subject: [PATCH 08/24] fix INFO (needs verbatim string support) --- src/StackExchange.Redis/RawResult.cs | 19 ++++++++++ src/StackExchange.Redis/ResultProcessor.cs | 4 ++- .../StackExchange.Redis.Tests/ConfigTests.cs | 36 +++++++++++++++---- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index ab94c2c45..025c938ed 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -429,6 +429,25 @@ private static GeoPosition AsGeoPosition(in Sequence coords) return s; } + internal string? GetVerbatimString(out ReadOnlySpan type) + { + // the first three bytes provide information about the format of the following string, which + // can be txt for plain text, or mkd for markdown. The fourth byte is always `:` + // Then the real string follows. + var value = GetString(); + if (value is not null && Resp3Type == ResultType.VerbatimString + && value.Length >= 4 && value[3] == ':') + { + type = value.AsSpan().Slice(0, 3); + value = value.Substring(4); + } + else + { + type = default; + } + return value; + } + internal bool TryGetDouble(out double val) { if (IsNull || Payload.IsEmpty) diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index c4f9e8018..95aa186d8 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -1278,8 +1278,10 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { string category = Normalize(null); var list = new List>>(); - using (var reader = new StringReader(result.GetString()!)) + var raw = result.GetVerbatimString(out _); + if (raw is not null) { + using var reader = new StringReader(raw); while (reader.ReadLine() is string line) { if (string.IsNullOrWhiteSpace(line)) continue; diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index f5a2764c7..92fbbc008 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -15,13 +15,22 @@ namespace StackExchange.Redis.Tests; -public class ConfigTests : TestBase +public class Resp2ConfigTests : ConfigTests { + public Resp2ConfigTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3ConfigTests : ConfigTests +{ + public Resp3ConfigTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} + +public abstract class ConfigTests : ProtocolFixedTestBase +{ + public ConfigTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public Version DefaultVersion = new (3, 0, 0); public Version DefaultAzureVersion = new (4, 0, 0); - public ConfigTests(ITestOutputHelper output) : base(output) { } - [Fact] public void SslProtocols_SingleValue() { @@ -232,7 +241,7 @@ public void ClearSlowlog() [Fact] public void ClientName() { - using var conn = Create(clientName: "Test Rig", allowAdmin: true); + using var conn = Create(clientName: "Test Rig", allowAdmin: true, shared: false); Assert.Equal("Test Rig", conn.ClientName); @@ -246,7 +255,7 @@ public void ClientName() [Fact] public void DefaultClientName() { - using var conn = Create(allowAdmin: true, caller: null); // force default naming to kick in + using var conn = Create(allowAdmin: true, caller: null, shared: false); // force default naming to kick in Assert.Equal($"{Environment.MachineName}(SE.Redis-v{Utils.GetLibVersion()})", conn.ClientName); var db = conn.GetDatabase(); @@ -274,7 +283,10 @@ public void ConnectWithSubscribeDisabled() Assert.True(conn.IsConnected); var servers = conn.GetServerSnapshot(); Assert.True(servers[0].IsConnected); - Assert.False(servers[0].IsSubscriberConnected); + if (!Resp3) + { + Assert.False(servers[0].IsSubscriberConnected); + } var ex = Assert.Throws(() => conn.GetSubscriber().Subscribe(RedisChannel.Literal(Me()), (_, _) => GC.KeepAlive(this))); Assert.Equal("This operation has been disabled in the command-map and cannot be used: SUBSCRIBE", ex.Message); @@ -373,12 +385,22 @@ public void GetInfoRaw() public void GetClients() { var name = Guid.NewGuid().ToString(); - using var conn = Create(clientName: name, allowAdmin: true); + using var conn = Create(clientName: name, allowAdmin: true, shared: false); var server = GetAnyPrimary(conn); var clients = server.ClientList(); Assert.True(clients.Length > 0, "no clients"); // ourselves! Assert.True(clients.Any(x => x.Name == name), "expected: " + name); + + if (server.Features.ClientId) + { + var id = conn.GetConnectionId(server.EndPoint, ConnectionType.Interactive); + Assert.NotNull(id); + Assert.True(clients.Any(x => x.Id == id), "expected: " + id); + id = conn.GetConnectionId(server.EndPoint, ConnectionType.Subscription); + Assert.NotNull(id); + Assert.True(clients.Any(x => x.Id == id), "expected: " + id); + } } [Fact] From cf956c13256eadc2c649aa2538b5ff7e6f7e69a8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 16 Aug 2023 14:41:12 +0100 Subject: [PATCH 09/24] generalize such that verbatim strings always return just the value portion unless explicitly requested --- src/StackExchange.Redis/RawResult.cs | 43 ++++++++++++---------- src/StackExchange.Redis/ResultProcessor.cs | 2 +- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index 025c938ed..305bf24af 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -385,14 +385,18 @@ private static GeoPosition AsGeoPosition(in Sequence coords) internal GeoPosition?[]? GetItemsAsGeoPositionArray() => this.ToArray((in RawResult item) => item.IsNull ? default : AsGeoPosition(item.GetItems())); - internal unsafe string? GetString() + internal unsafe string? GetString() => GetString(out _); + internal unsafe string? GetString(out ReadOnlySpan verbatimPrefix) { + verbatimPrefix = default; if (IsNull) return null; if (Payload.IsEmpty) return ""; + string s; if (Payload.IsSingleSegment) { - return Format.GetString(Payload.First.Span); + s = Format.GetString(Payload.First.Span); + return Resp3Type == ResultType.VerbatimString ? GetVerbatimString(s, out verbatimPrefix) : s; } var decoder = Encoding.UTF8.GetDecoder(); int charCount = 0; @@ -409,7 +413,7 @@ private static GeoPosition AsGeoPosition(in Sequence coords) decoder.Reset(); - string s = new string((char)0, charCount); + s = new string((char)0, charCount); fixed (char* sPtr = s) { char* cPtr = sPtr; @@ -426,26 +430,25 @@ private static GeoPosition AsGeoPosition(in Sequence coords) } } } - return s; - } + return Resp3Type == ResultType.VerbatimString ? GetVerbatimString(s, out verbatimPrefix) : s; - internal string? GetVerbatimString(out ReadOnlySpan type) - { - // the first three bytes provide information about the format of the following string, which - // can be txt for plain text, or mkd for markdown. The fourth byte is always `:` - // Then the real string follows. - var value = GetString(); - if (value is not null && Resp3Type == ResultType.VerbatimString - && value.Length >= 4 && value[3] == ':') - { - type = value.AsSpan().Slice(0, 3); - value = value.Substring(4); - } - else + static string? GetVerbatimString(string? value, out ReadOnlySpan type) { - type = default; + // the first three bytes provide information about the format of the following string, which + // can be txt for plain text, or mkd for markdown. The fourth byte is always `:` + // Then the real string follows. + if (value is not null + && value.Length >= 4 && value[3] == ':') + { + type = value.AsSpan().Slice(0, 3); + value = value.Substring(4); + } + else + { + type = default; + } + return value; } - return value; } internal bool TryGetDouble(out double val) diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 95aa186d8..c4d2311e5 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -1278,7 +1278,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { string category = Normalize(null); var list = new List>>(); - var raw = result.GetVerbatimString(out _); + var raw = result.GetString(); if (raw is not null) { using var reader = new StringReader(raw); From 0a5505f136d42d8e1288607703fed620ef4cdc44 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 16 Aug 2023 15:20:25 +0100 Subject: [PATCH 10/24] - fix pubsub failover tests (resp3 changes which connections need to die) - fix tests that assumed Create returned a real ConnectionMultiplexer --- .../ConnectionMultiplexer.cs | 3 + .../Interfaces/IConnectionMultiplexer.cs | 9 +++ src/StackExchange.Redis/RedisSubscriber.cs | 3 + .../ConnectCustomConfigTests.cs | 4 +- .../ExceptionFactoryTests.cs | 14 ++--- .../FailoverTests.cs | 2 +- .../Helpers/SharedConnectionFixture.cs | 8 +++ .../PubSubCommandTests.cs | 13 +++- .../PubSubMultiserverTests.cs | 59 +++++++++++++------ 9 files changed, 85 insertions(+), 30 deletions(-) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index bd787dce6..b829e0a16 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -47,6 +47,9 @@ public sealed partial class ConnectionMultiplexer : IInternalConnectionMultiplex internal EndPointCollection EndPoints { get; } internal ConfigurationOptions RawConfig { get; } internal ServerSelectionStrategy ServerSelectionStrategy { get; } + ServerSelectionStrategy IInternalConnectionMultiplexer.ServerSelectionStrategy => ServerSelectionStrategy; + ConnectionMultiplexer IInternalConnectionMultiplexer.UnderlyingMultiplexer => this; + internal Exception? LastException { get; set; } ConfigurationOptions IInternalConnectionMultiplexer.RawConfig => RawConfig; diff --git a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs index 621f1c7e1..0c1494641 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs @@ -1,10 +1,12 @@ using StackExchange.Redis.Maintenance; using StackExchange.Redis.Profiling; using System; +using System.Collections.Concurrent; using System.ComponentModel; using System.IO; using System.Net; using System.Threading.Tasks; +using static StackExchange.Redis.ConnectionMultiplexer; namespace StackExchange.Redis { @@ -20,6 +22,13 @@ internal interface IInternalConnectionMultiplexer : IConnectionMultiplexer ConfigurationOptions RawConfig { get; } long? GetConnectionId(EndPoint endPoint, ConnectionType type); + + ServerSelectionStrategy ServerSelectionStrategy { get; } + + int GetSubscriptionsCount(); + ConcurrentDictionary GetSubscriptions(); + + ConnectionMultiplexer UnderlyingMultiplexer { get; } } /// diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index 5a24a716e..cb4940e41 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -18,7 +18,10 @@ public partial class ConnectionMultiplexer private readonly ConcurrentDictionary subscriptions = new(); internal ConcurrentDictionary GetSubscriptions() => subscriptions; + ConcurrentDictionary IInternalConnectionMultiplexer.GetSubscriptions() => GetSubscriptions(); + internal int GetSubscriptionsCount() => subscriptions.Count; + int IInternalConnectionMultiplexer.GetSubscriptionsCount() => GetSubscriptionsCount(); internal Subscription GetOrAddSubscription(in RedisChannel channel, CommandFlags flags) { diff --git a/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs b/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs index 8eeea36e9..6041bf12c 100644 --- a/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectCustomConfigTests.cs @@ -47,7 +47,7 @@ public void DisabledCommandsStillConnectCluster(string disabledCommands) [Fact] public void TieBreakerIntact() { - using var conn = (Create(allowAdmin: true, log: Writer) as ConnectionMultiplexer)!; + using var conn = Create(allowAdmin: true, log: Writer); var tiebreaker = conn.GetDatabase().StringGet(conn.RawConfig.TieBreaker); Log($"Tiebreaker: {tiebreaker}"); @@ -61,7 +61,7 @@ public void TieBreakerIntact() [Fact] public void TieBreakerSkips() { - using var conn = (Create(allowAdmin: true, disabledCommands: new[] { "get" }, log: Writer) as ConnectionMultiplexer)!; + using var conn = Create(allowAdmin: true, disabledCommands: new[] { "get" }, log: Writer); Assert.Throws(() => conn.GetDatabase().StringGet(conn.RawConfig.TieBreaker)); foreach (var server in conn.GetServerSnapshot()) diff --git a/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs b/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs index 02309ec79..324c828f1 100644 --- a/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs +++ b/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs @@ -15,7 +15,7 @@ public void NullLastException() conn.GetDatabase(); Assert.Null(conn.GetServerSnapshot()[0].LastException); - var ex = ExceptionFactory.NoConnectionAvailable((conn as ConnectionMultiplexer)!, null, null); + var ex = ExceptionFactory.NoConnectionAvailable(conn.UnderlyingMultiplexer, null, null); Assert.Null(ex.InnerException); } @@ -42,7 +42,7 @@ public void MultipleEndpointsThrowConnectionException() conn.GetServer(endpoint).SimulateConnectionFailure(SimulatedFailureType.All); } - var ex = ExceptionFactory.NoConnectionAvailable((conn as ConnectionMultiplexer)!, null, null); + var ex = ExceptionFactory.NoConnectionAvailable(conn.UnderlyingMultiplexer, null, null); var outer = Assert.IsType(ex); Assert.Equal(ConnectionFailureType.UnableToResolvePhysicalConnection, outer.FailureType); var inner = Assert.IsType(outer.InnerException); @@ -68,7 +68,7 @@ public void ServerTakesPrecendenceOverSnapshot() conn.GetServer(conn.GetEndPoints()[0]).SimulateConnectionFailure(SimulatedFailureType.All); - var ex = ExceptionFactory.NoConnectionAvailable((conn as ConnectionMultiplexer)!, null, conn.GetServerSnapshot()[0]); + var ex = ExceptionFactory.NoConnectionAvailable(conn.UnderlyingMultiplexer, null, conn.GetServerSnapshot()[0]); Assert.IsType(ex); Assert.IsType(ex.InnerException); Assert.Equal(ex.InnerException, conn.GetServerSnapshot()[0].LastException); @@ -88,7 +88,7 @@ public void NullInnerExceptionForMultipleEndpointsWithNoLastException() conn.GetDatabase(); conn.AllowConnect = false; - var ex = ExceptionFactory.NoConnectionAvailable((conn as ConnectionMultiplexer)!, null, null); + var ex = ExceptionFactory.NoConnectionAvailable(conn.UnderlyingMultiplexer, null, null); Assert.IsType(ex); Assert.Null(ex.InnerException); } @@ -103,12 +103,12 @@ public void TimeoutException() { try { - using var conn = (Create(keepAlive: 1, connectTimeout: 10000, allowAdmin: true, shared: false) as ConnectionMultiplexer)!; + using var conn = Create(keepAlive: 1, connectTimeout: 10000, allowAdmin: true, shared: false); var server = GetServer(conn); conn.AllowConnect = false; var msg = Message.Create(-1, CommandFlags.None, RedisCommand.PING); - var rawEx = ExceptionFactory.Timeout(conn, "Test Timeout", msg, new ServerEndPoint(conn, server.EndPoint)); + var rawEx = ExceptionFactory.Timeout(conn.UnderlyingMultiplexer, "Test Timeout", msg, new ServerEndPoint(conn.UnderlyingMultiplexer, server.EndPoint)); var ex = Assert.IsType(rawEx); Writer.WriteLine("Exception: " + ex.Message); @@ -247,7 +247,7 @@ public void MessageFail(bool includeDetail, ConnectionFailureType failType, stri var resultBox = SimpleResultBox.Create(); message.SetSource(ResultProcessor.String, resultBox); - message.Fail(failType, null, "my annotation", conn as ConnectionMultiplexer); + message.Fail(failType, null, "my annotation", conn.UnderlyingMultiplexer); resultBox.GetResult(out var ex); Assert.NotNull(ex); diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index e9ff75d9a..811fbc69b 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -200,7 +200,7 @@ public async Task DereplicateGoesToPrimary() [Fact] public async Task SubscriptionsSurviveConnectionFailureAsync() { - using var conn = (Create(allowAdmin: true, shared: false, log: Writer, syncTimeout: 1000) as ConnectionMultiplexer)!; + using var conn = Create(allowAdmin: true, shared: false, log: Writer, syncTimeout: 1000); var profiler = conn.AddProfiler(); RedisChannel channel = RedisChannel.Literal(Me()); diff --git a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs index a4e061c28..6b6dd0c1e 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -52,13 +53,20 @@ public bool IgnoreConnect set => _inner.IgnoreConnect = value; } + public ServerSelectionStrategy ServerSelectionStrategy => _inner.ServerSelectionStrategy; + public ServerEndPoint GetServerEndPoint(EndPoint endpoint) => _inner.GetServerEndPoint(endpoint); public ReadOnlySpan GetServerSnapshot() => _inner.GetServerSnapshot(); + public ConnectionMultiplexer UnderlyingMultiplexer => _inner.UnderlyingMultiplexer; + private readonly IInternalConnectionMultiplexer _inner; public NonDisposingConnection(IInternalConnectionMultiplexer inner) => _inner = inner; + public int GetSubscriptionsCount() => _inner.GetSubscriptionsCount(); + public ConcurrentDictionary GetSubscriptions() => _inner.GetSubscriptions(); + public string ClientName => _inner.ClientName; public string Configuration => _inner.Configuration; diff --git a/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs b/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs index dd77dc106..27c2027d3 100644 --- a/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs @@ -7,10 +7,17 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class PubSubCommandTests : TestBase +public class Resp2PubSubCommandTests : PubSubCommandTests { - public PubSubCommandTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public Resp2PubSubCommandTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3PubSubCommandTests : PubSubCommandTests +{ + public Resp3PubSubCommandTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} +public abstract class PubSubCommandTests : ProtocolFixedTestBase +{ + public PubSubCommandTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } [Fact] public void SubscriberCount() diff --git a/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs b/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs index dcf706e76..154fe4ad7 100644 --- a/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs @@ -6,16 +6,24 @@ namespace StackExchange.Redis.Tests; -[Collection(SharedConnectionFixture.Key)] -public class PubSubMultiserverTests : TestBase +public class Resp2PubSubMultiserverTests : PubSubMultiserverTests { - public PubSubMultiserverTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public Resp2PubSubMultiserverTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } +} +public class Resp3PubSubMultiserverTests : PubSubMultiserverTests +{ + public Resp3PubSubMultiserverTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } +} +public abstract class PubSubMultiserverTests : ProtocolFixedTestBase +{ + public PubSubMultiserverTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; [Fact] public void ChannelSharding() { - using var conn = (Create(channelPrefix: Me()) as ConnectionMultiplexer)!; + using var conn = Create(channelPrefix: Me()); var defaultSlot = conn.ServerSelectionStrategy.HashSlot(default(RedisChannel)); var slot1 = conn.ServerSelectionStrategy.HashSlot(RedisChannel.Literal("hey")); @@ -31,7 +39,7 @@ public async Task ClusterNodeSubscriptionFailover() { Log("Connecting..."); - using var conn = (Create(allowAdmin: true) as ConnectionMultiplexer)!; + using var conn = Create(allowAdmin: true); var sub = conn.GetSubscriber(); var channel = RedisChannel.Literal(Me()); @@ -54,7 +62,7 @@ await sub.SubscribeAsync(channel, (_, val) => Log($" Published (1) to {publishedTo} subscriber(s)."); Assert.Equal(1, publishedTo); - var endpoint = sub.SubscribedEndpoint(channel); + var endpoint = sub.SubscribedEndpoint(channel)!; var subscribedServer = conn.GetServer(endpoint); var subscribedServerEndpoint = conn.GetServerEndPoint(endpoint); @@ -63,18 +71,27 @@ await sub.SubscribeAsync(channel, (_, val) => Assert.True(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); Assert.True(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); - Assert.True(conn.TryGetSubscription(channel, out var subscription)); + Assert.True(conn.GetSubscriptions().TryGetValue(channel, out var subscription)); var initialServer = subscription.GetCurrentServer(); Assert.NotNull(initialServer); Assert.True(initialServer.IsConnected); Log("Connected to: " + initialServer); conn.AllowConnect = false; - subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.AllSubscription); + if (Resp3) + { + subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.All); - Assert.True(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); - Assert.False(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); + Assert.False(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); + Assert.False(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); + } + else + { + subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.AllSubscription); + Assert.True(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); + Assert.False(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); + } await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnected); Assert.True(subscription.IsConnected); @@ -105,7 +122,7 @@ public async Task PrimaryReplicaSubscriptionFailover(CommandFlags flags, bool ex var config = TestConfig.Current.PrimaryServerAndPort + "," + TestConfig.Current.ReplicaServerAndPort; Log("Connecting..."); - using var conn = (Create(configuration: config, shared: false, allowAdmin: true) as ConnectionMultiplexer)!; + using var conn = Create(configuration: config, shared: false, allowAdmin: true); var sub = conn.GetSubscriber(); var channel = RedisChannel.Literal(Me() + flags.ToString()); // Individual channel per case to not overlap publishers @@ -127,7 +144,7 @@ await sub.SubscribeAsync(channel, (_, val) => Assert.Equal(1, count); Log($" Published (1) to {publishedTo} subscriber(s)."); - var endpoint = sub.SubscribedEndpoint(channel); + var endpoint = sub.SubscribedEndpoint(channel)!; var subscribedServer = conn.GetServer(endpoint); var subscribedServerEndpoint = conn.GetServerEndPoint(endpoint); @@ -136,17 +153,25 @@ await sub.SubscribeAsync(channel, (_, val) => Assert.True(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); Assert.True(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); - Assert.True(conn.TryGetSubscription(channel, out var subscription)); + Assert.True(conn.GetSubscriptions().TryGetValue(channel, out var subscription)); var initialServer = subscription.GetCurrentServer(); Assert.NotNull(initialServer); Assert.True(initialServer.IsConnected); Log("Connected to: " + initialServer); conn.AllowConnect = false; - subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.AllSubscription); - - Assert.True(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); - Assert.False(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); + if (Resp3) + { + subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.All); // need to kill the main connection + Assert.False(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); + Assert.False(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); + } + else + { + subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.AllSubscription); + Assert.True(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); + Assert.False(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); + } if (expectSuccess) { From 29c88d08797a8b1109b6d00f053c0c88750e518c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 16 Aug 2023 16:35:41 +0100 Subject: [PATCH 11/24] add RedisProtocol parsing on ClientInfo --- src/StackExchange.Redis/ClientInfo.cs | 5 +++ .../ConfigurationOptions.cs | 45 ++++++++++++------- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../StackExchange.Redis.Tests/ConfigTests.cs | 10 +++++ .../Helpers/ProtocolDependentFixture.cs | 4 +- tests/StackExchange.Redis.Tests/ScanTests.cs | 2 +- 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/StackExchange.Redis/ClientInfo.cs b/src/StackExchange.Redis/ClientInfo.cs index 65bb031d0..7baeca408 100644 --- a/src/StackExchange.Redis/ClientInfo.cs +++ b/src/StackExchange.Redis/ClientInfo.cs @@ -185,6 +185,11 @@ public ClientType ClientType /// public string? ProtocolVersion { get; private set; } + /// + /// Client RESP protocol version. Added in Redis 7.0 + /// + public RedisProtocol? Protocol => ConfigurationOptions.TryParseRedisProtocol(ProtocolVersion, out var value) ? value : null; + /// /// Client library name. Added in Redis 7.2 /// diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index dd445b88f..a20597a11 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -72,22 +72,7 @@ internal static SslProtocols ParseSslProtocols(string key, string? value) internal static RedisProtocol ParseRedisProtocol(string key, string value) { - // accept raw integers too, but only trust them if we recognize them - // (note we need to do this before enums, because Enum.TryParse will - // accept integers as the raw value, which is not what we want here) - if (Format.TryParseInt32(value, out int i32)) - { - switch (i32) - { - case 2: return RedisProtocol.Resp2; - case 3: return RedisProtocol.Resp3; - } - } - else - { - if (Enum.TryParse(value, true, out RedisProtocol tmp)) return tmp; - } - + if (TryParseRedisProtocol(value, out var protocol)) return protocol; throw new ArgumentOutOfRangeException(key, $"Keyword '{key}' requires a RedisProtocol value or a known protocol version number; the value '{value}' is not recognised."); } @@ -1052,5 +1037,33 @@ internal bool TryResp3() return Protocol.GetValueOrDefault() >= RedisProtocol.Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO); } } + + internal static bool TryParseRedisProtocol(string? value, out RedisProtocol protocol) + { + // accept raw integers too, but only trust them if we recognize them + // (note we need to do this before enums, because Enum.TryParse will + // accept integers as the raw value, which is not what we want here) + if (value is not null) + { + if (Format.TryParseInt32(value, out int i32)) + { + switch (i32) + { + case 2: + protocol = RedisProtocol.Resp2; + return true; + case 3: + protocol = RedisProtocol.Resp3; + return true; + } + } + else + { + if (Enum.TryParse(value, true, out protocol)) return true; + } + } + protocol = default; + return false; + } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index a7e3db152..eff457070 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ abstract StackExchange.Redis.RedisResult.ToString(out string? type) -> string? override sealed StackExchange.Redis.RedisResult.ToString() -> string! override StackExchange.Redis.Role.Master.Replica.ToString() -> string! +StackExchange.Redis.ClientInfo.Protocol.get -> StackExchange.Redis.RedisProtocol? StackExchange.Redis.ConfigurationOptions.Protocol.get -> StackExchange.Redis.RedisProtocol? StackExchange.Redis.ConfigurationOptions.Protocol.set -> void StackExchange.Redis.IServer.Protocol.get -> StackExchange.Redis.RedisProtocol diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 92fbbc008..2211881e7 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -400,6 +400,16 @@ public void GetClients() id = conn.GetConnectionId(server.EndPoint, ConnectionType.Subscription); Assert.NotNull(id); Assert.True(clients.Any(x => x.Id == id), "expected: " + id); + + var self = clients.First(x => x.Id == id); + if (server.Version.Major >= 7) + { + Assert.Equal(ExpectedProtocol, self.Protocol); + } + else + { + Assert.Null(self.Protocol); + } } } diff --git a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs index b6b1570c5..b0aff441b 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs @@ -56,13 +56,15 @@ public ProtocolFixedTestBase(ITestOutputHelper output, ProtocolDependentFixture public bool Resp3 { get; } + public RedisProtocol ExpectedProtocol => Resp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; + internal new IInternalConnectionMultiplexer Create(string? clientName = null, int? syncTimeout = null, bool? allowAdmin = null, int? keepAlive = null, int? connectTimeout = null, string? password = null, string? tieBreaker = null, TextWriter? log = null, bool fail = true, string[]? disabledCommands = null, string[]? enabledCommands = null, bool checkConnect = true, string? failMessage = null, string? channelPrefix = null, Proxy? proxy = null, string? configuration = null, bool logTransactionData = true, bool shared = true, int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, Version? require = null, RedisProtocol? protocol = null, [CallerMemberName] string? caller = null) { if (protocol is not null) { Assert.True(Resp3 && protocol != RedisProtocol.Resp3, "Test is demanding incorrect RESP"); } - protocol = Resp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; + protocol = ExpectedProtocol; if (shared && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy, protocol: null)) // we're handling protocol manually { // can use the fixture's *pair* of resp clients diff --git a/tests/StackExchange.Redis.Tests/ScanTests.cs b/tests/StackExchange.Redis.Tests/ScanTests.cs index e454c35c4..ab47ab807 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -32,7 +32,7 @@ public void KeysScan(bool supported) var db = conn.GetDatabase(dbId); var prefix = Me() + ":"; var server = GetServer(conn); - Assert.Equal(Resp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, server.Protocol); + Assert.Equal(ExpectedProtocol, server.Protocol); server.FlushDatabase(dbId); for (int i = 0; i < 100; i++) { From a3feb31000313dfec27220f71655f896ef6b822d Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Tue, 22 Aug 2023 10:47:26 -0400 Subject: [PATCH 12/24] Fix test logging --- tests/StackExchange.Redis.Tests/Resp3Tests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/Resp3Tests.cs b/tests/StackExchange.Redis.Tests/Resp3Tests.cs index 6d033147b..548d51a92 100644 --- a/tests/StackExchange.Redis.Tests/Resp3Tests.cs +++ b/tests/StackExchange.Redis.Tests/Resp3Tests.cs @@ -369,17 +369,17 @@ public async Task CheckCommandResult(string command, bool useResp3, ResultType r break; case STR_DAVE: var scontent = result.ToString(); - LogNoTime(scontent); + Log(scontent); Assert.NotNull(scontent); var isExpectedContent = scontent.StartsWith("Dave, ") || scontent.StartsWith("I'm sorry, Dave"); Assert.True(isExpectedContent); - LogNoTime(scontent); + Log(scontent); scontent = result.ToString(out var type); Assert.NotNull(scontent); isExpectedContent = scontent.StartsWith("Dave, ") || scontent.StartsWith("I'm sorry, Dave"); Assert.True(isExpectedContent); - LogNoTime(scontent); + Log(scontent); if (useResp3) { Assert.Equal("txt", type); From e592e0155336e6c4bb871c4399750c03a5903ea8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 23 Aug 2023 11:36:46 +0100 Subject: [PATCH 13/24] deal with problems with order of tests breaking connections; don't let the fixture hold broken connections --- src/StackExchange.Redis/PhysicalBridge.cs | 2 +- src/StackExchange.Redis/PhysicalConnection.cs | 12 ++++---- src/StackExchange.Redis/ResultProcessor.cs | 8 +---- src/StackExchange.Redis/ServerEndPoint.cs | 12 ++++---- .../Helpers/ProtocolDependentFixture.cs | 29 +++++++++++++++---- tests/StackExchange.Redis.Tests/Resp3Tests.cs | 4 ++- 6 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 3643942b2..3a4494821 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -116,7 +116,7 @@ public enum State : byte public RedisCommand LastCommand { get; private set; } /// - /// If we have completed handshake, report the actual protocol; if we're not sure, report null + /// If we have a connection, report the protocol being used /// public RedisProtocol? Protocol => physical?.Protocol; diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index d1749e2e1..4e37f34be 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -259,10 +259,12 @@ private enum ReadMode : byte public bool TransactionActive { get; internal set; } - public RedisProtocol? Protocol => _knownProtocol == 0 ? null : _knownProtocol; // advertise null if we haven't completed handshake - public void SetProtocol(RedisProtocol value) => _knownProtocol = value; - private RedisProtocol _knownProtocol; // note that this defaults to ZERO, not 2, until the handshake; just to avoid some storage; - + private RedisProtocol _protocol = RedisProtocol.Resp2; // all connections start as RESP2 + public RedisProtocol Protocol + { + get => _protocol; + set => _protocol = value; + } [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times")] internal void Shutdown() @@ -1826,7 +1828,7 @@ private int ProcessBuffer(ref ReadOnlySequence buffer) { _readStatus = ReadStatus.TryParseResult; var reader = new BufferReader(buffer); - var result = TryParseResult(_knownProtocol >= RedisProtocol.Resp3, _arena, in buffer, ref reader, IncludeDetailInExceptions, this); + var result = TryParseResult(_protocol >= RedisProtocol.Resp3, _arena, in buffer, ref reader, IncludeDetailInExceptions, this); try { if (result.HasValue) diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 6fd229af1..968c79c73 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -961,7 +961,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } else if (key.IsEqual(CommonReplies.proto) && val.TryGetInt64(out var i64)) { - connection.SetProtocol(i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); + connection.Protocol = (i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (HELLO) protocol: {connection.Protocol}"); } else if (key.IsEqual(CommonReplies.id) && val.TryGetInt64(out i64)) @@ -2620,12 +2620,6 @@ public override bool SetResult(PhysicalConnection connection, Message message, i } } - if (connection.Protocol is null) - { - // if we didn't get a valid response from HELLO, then we have to assume RESP2 at some point - connection.SetProtocol(RedisProtocol.Resp2); - } - return final; } diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 3049ecf7a..d9c6896ec 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -207,6 +207,9 @@ public Version Version set => SetConfig(ref version, value); } + /// + /// If we have a connection (interactive), report the protocol being used + /// public RedisProtocol? Protocol => interactive?.Protocol; public int WriteEverySeconds @@ -968,13 +971,8 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) hello.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, hello, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); - // note that we don't know the actual protocol yet; this still could be RESP2 if either HELLO isn't supported/reports an error, - // or if the server negotiation says "I understand HELLO, but we're talking RESP2" - } - else - { - // if we're not even issuing HELLO, we're RESP2 - connection.SetProtocol(RedisProtocol.Resp2); + // note that the server can reject RESP3 via either an -ERR response (HELLO not understood), or by simply saying "nope", + // so we don't set the actual .Protocol until we process the result of the HELLO request } // note: we auth EVEN IF we have used HELLO to AUTH; because otherwise the fallback/detection path is pure hell, diff --git a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs index b0aff441b..6f592953a 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; using Xunit; using Xunit.Abstractions; @@ -18,14 +19,32 @@ internal IInternalConnectionMultiplexer GetConnection(TestBase obj, bool useResp Version? require = useResp3 ? RedisFeatures.v6_0_0 : null; lock (this) { - if (useResp3) - { - return resp3 ??= new NonDisposingConnection(obj.Create(protocol: RedisProtocol.Resp3, require: require, caller: caller, shared: false, allowAdmin: true)); + ref NonDisposingConnection? field = ref useResp3 ? ref resp3 : ref resp2; + if (field is { IsConnected: false}) + { // abandon memoized connection if disconnected + var muxer = field.UnderlyingMultiplexer; + field = null; + muxer.Dispose(); } - else + var protocol = useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; + return field ??= VerifyAndWrap(obj.Create(protocol: protocol, require: require, caller: caller, shared: false, allowAdmin: true), useResp3); + } + + static NonDisposingConnection VerifyAndWrap(IInternalConnectionMultiplexer muxer, bool useResp3) + { + var ep = muxer.GetEndPoints().FirstOrDefault(); + Assert.NotNull(ep); + var server = muxer.GetServer(ep); + server.Ping(); + var sep = muxer.GetServerEndPoint(ep); + if (sep.Protocol is null) { - return resp2 ??= new NonDisposingConnection(obj.Create(protocol: RedisProtocol.Resp2, require: require, caller: caller, shared: false, allowAdmin: true)); + throw new InvalidOperationException("No RESP protocol; this means no connection?"); } + var expected = useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; + Assert.Equal(expected, sep.Protocol); + Assert.Equal(expected, server.Protocol); + return new NonDisposingConnection(muxer); } } diff --git a/tests/StackExchange.Redis.Tests/Resp3Tests.cs b/tests/StackExchange.Redis.Tests/Resp3Tests.cs index 548d51a92..401edd632 100644 --- a/tests/StackExchange.Redis.Tests/Resp3Tests.cs +++ b/tests/StackExchange.Redis.Tests/Resp3Tests.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Xunit; @@ -172,6 +173,7 @@ public async Task CheckLuaResult(string script, bool useResp3, ResultType resp2, { Skip.Inconclusive("debug protocol not available"); } + if (ep.Protocol is null) throw new InvalidOperationException($"No protocol! {ep.InteractiveConnectionState}"); Assert.Equal(useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, ep.Protocol); var db = muxer.GetDatabase(); From 951ee96282d179ea67d7c1d6a21142134e4ee3fb Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 23 Aug 2023 12:48:57 +0100 Subject: [PATCH 14/24] fix (or rather: re-fix) not connecting sub connection in RESP3 --- src/StackExchange.Redis/PhysicalConnection.cs | 9 +++------ src/StackExchange.Redis/ResultProcessor.cs | 8 +++++++- src/StackExchange.Redis/ServerEndPoint.cs | 5 +++++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 4e37f34be..22c9d2894 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -259,12 +259,9 @@ private enum ReadMode : byte public bool TransactionActive { get; internal set; } - private RedisProtocol _protocol = RedisProtocol.Resp2; // all connections start as RESP2 - public RedisProtocol Protocol - { - get => _protocol; - set => _protocol = value; - } + private RedisProtocol _protocol; // note starts at **zero**, not RESP2 + public RedisProtocol? Protocol => _protocol == 0 ? null : _protocol; + internal void SetProtocol(RedisProtocol value) => _protocol = value; [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times")] internal void Shutdown() diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 968c79c73..6fd229af1 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -961,7 +961,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } else if (key.IsEqual(CommonReplies.proto) && val.TryGetInt64(out var i64)) { - connection.Protocol = (i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); + connection.SetProtocol(i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (HELLO) protocol: {connection.Protocol}"); } else if (key.IsEqual(CommonReplies.id) && val.TryGetInt64(out i64)) @@ -2620,6 +2620,12 @@ public override bool SetResult(PhysicalConnection connection, Message message, i } } + if (connection.Protocol is null) + { + // if we didn't get a valid response from HELLO, then we have to assume RESP2 at some point + connection.SetProtocol(RedisProtocol.Resp2); + } + return final; } diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index d9c6896ec..659e5591b 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -974,6 +974,11 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) // note that the server can reject RESP3 via either an -ERR response (HELLO not understood), or by simply saying "nope", // so we don't set the actual .Protocol until we process the result of the HELLO request } + else + { + // if we're not even issuing HELLO, we're RESP2 + connection.SetProtocol(RedisProtocol.Resp2); + } // note: we auth EVEN IF we have used HELLO to AUTH; because otherwise the fallback/detection path is pure hell, // and: we're pipelined here, so... meh From 117b93f128f934705f9f792f470dd2823c6f0bc2 Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Wed, 23 Aug 2023 13:53:21 -0400 Subject: [PATCH 15/24] Tests: Add support for [RunPerProtocol] and simplify multi-protocol testing This is a simpler approach to duplicating tests, but also fixes many - when changing things I noticed several base classes were incorrect so we silently dropped many tests in the PR inadvertently. --- Directory.Packages.props | 4 +- tests/StackExchange.Redis.Tests/BitTests.cs | 14 +- .../StackExchange.Redis.Tests/ClusterTests.cs | 24 +-- .../StackExchange.Redis.Tests/ConfigTests.cs | 21 +- .../ConnectToUnexistingHostTests.cs | 2 +- tests/StackExchange.Redis.Tests/EnvoyTests.cs | 4 +- .../EventArgsTests.cs | 14 +- .../StackExchange.Redis.Tests/ExpiryTests.cs | 2 +- .../FailoverTests.cs | 4 +- tests/StackExchange.Redis.Tests/GeoTests.cs | 29 +-- tests/StackExchange.Redis.Tests/HashTests.cs | 14 +- .../Helpers/Attributes.cs | 100 ++++++++- .../Helpers/Extensions.cs | 4 +- .../Helpers/IRedisTest.cs | 8 + .../Helpers/ProtocolDependentFixture.cs | 116 ----------- .../Helpers/SharedConnectionFixture.cs | 38 +++- .../Helpers/TestContext.cs | 20 ++ .../HyperLogLogTests.cs | 14 +- .../Issues/SO10504853Tests.cs | 2 +- tests/StackExchange.Redis.Tests/KeyTests.cs | 14 +- tests/StackExchange.Redis.Tests/ListTests.cs | 15 +- .../OverloadCompatTests.cs | 1 + .../ProfilingTests.cs | 2 +- .../PubSubCommandTests.cs | 14 +- .../PubSubMultiserverTests.cs | 18 +- .../StackExchange.Redis.Tests/PubSubTests.cs | 14 +- .../{Resp3Tests.cs => RespProtocolTests.cs} | 192 +++++++++--------- tests/StackExchange.Redis.Tests/SSLTests.cs | 2 +- tests/StackExchange.Redis.Tests/ScanTests.cs | 17 +- .../ScriptingTests.cs | 16 +- .../ServerSnapshotTests.cs | 3 + tests/StackExchange.Redis.Tests/SetTests.cs | 15 +- .../SortedSetTests.cs | 20 +- .../StackExchange.Redis.Tests/StreamTests.cs | 15 +- .../StackExchange.Redis.Tests/StringTests.cs | 15 +- tests/StackExchange.Redis.Tests/TestBase.cs | 36 ++-- .../TransactionTests.cs | 15 +- .../xunit.runner.json | 2 +- 38 files changed, 387 insertions(+), 473 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/Helpers/IRedisTest.cs delete mode 100644 tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs create mode 100644 tests/StackExchange.Redis.Tests/Helpers/TestContext.cs rename tests/StackExchange.Redis.Tests/{Resp3Tests.cs => RespProtocolTests.cs} (60%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2304eb77e..2280f9df2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,7 @@ - - + + \ No newline at end of file diff --git a/tests/StackExchange.Redis.Tests/BitTests.cs b/tests/StackExchange.Redis.Tests/BitTests.cs index 61a1b3012..1a870f37e 100644 --- a/tests/StackExchange.Redis.Tests/BitTests.cs +++ b/tests/StackExchange.Redis.Tests/BitTests.cs @@ -3,17 +3,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2BitTests : BitTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class BitTests : TestBase { - public Resp2BitTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3BitTests : BitTests -{ - public Resp3BitTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} -public abstract class BitTests : ProtocolFixedTestBase -{ - public BitTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base (output, fixture, resp3) { } + public BitTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } [Fact] public void BasicOps() diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index 65e8725ed..ec97cd706 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -11,19 +11,11 @@ namespace StackExchange.Redis.Tests; - -public class Resp2ClusterTests : ClusterTests -{ - public Resp2ClusterTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3ClusterTests : ClusterTests -{ - public Resp3ClusterTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class ClusterTests : ProtocolFixedTestBase +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class ClusterTests : TestBase { - public ClusterTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public ClusterTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; @@ -59,7 +51,7 @@ public void ConnectUsesSingleSocket() var srv = conn.GetServer(ep); var counters = srv.GetCounters(); Assert.Equal(1, counters.Interactive.SocketCount); - Assert.Equal(Resp3 ? 0 : 1, counters.Subscription.SocketCount); + Assert.Equal(Context.IsResp3 ? 0 : 1, counters.Subscription.SocketCount); } } } @@ -127,7 +119,7 @@ public void Connect() { Log(fail.ToString()); } - Assert.True(false, "not all servers connected"); + Assert.Fail("not all servers connected"); } Assert.Equal(TestConfig.Current.ClusterServerCount / 2, replicas); @@ -248,7 +240,7 @@ public void TransactionWithMultiServerKeys() _ = tran.StringSetAsync(y, "y-val"); tran.Execute(); - Assert.True(false, "Expected single-slot rules to apply"); + Assert.Fail("Expected single-slot rules to apply"); // the rest no longer applies while we are following single-slot rules //// check that everything was aborted @@ -305,7 +297,7 @@ public void TransactionWithSameServerKeys() _ = tran.StringSetAsync(y, "y-val"); tran.Execute(); - Assert.True(false, "Expected single-slot rules to apply"); + Assert.Fail("Expected single-slot rules to apply"); // the rest no longer applies while we are following single-slot rules //// check that everything was aborted diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index e24c4c7da..84a8f916b 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -16,18 +16,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2ConfigTests : ConfigTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class ConfigTests : TestBase { - public Resp2ConfigTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3ConfigTests : ConfigTests -{ - public Resp3ConfigTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class ConfigTests : ProtocolFixedTestBase -{ - public ConfigTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public ConfigTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } public Version DefaultVersion = new (3, 0, 0); public Version DefaultAzureVersion = new (4, 0, 0); @@ -256,7 +249,7 @@ public void ClientName() [Fact] public void DefaultClientName() { - using var conn = Create(allowAdmin: true, caller: null, shared: false); // force default naming to kick in + using var conn = Create(allowAdmin: true, caller: "", shared: false); // force default naming to kick in Assert.Equal($"{Environment.MachineName}(SE.Redis-v{Utils.GetLibVersion()})", conn.ClientName); var db = conn.GetDatabase(); @@ -284,7 +277,7 @@ public void ConnectWithSubscribeDisabled() Assert.True(conn.IsConnected); var servers = conn.GetServerSnapshot(); Assert.True(servers[0].IsConnected); - if (!Resp3) + if (!Context.IsResp3) { Assert.False(servers[0].IsSubscriberConnected); } @@ -405,7 +398,7 @@ public void GetClients() var self = clients.First(x => x.Id == id); if (server.Version.Major >= 7) { - Assert.Equal(ExpectedProtocol, self.Protocol); + Assert.Equal(Context.Test.Protocol, self.Protocol); } else { diff --git a/tests/StackExchange.Redis.Tests/ConnectToUnexistingHostTests.cs b/tests/StackExchange.Redis.Tests/ConnectToUnexistingHostTests.cs index 35767d753..a8bfe69b0 100644 --- a/tests/StackExchange.Redis.Tests/ConnectToUnexistingHostTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectToUnexistingHostTests.cs @@ -29,7 +29,7 @@ public async Task FailsWithinTimeout() await Task.Delay(10000).ForAwait(); } - Assert.True(false, "Connect should fail with RedisConnectionException exception"); + Assert.Fail("Connect should fail with RedisConnectionException exception"); } catch (RedisConnectionException) { diff --git a/tests/StackExchange.Redis.Tests/EnvoyTests.cs b/tests/StackExchange.Redis.Tests/EnvoyTests.cs index fac91496c..5015a660d 100644 --- a/tests/StackExchange.Redis.Tests/EnvoyTests.cs +++ b/tests/StackExchange.Redis.Tests/EnvoyTests.cs @@ -25,13 +25,13 @@ public void TestBasicEnvoyConnection() var db = conn.GetDatabase(); - const string key = "foobar"; + var key = Me() + "foobar"; const string value = "barfoo"; db.StringSet(key, value); var expectedVal = db.StringGet(key); - Assert.Equal(expectedVal, value); + Assert.Equal(value, expectedVal); } catch (TimeoutException ex) when (ex.Message == "Connect timeout" || sb.ToString().Contains("Returned, but incorrectly")) { diff --git a/tests/StackExchange.Redis.Tests/EventArgsTests.cs b/tests/StackExchange.Redis.Tests/EventArgsTests.cs index 27245f1ec..74b5e369a 100644 --- a/tests/StackExchange.Redis.Tests/EventArgsTests.cs +++ b/tests/StackExchange.Redis.Tests/EventArgsTests.cs @@ -27,25 +27,25 @@ HashSlotMovedEventArgs hashSlotMovedArgsMock DiagnosticStub stub = new DiagnosticStub(); stub.ConfigurationChangedBroadcastHandler(default, endpointArgsMock); - Assert.Equal(stub.Message,DiagnosticStub.ConfigurationChangedBroadcastHandlerMessage); + Assert.Equal(DiagnosticStub.ConfigurationChangedBroadcastHandlerMessage, stub.Message); stub.ErrorMessageHandler(default, redisErrorArgsMock); - Assert.Equal(stub.Message, DiagnosticStub.ErrorMessageHandlerMessage); + Assert.Equal(DiagnosticStub.ErrorMessageHandlerMessage, stub.Message); stub.ConnectionFailedHandler(default, connectionFailedArgsMock); - Assert.Equal(stub.Message, DiagnosticStub.ConnectionFailedHandlerMessage); + Assert.Equal(DiagnosticStub.ConnectionFailedHandlerMessage, stub.Message); stub.InternalErrorHandler(default, internalErrorArgsMock); - Assert.Equal(stub.Message, DiagnosticStub.InternalErrorHandlerMessage); + Assert.Equal(DiagnosticStub.InternalErrorHandlerMessage, stub.Message); stub.ConnectionRestoredHandler(default, connectionFailedArgsMock); - Assert.Equal(stub.Message, DiagnosticStub.ConnectionRestoredHandlerMessage); + Assert.Equal(DiagnosticStub.ConnectionRestoredHandlerMessage, stub.Message); stub.ConfigurationChangedHandler(default, endpointArgsMock); - Assert.Equal(stub.Message, DiagnosticStub.ConfigurationChangedHandlerMessage); + Assert.Equal(DiagnosticStub.ConfigurationChangedHandlerMessage, stub.Message); stub.HashSlotMovedHandler(default, hashSlotMovedArgsMock); - Assert.Equal(stub.Message, DiagnosticStub.HashSlotMovedHandlerMessage); + Assert.Equal(DiagnosticStub.HashSlotMovedHandlerMessage, stub.Message); } public class DiagnosticStub diff --git a/tests/StackExchange.Redis.Tests/ExpiryTests.cs b/tests/StackExchange.Redis.Tests/ExpiryTests.cs index 305bab944..d69ab53d5 100644 --- a/tests/StackExchange.Redis.Tests/ExpiryTests.cs +++ b/tests/StackExchange.Redis.Tests/ExpiryTests.cs @@ -149,7 +149,7 @@ public void KeyExpiryTime(bool disablePTimes) var time = db.KeyExpireTime(key); Assert.NotNull(time); - Assert.Equal(expireTime, time.Value, TimeSpan.FromSeconds(30)); + Assert.Equal(expireTime, time!.Value, TimeSpan.FromSeconds(30)); // Without associated expiration time db.KeyDelete(key, CommandFlags.FireAndForget); diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index da7cbf80a..2449e6a4b 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -1,4 +1,5 @@ -using System; +#if NET6_0_OR_GREATER +using System; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -449,3 +450,4 @@ public async Task SubscriptionsSurvivePrimarySwitchAsync() } #endif } +#endif diff --git a/tests/StackExchange.Redis.Tests/GeoTests.cs b/tests/StackExchange.Redis.Tests/GeoTests.cs index b94d259e2..f1be0bad1 100644 --- a/tests/StackExchange.Redis.Tests/GeoTests.cs +++ b/tests/StackExchange.Redis.Tests/GeoTests.cs @@ -5,18 +5,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2GeoTests : StringTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class GeoTests : TestBase { - public Resp2GeoTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3GeoTests : StringTests -{ - public Resp3GeoTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class GeoTests : ProtocolFixedTestBase -{ - public GeoTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base (output, fixture, resp3) { } + public GeoTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } private static readonly GeoEntry palermo = new GeoEntry(13.361389, 38.115556, "Palermo"), @@ -47,8 +40,8 @@ public void GeoAdd() // Validate var pos = db.GeoPosition(key, palermo.Member); Assert.NotNull(pos); - Assert.Equal(palermo.Longitude, pos.Value.Longitude, 5); - Assert.Equal(palermo.Latitude, pos.Value.Latitude, 5); + Assert.Equal(palermo.Longitude, pos!.Value.Longitude, 5); + Assert.Equal(palermo.Latitude, pos!.Value.Latitude, 5); } [Fact] @@ -149,18 +142,18 @@ public void GeoRadius() Assert.Equal(0, results[0].Distance); var position0 = results[0].Position; Assert.NotNull(position0); - Assert.Equal(Math.Round(position0.Value.Longitude, 5), Math.Round(cefalù.Position.Longitude, 5)); - Assert.Equal(Math.Round(position0.Value.Latitude, 5), Math.Round(cefalù.Position.Latitude, 5)); + Assert.Equal(Math.Round(position0!.Value.Longitude, 5), Math.Round(cefalù.Position.Longitude, 5)); + Assert.Equal(Math.Round(position0!.Value.Latitude, 5), Math.Round(cefalù.Position.Latitude, 5)); Assert.False(results[0].Hash.HasValue); Assert.Equal(results[1].Member, palermo.Member); var distance1 = results[1].Distance; Assert.NotNull(distance1); - Assert.Equal(Math.Round(36.5319, 6), Math.Round(distance1.Value, 6)); + Assert.Equal(Math.Round(36.5319, 6), Math.Round(distance1!.Value, 6)); var position1 = results[1].Position; Assert.NotNull(position1); - Assert.Equal(Math.Round(position1.Value.Longitude, 5), Math.Round(palermo.Position.Longitude, 5)); - Assert.Equal(Math.Round(position1.Value.Latitude, 5), Math.Round(palermo.Position.Latitude, 5)); + Assert.Equal(Math.Round(position1!.Value.Longitude, 5), Math.Round(palermo.Position.Longitude, 5)); + Assert.Equal(Math.Round(position1!.Value.Latitude, 5), Math.Round(palermo.Position.Latitude, 5)); Assert.False(results[1].Hash.HasValue); results = db.GeoRadius(key, cefalù.Member, 60, GeoUnit.Miles, 2, Order.Ascending, GeoRadiusOptions.None); diff --git a/tests/StackExchange.Redis.Tests/HashTests.cs b/tests/StackExchange.Redis.Tests/HashTests.cs index 5cd87feee..34a2d12c1 100644 --- a/tests/StackExchange.Redis.Tests/HashTests.cs +++ b/tests/StackExchange.Redis.Tests/HashTests.cs @@ -8,20 +8,14 @@ namespace StackExchange.Redis.Tests; -public class Resp2HashTests : HashTests -{ - public Resp2HashTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3HashTests : HashTests -{ - public Resp3HashTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} /// /// Tests for . /// -public abstract class HashTests : ProtocolFixedTestBase +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class HashTests : TestBase { - public HashTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public HashTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public async Task TestIncrBy() diff --git a/tests/StackExchange.Redis.Tests/Helpers/Attributes.cs b/tests/StackExchange.Redis.Tests/Helpers/Attributes.cs index 2dce70904..6b72b659e 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/Attributes.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/Attributes.cs @@ -60,8 +60,21 @@ public class FactDiscoverer : Xunit.Sdk.FactDiscoverer { public FactDiscoverer(IMessageSink diagnosticMessageSink) : base(diagnosticMessageSink) { } - protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) - => new SkippableTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod); + public override IEnumerable Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + if (testMethod.Method.GetParameters().Any()) + { + return new[] { new ExecutionErrorTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, "[Fact] methods are not allowed to have parameters. Did you mean to use [Theory]?") }; + } + else if (testMethod.Method.IsGenericMethodDefinition) + { + return new[] { new ExecutionErrorTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, "[Fact] methods are not allowed to be generic.") }; + } + else + { + return testMethod.Expand(protocol => new SkippableTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, protocol: protocol)); + } + } } public class TheoryDiscoverer : Xunit.Sdk.TheoryDiscoverer @@ -69,29 +82,53 @@ public class TheoryDiscoverer : Xunit.Sdk.TheoryDiscoverer public TheoryDiscoverer(IMessageSink diagnosticMessageSink) : base(diagnosticMessageSink) { } protected override IEnumerable CreateTestCasesForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow) - => new[] { new SkippableTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, dataRow) }; + => testMethod.Expand(protocol => new SkippableTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, dataRow, protocol: protocol)); protected override IEnumerable CreateTestCasesForSkip(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, string skipReason) - => new[] { new SkippableTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod) }; + => testMethod.Expand(protocol => new SkippableTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, protocol: protocol)); protected override IEnumerable CreateTestCasesForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) - => new[] { new SkippableTheoryTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod) }; + => testMethod.Expand(protocol => new SkippableTheoryTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, protocol: protocol)); protected override IEnumerable CreateTestCasesForSkippedDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow, string skipReason) => new[] { new NamedSkippedDataRowTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, skipReason, dataRow) }; } -public class SkippableTestCase : XunitTestCase +public class SkippableTestCase : XunitTestCase, IRedisTest { + public RedisProtocol Protocol { get; set; } + public string ProtocolString => Protocol switch + { + RedisProtocol.Resp2 => "RESP2", + RedisProtocol.Resp3 => "RESP3", + _ => "UnknownProtocolFixMeeeeee" + }; + + protected override string GetUniqueID() => base.GetUniqueID() + ProtocolString; + protected override string GetDisplayName(IAttributeInfo factAttribute, string displayName) => - base.GetDisplayName(factAttribute, displayName).StripName(); + base.GetDisplayName(factAttribute, displayName).StripName() + "(" + ProtocolString + ")"; [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] public SkippableTestCase() { } - public SkippableTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod, object[]? testMethodArguments = null) + public SkippableTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod, object[]? testMethodArguments = null, RedisProtocol? protocol = null) : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) { + // TODO: Default RESP2 somewhere cleaner + Protocol = protocol ?? RedisProtocol.Resp2; + } + + public override void Serialize(IXunitSerializationInfo data) + { + data.AddValue(nameof(Protocol), (int)Protocol); + base.Serialize(data); + } + + public override void Deserialize(IXunitSerializationInfo data) + { + Protocol = (RedisProtocol)data.GetValue(nameof(Protocol)); + base.Deserialize(data); } public override async Task RunAsync( @@ -102,21 +139,28 @@ public override async Task RunAsync( CancellationTokenSource cancellationTokenSource) { var skipMessageBus = new SkippableMessageBus(messageBus); + TestBase.SetContext(new TestContext(this)); var result = await base.RunAsync(diagnosticMessageSink, skipMessageBus, constructorArguments, aggregator, cancellationTokenSource).ForAwait(); return result.Update(skipMessageBus); } } -public class SkippableTheoryTestCase : XunitTheoryTestCase +public class SkippableTheoryTestCase : XunitTheoryTestCase, IRedisTest { + public RedisProtocol Protocol { get; set; } + protected override string GetDisplayName(IAttributeInfo factAttribute, string displayName) => base.GetDisplayName(factAttribute, displayName).StripName(); [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] public SkippableTheoryTestCase() { } - public SkippableTheoryTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod) - : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod) { } + public SkippableTheoryTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod, RedisProtocol? protocol = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod) + { + // TODO: Default RESP2 somewhere cleaner + Protocol = protocol ?? RedisProtocol.Resp2; + } public override async Task RunAsync( IMessageSink diagnosticMessageSink, @@ -126,11 +170,21 @@ public override async Task RunAsync( CancellationTokenSource cancellationTokenSource) { var skipMessageBus = new SkippableMessageBus(messageBus); + TestBase.SetContext(new TestContext(this)); var result = await base.RunAsync(diagnosticMessageSink, skipMessageBus, constructorArguments, aggregator, cancellationTokenSource).ForAwait(); return result.Update(skipMessageBus); } } +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public class RunPerProtocol : Attribute +{ + public static RedisProtocol[] AllProtocols { get; } = new[] { RedisProtocol.Resp2, RedisProtocol.Resp3 }; + + public RedisProtocol[] Protocols { get; } + public RunPerProtocol(params RedisProtocol[] procotols) => Protocols = procotols ?? AllProtocols; +} + public class NamedSkippedDataRowTestCase : XunitSkippedDataRowTestCase { protected override string GetDisplayName(IAttributeInfo factAttribute, string displayName) => @@ -185,6 +239,30 @@ public static RunSummary Update(this RunSummary summary, SkippableMessageBus bus } return summary; } + + public static IEnumerable Expand(this ITestMethod testMethod, Func generator) + { + if ((testMethod.Method.GetCustomAttributes(typeof(RunPerProtocol)).FirstOrDefault() + ?? testMethod.TestClass.Class.GetCustomAttributes(typeof(RunPerProtocol)).FirstOrDefault()) is IAttributeInfo attr) + { + // params means not null but default empty + var protocols = attr.GetNamedArgument(nameof(RunPerProtocol.Protocols)); + if (protocols.Length == 0) + { + protocols = RunPerProtocol.AllProtocols; + } + var results = new List(); + foreach (var protocol in protocols) + { + results.Add(generator(protocol)); + } + return results; + } + else + { + return new[] { generator(RedisProtocol.Resp2) }; + } + } } /// diff --git a/tests/StackExchange.Redis.Tests/Helpers/Extensions.cs b/tests/StackExchange.Redis.Tests/Helpers/Extensions.cs index 25fd219ad..1d5f8f91c 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/Extensions.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/Extensions.cs @@ -17,11 +17,11 @@ static Extensions() #endif try { - VersionInfo += "\n Running on: " + RuntimeInformation.OSDescription; + VersionInfo += "\n Running on: " + RuntimeInformation.OSDescription; } catch (Exception) { - VersionInfo += "\n Failed to get OS version"; + VersionInfo += "\n Failed to get OS version"; } } diff --git a/tests/StackExchange.Redis.Tests/Helpers/IRedisTest.cs b/tests/StackExchange.Redis.Tests/Helpers/IRedisTest.cs new file mode 100644 index 000000000..76ea5bc1b --- /dev/null +++ b/tests/StackExchange.Redis.Tests/Helpers/IRedisTest.cs @@ -0,0 +1,8 @@ +using Xunit.Sdk; + +namespace StackExchange.Redis.Tests; + +public interface IRedisTest : IXunitTestCase +{ + public RedisProtocol Protocol { get; set; } +} diff --git a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs deleted file mode 100644 index 6f592953a..000000000 --- a/tests/StackExchange.Redis.Tests/Helpers/ProtocolDependentFixture.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using Xunit; -using Xunit.Abstractions; -using static StackExchange.Redis.Tests.SharedConnectionFixture; - -namespace StackExchange.Redis.Tests; - - -public class ProtocolDependentFixture : IDisposable // without this, test perf is intolerable -{ - public const string Key = nameof(ProtocolDependentFixture); - - private NonDisposingConnection? resp2, resp3; - internal IInternalConnectionMultiplexer GetConnection(TestBase obj, bool useResp3, [CallerMemberName] string caller = "") - { - Version? require = useResp3 ? RedisFeatures.v6_0_0 : null; - lock (this) - { - ref NonDisposingConnection? field = ref useResp3 ? ref resp3 : ref resp2; - if (field is { IsConnected: false}) - { // abandon memoized connection if disconnected - var muxer = field.UnderlyingMultiplexer; - field = null; - muxer.Dispose(); - } - var protocol = useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; - return field ??= VerifyAndWrap(obj.Create(protocol: protocol, require: require, caller: caller, shared: false, allowAdmin: true), useResp3); - } - - static NonDisposingConnection VerifyAndWrap(IInternalConnectionMultiplexer muxer, bool useResp3) - { - var ep = muxer.GetEndPoints().FirstOrDefault(); - Assert.NotNull(ep); - var server = muxer.GetServer(ep); - server.Ping(); - var sep = muxer.GetServerEndPoint(ep); - if (sep.Protocol is null) - { - throw new InvalidOperationException("No RESP protocol; this means no connection?"); - } - var expected = useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; - Assert.Equal(expected, sep.Protocol); - Assert.Equal(expected, server.Protocol); - return new NonDisposingConnection(muxer); - } - } - - public void Dispose() - { - resp2?.UnderlyingConnection?.Dispose(); - resp3?.UnderlyingConnection?.Dispose(); - } -} - -[Collection(ProtocolDependentFixture.Key)] -public abstract class ProtocolDependentTestBase : TestBase // ProtocolDependentFixture has separate access to resp2/resp3 connections -{ - public ProtocolDependentTestBase(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output) - => Fixture = fixture; - - public ProtocolDependentFixture Fixture { get; } -} - - -public abstract class ProtocolFixedTestBase : ProtocolDependentTestBase // extends that cability to apply/enforce correct RESP during Create -{ - public override string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) - => (Resp3 ? "R3:" : "R2:") + base.Me(filePath, caller); - - public ProtocolFixedTestBase(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture) - => Resp3 = resp3; - - public bool Resp3 { get; } - - public RedisProtocol ExpectedProtocol => Resp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2; - - internal new IInternalConnectionMultiplexer Create(string? clientName = null, int? syncTimeout = null, bool? allowAdmin = null, int? keepAlive = null, int? connectTimeout = null, string? password = null, string? tieBreaker = null, TextWriter? log = null, bool fail = true, string[]? disabledCommands = null, string[]? enabledCommands = null, bool checkConnect = true, string? failMessage = null, string? channelPrefix = null, Proxy? proxy = null, string? configuration = null, bool logTransactionData = true, bool shared = true, int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, Version? require = null, RedisProtocol? protocol = null, [CallerMemberName] string? caller = null) - { - if (protocol is not null) - { - Assert.True(Resp3 && protocol != RedisProtocol.Resp3, "Test is demanding incorrect RESP"); - } - protocol = ExpectedProtocol; - if (shared && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy, protocol: null)) // we're handling protocol manually - { - // can use the fixture's *pair* of resp clients - var conn = Fixture.GetConnection(this, Resp3, caller!); - ThrowIfIncorrectProtocol(conn, protocol); - ThrowIfBelowMinVersion(conn, require); - return conn; - } - else - { - if (Resp3 && (require is null || !require.IsAtLeast(RedisFeatures.v6_0_0))) - { - require = RedisFeatures.v6_0_0; - } - return base.Create(clientName, syncTimeout, allowAdmin, keepAlive, connectTimeout, password, tieBreaker, log, fail, disabledCommands, enabledCommands, checkConnect, failMessage, channelPrefix, proxy, configuration, logTransactionData, shared, - defaultDatabase, backlogPolicy, require, protocol, caller); - } - } -} - -/// -/// See . -/// -[CollectionDefinition(ProtocolDependentFixture.Key)] -public class ProtocolDependentCollection : ICollectionFixture -{ - // This class has no code, and is never created. Its purpose is simply - // to be the place to apply [CollectionDefinition] and all the - // ICollectionFixture<> interfaces. -} diff --git a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs index 3a664fd50..c88c0ec4d 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using StackExchange.Redis.Maintenance; @@ -18,7 +19,6 @@ public class SharedConnectionFixture : IDisposable public const string Key = "Shared Muxer"; private readonly ConnectionMultiplexer _actualConnection; - internal IInternalConnectionMultiplexer Connection { get; } public string Configuration { get; } public SharedConnectionFixture() @@ -33,8 +33,39 @@ public SharedConnectionFixture() ); _actualConnection.InternalError += OnInternalError; _actualConnection.ConnectionFailed += OnConnectionFailed; + } + + private NonDisposingConnection? resp2, resp3; + internal IInternalConnectionMultiplexer GetConnection(TestBase obj, RedisProtocol protocol, [CallerMemberName] string caller = "") + { + Version? require = protocol == RedisProtocol.Resp3 ? RedisFeatures.v6_0_0 : null; + lock (this) + { + ref NonDisposingConnection? field = ref protocol == RedisProtocol.Resp3 ? ref resp3 : ref resp2; + if (field is { IsConnected: false }) + { // abandon memoized connection if disconnected + var muxer = field.UnderlyingMultiplexer; + field = null; + muxer.Dispose(); + } + return field ??= VerifyAndWrap(obj.Create(protocol: protocol, require: require, caller: caller, shared: false, allowAdmin: true), protocol); + } - Connection = new NonDisposingConnection(_actualConnection); + static NonDisposingConnection VerifyAndWrap(IInternalConnectionMultiplexer muxer, RedisProtocol protocol) + { + var ep = muxer.GetEndPoints().FirstOrDefault(); + Assert.NotNull(ep); + var server = muxer.GetServer(ep); + server.Ping(); + var sep = muxer.GetServerEndPoint(ep); + if (sep.Protocol is null) + { + throw new InvalidOperationException("No RESP protocol; this means no connection?"); + } + Assert.Equal(protocol, sep.Protocol); + Assert.Equal(protocol, server.Protocol); + return new NonDisposingConnection(muxer); + } } internal sealed class NonDisposingConnection : IInternalConnectionMultiplexer @@ -201,7 +232,8 @@ public void ExportConfiguration(Stream destination, ExportOptions options = Expo public void Dispose() { - _actualConnection.Dispose(); + resp2?.UnderlyingConnection?.Dispose(); + resp3?.UnderlyingConnection?.Dispose(); GC.SuppressFinalize(this); } diff --git a/tests/StackExchange.Redis.Tests/Helpers/TestContext.cs b/tests/StackExchange.Redis.Tests/Helpers/TestContext.cs new file mode 100644 index 000000000..799f753b4 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/Helpers/TestContext.cs @@ -0,0 +1,20 @@ +namespace StackExchange.Redis.Tests; + +public class TestContext +{ + public IRedisTest Test { get; set; } + + public bool IsResp2 => Test.Protocol == RedisProtocol.Resp2; + public bool IsResp3 => Test.Protocol == RedisProtocol.Resp3; + + public string KeySuffix => Test.Protocol switch + { + RedisProtocol.Resp2 => "R2", + RedisProtocol.Resp3 => "R3", + _ => "", + }; + + public TestContext(IRedisTest test) => Test = test; + + public override string ToString() => $"Protocol: {Test.Protocol}"; +} diff --git a/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs b/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs index d78cc3764..e0451e9c5 100644 --- a/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs +++ b/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs @@ -3,17 +3,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2HyperLogLogTests : HyperLogLogTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class HyperLogLogTests : TestBase { - public Resp2HyperLogLogTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3HyperLogLogTests : HyperLogLogTests -{ - public Resp3HyperLogLogTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} -public abstract class HyperLogLogTests : ProtocolFixedTestBase -{ - public HyperLogLogTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public HyperLogLogTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void SingleKeyLength() diff --git a/tests/StackExchange.Redis.Tests/Issues/SO10504853Tests.cs b/tests/StackExchange.Redis.Tests/Issues/SO10504853Tests.cs index abf5cc3cc..ee2fd9bbc 100644 --- a/tests/StackExchange.Redis.Tests/Issues/SO10504853Tests.cs +++ b/tests/StackExchange.Redis.Tests/Issues/SO10504853Tests.cs @@ -75,7 +75,7 @@ public void ExecuteWithNonHashStartingPoint() try { db.Wait(taskResult); - Assert.True(false, "Should throw a WRONGTYPE"); + Assert.Fail("Should throw a WRONGTYPE"); } catch (AggregateException ex) { diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index 48df08b53..b0c028d56 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -9,17 +9,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2KeyTests : KeyTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class KeyTests : TestBase { - public Resp2KeyTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3KeyTests : KeyTests -{ - public Resp3KeyTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} -public abstract class KeyTests : ProtocolFixedTestBase -{ - public KeyTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public KeyTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void TestScan() diff --git a/tests/StackExchange.Redis.Tests/ListTests.cs b/tests/StackExchange.Redis.Tests/ListTests.cs index 1a522a129..5fdb5d60a 100644 --- a/tests/StackExchange.Redis.Tests/ListTests.cs +++ b/tests/StackExchange.Redis.Tests/ListTests.cs @@ -6,18 +6,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2ListTests : ListTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class ListTests : TestBase { - public Resp2ListTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3ListTests : ListTests -{ - public Resp3ListTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} -public abstract class ListTests : ProtocolFixedTestBase -{ - public ListTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } - + public ListTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void Ranges() diff --git a/tests/StackExchange.Redis.Tests/OverloadCompatTests.cs b/tests/StackExchange.Redis.Tests/OverloadCompatTests.cs index 91abbf5e9..e0b09d545 100644 --- a/tests/StackExchange.Redis.Tests/OverloadCompatTests.cs +++ b/tests/StackExchange.Redis.Tests/OverloadCompatTests.cs @@ -60,6 +60,7 @@ public async Task KeyExpire() await db.KeyExpireAsync(key, expireTime, when: when, flags: flags); } + [Fact] public async Task StringBitCount() { using var conn = Create(require: RedisFeatures.v2_6_0); diff --git a/tests/StackExchange.Redis.Tests/ProfilingTests.cs b/tests/StackExchange.Redis.Tests/ProfilingTests.cs index 52401c55a..7c4dcbe59 100644 --- a/tests/StackExchange.Redis.Tests/ProfilingTests.cs +++ b/tests/StackExchange.Redis.Tests/ProfilingTests.cs @@ -143,7 +143,7 @@ public void ManyThreads() Assert.Contains("SET", kinds); if (kinds.Count == 2 && !kinds.Contains("SELECT") && !kinds.Contains("GET")) { - Assert.True(false, "Non-SET, Non-SELECT, Non-GET command seen"); + Assert.Fail("Non-SET, Non-SELECT, Non-GET command seen"); } Assert.Equal(16 * CountPer, relevant.Count); diff --git a/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs b/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs index 27c2027d3..e689c980b 100644 --- a/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs @@ -7,17 +7,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2PubSubCommandTests : PubSubCommandTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class PubSubCommandTests : TestBase { - public Resp2PubSubCommandTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3PubSubCommandTests : PubSubCommandTests -{ - public Resp3PubSubCommandTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} -public abstract class PubSubCommandTests : ProtocolFixedTestBase -{ - public PubSubCommandTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public PubSubCommandTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void SubscriberCount() diff --git a/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs b/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs index 154fe4ad7..aa363984f 100644 --- a/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs @@ -6,17 +6,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2PubSubMultiserverTests : PubSubMultiserverTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class PubSubMultiserverTests : TestBase { - public Resp2PubSubMultiserverTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3PubSubMultiserverTests : PubSubMultiserverTests -{ - public Resp3PubSubMultiserverTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} -public abstract class PubSubMultiserverTests : ProtocolFixedTestBase -{ - public PubSubMultiserverTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public PubSubMultiserverTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; @@ -78,7 +72,7 @@ await sub.SubscribeAsync(channel, (_, val) => Log("Connected to: " + initialServer); conn.AllowConnect = false; - if (Resp3) + if (Context.IsResp3) { subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.All); @@ -160,7 +154,7 @@ await sub.SubscribeAsync(channel, (_, val) => Log("Connected to: " + initialServer); conn.AllowConnect = false; - if (Resp3) + if (Context.IsResp3) { subscribedServerEndpoint.SimulateConnectionFailure(SimulatedFailureType.All); // need to kill the main connection Assert.False(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs index 482b12add..697bf2771 100644 --- a/tests/StackExchange.Redis.Tests/PubSubTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubTests.cs @@ -12,17 +12,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2PubSubTests : PubSubTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class PubSubTests : TestBase { - public Resp2PubSubTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3PubSubTests : PubSubTests -{ - public Resp3PubSubTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} -public abstract class PubSubTests : ProtocolFixedTestBase -{ - public PubSubTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public PubSubTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public async Task ExplicitPublishMode() diff --git a/tests/StackExchange.Redis.Tests/Resp3Tests.cs b/tests/StackExchange.Redis.Tests/RespProtocolTests.cs similarity index 60% rename from tests/StackExchange.Redis.Tests/Resp3Tests.cs rename to tests/StackExchange.Redis.Tests/RespProtocolTests.cs index 401edd632..6c444e43c 100644 --- a/tests/StackExchange.Redis.Tests/Resp3Tests.cs +++ b/tests/StackExchange.Redis.Tests/RespProtocolTests.cs @@ -7,18 +7,17 @@ namespace StackExchange.Redis.Tests; -public sealed class Resp3Tests : ProtocolDependentTestBase +[Collection(SharedConnectionFixture.Key)] +public sealed class RespProtocolTests : TestBase { - public Resp3Tests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture) { } + public RespProtocolTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } - [Theory] - [InlineData(RedisProtocol.Resp2)] - [InlineData(RedisProtocol.Resp3)] - public async Task ConnectWithTiming(RedisProtocol protocol) + [Fact] + [RunPerProtocol] + public async Task ConnectWithTiming() { - using var conn = Create(protocol: protocol, shared: false, log: Writer); + using var conn = Create(shared: false, log: Writer); await conn.GetDatabase().PingAsync(); - } [Theory] @@ -74,20 +73,19 @@ public void ParseFormatConfigOptions(string configurationString, bool tryResp3, Assert.Equal(tryResp3, config.TryResp3()); } - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task TryConnect(bool useResp3) + [Fact] + [RunPerProtocol] + public async Task TryConnect() { - var muxer = Fixture.GetConnection(this, useResp3); + var muxer = Create(shared: false); await muxer.GetDatabase().PingAsync(); var server = muxer.GetServerEndPoint(muxer.GetEndPoints().Single()); - if (useResp3 && !server.GetFeatures().Resp3) + if (Context.IsResp3 && !server.GetFeatures().Resp3) { Skip.Inconclusive("server does not support RESP3"); } - if (useResp3) + if (Context.IsResp3) { Assert.Equal(RedisProtocol.Resp3, server.Protocol); } @@ -129,41 +127,41 @@ public async Task ConnectWithBrokenHello(string command, bool isResp3) } [Theory] - [InlineData("return 42", false, ResultType.Integer, ResultType.Integer, 42)] - [InlineData("return 'abc'", false, ResultType.BulkString, ResultType.BulkString, "abc")] - [InlineData(@"return {1,2,3}", false, ResultType.Array, ResultType.Array, ARR_123)] - [InlineData("return nil", false, ResultType.BulkString, ResultType.Null, null)] - [InlineData(@"return redis.pcall('hgetall', 'key')", false, ResultType.Array, ResultType.Array, MAP_ABC)] + [InlineData("return 42", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, 42)] + [InlineData("return 'abc'", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, "abc")] + [InlineData(@"return {1,2,3}", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, ARR_123)] + [InlineData("return nil", RedisProtocol.Resp2, ResultType.BulkString, ResultType.Null, null)] + [InlineData(@"return redis.pcall('hgetall', 'key')", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, MAP_ABC)] [InlineData(@"redis.setresp(3) -return redis.pcall('hgetall', 'key')", false, ResultType.Array, ResultType.Array, MAP_ABC)] - [InlineData("return true", false, ResultType.Integer, ResultType.Integer, 1)] - [InlineData("return false", false, ResultType.BulkString, ResultType.Null, null)] - [InlineData("redis.setresp(3) return true", false, ResultType.Integer, ResultType.Integer, 1)] - [InlineData("redis.setresp(3) return false", false, ResultType.Integer, ResultType.Integer, 0)] - - [InlineData("return { map = { a = 1, b = 2, c = 3 } }", false, ResultType.Array, ResultType.Array, MAP_ABC, 6)] - [InlineData("return { set = { a = 1, b = 2, c = 3 } }", false, ResultType.Array, ResultType.Array, SET_ABC, 6)] - [InlineData("return { double = 42 }", false, ResultType.BulkString, ResultType.BulkString, 42.0, 6)] - - [InlineData("return 42", true, ResultType.Integer, ResultType.Integer, 42)] - [InlineData("return 'abc'", true, ResultType.BulkString, ResultType.BulkString, "abc")] - [InlineData("return {1,2,3}", true, ResultType.Array, ResultType.Array, ARR_123)] - [InlineData("return nil", true, ResultType.BulkString, ResultType.Null, null)] - [InlineData(@"return redis.pcall('hgetall', 'key')", true, ResultType.Array, ResultType.Array, MAP_ABC)] +return redis.pcall('hgetall', 'key')", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, MAP_ABC)] + [InlineData("return true", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, 1)] + [InlineData("return false", RedisProtocol.Resp2, ResultType.BulkString, ResultType.Null, null)] + [InlineData("redis.setresp(3) return true", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, 1)] + [InlineData("redis.setresp(3) return false", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, 0)] + + [InlineData("return { map = { a = 1, b = 2, c = 3 } }", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, MAP_ABC, 6)] + [InlineData("return { set = { a = 1, b = 2, c = 3 } }", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, SET_ABC, 6)] + [InlineData("return { double = 42 }", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, 42.0, 6)] + + [InlineData("return 42", RedisProtocol.Resp3, ResultType.Integer, ResultType.Integer, 42)] + [InlineData("return 'abc'", RedisProtocol.Resp3, ResultType.BulkString, ResultType.BulkString, "abc")] + [InlineData("return {1,2,3}", RedisProtocol.Resp3, ResultType.Array, ResultType.Array, ARR_123)] + [InlineData("return nil", RedisProtocol.Resp3, ResultType.BulkString, ResultType.Null, null)] + [InlineData(@"return redis.pcall('hgetall', 'key')", RedisProtocol.Resp3, ResultType.Array, ResultType.Array, MAP_ABC)] [InlineData(@"redis.setresp(3) -return redis.pcall('hgetall', 'key')", true, ResultType.Array, ResultType.Map, MAP_ABC)] - [InlineData("return true", true, ResultType.Integer, ResultType.Integer, 1)] - [InlineData("return false", true, ResultType.BulkString, ResultType.Null, null)] - [InlineData("redis.setresp(3) return true", true, ResultType.Integer, ResultType.Boolean, true)] - [InlineData("redis.setresp(3) return false", true, ResultType.Integer, ResultType.Boolean, false)] - - [InlineData("return { map = { a = 1, b = 2, c = 3 } }", true, ResultType.Array, ResultType.Map, MAP_ABC, 6)] - [InlineData("return { set = { a = 1, b = 2, c = 3 } }", true, ResultType.Array, ResultType.Set, SET_ABC, 6)] - [InlineData("return { double = 42 }", true, ResultType.SimpleString, ResultType.Double, 42.0, 6)] - public async Task CheckLuaResult(string script, bool useResp3, ResultType resp2, ResultType resp3, object expected, int serverMin = 1) +return redis.pcall('hgetall', 'key')", RedisProtocol.Resp3, ResultType.Array, ResultType.Map, MAP_ABC)] + [InlineData("return true", RedisProtocol.Resp3, ResultType.Integer, ResultType.Integer, 1)] + [InlineData("return false", RedisProtocol.Resp3, ResultType.BulkString, ResultType.Null, null)] + [InlineData("redis.setresp(3) return true", RedisProtocol.Resp3, ResultType.Integer, ResultType.Boolean, true)] + [InlineData("redis.setresp(3) return false", RedisProtocol.Resp3, ResultType.Integer, ResultType.Boolean, false)] + + [InlineData("return { map = { a = 1, b = 2, c = 3 } }", RedisProtocol.Resp3, ResultType.Array, ResultType.Map, MAP_ABC, 6)] + [InlineData("return { set = { a = 1, b = 2, c = 3 } }", RedisProtocol.Resp3, ResultType.Array, ResultType.Set, SET_ABC, 6)] + [InlineData("return { double = 42 }", RedisProtocol.Resp3, ResultType.SimpleString, ResultType.Double, 42.0, 6)] + public async Task CheckLuaResult(string script, RedisProtocol protocol, ResultType resp2, ResultType resp3, object expected, int serverMin = 1) { // note Lua does not appear to return RESP3 types in any scenarios - var muxer = Fixture.GetConnection(this, useResp3); + var muxer = Create(protocol: protocol); var ep = muxer.GetServerEndPoint(muxer.GetEndPoints().Single()); if (serverMin > ep.Version.Major) { @@ -174,7 +172,7 @@ public async Task CheckLuaResult(string script, bool useResp3, ResultType resp2, Skip.Inconclusive("debug protocol not available"); } if (ep.Protocol is null) throw new InvalidOperationException($"No protocol! {ep.InteractiveConnectionState}"); - Assert.Equal(useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, ep.Protocol); + Assert.Equal(protocol, ep.Protocol); var db = muxer.GetDatabase(); if (expected is MAP_ABC) @@ -249,38 +247,38 @@ public async Task CheckLuaResult(string script, bool useResp3, ResultType resp2, //[InlineData("return true", true, ResultType.Integer, ResultType.Integer, 1)] - [InlineData("incrby", false, ResultType.Integer, ResultType.Integer, 42, "ikey", 2)] - [InlineData("incrby", true, ResultType.Integer, ResultType.Integer, 42, "ikey", 2)] - [InlineData("incrby", false, ResultType.Integer, ResultType.Integer, 2, "nkey", 2)] - [InlineData("incrby", true, ResultType.Integer, ResultType.Integer, 2, "nkey", 2)] + [InlineData("incrby", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, 42, "ikey", 2)] + [InlineData("incrby", RedisProtocol.Resp3, ResultType.Integer, ResultType.Integer, 42, "ikey", 2)] + [InlineData("incrby", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, 2, "nkey", 2)] + [InlineData("incrby", RedisProtocol.Resp3, ResultType.Integer, ResultType.Integer, 2, "nkey", 2)] - [InlineData("get", false, ResultType.BulkString, ResultType.BulkString, "40", "ikey")] - [InlineData("get", true, ResultType.BulkString, ResultType.BulkString, "40", "ikey")] - [InlineData("get", false, ResultType.BulkString, ResultType.Null, null, "nkey")] - [InlineData("get", true, ResultType.BulkString, ResultType.Null, null, "nkey")] + [InlineData("get", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, "40", "ikey")] + [InlineData("get", RedisProtocol.Resp3, ResultType.BulkString, ResultType.BulkString, "40", "ikey")] + [InlineData("get", RedisProtocol.Resp2, ResultType.BulkString, ResultType.Null, null, "nkey")] + [InlineData("get", RedisProtocol.Resp3, ResultType.BulkString, ResultType.Null, null, "nkey")] - [InlineData("smembers", false, ResultType.Array, ResultType.Array, SET_ABC, "skey")] - [InlineData("smembers", true, ResultType.Array, ResultType.Set, SET_ABC, "skey")] - [InlineData("smembers", false, ResultType.Array, ResultType.Array, EMPTY_ARR, "nkey")] - [InlineData("smembers", true, ResultType.Array, ResultType.Set, EMPTY_ARR, "nkey")] + [InlineData("smembers", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, SET_ABC, "skey")] + [InlineData("smembers", RedisProtocol.Resp3, ResultType.Array, ResultType.Set, SET_ABC, "skey")] + [InlineData("smembers", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, EMPTY_ARR, "nkey")] + [InlineData("smembers", RedisProtocol.Resp3, ResultType.Array, ResultType.Set, EMPTY_ARR, "nkey")] - [InlineData("hgetall", false, ResultType.Array, ResultType.Array, MAP_ABC, "hkey")] - [InlineData("hgetall", true, ResultType.Array, ResultType.Map, MAP_ABC, "hkey")] - [InlineData("hgetall", false, ResultType.Array, ResultType.Array, EMPTY_ARR, "nkey")] - [InlineData("hgetall", true, ResultType.Array, ResultType.Map, EMPTY_ARR, "nkey")] + [InlineData("hgetall", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, MAP_ABC, "hkey")] + [InlineData("hgetall", RedisProtocol.Resp3, ResultType.Array, ResultType.Map, MAP_ABC, "hkey")] + [InlineData("hgetall", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, EMPTY_ARR, "nkey")] + [InlineData("hgetall", RedisProtocol.Resp3, ResultType.Array, ResultType.Map, EMPTY_ARR, "nkey")] - [InlineData("sismember", false, ResultType.Integer, ResultType.Integer, true, "skey", "b")] - [InlineData("sismember", true, ResultType.Integer, ResultType.Integer, true, "skey", "b")] - [InlineData("sismember", false, ResultType.Integer, ResultType.Integer, false, "nkey", "b")] - [InlineData("sismember", true, ResultType.Integer, ResultType.Integer, false, "nkey", "b")] - [InlineData("sismember", false, ResultType.Integer, ResultType.Integer, false, "skey", "d")] - [InlineData("sismember", true, ResultType.Integer, ResultType.Integer, false, "skey", "d")] + [InlineData("sismember", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, true, "skey", "b")] + [InlineData("sismember", RedisProtocol.Resp3, ResultType.Integer, ResultType.Integer, true, "skey", "b")] + [InlineData("sismember", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, false, "nkey", "b")] + [InlineData("sismember", RedisProtocol.Resp3, ResultType.Integer, ResultType.Integer, false, "nkey", "b")] + [InlineData("sismember", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, false, "skey", "d")] + [InlineData("sismember", RedisProtocol.Resp3, ResultType.Integer, ResultType.Integer, false, "skey", "d")] - [InlineData("latency", false, ResultType.BulkString, ResultType.BulkString, STR_DAVE, "doctor")] - [InlineData("latency", true, ResultType.BulkString, ResultType.VerbatimString, STR_DAVE, "doctor")] + [InlineData("latency", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, STR_DAVE, "doctor")] + [InlineData("latency", RedisProtocol.Resp3, ResultType.BulkString, ResultType.VerbatimString, STR_DAVE, "doctor")] - [InlineData("incrbyfloat", false, ResultType.BulkString, ResultType.BulkString, 41.5, "ikey", 1.5)] - [InlineData("incrbyfloat", true, ResultType.BulkString, ResultType.BulkString, 41.5, "ikey", 1.5)] + [InlineData("incrbyfloat", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, 41.5, "ikey", 1.5)] + [InlineData("incrbyfloat", RedisProtocol.Resp3, ResultType.BulkString, ResultType.BulkString, 41.5, "ikey", 1.5)] /* DEBUG PROTOCOL * Reply with a test value of the specified type. can be: string, @@ -290,45 +288,45 @@ public async Task CheckLuaResult(string script, bool useResp3, ResultType resp2, * NOTE: "debug protocol" may be disabled in later default server configs; if this starts * failing when we upgrade the test server: update the config to re-enable the command */ - [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] - [InlineData("debug", true, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] - [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "double")] - [InlineData("debug", true, ResultType.SimpleString, ResultType.Double, ANY, "protocol", "double")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "double")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.SimpleString, ResultType.Double, ANY, "protocol", "double")] - [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "bignum")] - [InlineData("debug", true, ResultType.SimpleString, ResultType.BigInteger, ANY, "protocol", "bignum")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "bignum")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.SimpleString, ResultType.BigInteger, ANY, "protocol", "bignum")] - [InlineData("debug", false, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] - [InlineData("debug", true, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] - [InlineData("debug", false, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] - [InlineData("debug", true, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] - [InlineData("debug", false, ResultType.Array, ResultType.Array, ANY, "protocol", "set")] - [InlineData("debug", true, ResultType.Array, ResultType.Set, ANY, "protocol", "set")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, ANY, "protocol", "set")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Array, ResultType.Set, ANY, "protocol", "set")] - [InlineData("debug", false, ResultType.Array, ResultType.Array, ANY, "protocol", "map")] - [InlineData("debug", true, ResultType.Array, ResultType.Map, ANY, "protocol", "map")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, ANY, "protocol", "map")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Array, ResultType.Map, ANY, "protocol", "map")] - [InlineData("debug", false, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "verbatim")] - [InlineData("debug", true, ResultType.BulkString, ResultType.VerbatimString, ANY, "protocol", "verbatim")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "verbatim")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.BulkString, ResultType.VerbatimString, ANY, "protocol", "verbatim")] - [InlineData("debug", false, ResultType.Integer, ResultType.Integer, true, "protocol", "true")] - [InlineData("debug", true, ResultType.Integer, ResultType.Boolean, true, "protocol", "true")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, true, "protocol", "true")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Integer, ResultType.Boolean, true, "protocol", "true")] - [InlineData("debug", false, ResultType.Integer, ResultType.Integer, false, "protocol", "false")] - [InlineData("debug", true, ResultType.Integer, ResultType.Boolean, false, "protocol", "false")] + [InlineData("debug", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, false, "protocol", "false")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Integer, ResultType.Boolean, false, "protocol", "false")] - public async Task CheckCommandResult(string command, bool useResp3, ResultType resp2, ResultType resp3, object expected, params object[] args) + public async Task CheckCommandResult(string command, RedisProtocol protocol, ResultType resp2, ResultType resp3, object expected, params object[] args) { - var muxer = Fixture.GetConnection(this, useResp3); + var muxer = Create(protocol: protocol); var ep = muxer.GetServerEndPoint(muxer.GetEndPoints().Single()); if (command == "debug" && args.Length > 0 && args[0] is "protocol" && !ep.GetFeatures().Resp3 /* v6 check */ ) { Skip.Inconclusive("debug protocol not available"); } - Assert.Equal(useResp3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, ep.Protocol); + Assert.Equal(protocol, ep.Protocol); var db = muxer.GetDatabase(); if (args.Length > 0) @@ -382,7 +380,7 @@ public async Task CheckCommandResult(string command, bool useResp3, ResultType r isExpectedContent = scontent.StartsWith("Dave, ") || scontent.StartsWith("I'm sorry, Dave"); Assert.True(isExpectedContent); Log(scontent); - if (useResp3) + if (protocol == RedisProtocol.Resp3) { Assert.Equal("txt", type); } diff --git a/tests/StackExchange.Redis.Tests/SSLTests.cs b/tests/StackExchange.Redis.Tests/SSLTests.cs index 74c16e20c..87e589b97 100644 --- a/tests/StackExchange.Redis.Tests/SSLTests.cs +++ b/tests/StackExchange.Redis.Tests/SSLTests.cs @@ -334,7 +334,7 @@ public void RedisLabsEnvironmentVariableClientCertificate(bool setEnv) using var conn = ConnectionMultiplexer.Connect(options); RedisKey key = Me(); - if (!setEnv) Assert.True(false, "Could not set environment"); + if (!setEnv) Assert.Fail("Could not set environment"); var db = conn.GetDatabase(); db.KeyDelete(key, CommandFlags.FireAndForget); diff --git a/tests/StackExchange.Redis.Tests/ScanTests.cs b/tests/StackExchange.Redis.Tests/ScanTests.cs index ab47ab807..bcab2da4c 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -7,18 +7,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2ScanTests : StringTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class ScanTests : TestBase { - public Resp2ScanTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3ScanTests : StringTests -{ - public Resp3ScanTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class ScanTests : ProtocolFixedTestBase -{ - public ScanTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public ScanTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Theory] [InlineData(true)] @@ -32,7 +25,7 @@ public void KeysScan(bool supported) var db = conn.GetDatabase(dbId); var prefix = Me() + ":"; var server = GetServer(conn); - Assert.Equal(ExpectedProtocol, server.Protocol); + Assert.Equal(Context.Test.Protocol, server.Protocol); server.FlushDatabase(dbId); for (int i = 0; i < 100; i++) { diff --git a/tests/StackExchange.Redis.Tests/ScriptingTests.cs b/tests/StackExchange.Redis.Tests/ScriptingTests.cs index c3c35bfce..f34c94299 100644 --- a/tests/StackExchange.Redis.Tests/ScriptingTests.cs +++ b/tests/StackExchange.Redis.Tests/ScriptingTests.cs @@ -10,19 +10,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2ScriptingTests : ScriptingTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class ScriptingTests : TestBase { - public Resp2ScriptingTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3ScriptingTests : ScriptingTests -{ - public Resp3ScriptingTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - - -public abstract class ScriptingTests : ProtocolFixedTestBase -{ - public ScriptingTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public ScriptingTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } private IConnectionMultiplexer GetScriptConn(bool allowAdmin = false) { diff --git a/tests/StackExchange.Redis.Tests/ServerSnapshotTests.cs b/tests/StackExchange.Redis.Tests/ServerSnapshotTests.cs index 01688a337..ed1d995a5 100644 --- a/tests/StackExchange.Redis.Tests/ServerSnapshotTests.cs +++ b/tests/StackExchange.Redis.Tests/ServerSnapshotTests.cs @@ -9,6 +9,8 @@ namespace StackExchange.Redis.Tests; public class ServerSnapshotTests { [Fact] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Assertions", "xUnit2012:Do not use boolean check to check if a value exists in a collection", Justification = "Explicit testing")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Assertions", "xUnit2013:Do not use equality check to check for collection size.", Justification = "Explicit testing")] public void EmptyBehaviour() { var snapshot = ServerSnapshot.Empty; @@ -49,6 +51,7 @@ public void EmptyBehaviour() [InlineData(5, 0)] [InlineData(5, 3)] [InlineData(5, 5)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Assertions", "xUnit2012:Do not use boolean check to check if a value exists in a collection", Justification = "Explicit testing")] public void NonEmptyBehaviour(int count, int replicaCount) { var snapshot = ServerSnapshot.Empty; diff --git a/tests/StackExchange.Redis.Tests/SetTests.cs b/tests/StackExchange.Redis.Tests/SetTests.cs index ce0560e62..d90e4a8c3 100644 --- a/tests/StackExchange.Redis.Tests/SetTests.cs +++ b/tests/StackExchange.Redis.Tests/SetTests.cs @@ -6,18 +6,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2SetTests : SetTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class SetTests : TestBase { - public Resp2SetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3SetTests : SetTests -{ - public Resp3SetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class SetTests : ProtocolFixedTestBase -{ - public SetTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base (output, fixture, resp3) { } + public SetTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void SetContains() diff --git a/tests/StackExchange.Redis.Tests/SortedSetTests.cs b/tests/StackExchange.Redis.Tests/SortedSetTests.cs index ce91f110a..49c464142 100644 --- a/tests/StackExchange.Redis.Tests/SortedSetTests.cs +++ b/tests/StackExchange.Redis.Tests/SortedSetTests.cs @@ -5,19 +5,11 @@ namespace StackExchange.Redis.Tests; - -public class Resp2SortedSetTests : SortedSetTests -{ - public Resp2SortedSetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3SortedSetTests : SortedSetTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class SortedSetTests : TestBase { - public Resp3SortedSetTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class SortedSetTests : ProtocolFixedTestBase -{ - public SortedSetTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public SortedSetTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } private static readonly SortedSetEntry[] entries = new SortedSetEntry[] { @@ -362,7 +354,7 @@ public void SortedSetRangeViaExecute() var result = db.Execute("ZRANGE", new object[] { key, 0, -1, "WITHSCORES" }); - if (Resp3) + if (Context.IsResp3) { AssertJaggedArrayEntries(result); } @@ -1218,7 +1210,7 @@ public void SortedSetScoresSingle() var score = db.SortedSetScore(key, memberName); Assert.NotNull(score); - Assert.Equal((double)1.5, score.Value); + Assert.Equal((double)1.5, score); } [Fact] diff --git a/tests/StackExchange.Redis.Tests/StreamTests.cs b/tests/StackExchange.Redis.Tests/StreamTests.cs index c2637c93a..d203be38c 100644 --- a/tests/StackExchange.Redis.Tests/StreamTests.cs +++ b/tests/StackExchange.Redis.Tests/StreamTests.cs @@ -7,18 +7,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2StreamTests : StreamTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class StreamTests : TestBase { - public Resp2StreamTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3StreamTests : StreamTests -{ - public Resp3StreamTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class StreamTests : ProtocolFixedTestBase -{ - public StreamTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public StreamTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void IsStreamType() diff --git a/tests/StackExchange.Redis.Tests/StringTests.cs b/tests/StackExchange.Redis.Tests/StringTests.cs index 298a21990..23acf737f 100644 --- a/tests/StackExchange.Redis.Tests/StringTests.cs +++ b/tests/StackExchange.Redis.Tests/StringTests.cs @@ -8,21 +8,14 @@ namespace StackExchange.Redis.Tests; -public class Resp2StringTests : StringTests -{ - public Resp2StringTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3StringTests : StringTests -{ - public Resp3StringTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - /// /// Tests for . /// -public abstract class StringTests : ProtocolFixedTestBase +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class StringTests : TestBase { - public StringTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public StringTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public async Task Append() diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 532569674..2dfef45d6 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -22,6 +22,13 @@ public abstract class TestBase : IDisposable protected virtual string GetConfiguration() => GetDefaultConfiguration(); internal static string GetDefaultConfiguration() => TestConfig.Current.PrimaryServerAndPort; + /// + /// Gives the current TestContext, propulated by the runner (this type of thing will be built-in in xUnit 3.x) + /// + protected TestContext Context => _context.Value!; + private static readonly AsyncLocal _context = new(); + public static void SetContext(TestContext context) => _context.Value = context; + private readonly SharedConnectionFixture? _fixture; protected bool SharedFixtureAvailable => _fixture != null && _fixture.IsEnabled; @@ -30,6 +37,7 @@ protected TestBase(ITestOutputHelper output, SharedConnectionFixture? fixture = { Output = output; Output.WriteFrameworkVersion(); + Output.WriteLine(" Context: " + Context.ToString()); Writer = new TextWriterOutputHelper(output, TestConfig.Current.LogToConsole); _fixture = fixture; ClearAmbientFailures(); @@ -251,26 +259,30 @@ internal virtual IInternalConnectionMultiplexer Create( BacklogPolicy? backlogPolicy = null, Version? require = null, RedisProtocol? protocol = null, - [CallerMemberName] string? caller = null) + [CallerMemberName] string caller = "") { if (Output == null) { - Assert.True(false, "Failure: Be sure to call the TestBase constructor like this: BasicOpsTests(ITestOutputHelper output) : base(output) { }"); + Assert.Fail("Failure: Be sure to call the TestBase constructor like this: BasicOpsTests(ITestOutputHelper output) : base(output) { }"); } + // Default to protocol context if not explicitly passed in + protocol ??= Context.Test.Protocol; + // Share a connection if instructed to and we can - many specifics mean no sharing if (shared && expectedFailCount == 0 && _fixture != null && _fixture.IsEnabled - && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy, protocol)) + && CanShare(allowAdmin, password, tieBreaker, fail, disabledCommands, enabledCommands, channelPrefix, proxy, configuration, defaultDatabase, backlogPolicy)) { configuration = GetConfiguration(); + var fixtureConn = _fixture.GetConnection(this, protocol.Value, caller: caller); // Only return if we match - ThrowIfIncorrectProtocol(_fixture.Connection, protocol); + ThrowIfIncorrectProtocol(fixtureConn, protocol); if (configuration == _fixture.Configuration) { - ThrowIfBelowMinVersion(_fixture.Connection, require); - return _fixture.Connection; + ThrowIfBelowMinVersion(fixtureConn, require); + return fixtureConn; } } @@ -306,8 +318,7 @@ internal static bool CanShare( Proxy? proxy, string? configuration, int? defaultDatabase, - BacklogPolicy? backlogPolicy, - RedisProtocol? protocol + BacklogPolicy? backlogPolicy ) => enabledCommands == null && disabledCommands == null @@ -319,8 +330,7 @@ internal static bool CanShare( && tieBreaker == null && defaultDatabase == null && (allowAdmin == null || allowAdmin == true) - && backlogPolicy == null - && protocol is null; + && backlogPolicy == null; internal void ThrowIfIncorrectProtocol(IInternalConnectionMultiplexer conn, RedisProtocol? requiredProtocol) { @@ -378,7 +388,7 @@ public static ConnectionMultiplexer CreateDefault( int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, RedisProtocol? protocol = null, - [CallerMemberName] string? caller = null) + [CallerMemberName] string caller = "") { StringWriter? localLog = null; if (log == null) @@ -406,7 +416,7 @@ public static ConnectionMultiplexer CreateDefault( if (tieBreaker != null) config.TieBreaker = tieBreaker; if (password != null) config.Password = string.IsNullOrEmpty(password) ? null : password; if (clientName != null) config.ClientName = clientName; - else if (caller != null) config.ClientName = caller; + else if (!string.IsNullOrEmpty(caller)) config.ClientName = caller; if (syncTimeout != null) config.SyncTimeout = syncTimeout.Value; if (allowAdmin != null) config.AllowAdmin = allowAdmin.Value; if (keepAlive != null) config.KeepAlive = keepAlive.Value; @@ -470,7 +480,7 @@ public static ConnectionMultiplexer CreateDefault( } public virtual string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) => - Environment.Version.ToString() + Path.GetFileNameWithoutExtension(filePath) + "-" + caller; + Environment.Version.ToString() + Path.GetFileNameWithoutExtension(filePath) + "-" + caller + Context.KeySuffix; protected TimeSpan RunConcurrent(Action work, int threads, int timeout = 10000, [CallerMemberName] string? caller = null) { diff --git a/tests/StackExchange.Redis.Tests/TransactionTests.cs b/tests/StackExchange.Redis.Tests/TransactionTests.cs index 372470e13..c9ccec71b 100644 --- a/tests/StackExchange.Redis.Tests/TransactionTests.cs +++ b/tests/StackExchange.Redis.Tests/TransactionTests.cs @@ -5,18 +5,11 @@ namespace StackExchange.Redis.Tests; -public class Resp2TransactionTests : TransactionTests +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] +public class TransactionTests : TestBase { - public Resp2TransactionTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, false) { } -} -public class Resp3TransactionTests : TransactionTests -{ - public Resp3TransactionTests(ITestOutputHelper output, ProtocolDependentFixture fixture) : base(output, fixture, true) { } -} - -public abstract class TransactionTests : ProtocolFixedTestBase -{ - public TransactionTests(ITestOutputHelper output, ProtocolDependentFixture fixture, bool resp3) : base(output, fixture, resp3) { } + public TransactionTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void BasicEmptyTran() diff --git a/tests/StackExchange.Redis.Tests/xunit.runner.json b/tests/StackExchange.Redis.Tests/xunit.runner.json index 65a35fb2f..8bca1f742 100644 --- a/tests/StackExchange.Redis.Tests/xunit.runner.json +++ b/tests/StackExchange.Redis.Tests/xunit.runner.json @@ -1,6 +1,6 @@ { "methodDisplay": "classAndMethod", - "maxParallelThreads": 8, + "maxParallelThreads": 16, "diagnosticMessages": false, "longRunningTestSeconds": 60 } \ No newline at end of file From 126e32a54cb73ffdb4b877334e146a8b41e85fda Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Mon, 4 Sep 2023 20:22:10 -0400 Subject: [PATCH 16/24] Test fixups --- tests/StackExchange.Redis.Tests/AsyncTests.cs | 8 +++-- .../Issues/BgSaveResponseTests.cs | 2 +- tests/StackExchange.Redis.Tests/TestBase.cs | 32 +++++++++---------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/AsyncTests.cs b/tests/StackExchange.Redis.Tests/AsyncTests.cs index 760cc4c94..e42e2f07d 100644 --- a/tests/StackExchange.Redis.Tests/AsyncTests.cs +++ b/tests/StackExchange.Redis.Tests/AsyncTests.cs @@ -45,7 +45,8 @@ public void AsyncTasksReportFailureIfServerUnavailable() [Fact] public async Task AsyncTimeoutIsNoticed() { - using var conn = Create(syncTimeout: 1000); + using var conn = Create(syncTimeout: 1000, asyncTimeout: 1000); + using var pauseConn = Create(); var opt = ConfigurationOptions.Parse(conn.Configuration); if (!Debugger.IsAttached) { // we max the timeouts if a debugger is detected @@ -59,11 +60,14 @@ public async Task AsyncTimeoutIsNoticed() Assert.Contains("; async timeouts: 0;", conn.GetStatus()); - await db.ExecuteAsync("client", "pause", 4000).ForAwait(); // client pause returns immediately + // This is done on another connection, because it queues a SELECT due to being an unknown command that will not timeout + // at the head of the queue + await pauseConn.GetDatabase().ExecuteAsync("client", "pause", 4000).ForAwait(); // client pause returns immediately var ms = Stopwatch.StartNew(); var ex = await Assert.ThrowsAsync(async () => { + Log("Issuing StringGetAsync"); await db.StringGetAsync(key).ForAwait(); // but *subsequent* operations are paused ms.Stop(); Log($"Unexpectedly succeeded after {ms.ElapsedMilliseconds}ms"); diff --git a/tests/StackExchange.Redis.Tests/Issues/BgSaveResponseTests.cs b/tests/StackExchange.Redis.Tests/Issues/BgSaveResponseTests.cs index 0c54c40ff..a7bcfc737 100644 --- a/tests/StackExchange.Redis.Tests/Issues/BgSaveResponseTests.cs +++ b/tests/StackExchange.Redis.Tests/Issues/BgSaveResponseTests.cs @@ -13,7 +13,7 @@ public BgSaveResponseTests(ITestOutputHelper output) : base (output) { } [InlineData(SaveType.BackgroundRewriteAppendOnlyFile)] public async Task ShouldntThrowException(SaveType saveType) { - using var conn = Create(null, null, true); + using var conn = Create(allowAdmin: true); var Server = GetServer(conn); Server.Save(saveType); diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 2dfef45d6..c0dfb028c 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -239,6 +239,7 @@ protected static IServer GetAnyPrimary(IConnectionMultiplexer muxer) internal virtual IInternalConnectionMultiplexer Create( string? clientName = null, int? syncTimeout = null, + int? asyncTimeout = null, bool? allowAdmin = null, int? keepAlive = null, int? connectTimeout = null, @@ -289,7 +290,7 @@ internal virtual IInternalConnectionMultiplexer Create( var conn = CreateDefault( Writer, configuration ?? GetConfiguration(), - clientName, syncTimeout, allowAdmin, keepAlive, + clientName, syncTimeout, asyncTimeout, allowAdmin, keepAlive, connectTimeout, password, tieBreaker, log, fail, disabledCommands, enabledCommands, checkConnect, failMessage, @@ -371,6 +372,7 @@ public static ConnectionMultiplexer CreateDefault( string configuration, string? clientName = null, int? syncTimeout = null, + int? asyncTimeout = null, bool? allowAdmin = null, int? keepAlive = null, int? connectTimeout = null, @@ -391,10 +393,7 @@ public static ConnectionMultiplexer CreateDefault( [CallerMemberName] string caller = "") { StringWriter? localLog = null; - if (log == null) - { - log = localLog = new StringWriter(); - } + log ??= localLog = new StringWriter(); try { var config = ConfigurationOptions.Parse(configuration); @@ -412,18 +411,19 @@ public static ConnectionMultiplexer CreateDefault( syncTimeout = int.MaxValue; } - if (channelPrefix != null) config.ChannelPrefix = RedisChannel.Literal(channelPrefix); - if (tieBreaker != null) config.TieBreaker = tieBreaker; - if (password != null) config.Password = string.IsNullOrEmpty(password) ? null : password; - if (clientName != null) config.ClientName = clientName; + if (channelPrefix is not null) config.ChannelPrefix = RedisChannel.Literal(channelPrefix); + if (tieBreaker is not null) config.TieBreaker = tieBreaker; + if (password is not null) config.Password = string.IsNullOrEmpty(password) ? null : password; + if (clientName is not null) config.ClientName = clientName; else if (!string.IsNullOrEmpty(caller)) config.ClientName = caller; - if (syncTimeout != null) config.SyncTimeout = syncTimeout.Value; - if (allowAdmin != null) config.AllowAdmin = allowAdmin.Value; - if (keepAlive != null) config.KeepAlive = keepAlive.Value; - if (connectTimeout != null) config.ConnectTimeout = connectTimeout.Value; - if (proxy != null) config.Proxy = proxy.Value; - if (defaultDatabase != null) config.DefaultDatabase = defaultDatabase.Value; - if (backlogPolicy != null) config.BacklogPolicy = backlogPolicy; + if (syncTimeout is not null) config.SyncTimeout = syncTimeout.Value; + if (asyncTimeout is not null) config.AsyncTimeout = asyncTimeout.Value; + if (allowAdmin is not null) config.AllowAdmin = allowAdmin.Value; + if (keepAlive is not null) config.KeepAlive = keepAlive.Value; + if (connectTimeout is not null) config.ConnectTimeout = connectTimeout.Value; + if (proxy is not null) config.Proxy = proxy.Value; + if (defaultDatabase is not null) config.DefaultDatabase = defaultDatabase.Value; + if (backlogPolicy is not null) config.BacklogPolicy = backlogPolicy; if (protocol is not null) config.Protocol = protocol; var watch = Stopwatch.StartNew(); var task = ConnectionMultiplexer.ConnectAsync(config, log); From 8c388a9e6d282d0ea42f7ddb1124ab3c67f63146 Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Mon, 4 Sep 2023 20:35:11 -0400 Subject: [PATCH 17/24] Fix tests on 3.1 server setup We have to get off AppVeyor and on to something else, testing against 3.1 in the mainline is kinda bananapants at this point. --- tests/StackExchange.Redis.Tests/ClusterTests.cs | 6 ++---- tests/StackExchange.Redis.Tests/ScriptingTests.cs | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index ec97cd706..535b4a91a 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -200,10 +200,9 @@ public void IntentionalWrongServer() [Fact] public void TransactionWithMultiServerKeys() { + using var conn = Create(); var ex = Assert.Throws(() => { - using var conn = Create(); - // connect var cluster = conn.GetDatabase(); var anyServer = conn.GetServer(conn.GetEndPoints()[0]); @@ -258,10 +257,9 @@ public void TransactionWithMultiServerKeys() [Fact] public void TransactionWithSameServerKeys() { + using var conn = Create(); var ex = Assert.Throws(() => { - using var conn = Create(); - // connect var cluster = conn.GetDatabase(); var anyServer = conn.GetServer(conn.GetEndPoints()[0]); diff --git a/tests/StackExchange.Redis.Tests/ScriptingTests.cs b/tests/StackExchange.Redis.Tests/ScriptingTests.cs index f34c94299..35bfbf36d 100644 --- a/tests/StackExchange.Redis.Tests/ScriptingTests.cs +++ b/tests/StackExchange.Redis.Tests/ScriptingTests.cs @@ -248,10 +248,9 @@ public void NonAsciiScripts() [Fact] public async Task ScriptThrowsError() { + using var conn = GetScriptConn(); await Assert.ThrowsAsync(async () => { - using var conn = GetScriptConn(); - var db = conn.GetDatabase(); try { From af96022ddaeed15e953da0ca6bca203911b59eba Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Mon, 4 Sep 2023 20:59:36 -0400 Subject: [PATCH 18/24] Stream tests: fix unique keys These were colliding on RESP2/RESP3 but also using an entirely different system...let's simplify! --- .../StackExchange.Redis.Tests/StreamTests.cs | 145 +++++++++--------- 1 file changed, 74 insertions(+), 71 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/StreamTests.cs b/tests/StackExchange.Redis.Tests/StreamTests.cs index d203be38c..305e38298 100644 --- a/tests/StackExchange.Redis.Tests/StreamTests.cs +++ b/tests/StackExchange.Redis.Tests/StreamTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Newtonsoft.Json; using Xunit; @@ -13,13 +14,16 @@ public class StreamTests : TestBase { public StreamTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + public override string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) => + base.Me(filePath, caller) + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + [Fact] public void IsStreamType() { using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("type_check"); + var key = Me(); db.StreamAdd(key, "field1", "value1"); var keyType = db.KeyType(key); @@ -33,7 +37,8 @@ public void StreamAddSinglePairWithAutoId() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var messageId = db.StreamAdd(GetUniqueKey("auto_id"), "field1", "value1"); + var key = Me(); + var messageId = db.StreamAdd(key, "field1", "value1"); Assert.True(messageId != RedisValue.Null && ((string?)messageId)?.Length > 0); } @@ -44,7 +49,7 @@ public void StreamAddMultipleValuePairsWithAutoId() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("multiple_value_pairs"); + var key = Me(); var fields = new[] { new NameValueEntry("field1", "value1"), @@ -73,7 +78,7 @@ public void StreamAddWithManualId() var db = conn.GetDatabase(); const string id = "42-0"; - var key = GetUniqueKey("manual_id"); + var key = Me(); var messageId = db.StreamAdd(key, "field1", "value1", id); @@ -87,7 +92,7 @@ public void StreamAddMultipleValuePairsWithManualId() var db = conn.GetDatabase(); const string id = "42-0"; - var key = GetUniqueKey("manual_id_multiple_values"); + var key = Me(); var fields = new[] { @@ -493,7 +498,7 @@ public void StreamConsumerGroupSetId() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_set_id"); + var key = Me(); const string groupName = "test_group", consumer = "consumer"; @@ -524,7 +529,7 @@ public void StreamConsumerGroupWithNoConsumers() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_with_no_consumers"); + var key = Me(); const string groupName = "test_group"; // Create a stream @@ -545,7 +550,7 @@ public void StreamCreateConsumerGroup() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_create"); + var key = Me(); const string groupName = "test_group"; // Create a stream @@ -563,7 +568,7 @@ public void StreamCreateConsumerGroupBeforeCreatingStream() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_create_before_stream"); + var key = Me(); // Ensure the key doesn't exist. var keyExistsBeforeCreate = db.KeyExists(key); @@ -584,7 +589,7 @@ public void StreamCreateConsumerGroupFailsIfKeyDoesntExist() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_create_before_stream_should_fail"); + var key = Me(); // Pass 'false' for 'createStream' to ensure that an // exception is thrown when the stream doesn't exist. @@ -601,7 +606,7 @@ public void StreamCreateConsumerGroupSucceedsWhenKeyExists() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_create_after_stream"); + var key = Me(); db.StreamAdd(key, "f1", "v1"); @@ -622,7 +627,7 @@ public void StreamConsumerGroupReadOnlyNewMessagesWithEmptyResponse() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_read"); + var key = Me(); const string groupName = "test_group"; // Create a stream @@ -644,7 +649,7 @@ public void StreamConsumerGroupReadFromStreamBeginning() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_read_beginning"); + var key = Me(); const string groupName = "test_group"; var id1 = db.StreamAdd(key, "field1", "value1"); @@ -665,7 +670,7 @@ public void StreamConsumerGroupReadFromStreamBeginningWithCount() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_read_with_count"); + var key = Me(); const string groupName = "test_group"; var id1 = db.StreamAdd(key, "field1", "value1"); @@ -690,7 +695,7 @@ public void StreamConsumerGroupAcknowledgeMessage() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_ack"); + var key = Me(); const string groupName = "test_group", consumer = "test_consumer"; @@ -728,7 +733,7 @@ public void StreamConsumerGroupClaimMessages() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_claim"); + var key = Me(); const string groupName = "test_group", consumer1 = "test_consumer_1", consumer2 = "test_consumer_2"; @@ -775,7 +780,7 @@ public void StreamConsumerGroupClaimMessagesReturningIds() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_claim_view_ids"); + var key = Me(); const string groupName = "test_group", consumer1 = "test_consumer_1", consumer2 = "test_consumer_2"; @@ -827,8 +832,8 @@ public void StreamConsumerGroupReadMultipleOneReadBeginningOneReadNew() var db = conn.GetDatabase(); const string groupName = "test_group"; - var stream1 = GetUniqueKey("stream1a"); - var stream2 = GetUniqueKey("stream2a"); + var stream1 = Me() + "a"; + var stream2 = Me() + "b"; db.StreamAdd(stream1, "field1-1", "value1-1"); db.StreamAdd(stream1, "field1-2", "value1-2"); @@ -866,8 +871,8 @@ public void StreamConsumerGroupReadMultipleOnlyNewMessagesExpectNoResult() var db = conn.GetDatabase(); const string groupName = "test_group"; - var stream1 = GetUniqueKey("stream1b"); - var stream2 = GetUniqueKey("stream2b"); + var stream1 = Me() + "a"; + var stream2 = Me() + "b"; db.StreamAdd(stream1, "field1-1", "value1-1"); db.StreamAdd(stream2, "field2-1", "value2-1"); @@ -898,8 +903,8 @@ public void StreamConsumerGroupReadMultipleOnlyNewMessagesExpect1Result() var db = conn.GetDatabase(); const string groupName = "test_group"; - var stream1 = GetUniqueKey("stream1c"); - var stream2 = GetUniqueKey("stream2c"); + var stream1 = Me() + "a"; + var stream2 = Me() + "b"; // These messages won't be read. db.StreamAdd(stream1, "field1-1", "value1-1"); @@ -937,8 +942,8 @@ public void StreamConsumerGroupReadMultipleRestrictCount() var db = conn.GetDatabase(); const string groupName = "test_group"; - var stream1 = GetUniqueKey("stream1d"); - var stream2 = GetUniqueKey("stream2d"); + var stream1 = Me() + "a"; + var stream2 = Me() + "b"; var id1_1 = db.StreamAdd(stream1, "field1-1", "value1-1"); var id1_2 = db.StreamAdd(stream1, "field1-2", "value1-2"); @@ -974,7 +979,7 @@ public void StreamConsumerGroupViewPendingInfoNoConsumers() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_pending_info_no_consumers"); + var key = Me(); const string groupName = "test_group"; db.StreamAdd(key, "field1", "value1"); @@ -996,7 +1001,7 @@ public void StreamConsumerGroupViewPendingInfoWhenNothingPending() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_pending_info_nothing_pending"); + var key = Me(); const string groupName = "test_group"; db.StreamAdd(key, "field1", "value1"); @@ -1018,7 +1023,7 @@ public void StreamConsumerGroupViewPendingInfoSummary() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_pending_info"); + var key = Me(); const string groupName = "test_group", consumer1 = "test_consumer_1", consumer2 = "test_consumer_2"; @@ -1056,7 +1061,7 @@ public async Task StreamConsumerGroupViewPendingMessageInfo() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_pending_messages"); + var key = Me(); const string groupName = "test_group", consumer1 = "test_consumer_1", consumer2 = "test_consumer_2"; @@ -1093,7 +1098,7 @@ public void StreamConsumerGroupViewPendingMessageInfoForConsumer() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_pending_for_consumer"); + var key = Me(); const string groupName = "test_group", consumer1 = "test_consumer_1", consumer2 = "test_consumer_2"; @@ -1127,7 +1132,7 @@ public void StreamDeleteConsumer() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("delete_consumer"); + var key = Me(); const string groupName = "test_group", consumer = "test_consumer"; @@ -1158,7 +1163,7 @@ public void StreamDeleteConsumerGroup() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("delete_consumer_group"); + var key = Me(); const string groupName = "test_group", consumer = "test_consumer"; @@ -1187,7 +1192,7 @@ public void StreamDeleteMessage() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("delete_msg"); + var key = Me(); db.StreamAdd(key, "field1", "value1"); db.StreamAdd(key, "field2", "value2"); @@ -1207,7 +1212,7 @@ public void StreamDeleteMessages() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("delete_msgs"); + var key = Me(); db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1224,7 +1229,7 @@ public void StreamDeleteMessages() [Fact] public void StreamGroupInfoGet() { - var key = GetUniqueKey("group_info"); + var key = Me(); const string group1 = "test_group_1", group2 = "test_group_2", consumer1 = "test_consumer_1", @@ -1286,7 +1291,7 @@ public void StreamGroupConsumerInfoGet() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("group_consumer_info"); + var key = Me(); const string group = "test_group", consumer1 = "test_consumer_1", consumer2 = "test_consumer_2"; @@ -1318,7 +1323,7 @@ public void StreamInfoGet() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("stream_info"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); db.StreamAdd(key, "field2", "value2"); @@ -1340,7 +1345,7 @@ public void StreamInfoGetWithEmptyStream() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("stream_info_empty"); + var key = Me(); // Add an entry and then delete it so the stream is empty, then run streaminfo // to ensure it functions properly on an empty stream. Namely, the first-entry @@ -1363,7 +1368,7 @@ public void StreamNoConsumerGroups() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("stream_with_no_consumers"); + var key = Me(); db.StreamAdd(key, "field1", "value1"); @@ -1379,7 +1384,7 @@ public void StreamPendingNoMessagesOrConsumers() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("stream_pending_empty"); + var key = Me(); const string groupName = "test_group"; var id = db.StreamAdd(key, "field1", "value1"); @@ -1438,7 +1443,7 @@ public void StreamRead() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("read"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1459,7 +1464,7 @@ public void StreamReadEmptyStream() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("read_empty_stream"); + var key = Me(); // Write to a stream to create the key. var id1 = db.StreamAdd(key, "field1", "value1"); @@ -1481,8 +1486,8 @@ public void StreamReadEmptyStreams() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key1 = GetUniqueKey("read_empty_stream_1"); - var key2 = GetUniqueKey("read_empty_stream_2"); + var key1 = Me() + "a"; + var key2 = Me() + "b"; // Write to a stream to create the key. var id1 = db.StreamAdd(key1, "field1", "value1"); @@ -1526,7 +1531,7 @@ public void StreamReadExpectedExceptionInvalidCountSingleStream() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("read_exception_invalid_count_single"); + var key = Me(); Assert.Throws(() => db.StreamRead(key, "0-0", 0)); } @@ -1555,8 +1560,8 @@ public void StreamReadMultipleStreams() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key1 = GetUniqueKey("read_multi_1a"); - var key2 = GetUniqueKey("read_multi_2a"); + var key1 = Me() + "a"; + var key2 = Me() + "b"; var id1 = db.StreamAdd(key1, "field1", "value1"); var id2 = db.StreamAdd(key1, "field2", "value2"); @@ -1591,8 +1596,8 @@ public void StreamReadMultipleStreamsWithCount() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key1 = GetUniqueKey("read_multi_count_1"); - var key2 = GetUniqueKey("read_multi_count_2"); + var key1 = Me() + "a"; + var key2 = Me() + "b"; var id1 = db.StreamAdd(key1, "field1", "value1"); db.StreamAdd(key1, "field2", "value2"); @@ -1625,8 +1630,8 @@ public void StreamReadMultipleStreamsWithReadPastSecondStream() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key1 = GetUniqueKey("read_multi_1b"); - var key2 = GetUniqueKey("read_multi_2b"); + var key1 = Me() + "a"; + var key2 = Me() + "b"; db.StreamAdd(key1, "field1", "value1"); db.StreamAdd(key1, "field2", "value2"); @@ -1656,8 +1661,8 @@ public void StreamReadMultipleStreamsWithEmptyResponse() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key1 = GetUniqueKey("read_multi_1c"); - var key2 = GetUniqueKey("read_multi_2c"); + var key1 = Me() + "a"; + var key2 = Me() + "b"; db.StreamAdd(key1, "field1", "value1"); var id2 = db.StreamAdd(key1, "field2", "value2"); @@ -1683,7 +1688,7 @@ public void StreamReadPastEndOfStream() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("read_empty"); + var key = Me(); db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1701,7 +1706,7 @@ public void StreamReadRange() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("range"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1719,7 +1724,7 @@ public void StreamReadRangeOfEmptyStream() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("range_empty"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1739,7 +1744,7 @@ public void StreamReadRangeWithCount() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("range_count"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); db.StreamAdd(key, "field2", "value2"); @@ -1756,7 +1761,7 @@ public void StreamReadRangeReverse() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("rangerev"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1774,7 +1779,7 @@ public void StreamReadRangeReverseWithCount() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("rangerev_count"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1791,7 +1796,7 @@ public void StreamReadWithAfterIdAndCount_1() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("read1"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1810,7 +1815,7 @@ public void StreamReadWithAfterIdAndCount_2() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("read2"); + var key = Me(); var id1 = db.StreamAdd(key, "field1", "value1"); var id2 = db.StreamAdd(key, "field2", "value2"); @@ -1831,7 +1836,7 @@ public void StreamTrimLength() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("trimlen"); + var key = Me(); // Add a couple items and check length. db.StreamAdd(key, "field1", "value1"); @@ -1852,7 +1857,7 @@ public void StreamVerifyLength() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("len"); + var key = Me(); // Add a couple items and check length. db.StreamAdd(key, "field1", "value1"); @@ -1869,7 +1874,7 @@ public async Task AddWithApproxCountAsync() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("approx-async"); + var key = Me(); await db.StreamAddAsync(key, "field", "value", maxLength: 10, useApproximateMaxLength: true, flags: CommandFlags.None).ConfigureAwait(false); } @@ -1879,7 +1884,7 @@ public void AddWithApproxCount() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("approx"); + var key = Me(); db.StreamAdd(key, "field", "value", maxLength: 10, useApproximateMaxLength: true, flags: CommandFlags.None); } @@ -1889,7 +1894,7 @@ public void StreamReadGroupWithNoAckShowsNoPendingMessages() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key = GetUniqueKey("read_group_noack"); + var key = Me(); const string groupName = "test_group", consumer = "consumer"; @@ -1915,8 +1920,8 @@ public void StreamReadGroupMultiStreamWithNoAckShowsNoPendingMessages() using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var key1 = GetUniqueKey("read_group_noack1"); - var key2 = GetUniqueKey("read_group_noack2"); + var key1 = Me() + "a"; + var key2 = Me() + "b"; const string groupName = "test_group", consumer = "consumer"; @@ -1945,15 +1950,13 @@ public void StreamReadGroupMultiStreamWithNoAckShowsNoPendingMessages() Assert.Equal(0, pending2.PendingMessageCount); } - private static RedisKey GetUniqueKey(string type) => $"{type}_stream_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; - [Fact] public async Task StreamReadIndexerUsage() { using var conn = Create(require: RedisFeatures.v5_0_0); var db = conn.GetDatabase(); - var streamName = GetUniqueKey("read-group-indexer"); + var streamName = Me(); await db.StreamAddAsync(streamName, new[] { new NameValueEntry("x", "blah"), From 6d5367209e8a6bc383aeee7eeea47d059c39e810 Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Tue, 5 Sep 2023 10:58:54 -0400 Subject: [PATCH 19/24] Bump version to 2.7 --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index f5ce755a1..c37674c0e 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "2.6", + "version": "2.7", "versionHeightOffset": -1, "assemblyVersion": "2.0", "publicReleaseRefSpec": [ From 5c24f7a784b35ce7380e0ed970208756d81ff203 Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Tue, 5 Sep 2023 10:59:47 -0400 Subject: [PATCH 20/24] Remove unused usings to tidy up --- src/StackExchange.Redis/ChannelMessageQueue.cs | 6 ++++-- src/StackExchange.Redis/ConfigurationOptions.cs | 1 - src/StackExchange.Redis/DebuggingAids.cs | 5 +---- src/StackExchange.Redis/LoggingPipe.cs | 8 +------- src/StackExchange.Redis/RedisDatabase.cs | 1 - src/StackExchange.Redis/ResultBox.cs | 1 - src/StackExchange.Redis/ResultTypeExtensions.cs | 5 +---- src/StackExchange.Redis/ScriptParameterMapper.cs | 1 - 8 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/StackExchange.Redis/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index ffb82507d..3bf7635f3 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -1,10 +1,11 @@ using System; using System.Collections.Generic; -using System.Reflection; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +#if NETCOREAPP3_1 +using System.Reflection; +#endif namespace StackExchange.Redis { @@ -126,6 +127,7 @@ public ValueTask ReadAsync(CancellationToken cancellationToken = /// The (approximate) count of items in the Channel. public bool TryGetCount(out int count) { + // This is specific to netcoreapp3.1, because full framework was out of band and the new prop is present #if NETCOREAPP3_1 // get this using the reflection try diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 5b1457a72..a85232172 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Globalization; using System.Linq; using System.Net; using System.Net.Security; diff --git a/src/StackExchange.Redis/DebuggingAids.cs b/src/StackExchange.Redis/DebuggingAids.cs index 6fb3b380e..46e81611f 100644 --- a/src/StackExchange.Redis/DebuggingAids.cs +++ b/src/StackExchange.Redis/DebuggingAids.cs @@ -1,7 +1,4 @@ -using System; -using System.Diagnostics; - -namespace StackExchange.Redis +namespace StackExchange.Redis { #if VERBOSE partial class ConnectionMultiplexer diff --git a/src/StackExchange.Redis/LoggingPipe.cs b/src/StackExchange.Redis/LoggingPipe.cs index ba2343d23..3c89110ae 100644 --- a/src/StackExchange.Redis/LoggingPipe.cs +++ b/src/StackExchange.Redis/LoggingPipe.cs @@ -1,10 +1,4 @@ -using System; -using System.Buffers; -using System.IO; -using System.IO.Pipelines; -using System.Runtime.InteropServices; - -namespace StackExchange.Redis +namespace StackExchange.Redis { #if LOGOUTPUT sealed class LoggingPipe : IDuplexPipe diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index d24a0be5f..85cf25576 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -2,7 +2,6 @@ using System.Buffers; using System.Collections.Generic; using System.Net; -using System.Text; using System.Threading.Tasks; using Pipelines.Sockets.Unofficial.Arenas; diff --git a/src/StackExchange.Redis/ResultBox.cs b/src/StackExchange.Redis/ResultBox.cs index 111394321..c61221018 100644 --- a/src/StackExchange.Redis/ResultBox.cs +++ b/src/StackExchange.Redis/ResultBox.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; diff --git a/src/StackExchange.Redis/ResultTypeExtensions.cs b/src/StackExchange.Redis/ResultTypeExtensions.cs index b4578450f..e2f941f00 100644 --- a/src/StackExchange.Redis/ResultTypeExtensions.cs +++ b/src/StackExchange.Redis/ResultTypeExtensions.cs @@ -1,7 +1,4 @@ -using System; -using System.Diagnostics; - -namespace StackExchange.Redis +namespace StackExchange.Redis { internal static class ResultTypeExtensions { diff --git a/src/StackExchange.Redis/ScriptParameterMapper.cs b/src/StackExchange.Redis/ScriptParameterMapper.cs index 390db8180..9960d87b7 100644 --- a/src/StackExchange.Redis/ScriptParameterMapper.cs +++ b/src/StackExchange.Redis/ScriptParameterMapper.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using System.Reflection; From eb36efb26e7b67b6fb2eccfa3ed2bee7b3381f6f Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Tue, 5 Sep 2023 12:35:54 -0400 Subject: [PATCH 21/24] Doc tweaks and minor fixes --- docs/Configuration.md | 23 ++++++------- docs/Resp3.md | 36 ++++++++++----------- src/StackExchange.Redis/ClientInfo.cs | 8 ++--- src/StackExchange.Redis/Enums/ResultType.cs | 7 ++-- src/StackExchange.Redis/RawResult.cs | 2 +- src/StackExchange.Redis/RedisFeatures.cs | 1 + src/StackExchange.Redis/RedisProtocol.cs | 2 +- src/StackExchange.Redis/RedisResult.cs | 10 +++--- src/StackExchange.Redis/RedisValue.cs | 22 ------------- src/StackExchange.Redis/ServerEndPoint.cs | 2 +- 10 files changed, 46 insertions(+), 67 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index f0174b84c..1b93901a0 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -274,18 +274,15 @@ config.ReconnectRetryPolicy = new LinearRetry(5000); ``` ## Redis protocol -``` -Without any additional prompting, StackExchange.Redis will use the RESP2 protocol; this means that pub/sub requires a separatate connection to the server. RESP3 is a newer protocol -(usually, but not always, available on v6 servers and above) which allows (smong other changes) pub/sub messages to be communicated on the *same* connection - which can be very +Without specific configuration, StackExchange.Redis will use the RESP2 protocol; this means that pub/sub requires a separatate connection to the server. RESP3 is a newer protocol +(usually, but not always, available on v6 servers and above) which allows (among other changes) pub/sub messages to be communicated on the *same* connection - which can be very desirable in servers with a large number of clients. The protocol handshake needs to happen very early in the connection, so *by default* the library does not attempt a RESP3 connection -unless it has reason to expect it to work: - -This can be considered, in order: - -- the `HELLO` command has been disabled: RESP2 is used -- a protocol *other than* `resp3` or `3` is specified: RESP2 is used -- a protocol of `resp3` or `3` is specified: RESP3 is attempted (with fallback if it fails) -- a version of at least 6 is specified: RESP3 is attempted (with fallback if it fails) -- in all other scenarios: RESP2 is used - +unless it has reason to expect it to work. + +The library determines whether to use RESP3 by: +- The `HELLO` command has been disabled: RESP2 is used +- A protocol *other than* `resp3` or `3` is specified: RESP2 is used +- A protocol of `resp3` or `3` is specified: RESP3 is attempted (with fallback if it fails) +- A version of at least 6 is specified: RESP3 is attempted (with fallback if it fails) +- In all other scenarios: RESP2 is used diff --git a/docs/Resp3.md b/docs/Resp3.md index 0b99107fd..eb0713245 100644 --- a/docs/Resp3.md +++ b/docs/Resp3.md @@ -1,45 +1,45 @@ # RESP3 and StackExchange.Redis -RESP2 and RESP3 are evolutions of the Redis protocol, with RESP3 existing from Redis server version 6 onwards; the main differences are: +RESP2 and RESP3 are evolutions of the Redis protocol, with RESP3 existing from Redis server version 6 onwards (v7.2+ for Redis Enterprise). The main differences are: 1. RESP3 can carry out-of-band / "push" messages on a single connection, where-as RESP2 requires a separate connection for these messages 2. RESP3 can (when appropriate) convey additional semantic meaning about returned payloads inside the same result structure 3. Some commands (see [this topic](https://github.com/redis/redis-doc/issues/2511)) return different result structures in RESP3 mode; for example a flat interleaved array might become a jagged array -For most people, the bullet "1" is the main reason to consider RESP3, as in high-usage servers, this can halve the number of connections required. +For most people, #1 is the main reason to consider RESP3, as in high-usage servers - this can halve the number of connections required. This is particularly useful in hosted environments where the number of inbound connections to the server is capped as part of a service plan. Alternatively, where users are currently choosing to disable the out-of-band connection to achieve this, they may now be able to re-enable this (for example, to receive server maintenance notifications) *without* incurring any additional connection overhead. -Because of the significance of bullet "3" (and to avoid breaking your code), the library does not currently default to RESP3 mode; this must be enabled explicitly +Because of the significance of #3 (and to avoid breaking your code), the library does not currently default to RESP3 mode. This must be enabled explicitly via `ConfigurationOptions.Protocol` or by adding `,protocol=resp3` (or `,protocol=3`) to the configuration string. --- -Bullet "3" is a critical one; the library *should* already handle all documented commands that have revised results in RESP3, but if you're using +#3 is a critical one - the library *should* already handle all documented commands that have revised results in RESP3, but if you're using `Execute[Async]` to issue ad-hoc commands, you may need to update your processing code to compensate for this, ideally using detection to handle *either* format so that the same code works in both REP2 and RESP3. Since the impacted commands are handled internally by the library, in reality -this should not usually present a difficulty. +this should usually not usually present a difficulty. -The minor (bullet "2") and major (bullet "3") differences to results are only visible to your code when using: +The minor (#2) and major (#3) differences to results are only visible to your code when using: - Lua scripts invoked via the `ScriptEvaluate[Async](...)` or related APIs, that either: - - uses the `redis.setresp(3)` API and returns a value from `redis.[p]call(...)` - - returns a value that satisfies the [LUA to RESP3 type conversion rules](https://redis.io/docs/manual/programmability/lua-api/#lua-to-resp3-type-conversion) -- ad-hoc commands (in particular: *modules*) that are invoked via the `Execute[Async](string command, ...)` API + - Uses the `redis.setresp(3)` API and returns a value from `redis.[p]call(...)` + - Returns a value that satisfies the [LUA to RESP3 type conversion rules](https://redis.io/docs/manual/programmability/lua-api/#lua-to-resp3-type-conversion) +- Ad-hoc commands (in particular: *modules*) that are invoked via the `Execute[Async](string command, ...)` API -both which return `RedisResult`. **If you are not using these APIs, you should not need to do anything additional.** +...both which return `RedisResult`. **If you are not using these APIs, you should not need to do anything additional.** Historically, you could use the `RedisResult.Type` property to query the type of data returned (integer, string, etc). In particular: -- two new properties are added: `RedisResult.Resp2Type` and `RedisResult.Resp3Type` - - the `Resp3Type` property exposes the new semantic data (when using RESP3), for example it can indicate that a value is a double-precision number, a boolean, a map, etc (types that did not historically exist) - - the `Resp2Type` property exposes the same value that *would* have been returned if this data had been returned over RESP2 - - the `Type` property is now marked obsolete, but functions identically to `Resp2Type`, so that pre-existing code (for example, that has a `switch` on the type) is not impacted by RESP3 -- the `ResultType.MultiBulk` is superseded by `ResultType.Array` (this is a nomenclature change only; they are the same value and function identically) +- Two new properties are added: `RedisResult.Resp2Type` and `RedisResult.Resp3Type` + - The `Resp3Type` property exposes the new semantic data (when using RESP3) - for example, it can indicate that a value is a double-precision number, a boolean, a map, etc (types that did not historically exist) + - The `Resp2Type` property exposes the same value that *would* have been returned if this data had been returned over RESP2 + - The `Type` property is now marked obsolete, but functions identically to `Resp2Type`, so that pre-existing code (for example, that has a `switch` on the type) is not impacted by RESP3 +- The `ResultType.MultiBulk` is superseded by `ResultType.Array` (this is a nomenclature change only; they are the same value and function identically) Possible changes required due to RESP3: -1. to prevent build warnings, replace usage of `ResultType.MultiBulk` with `ResultType.Array`, and usage of `RedisResult.Type` with `RedisResult.Resp2Type` -2. if you wish to exploit the additional semantic data when enabling RESP3, use `RedisResult.Resp3Type` where appropriate -3. if you are enabling RESP3, you must verify whether the commands you are using can give different result shapes on RESP3 connections \ No newline at end of file +1. To prevent build warnings, replace usage of `ResultType.MultiBulk` with `ResultType.Array`, and usage of `RedisResult.Type` with `RedisResult.Resp2Type` +2. If you wish to exploit the additional semantic data when enabling RESP3, use `RedisResult.Resp3Type` where appropriate +3. If you are enabling RESP3, you must verify whether the commands you are using can give different result shapes on RESP3 connections \ No newline at end of file diff --git a/src/StackExchange.Redis/ClientInfo.cs b/src/StackExchange.Redis/ClientInfo.cs index 7baeca408..215403fe8 100644 --- a/src/StackExchange.Redis/ClientInfo.cs +++ b/src/StackExchange.Redis/ClientInfo.cs @@ -181,23 +181,23 @@ public ClientType ClientType } /// - /// Client RESP protocol version. Added in Redis 7.0 + /// Client RESP protocol version. Added in Redis 7.0. /// public string? ProtocolVersion { get; private set; } /// - /// Client RESP protocol version. Added in Redis 7.0 + /// Client RESP protocol version. Added in Redis 7.0. /// public RedisProtocol? Protocol => ConfigurationOptions.TryParseRedisProtocol(ProtocolVersion, out var value) ? value : null; /// - /// Client library name. Added in Redis 7.2 + /// Client library name. Added in Redis 7.2. /// /// public string? LibraryName { get; private set; } /// - /// Client library version. Added in Redis 7.2 + /// Client library version. Added in Redis 7.2. /// /// public string? LibraryVersion { get; private set; } diff --git a/src/StackExchange.Redis/Enums/ResultType.cs b/src/StackExchange.Redis/Enums/ResultType.cs index 2e3f1d8a9..84f531043 100644 --- a/src/StackExchange.Redis/Enums/ResultType.cs +++ b/src/StackExchange.Redis/Enums/ResultType.cs @@ -33,7 +33,7 @@ public enum ResultType : byte BulkString = 4, /// - /// Multi-bulk replies represent complex results such as arrays. + /// Array of results (former Multi-bulk). /// Array = 5, @@ -95,7 +95,10 @@ public enum ResultType : byte Attribute = (3 << 3) | Array, /// - /// Out of band data. The format is like the type, but the client should just check the first string element, stating the type of the out of band data, a call a callback if there is one registered for this specific type of push information. Push types are not related to replies, since they are information that the server may push at any time in the connection, so the client should keep reading if it is reading the reply of a command. + /// Out of band data. The format is like the type, but the client should just check the first string element, + /// stating the type of the out of band data, a call a callback if there is one registered for this specific type of push information. + /// Push types are not related to replies, since they are information that the server may push at any time in the connection, + /// so the client should keep reading if it is reading the reply of a command. /// Push = (4 << 3) | Array, } diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index a1340e7ab..1581c29c9 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -200,7 +200,7 @@ internal RedisValue AsRedisValue() { case (byte)'t': return (RedisValue)true; case (byte)'f': return (RedisValue)false; - }; + } } switch (Resp2TypeBulkString) { diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index fbcc66d5d..7e3bb77dc 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -333,6 +333,7 @@ orderby prop.Name public static bool operator !=(RedisFeatures left, RedisFeatures right) => !left.Version.IsEqual(right.Version); } } + internal static class VersionExtensions { // normalize two version parts and smash them together into a long; if either part is -ve, diff --git a/src/StackExchange.Redis/RedisProtocol.cs b/src/StackExchange.Redis/RedisProtocol.cs index 2b2635388..8c1c9b869 100644 --- a/src/StackExchange.Redis/RedisProtocol.cs +++ b/src/StackExchange.Redis/RedisProtocol.cs @@ -1,7 +1,7 @@ namespace StackExchange.Redis; /// -/// Indicates the protocol for communicating with the server +/// Indicates the protocol for communicating with the server. /// public enum RedisProtocol { diff --git a/src/StackExchange.Redis/RedisResult.cs b/src/StackExchange.Redis/RedisResult.cs index 0a63f0275..61e58f578 100644 --- a/src/StackExchange.Redis/RedisResult.cs +++ b/src/StackExchange.Redis/RedisResult.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis public abstract class RedisResult { /// - /// Do not use + /// Do not use. /// [Obsolete("Please specify a result type", true)] // retained purely for binary compat public RedisResult() : this(default) { } @@ -40,8 +40,8 @@ public static RedisResult Create(RedisValue[] values) /// Create a new RedisResult representing an array of values. /// /// The s to create a result from. + /// The explicit data type. /// new . - /// The explicit data type public static RedisResult Create(RedisValue[] values, ResultType resultType) => values == null ? NullArray : values.Length == 0 ? EmptyArray(resultType) : new ArrayRedisResult(Array.ConvertAll(values, value => new SingleRedisResult(value, null)), resultType); @@ -58,8 +58,8 @@ public static RedisResult Create(RedisResult[] values) /// Create a new RedisResult representing an array of values. /// /// The s to create a result from. + /// The explicit data type. /// new . - /// The explicit data type public static RedisResult Create(RedisResult[] values, ResultType resultType) => values == null ? NullArray : values.Length == 0 ? EmptyArray(resultType) : new ArrayRedisResult(values, resultType); @@ -97,7 +97,7 @@ public static RedisResult Create(RedisResult[] values, ResultType resultType) /// /// Gets the string content as per , but also obtains the declared type from verbatim strings (for example LATENCY DOCTOR) /// - /// The type of the returned string + /// The type of the returned string. /// The content public abstract string? ToString(out string? type); @@ -333,7 +333,7 @@ public Dictionary ToDictionary(IEqualityComparer? c } /// - /// Get a sub-item by index + /// Get a sub-item by index. /// public virtual RedisResult this[int index] => throw new InvalidOperationException("Indexers can only be used on array results"); diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index de129f868..a0b045cf4 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -329,28 +329,6 @@ internal StorageType Type } } - internal Version? TryGetVersion() - { - switch (Type) - { - case StorageType.Int64: - var value64 = OverlappedValueInt64; - return value64 >= 0 & value64 <= int.MaxValue ? new Version((int)value64, 0) : null; - case StorageType.Raw when _memory.Length < 128: -#if NETCOREAPP3_1_OR_GREATER - Span chars = stackalloc char[Encoding.UTF8.GetMaxCharCount(_memory.Length)]; - int count = Encoding.UTF8.GetChars(_memory.Span, chars); - return Format.TryParseVersion(chars.Slice(0, count), out var version) ? version : null; -#else - return Format.TryParseVersion(ToString(), out var version) ? version : null; -#endif - case StorageType.String: - return Format.TryParseVersion((string)_objectOrSentinel!, out version) ? version : null; - default: - return null; - } - } - /// /// Get the size of this value in bytes /// diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 659e5591b..ebb66ec2a 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -964,7 +964,7 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) // volume, we can afford to optimize for a good stack-trace rather than avoiding state machines. ResultProcessor? autoConfig = null; - if (Multiplexer.RawConfig.TryResp3()) // note this includes a availability check on HELLO + if (Multiplexer.RawConfig.TryResp3()) // note this includes an availability check on HELLO { log?.LogInformation($"{Format.ToString(this)}: Authenticating via HELLO"); var hello = Message.CreateHello(3, user, password, clientName, CommandFlags.FireAndForget); From 517eaeae5a5e8afb788f1eb5087b550f5e8e8b77 Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Tue, 5 Sep 2023 12:38:35 -0400 Subject: [PATCH 22/24] Remove console write --- src/StackExchange.Redis/RedisResult.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/StackExchange.Redis/RedisResult.cs b/src/StackExchange.Redis/RedisResult.cs index 61e58f578..b39a646e0 100644 --- a/src/StackExchange.Redis/RedisResult.cs +++ b/src/StackExchange.Redis/RedisResult.cs @@ -109,10 +109,6 @@ internal static bool TryCreate(PhysicalConnection connection, in RawResult resul { try { - if (result.Resp3Type == ResultType.Null) - { - Console.Write("hi"); - } switch (result.Resp2TypeBulkString) { case ResultType.Integer: From 18e0ea927147a652e953fda4e9a9d04037b3de40 Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Tue, 5 Sep 2023 13:13:30 -0400 Subject: [PATCH 23/24] Doc + ref fixes --- docs/Configuration.md | 1 - src/StackExchange.Redis/Enums/ResultType.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index 1b93901a0..881916112 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -284,5 +284,4 @@ The library determines whether to use RESP3 by: - The `HELLO` command has been disabled: RESP2 is used - A protocol *other than* `resp3` or `3` is specified: RESP2 is used - A protocol of `resp3` or `3` is specified: RESP3 is attempted (with fallback if it fails) -- A version of at least 6 is specified: RESP3 is attempted (with fallback if it fails) - In all other scenarios: RESP2 is used diff --git a/src/StackExchange.Redis/Enums/ResultType.cs b/src/StackExchange.Redis/Enums/ResultType.cs index 84f531043..ca09f64b0 100644 --- a/src/StackExchange.Redis/Enums/ResultType.cs +++ b/src/StackExchange.Redis/Enums/ResultType.cs @@ -95,7 +95,7 @@ public enum ResultType : byte Attribute = (3 << 3) | Array, /// - /// Out of band data. The format is like the type, but the client should just check the first string element, + /// Out of band data. The format is like the type, but the client should just check the first string element, /// stating the type of the out of band data, a call a callback if there is one registered for this specific type of push information. /// Push types are not related to replies, since they are information that the server may push at any time in the connection, /// so the client should keep reading if it is reading the reply of a command. From 8ddd99aa581d74a8da145520b9fc4761167a7fab Mon Sep 17 00:00:00 2001 From: Nick Craver Date: Thu, 7 Sep 2023 11:49:02 -0400 Subject: [PATCH 24/24] Doc fixes --- docs/Configuration.md | 2 +- docs/Resp3.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index 881916112..2f63c5358 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -275,7 +275,7 @@ config.ReconnectRetryPolicy = new LinearRetry(5000); ## Redis protocol -Without specific configuration, StackExchange.Redis will use the RESP2 protocol; this means that pub/sub requires a separatate connection to the server. RESP3 is a newer protocol +Without specific configuration, StackExchange.Redis will use the RESP2 protocol; this means that pub/sub requires a separate connection to the server. RESP3 is a newer protocol (usually, but not always, available on v6 servers and above) which allows (among other changes) pub/sub messages to be communicated on the *same* connection - which can be very desirable in servers with a large number of clients. The protocol handshake needs to happen very early in the connection, so *by default* the library does not attempt a RESP3 connection unless it has reason to expect it to work. diff --git a/docs/Resp3.md b/docs/Resp3.md index eb0713245..126b460f4 100644 --- a/docs/Resp3.md +++ b/docs/Resp3.md @@ -19,7 +19,7 @@ via `ConfigurationOptions.Protocol` or by adding `,protocol=resp3` (or `,protoco #3 is a critical one - the library *should* already handle all documented commands that have revised results in RESP3, but if you're using `Execute[Async]` to issue ad-hoc commands, you may need to update your processing code to compensate for this, ideally using detection to handle *either* format so that the same code works in both REP2 and RESP3. Since the impacted commands are handled internally by the library, in reality -this should usually not usually present a difficulty. +this should not usually present a difficulty. The minor (#2) and major (#3) differences to results are only visible to your code when using: