From cfb110cf4a6014eea305ababefda6f0559504178 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 7 Sep 2023 17:02:10 +0100 Subject: [PATCH] Protocol support: RESP3 (#2396) Overall changes: - [x] introduce `Resp2Type` and `Resp3Type` shims (`Resp2Type` has reduced types); existing code using `[Obsolete] Type` uses `Resp2Type` for minimal code impact - [x] mark existing `Type` as `[Obsolete]`, and proxy to `Resp2Type` for compat - [x] deal with null handling differences - [x] deal with `Boolean`, which works very differently (`t`/`f` instead of `1`/`0`) - [x] deal with `[+|-]{inf|nan}` when parsing doubles (explicitly called out in the RESP3 spec) - [x] parse new tokens - [x] `HELLO` handshake - [x] core message and result handling - [x] validation and fallback - [x] prove all return types (see: https://github.com/redis/redis-specifications/issues/15) - [x] streamed RESP3 omitting; not implemented by server; can revisit - [x] deal with pub/sub differences - [x] check routing of publish etc - [x] check re-wire of subscription if failed - [x] check receive notifications - [x] connection management (i.e. not to spin up in resp3) - [x] connection fallback spinup (i.e. if we were trying resp3 but failed) - [x] other - [x] [undocumented RESP3 delta](https://github.com/redis/redis-doc/pull/2513) - [x] run core tests in both RESP2 and RESP3 - [x] compensate for tests that expect separate subscription connections Co-authored-by: Nick Craver Co-authored-by: Nick Craver --- Directory.Packages.props | 4 +- StackExchange.Redis.sln | 1 + docs/Configuration.md | 41 +- docs/ReleaseNotes.md | 1 + docs/Resp3.md | 45 ++ docs/index.md | 1 + .../APITypes/LatencyHistoryEntry.cs | 2 +- .../APITypes/LatencyLatestEntry.cs | 2 +- .../ChannelMessageQueue.cs | 5 +- src/StackExchange.Redis/ClientInfo.cs | 13 +- src/StackExchange.Redis/CommandTrace.cs | 4 +- src/StackExchange.Redis/Condition.cs | 10 +- .../ConfigurationOptions.cs | 88 ++- .../ConnectionMultiplexer.Compat.cs | 3 + .../ConnectionMultiplexer.cs | 14 +- src/StackExchange.Redis/DebuggingAids.cs | 5 +- src/StackExchange.Redis/Enums/CommandFlags.cs | 1 + src/StackExchange.Redis/Enums/RedisCommand.cs | 2 + src/StackExchange.Redis/Enums/ResultType.cs | 74 ++- src/StackExchange.Redis/ExceptionFactory.cs | 10 +- src/StackExchange.Redis/Format.cs | 138 +++- .../Interfaces/IConnectionMultiplexer.cs | 13 + src/StackExchange.Redis/Interfaces/IServer.cs | 7 + src/StackExchange.Redis/LoggingPipe.cs | 8 +- src/StackExchange.Redis/Message.cs | 57 +- src/StackExchange.Redis/PhysicalBridge.cs | 8 +- src/StackExchange.Redis/PhysicalConnection.cs | 162 +++-- .../Profiling/ProfiledCommand.cs | 2 +- .../PublicAPI/PublicAPI.Shipped.txt | 1 - .../PublicAPI/PublicAPI.Unshipped.txt | 32 +- src/StackExchange.Redis/RawResult.cs | 170 +++-- src/StackExchange.Redis/RedisDatabase.cs | 14 +- src/StackExchange.Redis/RedisFeatures.cs | 144 +++-- src/StackExchange.Redis/RedisLiterals.cs | 11 +- src/StackExchange.Redis/RedisProtocol.cs | 21 + src/StackExchange.Redis/RedisResult.cs | 150 ++++- src/StackExchange.Redis/RedisServer.cs | 8 +- src/StackExchange.Redis/RedisSubscriber.cs | 3 + src/StackExchange.Redis/RedisTransaction.cs | 9 +- src/StackExchange.Redis/ResultBox.cs | 1 - src/StackExchange.Redis/ResultProcessor.cs | 597 +++++++++++------- .../ResultTypeExtensions.cs | 11 + src/StackExchange.Redis/Role.cs | 3 + .../ScriptParameterMapper.cs | 8 +- src/StackExchange.Redis/ServerEndPoint.cs | 133 +++- .../AggressiveTests.cs | 4 +- tests/StackExchange.Redis.Tests/AsyncTests.cs | 8 +- tests/StackExchange.Redis.Tests/BitTests.cs | 1 + .../StackExchange.Redis.Tests/ClusterTests.cs | 19 +- .../StackExchange.Redis.Tests/ConfigTests.cs | 37 +- .../ConnectCustomConfigTests.cs | 4 +- .../ConnectToUnexistingHostTests.cs | 2 +- tests/StackExchange.Redis.Tests/EnvoyTests.cs | 4 +- .../EventArgsTests.cs | 14 +- .../ExceptionFactoryTests.cs | 14 +- .../StackExchange.Redis.Tests/ExpiryTests.cs | 2 +- .../FailoverTests.cs | 6 +- tests/StackExchange.Redis.Tests/GeoTests.cs | 15 +- tests/StackExchange.Redis.Tests/HashTests.cs | 1 + .../Helpers/Attributes.cs | 100 ++- .../Helpers/Extensions.cs | 4 +- .../Helpers/IRedisTest.cs | 8 + .../Helpers/SharedConnectionFixture.cs | 52 +- .../Helpers/TestContext.cs | 20 + .../HyperLogLogTests.cs | 3 +- .../Issues/BgSaveResponseTests.cs | 2 +- .../Issues/SO10504853Tests.cs | 2 +- .../Issues/SO25567566Tests.cs | 2 +- tests/StackExchange.Redis.Tests/KeyTests.cs | 5 +- tests/StackExchange.Redis.Tests/ListTests.cs | 1 + .../StackExchange.Redis.Tests/LockingTests.cs | 2 +- .../StackExchange.Redis.Tests/MemoryTests.cs | 8 +- .../OverloadCompatTests.cs | 1 + tests/StackExchange.Redis.Tests/ParseTests.cs | 2 +- .../ProfilingTests.cs | 4 +- .../PubSubCommandTests.cs | 3 +- .../PubSubMultiserverTests.cs | 47 +- .../StackExchange.Redis.Tests/PubSubTests.cs | 3 +- .../RawResultTests.cs | 18 +- .../RespProtocolTests.cs | 431 +++++++++++++ tests/StackExchange.Redis.Tests/RoleTests.cs | 6 +- tests/StackExchange.Redis.Tests/SSLTests.cs | 2 +- tests/StackExchange.Redis.Tests/ScanTests.cs | 4 +- .../ScriptingTests.cs | 10 +- .../StackExchange.Redis.Tests/SecureTests.cs | 2 +- .../ServerSnapshotTests.cs | 3 + tests/StackExchange.Redis.Tests/SetTests.cs | 3 +- .../SortedSetTests.cs | 78 ++- .../StackExchange.Redis.Tests/StreamTests.cs | 146 ++--- .../StackExchange.Redis.Tests/StringTests.cs | 1 + tests/StackExchange.Redis.Tests/TestBase.cs | 139 ++-- .../TransactionTests.cs | 1 + .../xunit.runner.json | 2 +- toys/StackExchange.Redis.Server/RespServer.cs | 6 +- .../TypedRedisValue.cs | 12 +- version.json | 2 +- 96 files changed, 2530 insertions(+), 773 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/IRedisTest.cs create mode 100644 tests/StackExchange.Redis.Tests/Helpers/TestContext.cs create mode 100644 tests/StackExchange.Redis.Tests/RespProtocolTests.cs 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/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 5893bf855..2f63c5358 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: - LoggerFactory (`ILoggerFactory`) - Default: `null` @@ -123,8 +124,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 | @@ -132,7 +134,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: @@ -161,7 +163,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: @@ -184,8 +187,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 @@ -205,7 +209,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: @@ -218,8 +222,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+{ @@ -229,7 +234,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. @@ -240,8 +245,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. @@ -266,3 +272,16 @@ config.ReconnectRetryPolicy = new LinearRetry(5000); //5 5000 //6 5000 ``` + +## Redis 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. + +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) +- In all other scenarios: RESP2 is used diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index ee25d40a0..b238fe10c 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)) - Add: `ConfigurationOptions.LoggerFactory` for logging to an `ILoggerFactory` (e.g. `ILogger`) all connection and error events ([#2051 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2051)) diff --git a/docs/Resp3.md b/docs/Resp3.md new file mode 100644 index 000000000..126b460f4 --- /dev/null +++ b/docs/Resp3.md @@ -0,0 +1,45 @@ +# RESP3 and StackExchange.Redis + +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, #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 #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. + +--- + +#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. + +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 + +...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) + +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 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..3bf7635f3 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; -using System.Reflection; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +#if NETCOREAPP3_1 +using System.Reflection; +#endif namespace StackExchange.Redis { @@ -125,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/ClientInfo.cs b/src/StackExchange.Redis/ClientInfo.cs index 4fa0aa378..215403fe8 100644 --- a/src/StackExchange.Redis/ClientInfo.cs +++ b/src/StackExchange.Redis/ClientInfo.cs @@ -181,18 +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 library name. Added in Redis 7.2 + /// 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. /// /// 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; } @@ -280,7 +285,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 12c372715..a85232172 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -47,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) @@ -67,6 +70,12 @@ internal static SslProtocols ParseSslProtocols(string key, string? value) return tmp; } + internal static RedisProtocol ParseRedisProtocol(string key, string value) + { + 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."); + } + internal static void Unknown(string key) => throw new ArgumentException($"Keyword '{key}' is not supported.", key); @@ -99,7 +108,8 @@ internal const string WriteBuffer = "writeBuffer", CheckCertificateRevocation = "checkCertificateRevocation", Tunnel = "tunnel", - SetClientLibrary = "setlib"; + SetClientLibrary = "setlib", + Protocol = "protocol"; private static readonly Dictionary normalizedOptions = new[] { @@ -128,7 +138,8 @@ internal const string TieBreaker, Version, WriteBuffer, - CheckCertificateRevocation + CheckCertificateRevocation, + Protocol, }.ToDictionary(x => x, StringComparer.OrdinalIgnoreCase); public static string TryNormalize(string value) @@ -414,6 +425,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; @@ -483,6 +495,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; @@ -530,6 +543,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; @@ -604,6 +618,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; @@ -695,6 +710,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow Tunnel = Tunnel, setClientLibrary = setClientLibrary, LibraryName = LibraryName, + Protocol = Protocol, }; /// @@ -775,12 +791,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) @@ -956,6 +980,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: @@ -998,5 +1025,58 @@ 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); + } + } + + 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/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 cc239ad3f..64267aa2f 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; @@ -48,6 +49,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; @@ -70,6 +74,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; @@ -83,6 +88,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; @@ -135,7 +141,7 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? se Logger = configuration.LoggerFactory?.CreateLogger(); 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); } @@ -883,6 +889,8 @@ public ServerSnapshotFiltered(ServerEndPoint[] endpoints, int count, Func GetServerEndPoint(endpoint); + [return: NotNullIfNotNull(nameof(endpoint))] internal ServerEndPoint? GetServerEndPoint(EndPoint? endpoint, ILogger? log = null, bool activate = true) { @@ -908,7 +916,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..ca09f64b0 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,75 @@ public enum ResultType : byte /// Bulk strings represent typical user content values. /// BulkString = 4, + + /// + /// Array of results (former Multi-bulk). + /// + 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 c1e53a329..fd1953de6 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..0c1494641 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs @@ -1,9 +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 { @@ -14,10 +17,18 @@ internal interface IInternalConnectionMultiplexer : IConnectionMultiplexer bool IgnoreConnect { get; set; } ReadOnlySpan GetServerSnapshot(); + ServerEndPoint GetServerEndPoint(EndPoint endpoint); ConfigurationOptions RawConfig { get; } long? GetConnectionId(EndPoint endPoint, ConnectionType type); + + ServerSelectionStrategy ServerSelectionStrategy { get; } + + int GetSubscriptionsCount(); + ConcurrentDictionary GetSubscriptions(); + + ConnectionMultiplexer UnderlyingMultiplexer { get; } } /// @@ -49,6 +60,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 +77,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..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. /// @@ -357,6 +362,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 +474,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/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/Message.cs b/src/StackExchange.Redis/Message.cs index 65df7d0e7..9acf41fb1 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -1,12 +1,12 @@ -using System; +using Microsoft.Extensions.Logging; +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 Microsoft.Extensions.Logging; -using StackExchange.Redis.Profiling; namespace StackExchange.Redis { @@ -479,6 +479,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: @@ -637,6 +638,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. /// @@ -706,6 +710,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 7041cf0af..3a4494821 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -69,6 +69,7 @@ internal sealed class PhysicalBridge : IDisposable #endif internal string? PhysicalName => physical?.ToString(); + public DateTime? ConnectedAt { get; private set; } public PhysicalBridge(ServerEndPoint serverEndPoint, ConnectionType type, int timeoutMilliseconds) @@ -114,6 +115,11 @@ public enum State : byte public RedisCommand LastCommand { get; private set; } + /// + /// If we have a connection, report the protocol being used + /// + public RedisProtocol? Protocol => physical?.Protocol; + public void Dispose() { isDisposed = true; @@ -1481,7 +1487,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 eb0787606..22c9d2894 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -259,6 +259,10 @@ private enum ReadMode : byte public bool TransactionActive { get; internal set; } + 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() { @@ -1508,7 +1512,7 @@ internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, Sock var configOptions = config.SslClientAuthenticationOptions?.Invoke(host); if (configOptions is not null) { - await ssl.AuthenticateAsClientAsync(configOptions); + await ssl.AuthenticateAsClientAsync(configOptions).ForAwait(); } else { @@ -1564,7 +1568,7 @@ internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, Sock 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; @@ -1668,14 +1672,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; @@ -1684,7 +1688,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; @@ -1821,7 +1825,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(_protocol >= RedisProtocol.Resp3, _arena, in buffer, ref reader, IncludeDetailInExceptions, this); try { if (result.HasValue) @@ -1876,34 +1880,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; } @@ -1911,11 +1920,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)) @@ -1946,7 +1959,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); } @@ -1955,7 +1968,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; @@ -1963,7 +1976,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 @@ -1997,36 +2010,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 @@ -2037,9 +2097,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 c49cf328a..459f66cf8 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..eff457070 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,31 @@ - \ 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.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 +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 ee4aeaa86..1581c29c9 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 { @@ -15,16 +15,22 @@ internal readonly struct RawResult private readonly ReadOnlySequence _payload; internal ReadOnlySequence Payload => _payload; - 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) { @@ -32,40 +38,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})", }; } @@ -121,7 +163,7 @@ public bool MoveNext() } internal RedisChannel AsRedisChannel(byte[]? channelPrefix, RedisChannel.PatternMode mode) { - switch (Type) + switch (Resp2TypeBulkString) { case ResultType.SimpleString: case ResultType.BulkString: @@ -136,20 +178,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; @@ -159,13 +212,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: @@ -174,7 +227,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) @@ -244,6 +297,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, @@ -325,14 +387,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; } #if NET6_0_OR_GREATER // use system-provided sequence decoder @@ -353,7 +419,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; @@ -371,15 +437,33 @@ private static GeoPosition AsGeoPosition(in Sequence coords) } } } - return s; + + return Resp3Type == ResultType.VerbatimString ? GetVerbatimString(s, out verbatimPrefix) : s; static void Throw() => throw new InvalidOperationException("Invalid result from GetChars"); #endif + static string? GetVerbatimString(string? value, 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. + 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; + } } internal bool TryGetDouble(out double val) { - if (IsNull) + if (IsNull || Payload.IsEmpty) { val = 0; return false; @@ -389,6 +473,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); } @@ -406,5 +498,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..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; @@ -1549,12 +1548,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(); } } @@ -4702,14 +4701,14 @@ private abstract class ScanResultProcessor : ResultProcessor.ScanResult(i64, oversized, count, true); @@ -4761,6 +4760,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 +5048,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,57 @@ 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..8c1c9b869 --- /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..b39a646e0 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. + /// The explicit data type. + /// new . + 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. + /// The explicit data type. + /// new . + 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,14 @@ internal static bool TryCreate(PhysicalConnection connection, in RawResult resul { try { - switch (result.Type) + 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 +125,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 +142,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 +161,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 +328,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 +357,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 +384,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 +526,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 +566,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 +647,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 +670,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 b8e0de696..bce499bec 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; @@ -917,12 +919,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/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/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/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/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 236946d57..6fd229af1 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -1,4 +1,6 @@ -using System; +using Microsoft.Extensions.Logging; +using Pipelines.Sockets.Unofficial.Arenas; +using System; using System.Buffers; using System.Collections.Generic; using System.Diagnostics; @@ -8,8 +10,6 @@ using System.Net; using System.Text; using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; -using Pipelines.Sockets.Unofficial.Arenas; namespace StackExchange.Redis { @@ -57,8 +57,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(); @@ -335,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 @@ -348,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; @@ -398,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; } @@ -455,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)) @@ -481,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: @@ -549,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(); @@ -574,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; @@ -606,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); } @@ -614,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) { @@ -655,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)) @@ -740,6 +802,7 @@ public override bool SetResult(PhysicalConnection connection, Message message, i server.IsReplica = true; } } + return base.SetResult(connection, message, result); } @@ -747,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.ConnectionId = clientId; + Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (CLIENT) connection-id: {clientId}"); + } + } + break; case ResultType.BulkString: if (message?.Command == RedisCommand.INFO) { @@ -773,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?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) role: primary"); - break; - case "replica": - case "slave": - server.IsReplica = true; - Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) role: replica"); - break; + server.IsReplica = isReplica; + Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) role: {(isReplica ? "replica" : "primary")}"); } } else if ((val = Extract(line, "master_host:")) != null) @@ -796,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?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) version: " + version); @@ -804,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?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) server-type: standalone"); - break; - case "cluster": - server.ServerType = ServerType.Cluster; - Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) server-type: cluster"); - break; - case "sentinel": - server.ServerType = ServerType.Sentinel; - Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) server-type: sentinel"); - break; + server.ServerType = serverType; + Log?.LogInformation($"{Format.ToString(server)}: Auto-configured (INFO) server-type: {serverType}"); } } else if ((val = Extract(line, "run_id:")) != null) @@ -839,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(); @@ -888,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?.LogInformation($"{Format.ToString(server)}: Auto-configured (HELLO) server-version: {version}"); + } + else if (key.IsEqual(CommonReplies.proto) && val.TryGetInt64(out var i64)) + { + 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)) + { + connection.ConnectionId = i64; + Log?.LogInformation($"{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?.LogInformation($"{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?.LogInformation($"{Format.ToString(server)}: Auto-configured (HELLO) role: {(isReplica ? "replica" : "primary")}"); + } + } + } else if (message?.Command == RedisCommand.SENTINEL) { server.ServerType = ServerType.Sentinel; @@ -904,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(ILogger? log) => log is null ? AutoConfigure : new AutoConfigureProcessor(log); } private sealed class BooleanProcessor : ResultProcessor @@ -915,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)) @@ -931,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) @@ -948,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()); @@ -971,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()!; @@ -989,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: @@ -1024,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)) @@ -1034,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) { @@ -1068,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 @@ -1093,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; @@ -1143,12 +1275,14 @@ private sealed class InfoProcessor : ResultProcessor>>(); - using (var reader = new StringReader(result.GetString()!)) + var raw = result.GetString(); + if (raw is not null) { + using var reader = new StringReader(raw); while (reader.ReadLine() is string line) { if (string.IsNullOrWhiteSpace(line)) continue; @@ -1189,7 +1323,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; @@ -1202,28 +1336,7 @@ private class Int64Processor : ResultProcessor { protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { - switch (result.Type) - { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - long i64; - if (result.TryGetInt64(out i64)) - { - SetResult(message, i64); - return true; - } - break; - } - return false; - } - } - - 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: @@ -1232,7 +1345,6 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes if (result.TryGetInt64(out i64)) { SetResult(message, i64); - connection.ConnectionId = i64; return true; } break; @@ -1245,7 +1357,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)) @@ -1262,7 +1374,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); @@ -1276,7 +1388,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: @@ -1302,7 +1414,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: @@ -1344,9 +1456,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))!; @@ -1362,9 +1474,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; @@ -1377,7 +1489,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: @@ -1393,7 +1505,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: @@ -1412,7 +1524,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: @@ -1422,7 +1534,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; @@ -1435,7 +1547,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); @@ -1450,9 +1562,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); @@ -1466,9 +1578,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; @@ -1481,7 +1593,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); @@ -1495,9 +1607,9 @@ private sealed class RedisValueGeoPositionProcessor : ResultProcessor Parse(item, radiusOptions), options)!; SetResult(message, typed); @@ -1610,10 +1722,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: @@ -1790,7 +1901,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: @@ -1806,7 +1917,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(); @@ -1849,7 +1960,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes return true; } - if (result.Type != ResultType.MultiBulk) + if (result.Resp2TypeArray != ResultType.Array) { return false; } @@ -1858,23 +1969,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 { @@ -1930,26 +2065,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 + { + // root is a map of named inner-arrays + streams = RedisStreamInterleavedProcessor.Instance.ParseArray(result, false, out _, this)!; // null-checked + } + else { - var details = item.GetItems(); + 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. /// @@ -1959,7 +2113,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(); @@ -1988,7 +2142,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(); @@ -2149,7 +2303,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; } @@ -2186,7 +2340,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; } @@ -2262,7 +2416,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; } @@ -2306,7 +2460,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 . /// @@ -2333,7 +2495,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; } @@ -2345,7 +2507,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) @@ -2365,34 +2527,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()!); } @@ -2400,14 +2545,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) { @@ -2424,7 +2569,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: @@ -2474,6 +2619,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; } @@ -2484,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; @@ -2511,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; @@ -2543,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) { @@ -2573,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(); @@ -2607,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(); @@ -2649,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>( @@ -2691,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..e2f941f00 --- /dev/null +++ b/src/StackExchange.Redis/ResultTypeExtensions.cs @@ -0,0 +1,11 @@ +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..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; @@ -224,12 +223,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 a90023580..ebb66ec2a 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -94,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); @@ -159,7 +167,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; @@ -199,6 +207,11 @@ 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 { get => writeEverySeconds; @@ -222,12 +235,16 @@ public void Dispose() public PhysicalBridge? GetBridge(ConnectionType type, bool create = true, ILogger? log = null) { if (isDisposed) return null; - return type switch + switch (type) { - ConnectionType.Interactive => 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) @@ -237,6 +254,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: @@ -247,7 +265,7 @@ public void Dispose() break; } - return message.IsForSubscriptionBridge + return (message.IsForSubscriptionBridge && !KnowOrAssumeResp3()) ? subscription ??= CreateBridge(ConnectionType.Subscription, null) : interactive ??= CreateBridge(ConnectionType.Interactive, null); } @@ -261,10 +279,13 @@ public void Dispose() 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); + if (!KnowOrAssumeResp3()) + { + return subscription ?? (create ? subscription = CreateBridge(ConnectionType.Subscription, null) : null); + } + break; } + return interactive ?? (create ? interactive = CreateBridge(ConnectionType.Interactive, null) : null); } public RedisFeatures GetFeatures() => new RedisFeatures(version); @@ -366,7 +387,7 @@ internal async Task AutoConfigureAsync(PhysicalConnection? connection, ILogger? var features = GetFeatures(); Message msg; - var autoConfigProcessor = new ResultProcessor.AutoConfigureProcessor(log); + var autoConfigProcessor = ResultProcessor.AutoConfigureProcessor.Create(log); if (commandMap.IsAvailable(RedisCommand.CONFIG)) { @@ -630,10 +651,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) { @@ -659,7 +683,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 @@ -695,7 +719,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); @@ -902,14 +926,70 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? 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 + + // 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 an availability check on HELLO + { + log?.LogInformation($"{Format.ToString(this)}: Authenticating via HELLO"); + var hello = Message.CreateHello(3, user, password, clientName, CommandFlags.FireAndForget); + hello.SetInternalCall(); + await WriteDirectOrQueueFireAndForgetAsync(connection, hello, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); + + // 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 + if (!string.IsNullOrWhiteSpace(user) && Multiplexer.CommandMap.IsAvailable(RedisCommand.AUTH)) { log?.LogInformation($"{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?.LogInformation($"{Format.ToString(this)}: Authenticating (password)"); msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.AUTH, (RedisValue)password); @@ -919,18 +999,14 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? 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?.LogInformation($"{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?.LogInformation($"{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 @@ -961,13 +1037,14 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? 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(); + 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/AggressiveTests.cs b/tests/StackExchange.Redis.Tests/AggressiveTests.cs index 2e8b23f5c..375e2da3d 100644 --- a/tests/StackExchange.Redis.Tests/AggressiveTests.cs +++ b/tests/StackExchange.Redis.Tests/AggressiveTests.cs @@ -234,7 +234,7 @@ private void TranRunIntegers(IDatabase db) Log($"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) Log($"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/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/BitTests.cs b/tests/StackExchange.Redis.Tests/BitTests.cs index 5dd3d05c2..1a870f37e 100644 --- a/tests/StackExchange.Redis.Tests/BitTests.cs +++ b/tests/StackExchange.Redis.Tests/BitTests.cs @@ -3,6 +3,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class BitTests : TestBase { diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index c945812a8..535b4a91a 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -11,9 +11,12 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] public class ClusterTests : TestBase { - public ClusterTests(ITestOutputHelper output) : base (output) { } + public ClusterTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts + ",connectTimeout=10000"; [Fact] @@ -48,7 +51,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(Context.IsResp3 ? 0 : 1, counters.Subscription.SocketCount); } } } @@ -116,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); @@ -197,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]); @@ -237,7 +239,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 @@ -255,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]); @@ -294,7 +295,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 9229228ab..84a8f916b 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -16,13 +16,15 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] +[Collection(SharedConnectionFixture.Key)] public class ConfigTests : TestBase { + public ConfigTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + 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() { @@ -233,7 +235,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); @@ -247,7 +249,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: "", shared: false); // force default naming to kick in Assert.Equal($"{Environment.MachineName}(SE.Redis-v{Utils.GetLibVersion()})", conn.ClientName); var db = conn.GetDatabase(); @@ -275,7 +277,10 @@ public void ConnectWithSubscribeDisabled() Assert.True(conn.IsConnected); var servers = conn.GetServerSnapshot(); Assert.True(servers[0].IsConnected); - Assert.False(servers[0].IsSubscriberConnected); + if (!Context.IsResp3) + { + 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); @@ -374,12 +379,32 @@ 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); + + var self = clients.First(x => x.Id == id); + if (server.Version.Major >= 7) + { + Assert.Equal(Context.Test.Protocol, self.Protocol); + } + else + { + Assert.Null(self.Protocol); + } + } } [Fact] 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/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/ExceptionFactoryTests.cs b/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs index 864448610..1922c3edf 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); Log("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/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 aec2983e4..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; @@ -200,7 +201,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()); @@ -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 562bd1f5b..f1be0bad1 100644 --- a/tests/StackExchange.Redis.Tests/GeoTests.cs +++ b/tests/StackExchange.Redis.Tests/GeoTests.cs @@ -5,6 +5,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class GeoTests : TestBase { @@ -39,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] @@ -141,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 3022779c4..34a2d12c1 100644 --- a/tests/StackExchange.Redis.Tests/HashTests.cs +++ b/tests/StackExchange.Redis.Tests/HashTests.cs @@ -11,6 +11,7 @@ namespace StackExchange.Redis.Tests; /// /// Tests for . /// +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class HashTests : TestBase { 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/SharedConnectionFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs index f61e73e32..c88c0ec4d 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using StackExchange.Redis.Maintenance; @@ -17,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() @@ -32,12 +33,45 @@ 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); + } } - private class NonDisposingConnection : IInternalConnectionMultiplexer + internal sealed class NonDisposingConnection : IInternalConnectionMultiplexer { + public IInternalConnectionMultiplexer UnderlyingConnection => _inner; + public bool AllowConnect { get => _inner.AllowConnect; @@ -50,11 +84,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; @@ -189,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 d110c86b6..e0451e9c5 100644 --- a/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs +++ b/tests/StackExchange.Redis.Tests/HyperLogLogTests.cs @@ -3,10 +3,11 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class HyperLogLogTests : TestBase { - public HyperLogLogTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public HyperLogLogTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] public void SingleKeyLength() 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/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/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/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index 062f2caaa..b0c028d56 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -9,17 +9,18 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class KeyTests : TestBase { - public KeyTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public KeyTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Fact] 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/ListTests.cs b/tests/StackExchange.Redis.Tests/ListTests.cs index bb212db14..5fdb5d60a 100644 --- a/tests/StackExchange.Redis.Tests/ListTests.cs +++ b/tests/StackExchange.Redis.Tests/ListTests.cs @@ -6,6 +6,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class ListTests : TestBase { 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/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/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/ParseTests.cs b/tests/StackExchange.Redis.Tests/ParseTests.cs index a7c4248aa..80e1fbbef 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) { Log($"{result} - {result.GetString()}"); found++; diff --git a/tests/StackExchange.Redis.Tests/ProfilingTests.cs b/tests/StackExchange.Redis.Tests/ProfilingTests.cs index 41aef9337..7c4dcbe59 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 }); @@ -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 dd77dc106..e689c980b 100644 --- a/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubCommandTests.cs @@ -7,10 +7,11 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class PubSubCommandTests : TestBase { - public PubSubCommandTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + 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 dcf706e76..aa363984f 100644 --- a/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs @@ -6,16 +6,18 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class PubSubMultiserverTests : TestBase { public PubSubMultiserverTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + 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 +33,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 +56,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 +65,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 (Context.IsResp3) + { + 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 +116,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 +138,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 +147,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 (Context.IsResp3) + { + 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) { diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs index 833d888e9..697bf2771 100644 --- a/tests/StackExchange.Redis.Tests/PubSubTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubTests.cs @@ -12,6 +12,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class PubSubTests : TestBase { @@ -376,7 +377,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/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/RespProtocolTests.cs b/tests/StackExchange.Redis.Tests/RespProtocolTests.cs new file mode 100644 index 000000000..6c444e43c --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RespProtocolTests.cs @@ -0,0 +1,431 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace StackExchange.Redis.Tests; + +[Collection(SharedConnectionFixture.Key)] +public sealed class RespProtocolTests : TestBase +{ + public RespProtocolTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } + + [Fact] + [RunPerProtocol] + public async Task ConnectWithTiming() + { + using var conn = Create(shared: false, log: Writer); + await conn.GetDatabase().PingAsync(); + } + + [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()); + } + + [Fact] + [RunPerProtocol] + public async Task TryConnect() + { + var muxer = Create(shared: false); + await muxer.GetDatabase().PingAsync(); + + var server = muxer.GetServerEndPoint(muxer.GetEndPoints().Single()); + if (Context.IsResp3 && !server.GetFeatures().Resp3) + { + Skip.Inconclusive("server does not support RESP3"); + } + if (Context.IsResp3) + { + Assert.Equal(RedisProtocol.Resp3, server.Protocol); + } + else + { + Assert.Equal(RedisProtocol.Resp2, server.Protocol); + } + var cid = server.GetBridge(RedisCommand.GET)?.ConnectionId; + 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 ? RedisProtocol.Resp3 : RedisProtocol.Resp2, ep.Protocol); + var result = await muxer.GetDatabase().ExecuteAsync("latency", "doctor"); + Assert.Equal(isResp3 ? ResultType.VerbatimString : ResultType.BulkString, result.Resp3Type); + } + + [Theory] + [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')", 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')", 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 = Create(protocol: protocol); + 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"); + } + if (ep.Protocol is null) throw new InvalidOperationException($"No protocol! {ep.InteractiveConnectionState}"); + Assert.Equal(protocol, ep.Protocol); + + 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", 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", 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", 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", 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", 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", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, STR_DAVE, "doctor")] + [InlineData("latency", RedisProtocol.Resp3, ResultType.BulkString, ResultType.VerbatimString, STR_DAVE, "doctor")] + + [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, + * 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", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "string")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "double")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.SimpleString, ResultType.Double, ANY, "protocol", "double")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "bignum")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.SimpleString, ResultType.BigInteger, ANY, "protocol", "bignum")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.BulkString, ResultType.Null, null, "protocol", "null")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Array, ResultType.Array, ANY, "protocol", "array")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, ANY, "protocol", "set")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Array, ResultType.Set, ANY, "protocol", "set")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.Array, ResultType.Array, ANY, "protocol", "map")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Array, ResultType.Map, ANY, "protocol", "map")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.BulkString, ResultType.BulkString, ANY, "protocol", "verbatim")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.BulkString, ResultType.VerbatimString, ANY, "protocol", "verbatim")] + + [InlineData("debug", RedisProtocol.Resp2, ResultType.Integer, ResultType.Integer, true, "protocol", "true")] + [InlineData("debug", RedisProtocol.Resp3, ResultType.Integer, ResultType.Boolean, true, "protocol", "true")] + + [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, RedisProtocol protocol, ResultType resp2, ResultType resp3, object expected, params object[] args) + { + 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(protocol, ep.Protocol); + + 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(); + Log(scontent); + Assert.NotNull(scontent); + var isExpectedContent = scontent.StartsWith("Dave, ") || scontent.StartsWith("I'm sorry, Dave"); + Assert.True(isExpectedContent); + Log(scontent); + + scontent = result.ToString(out var type); + Assert.NotNull(scontent); + isExpectedContent = scontent.StartsWith("Dave, ") || scontent.StartsWith("I'm sorry, Dave"); + Assert.True(isExpectedContent); + Log(scontent); + if (protocol == RedisProtocol.Resp3) + { + 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 021ce8ebb..405ac39b9 100644 --- a/tests/StackExchange.Redis.Tests/RoleTests.cs +++ b/tests/StackExchange.Redis.Tests/RoleTests.cs @@ -24,9 +24,11 @@ public void PrimaryRole(bool allowAdmin) // should work with or without admin no Assert.NotNull(primary.Replicas); Log($"Searching for: {TestConfig.Current.ReplicaServer}:{TestConfig.Current.ReplicaPort}"); Log($"Replica count: {primary.Replicas.Count}"); - foreach (var r in primary.Replicas) + Assert.NotEmpty(primary.Replicas); + foreach (var replica in primary.Replicas) { - Log($" Replica: {r.Ip}:{r.Port} (offset: {r.ReplicationOffset})"); + Log($" Replica: {replica.Ip}:{replica.Port} (offset: {replica.ReplicationOffset})"); + Log(replica.ToString()); } Assert.Contains(primary.Replicas, r => r.Ip == TestConfig.Current.ReplicaServer && 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 b90d37592..bcab2da4c 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -7,10 +7,11 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class ScanTests : TestBase { - public ScanTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public ScanTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } [Theory] [InlineData(true)] @@ -24,6 +25,7 @@ public void KeysScan(bool supported) var db = conn.GetDatabase(dbId); var prefix = Me() + ":"; var server = GetServer(conn); + 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 00919d0c0..35bfbf36d 100644 --- a/tests/StackExchange.Redis.Tests/ScriptingTests.cs +++ b/tests/StackExchange.Redis.Tests/ScriptingTests.cs @@ -10,10 +10,11 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class ScriptingTests : TestBase { - public ScriptingTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + public ScriptingTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { } private IConnectionMultiplexer GetScriptConn(bool allowAdmin = false) { @@ -247,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 { @@ -791,13 +791,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/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/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 ea7043cf8..d90e4a8c3 100644 --- a/tests/StackExchange.Redis.Tests/SetTests.cs +++ b/tests/StackExchange.Redis.Tests/SetTests.cs @@ -6,10 +6,11 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class SetTests : TestBase { - public SetTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { } + 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 3b99478ce..49c464142 100644 --- a/tests/StackExchange.Redis.Tests/SortedSetTests.cs +++ b/tests/StackExchange.Redis.Tests/SortedSetTests.cs @@ -5,6 +5,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class SortedSetTests : TestBase { @@ -327,6 +328,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 (Context.IsResp3) + { + 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() { @@ -1134,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 824ff48a3..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; @@ -7,18 +8,22 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] 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); @@ -32,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); } @@ -43,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"), @@ -72,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); @@ -86,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[] { @@ -492,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"; @@ -523,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 @@ -544,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 @@ -562,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); @@ -583,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. @@ -600,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"); @@ -621,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 @@ -643,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"); @@ -664,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"); @@ -689,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"; @@ -727,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"; @@ -774,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"; @@ -826,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"); @@ -865,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"); @@ -897,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"); @@ -936,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"); @@ -973,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"); @@ -995,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"); @@ -1017,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"; @@ -1055,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"; @@ -1092,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"; @@ -1126,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"; @@ -1157,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"; @@ -1186,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"); @@ -1206,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"); @@ -1223,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", @@ -1285,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"; @@ -1317,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"); @@ -1339,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 @@ -1362,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"); @@ -1378,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"); @@ -1437,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"); @@ -1458,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"); @@ -1480,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"); @@ -1525,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)); } @@ -1554,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"); @@ -1590,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"); @@ -1624,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"); @@ -1655,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"); @@ -1682,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"); @@ -1700,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"); @@ -1718,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"); @@ -1738,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"); @@ -1755,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"); @@ -1773,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"); @@ -1790,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"); @@ -1809,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"); @@ -1830,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"); @@ -1851,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"); @@ -1868,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); } @@ -1878,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); } @@ -1888,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"; @@ -1914,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"; @@ -1944,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"), diff --git a/tests/StackExchange.Redis.Tests/StringTests.cs b/tests/StackExchange.Redis.Tests/StringTests.cs index 9ba3c73bd..23acf737f 100644 --- a/tests/StackExchange.Redis.Tests/StringTests.cs +++ b/tests/StackExchange.Redis.Tests/StringTests.cs @@ -11,6 +11,7 @@ namespace StackExchange.Redis.Tests; /// /// Tests for . /// +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class StringTests : TestBase { diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index d1435d26f..c0dfb028c 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; @@ -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(); @@ -231,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, @@ -250,50 +259,47 @@ internal virtual IInternalConnectionMultiplexer Create( int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, Version? require = null, - [CallerMemberName] string? caller = null) + RedisProtocol? protocol = 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 + 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)) { configuration = GetConfiguration(); + var fixtureConn = _fixture.GetConnection(this, protocol.Value, caller: caller); // Only return if we match + ThrowIfIncorrectProtocol(fixtureConn, protocol); + if (configuration == _fixture.Configuration) { - ThrowIfBelowMinVersion(_fixture.Connection, require); - return _fixture.Connection; + ThrowIfBelowMinVersion(fixtureConn, require); + return fixtureConn; } } var conn = CreateDefault( Writer, configuration ?? GetConfiguration(), - clientName, syncTimeout, allowAdmin, keepAlive, + clientName, syncTimeout, asyncTimeout, allowAdmin, keepAlive, connectTimeout, password, tieBreaker, log, fail, disabledCommands, enabledCommands, checkConnect, failMessage, channelPrefix, proxy, logTransactionData, defaultDatabase, - backlogPolicy, + backlogPolicy, protocol, caller); + ThrowIfIncorrectProtocol(conn, protocol); ThrowIfBelowMinVersion(conn, require); conn.InternalError += OnInternalError; @@ -302,15 +308,57 @@ 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 + ) + => enabledCommands == null + && disabledCommands == null + && fail + && channelPrefix == null + && proxy == null + && configuration == null + && password == null + && tieBreaker == null + && defaultDatabase == null + && (allowAdmin == null || allowAdmin == true) + && backlogPolicy == null; + + internal void ThrowIfIncorrectProtocol(IInternalConnectionMultiplexer conn, RedisProtocol? requiredProtocol) + { + if (requiredProtocol is null) + { + return; + } + + var serverProtocol = conn.GetServerEndPoint(conn.GetEndPoints()[0]).Protocol ?? 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) { 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}.") { @@ -324,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, @@ -340,13 +389,11 @@ public static ConnectionMultiplexer CreateDefault( bool logTransactionData = true, int? defaultDatabase = null, BacklogPolicy? backlogPolicy = null, - [CallerMemberName] string? caller = null) + RedisProtocol? protocol = null, + [CallerMemberName] string caller = "") { StringWriter? localLog = null; - if (log == null) - { - log = localLog = new StringWriter(); - } + log ??= localLog = new StringWriter(); try { var config = ConfigurationOptions.Parse(configuration); @@ -364,18 +411,20 @@ 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; - else if (caller != null) 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 (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 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); if (!task.Wait(config.ConnectTimeout >= (int.MaxValue / 2) ? int.MaxValue : config.ConnectTimeout * 2)) @@ -430,10 +479,10 @@ public static ConnectionMultiplexer CreateDefault( } } - public static string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) => - Environment.Version.ToString() + Path.GetFileNameWithoutExtension(filePath) + "-" + caller; + public virtual string Me([CallerFilePath] string? filePath = null, [CallerMemberName] string? caller = null) => + Environment.Version.ToString() + Path.GetFileNameWithoutExtension(filePath) + "-" + caller + Context.KeySuffix; - 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)); diff --git a/tests/StackExchange.Redis.Tests/TransactionTests.cs b/tests/StackExchange.Redis.Tests/TransactionTests.cs index 075c0eb2c..c9ccec71b 100644 --- a/tests/StackExchange.Redis.Tests/TransactionTests.cs +++ b/tests/StackExchange.Redis.Tests/TransactionTests.cs @@ -5,6 +5,7 @@ namespace StackExchange.Redis.Tests; +[RunPerProtocol] [Collection(SharedConnectionFixture.Key)] public class TransactionTests : TestBase { 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 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(); 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": [