From b148491635a88ecbe1384d78144a4ac6da7964d3 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Mon, 30 Nov 2020 16:09:35 -0800 Subject: [PATCH 01/63] Set Swift 5.2 as the minimum version --- .gitlab/ci/ubuntu-bionic.yml | 4 --- .gitlab/ci/ubuntu-xenial.yml | 4 --- Package.swift | 47 +++++++++++++++++++++++++++++++----- README.md | 19 ++++++++------- 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/.gitlab/ci/ubuntu-bionic.yml b/.gitlab/ci/ubuntu-bionic.yml index 5c9181e9..a508064f 100644 --- a/.gitlab/ci/ubuntu-bionic.yml +++ b/.gitlab/ci/ubuntu-bionic.yml @@ -12,7 +12,3 @@ swift 5.3: swift 5.2: extends: .unit-test image: swift:5.2-bionic - -swift 5.1: - extends: .unit-test - image: swift:5.1-bionic diff --git a/.gitlab/ci/ubuntu-xenial.yml b/.gitlab/ci/ubuntu-xenial.yml index d3336fb1..2e3e3856 100644 --- a/.gitlab/ci/ubuntu-xenial.yml +++ b/.gitlab/ci/ubuntu-xenial.yml @@ -12,7 +12,3 @@ swift 5.3: swift 5.2: extends: .unit-test image: swift:5.2-xenial - -swift 5.1: - extends: .unit-test - image: swift:5.1-xenial diff --git a/Package.swift b/Package.swift index 942c1bc0..d2fa54b0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.2 //===----------------------------------------------------------------------===// // // This source file is part of the RediStack open source project @@ -28,11 +28,46 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0") ], targets: [ - .target(name: "RediStack", dependencies: ["NIO", "Logging", "Metrics"]), + .target( + name: "RediStack", + dependencies: [ + .product(name: "NIO", package: "swift-nio"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Metrics", package: "swift-metrics") + ] + ), + .testTarget( + name: "RediStackTests", + dependencies: [ + "RediStack", "RediStackTestUtils", + .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOTestUtils", package: "swift-nio") + ] + ), + .target(name: "RedisTypes", dependencies: ["RediStack"]), - .target(name: "RediStackTestUtils", dependencies: ["NIO", "RediStack"]), - .testTarget(name: "RediStackTests", dependencies: ["RediStack", "NIO", "RediStackTestUtils", "NIOTestUtils"]), - .testTarget(name: "RedisTypesTests", dependencies: ["RediStack", "NIO", "RediStackTestUtils", "RedisTypes"]), - .testTarget(name: "RediStackIntegrationTests", dependencies: ["RediStack", "NIO", "RediStackTestUtils"]) + .testTarget( + name: "RedisTypesTests", + dependencies: [ + "RediStack", "RedisTypes", "RediStackTestUtils", + .product(name: "NIO", package: "swift-nio") + ] + ), + + .target( + name: "RediStackTestUtils", + dependencies: [ + .product(name: "NIO", package: "swift-nio"), + "RediStack" + ] + ), + + .testTarget( + name: "RediStackIntegrationTests", + dependencies: [ + "RediStack", "RediStackTestUtils", + .product(name: "NIO", package: "swift-nio") + ] + ) ] ) diff --git a/README.md b/README.md index 29dfd5d7..4d1b2eae 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,10 @@ The table below lists the major releases alongside their compatible language, de | RediStack Release | [Swift](https://swift.org/download) | [Redis](https://redis.io) | [SwiftNIO](https://github.com/apple/swift-nio) | [SwiftLog](https://github.com/apple/swift-log) | [SwiftMetrics](https://github.com/apple/swift-metrics) | |:-----------------:|:-----------------------------------:|:-------------------------:|:----------------------------------------------:|:----------------------------------------------:|:------------------------------:| -| `from: "1.0.0"` | 5.1+ | 3.x**¹** < 6.x | 2.x | 1.x | 1.x ..< 3.0 | +| `from: "1.0.0"` | 5.1+ | 3.x**¹** ..< 6.x | 2.x | 1.x | 1.x ..< 3.0 | +| `from: "2.0.0"` | 5.2+ | 3.x**¹** ... 6.x | 2.x | 1.x | 1.x ..< 3.0 | -> **¹** _Use of newer Redis features on older Redis versions is done at your own risk. See Redis' release notes for [v5](https://raw.githubusercontent.com/antirez/redis/5.0/00-RELEASENOTES), [v4](https://raw.githubusercontent.com/antirez/redis/4.0/00-RELEASENOTES), and [v3](https://raw.githubusercontent.com/antirez/redis/3.0/00-RELEASENOTES) for what is supported for each version of Redis._ +> **¹** _Use of newer Redis features on older Redis versions is done at your own risk. See Redis' release notes for [v6](https://raw.githubusercontent.com/antirez/redis/6.0/00-RELEASENOTES), [v5](https://raw.githubusercontent.com/antirez/redis/5.0/00-RELEASENOTES), [v4](https://raw.githubusercontent.com/antirez/redis/4.0/00-RELEASENOTES), and [v3](https://raw.githubusercontent.com/antirez/redis/3.0/00-RELEASENOTES) for what is supported for each version of Redis._ ### Supported Operating Systems @@ -47,7 +48,7 @@ To install **RediStack**, just add the package as a dependency in your **Package ```swift dependencies: [ - .package(url: "https://gitlab.com/mordil/RediStack.git", from: "1.0.0") + .package(url: "https://gitlab.com/mordil/RediStack.git", from: "2.0.0") ] ``` @@ -117,15 +118,15 @@ This policy is to balance the desire for as much backwards compatibility as poss The following table shows the combination of Swift language versions and operating systems that receive regular unit testing (either in development, or with CI). -| Platform | Swift 5.1 | 5.2 | 5.3 | Trunk | +| Platform | Swift 5.2 | 5.3 | 5.4 | Trunk | |:----------------------|:------------------:|:------------------:|:------------------:|:------------------:| -| macOS Latest (Intel) | | | :white_check_mark: | | -| Ubuntu 20.04 (Focal) | | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| macOS Latest (Intel) | | :white_check_mark: | | | +| Ubuntu 20.04 (Focal) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Amazon Linux 2 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| CentOS 7 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| CentOS 8 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Amazon Linux 2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| CentOS 7 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| CentOS 8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | ## License From ba35a1f91d4cb0047c5b85a6feede0c8bc25c845 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Wed, 2 Dec 2020 00:39:43 -0800 Subject: [PATCH 02/63] Add historical test matrix to README --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4d1b2eae..0625eec3 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Major version releases will drop support for any version of Swift older than the This policy is to balance the desire for as much backwards compatibility as possible, while also being able to take advantage of new Swift features for the best API design possible. The following table shows the combination of Swift language versions and operating systems that -receive regular unit testing (either in development, or with CI). +receive regular unit testing (either in development, or with CI) against the **current version** of **RediStack**. | Platform | Swift 5.2 | 5.3 | 5.4 | Trunk | |:----------------------|:------------------:|:------------------:|:------------------:|:------------------:| @@ -128,6 +128,23 @@ receive regular unit testing (either in development, or with CI). | CentOS 7 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | CentOS 8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +For older versions of **RediStack**, view each summary below. + +
+RediStack 1.x + +| Platform | Swift 5.1 | 5.2 | 5.3 | Trunk | +|:----------------------|:------------------:|:------------------:|:------------------:|:------------------:| +| macOS Latest (Intel) | | | :white_check_mark: | | +| Ubuntu 20.04 (Focal) | | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Amazon Linux 2 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| CentOS 7 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| CentOS 8 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | + +
+ ## License [Apache 2.0](https://gitlab.com/Mordil/RediStack/blob/master/LICENSE.txt) From 61fb40b194564f12ba2517168862459d783e4c98 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Wed, 2 Dec 2020 00:41:19 -0800 Subject: [PATCH 03/63] Remove Trunk column of historical test matrix in README --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0625eec3..a6bd9b2b 100644 --- a/README.md +++ b/README.md @@ -133,15 +133,15 @@ For older versions of **RediStack**, view each summary below.
RediStack 1.x -| Platform | Swift 5.1 | 5.2 | 5.3 | Trunk | -|:----------------------|:------------------:|:------------------:|:------------------:|:------------------:| -| macOS Latest (Intel) | | | :white_check_mark: | | -| Ubuntu 20.04 (Focal) | | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Amazon Linux 2 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| CentOS 7 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| CentOS 8 | | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Platform | Swift 5.1 | 5.2 | 5.3 | +|:----------------------|:------------------:|:------------------:|:------------------:| +| macOS Latest (Intel) | | | :white_check_mark: | +| Ubuntu 20.04 (Focal) | | :white_check_mark: | :white_check_mark: | +| Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Amazon Linux 2 | | :white_check_mark: | :white_check_mark: | +| CentOS 7 | | :white_check_mark: | :white_check_mark: | +| CentOS 8 | | :white_check_mark: | :white_check_mark: |
From 3c6713038d8fdb8dad402e02ec9c139f53963ca0 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Fri, 27 Nov 2020 00:11:02 -0800 Subject: [PATCH 04/63] Refactor RedisCommand into a general use object Motivation: RediStack today represents a command as a temporary object for the purpose of writing to the channel. While it is useful to have an object for that purpose, commands handled in this way require immediate execution and aren't available for other purposes. Commands can serve a better purpose as a lightweight object to support delayed execution, so that pipeling as described in issue #63 could be possible. Modifications: - Add: `get` overloads for JSON codable interaction on `RedisClient` - Add: New `RedisZRangeResultOption` type for better interactions with zrange operations that can optionally return scores - Add: New `RedisHashFieldKey` for type-safety when working with Hash field keys much like `RedisKey` - Change: A few API types from enums to structs for library evolution - Change: `RedisCommandHandler` to operate on a tuple of `RESPValue, EventLoopPromise` rather than `RedisCommand` - Change: `RedisCommand` to be a generic struct with the keyword, arguments, and a transform closure to defer execution - Change: Almost all `RedisClient` command extensions to be factory methods on `RedisCommand` instead - Change: Many response types to be optional to avoid developers having to do `isNull` checks on their own - Change: `RedisClient.send(command:arguments:)` to be generic with `send(_:)` as the signature - Rename: RedisClient extensions for scan methods to be more discoverable and legible as `scanHashField`, etc. Result: It should be easier to support a clean pipelining API with deferred command execution, with extensions being easier for 2nd party developers, and the maintenance overhead of all of the command extensions should be a little lighter when it comes to changing HOW commands are sent such as adding a context parameter --- .../ChannelHandlers/RedisCommandHandler.swift | 37 +- .../RediStack/Commands/BasicCommands.swift | 244 -- .../Commands/ConnectionCommands.swift | 88 + Sources/RediStack/Commands/HashCommands.swift | 573 ++-- Sources/RediStack/Commands/KeyCommands.swift | 133 + Sources/RediStack/Commands/ListCommands.swift | 1401 +++----- .../RediStack/Commands/PubSubCommands.swift | 121 +- Sources/RediStack/Commands/RedisCommand.swift | 112 + .../RediStack/Commands/ServerCommands.swift | 48 + Sources/RediStack/Commands/SetCommands.swift | 562 +--- .../Commands/SortedSetCommands.swift | 2805 +++++++---------- .../RediStack/Commands/StringCommands.swift | 687 ++-- Sources/RediStack/RESP/RESPValue.swift | 30 +- Sources/RediStack/RedisClient.swift | 19 +- Sources/RediStack/RedisConnection.swift | 68 +- Sources/RediStack/RedisConnectionPool.swift | 10 +- Sources/RediStack/RedisLogging.swift | 9 +- .../EmbeddedMockRedisServer.swift | 4 +- .../Extensions/RediStack.swift | 6 + ...disConnectionPoolIntegrationTestCase.swift | 2 +- .../RedisIntegrationTestCase.swift | 2 +- Sources/RedisTypes/RedisSet.swift | 20 +- .../Commands/ConnectionCommandsTests.swift | 36 + .../Commands/HashCommandsTests.swift | 88 +- ...andsTests.swift => KeyCommandsTests.swift} | 81 +- .../Commands/ListCommandsTests.swift | 188 +- .../Commands/PubSubCommandsTests.swift | 7 +- .../Commands/ServerCommandsTests.swift | 43 + .../Commands/SetCommandsTests.swift | 140 +- .../Commands/SortedSetCommandsTests.swift | 272 +- .../Commands/StringCommandsTests.swift | 78 +- .../RedisConnectionPoolTests.swift | 9 +- 32 files changed, 3308 insertions(+), 4615 deletions(-) delete mode 100644 Sources/RediStack/Commands/BasicCommands.swift create mode 100644 Sources/RediStack/Commands/ConnectionCommands.swift create mode 100644 Sources/RediStack/Commands/KeyCommands.swift create mode 100644 Sources/RediStack/Commands/RedisCommand.swift create mode 100644 Sources/RediStack/Commands/ServerCommands.swift create mode 100644 Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift rename Tests/RediStackIntegrationTests/Commands/{BasicCommandsTests.swift => KeyCommandsTests.swift} (65%) create mode 100644 Tests/RediStackIntegrationTests/Commands/ServerCommandsTests.swift diff --git a/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift b/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift index 6b9f2045..f1716dca 100644 --- a/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift +++ b/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift @@ -14,27 +14,18 @@ import NIO -/// The `NIO.ChannelOutboundHandler.OutboundIn` type for `RedisCommandHandler`. -/// -/// This holds the full command message to be sent to Redis, and an `NIO.EventLoopPromise` to be fulfilled when a response has been received. -/// - Important: This struct has _reference semantics_ due to the retention of the `NIO.EventLoopPromise`. -public struct RedisCommand { - /// A message waiting to be sent to Redis. A full message contains a command keyword and its arguments stored as a single `RESPValue.array`. - public let message: RESPValue - /// A promise to be fulfilled with the sent message's response from Redis. - public let responsePromise: EventLoopPromise - - public init(message: RESPValue, responsePromise promise: EventLoopPromise) { - self.message = message - self.responsePromise = promise - } -} - /// An object that operates in a First In, First Out (FIFO) request-response cycle. /// /// `RedisCommandHandler` is a `NIO.ChannelDuplexHandler` that sends `RedisCommand` instances to Redis, /// and fulfills the command's `NIO.EventLoopPromise` as soon as a `RESPValue` response has been received from Redis. public final class RedisCommandHandler { + /// The data payload that the command handler is expecting to receive in the channel to process sending to Redis. + /// ## message + /// This value is expected to be a fully serialized command with it's keyword and arguments in a bulk string array ready to be sent to Redis as-is. + /// ## responsePromise + /// This is a `NIO.EventLoopPromise` that will be resolved once a response from Redis has been received. + public typealias OutboundCommandPayload = (message: RESPValue, responsePromise: EventLoopPromise) + /// FIFO queue of promises waiting to receive a response value from a sent command. private var commandResponseQueue: CircularBuffer> private var state: State = .default @@ -115,27 +106,25 @@ extension RedisCommandHandler: ChannelInboundHandler { // MARK: ChannelOutboundHandler extension RedisCommandHandler: ChannelOutboundHandler { - /// See `NIO.ChannelOutboundHandler.OutboundIn` - public typealias OutboundIn = RedisCommand - /// See `NIO.ChannelOutboundHandler.OutboundOut` + public typealias OutboundIn = OutboundCommandPayload public typealias OutboundOut = RESPValue /// Invoked by SwiftNIO when a `write` has been requested on the `Channel`. /// - /// This unwraps a `RedisCommand`, storing the `NIO.EventLoopPromise` in a command queue, + /// This unwraps a `OutboundCommandPayload` tuple, storing the `NIO.EventLoopPromise` in a command queue /// to fulfill later with the response to the command that is about to be sent through the `NIO.Channel`. /// /// See `NIO.ChannelOutboundHandler.write(context:data:promise:)` public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - let commandContext = self.unwrapOutboundIn(data) + let commandPayload = self.unwrapOutboundIn(data) switch self.state { - case let .error(e): commandContext.responsePromise.fail(e) + case let .error(e): commandPayload.responsePromise.fail(e) case .default: - self.commandResponseQueue.append(commandContext.responsePromise) + self.commandResponseQueue.append(commandPayload.responsePromise) context.write( - self.wrapOutboundOut(commandContext.message), + self.wrapOutboundOut(commandPayload.message), promise: promise ) } diff --git a/Sources/RediStack/Commands/BasicCommands.swift b/Sources/RediStack/Commands/BasicCommands.swift deleted file mode 100644 index fd4814ab..00000000 --- a/Sources/RediStack/Commands/BasicCommands.swift +++ /dev/null @@ -1,244 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the RediStack open source project -// -// Copyright (c) 2019-2020 RediStack project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of RediStack project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import NIO - -extension RedisClient { - /// Echos the provided message through the Redis instance. - /// - /// See [https://redis.io/commands/echo](https://redis.io/commands/echo) - /// - Parameter message: The message to echo. - /// - Returns: The message sent with the command. - public func echo(_ message: String) -> EventLoopFuture { - let args = [RESPValue(bulk: message)] - return send(command: "ECHO", with: args) - .tryConverting() - } - - /// Pings the server, which will respond with a message. - /// - /// See [https://redis.io/commands/ping](https://redis.io/commands/ping) - /// - Parameter message: The optional message that the server should respond with. - /// - Returns: The provided message or Redis' default response of `"PONG"`. - public func ping(with message: String? = nil) -> EventLoopFuture { - let args: [RESPValue] = message != nil - ? [.init(bulk: message!)] // safe because we did a nil pre-check - : [] - return send(command: "PING", with: args) - .flatMapThrowing { - // because PING is a special command allowed during pub/sub, we do manual conversion - // this is because the response format is different in pub/sub ([pong,]) - guard let response = $0.string ?? $0.array?[1].string else { - throw RedisClientError.assertionFailure(message: "ping message not found") - } - // if no message was sent in the ping in pubsub, then the response will be an empty string - // so we mimic a normal PONG response as if we weren't in pubsub - return response.isEmpty ? "PONG" : response - } - } - - /// Select the Redis logical database having the specified zero-based numeric index. - /// - Note: New connections always use the database `0`. - /// - /// [https://redis.io/commands/select](https://redis.io/commands/select) - /// - Parameter index: The 0-based index of the database that will receive later commands. - /// - Returns: An `EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - public func select(database index: Int) -> EventLoopFuture { - let args = [RESPValue(bulk: index)] - return send(command: "SELECT", with: args) - .map { _ in return () } - } - - /// Swaps the data of two Redis databases by their index IDs. - /// - /// See [https://redis.io/commands/swapdb](https://redis.io/commands/swapdb) - /// - Parameters: - /// - first: The index of the first database. - /// - second: The index of the second database. - /// - Returns: `true` if the swap was successful. - public func swapDatabase(_ first: Int, with second: Int) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(bulk: first), - .init(bulk: second) - ] - return send(command: "SWAPDB", with: args) - .tryConverting(to: String.self) - .map { return $0 == "OK" } - } - - /// Requests the client to authenticate with Redis to allow other commands to be executed. - /// - /// [https://redis.io/commands/auth](https://redis.io/commands/auth) - /// - Parameter password: The password to authenticate with. - /// - Returns: A `NIO.EventLoopFuture` that resolves if the password was accepted, otherwise it fails. - public func authorize(with password: String) -> EventLoopFuture { - let args = [RESPValue(bulk: password)] - return send(command: "AUTH", with: args) - .map { _ in return () } - } - - /// Removes the specified keys. A key is ignored if it does not exist. - /// - /// [https://redis.io/commands/del](https://redis.io/commands/del) - /// - Parameter keys: A list of keys to delete from the database. - /// - Returns: The number of keys deleted from the database. - public func delete(_ keys: [RedisKey]) -> EventLoopFuture { - guard keys.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } - - let args = keys.map(RESPValue.init) - return send(command: "DEL", with: args) - .tryConverting() - } - - /// Removes the specified keys. A key is ignored if it does not exist. - /// - /// [https://redis.io/commands/del](https://redis.io/commands/del) - /// - Parameter keys: A list of keys to delete from the database. - /// - Returns: The number of keys deleted from the database. - public func delete(_ keys: RedisKey...) -> EventLoopFuture { - return self.delete(keys) - } - - /// Checks the existence of the provided keys in the database. - /// - /// [https://redis.io/commands/exists](https://redis.io/commands/exists) - /// - Parameter keys: A list of keys whose existence will be checked for in the database. - /// - Returns: The number of provided keys which exist in the database. - public func exists(_ keys: [RedisKey]) -> EventLoopFuture { - let args: [RESPValue] = keys.map { - RESPValue(from: $0) - } - return self.send(command: "EXISTS", with: args) - .tryConverting(to: Int.self) - } - - /// Checks the existence of the provided keys in the database. - /// - /// [https://redis.io/commands/exists](https://redis.io/commands/exists) - /// - Parameter keys: A list of keys whose existence will be checked for in the database. - /// - Returns: The number of provided keys which exist in the database. - public func exists(_ keys: RedisKey...) -> EventLoopFuture { - return self.exists(keys) - } - - /// Sets a timeout on key. After the timeout has expired, the key will automatically be deleted. - /// - Note: A key with an associated timeout is often said to be "volatile" in Redis terminology. - /// - /// [https://redis.io/commands/expire](https://redis.io/commands/expire) - /// - Parameters: - /// - key: The key to set the expiration on. - /// - timeout: The time from now the key will expire at. - /// - Returns: `true` if the expiration was set. - public func expire(_ key: RedisKey, after timeout: TimeAmount) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: timeout.seconds) - ] - return send(command: "EXPIRE", with: args) - .tryConverting(to: Int.self) - .map { return $0 == 1 } - } -} - -// MARK: TTL - -extension RedisClient { - /// Returns the remaining time-to-live (in seconds) of the provided key. - /// - /// [https://redis.io/commands/ttl](https://redis.io/commands/ttl) - /// - Parameter key: The key to check the time-to-live on. - /// - Returns: The number of seconds before the given key will expire. - public func ttl(_ key: RedisKey) -> EventLoopFuture { - let args: [RESPValue] = [RESPValue(from: key)] - return self.send(command: "TTL", with: args) - .tryConverting(to: Int64.self) - .map { .init(seconds: $0) } - } - - /// Returns the remaining time-to-live (in milliseconds) of the provided key. - /// - /// [https://redis.io/commands/pttl](https://redis.io/commands/pttl) - /// - Parameter key: The key to check the time-to-live on. - /// - Returns: The number of milliseconds before the given key will expire. - public func pttl(_ key: RedisKey) -> EventLoopFuture { - let args: [RESPValue] = [RESPValue(from: key)] - return self.send(command: "PTTL", with: args) - .tryConverting(to: Int64.self) - .map { .init(milliseconds: $0) } - } -} - -// MARK: Scan - -extension RedisClient { - /// Incrementally iterates over all keys in the currently selected database. - /// - /// [https://redis.io/commands/scan](https://redis.io/commands/scan) - /// - Parameters: - /// - position: The cursor position to start from. - /// - match: A glob-style pattern to filter values to be selected from the result set. - /// - count: The number of elements to advance by. Redis default is 10. - /// - Returns: A cursor position for additional invocations with a limited collection of keys found in the database. - public func scan( - startingFrom position: Int = 0, - matching match: String? = nil, - count: Int? = nil - ) -> EventLoopFuture<(Int, [String])> { - return _scan(command: "SCAN", nil, position, match, count) - } - - @usableFromInline - internal func _scan( - command: String, - resultType: T.Type = T.self, - _ key: RedisKey?, - _ pos: Int, - _ match: String?, - _ count: Int? - ) -> EventLoopFuture<(Int, T)> - where - T: RESPValueConvertible - { - var args: [RESPValue] = [.init(bulk: pos)] - - if let k = key { - args.insert(.init(from: k), at: 0) - } - - if let m = match { - args.append(.init(bulk: "match")) - args.append(.init(bulk: m)) - } - if let c = count { - args.append(.init(bulk: "count")) - args.append(.init(bulk: c)) - } - - let response = send(command: command, with: args).tryConverting(to: [RESPValue].self) - let position = response.flatMapThrowing { result -> Int in - guard - let value = result[0].string, - let position = Int(value) - else { - throw RedisClientError.assertionFailure(message: "Unexpected value in response: \(result[0])") - } - return position - } - let elements = response - .map { return $0[1] } - .tryConverting(to: resultType) - - return position.and(elements) - } -} diff --git a/Sources/RediStack/Commands/ConnectionCommands.swift b/Sources/RediStack/Commands/ConnectionCommands.swift new file mode 100644 index 00000000..37f816f3 --- /dev/null +++ b/Sources/RediStack/Commands/ConnectionCommands.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIO + +// MARK: Connection + +extension RedisCommand { + /// [ECHO](https://redis.io/commands/echo) + /// - Parameter message: The message to echo. + public static func echo(_ message: String) -> RedisCommand { + let args = [RESPValue(bulk: message)] + return .init(keyword: "ECHO", arguments: args) + } + + /// [PING](https://redis.io/commands/ping) + /// - Parameter message: The optional message that the server should respond with instead of the default. + public static func ping(with message: String? = nil) -> RedisCommand { + let args = message.map { [RESPValue(bulk: $0)] } ?? [] + return .init(keyword: "PING", arguments: args) { + // because PING is a special command allowed during pub/sub, we do manual conversion + // this is because the response format is different in pub/sub ([pong,]) + guard let response = $0.string ?? $0.array?[1].string else { + throw RedisClientError.assertionFailure(message: "ping message not found") + } + // if no message was sent in the ping in pubsub, then the response will be an empty string + // so we mimic a normal PONG response as if we weren't in pubsub + return response.isEmpty ? "PONG" : response + } + } + + /// [AUTH](https://redis.io/commands/auth) + /// - Parameter password: The password to authenticate with. + public static func auth(with password: String) -> RedisCommand { + let args = [RESPValue(bulk: password)] + return .init(keyword: "AUTH", arguments: args) + } + + /// [SELECT](https://redis.io/commands/select) + /// - Parameter index: The 0-based index of the database that the connection that sends this command will execute later commands against. + public static func select(database index: Int) -> RedisCommand { + let args = [RESPValue(bulk: index)] + return .init(keyword: "SELECT", arguments: args) + } +} + +// MARK: - + +extension RedisClient { + /// Pings the server, which will respond with a message. + /// + /// See `RedisCommand.ping(with:)` + /// - Parameter message: The optional message that the server should respond with instead of the default. + /// - Returns: A `NIO.EventLoopFuture` that resolves the given `message` or Redis' default response of `PONG`. + public func ping(with message: String? = nil) -> EventLoopFuture { + return self.send(.ping(with: message)) + } + + /// Requests the client to authenticate with Redis to allow other commands to be executed. + /// + /// See `RedisCommand.auth(with:)` + /// - Parameter password: The password to authenticate with. + /// - Returns: A `NIO.EventLoopFuture` that resolves if the password as accepted, otherwise it fails. + public func authorize(with password: String) -> EventLoopFuture { + return self.send(.auth(with: password)) + } + + /// Selects the Redis logical database having the given zero-based numeric index. + /// + /// See `RedisCommand.select(database:)` + /// - Note: New connections always use the database `0`. + /// - Parameter index: The 0-based index of the database that the connection sending this command will execute later commands against. + /// - Returns: A `NIO.EventLoopFuture` resolving once the operation has succeeded. + public func select(database index: Int) -> EventLoopFuture { + return self.send(.select(database: index)) + } +} diff --git a/Sources/RediStack/Commands/HashCommands.swift b/Sources/RediStack/Commands/HashCommands.swift index 66ea877e..3db2c851 100644 --- a/Sources/RediStack/Commands/HashCommands.swift +++ b/Sources/RediStack/Commands/HashCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -14,445 +14,324 @@ import NIO -// MARK: Static Helpers +// MARK: Hashes -extension RedisClient { - @usableFromInline - internal static func _mapHashResponse(_ values: [RESPValue]) throws -> [String: RESPValue] { - guard values.count > 0 else { return [:] } - - var result: [String: RESPValue] = [:] - - var index = 0 - repeat { - guard let field = String(fromRESP: values[index]) else { - throw RedisClientError.assertionFailure( - message: "Received non-string value where string hash field key was expected. Raw Value: \(values[index])" - ) - } - let value = values[index + 1] - result[field] = value - index += 2 - } while (index < values.count) - - return result - } -} - -// MARK: General - -extension RedisClient { - /// Removes the specified fields from a hash. - /// - /// See [https://redis.io/commands/hdel](https://redis.io/commands/hdel) +extension RedisCommand { + /// [HDEL](https://redis.io/commands/hdel) /// - Parameters: - /// - fields: The list of field names that should be removed from the hash. + /// - fields: The list of field keys that should be removed from the hash. /// - key: The key of the hash to delete from. - /// - Returns: The number of fields that were deleted. - public func hdel(_ fields: [String], from key: RedisKey) -> EventLoopFuture { - guard fields.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } - - var args: [RESPValue] = [.init(from: key)] + public static func hdel(_ fields: [RedisHashFieldKey], from key: RedisKey) -> RedisCommand { + assert(fields.count > 0, "at least 1 field should be provided") + + var args = [RESPValue(from: key)] args.append(convertingContentsOf: fields) - - return send(command: "HDEL", with: args) - .tryConverting() - } - /// Removes the specified fields from a hash. - /// - /// See [https://redis.io/commands/hdel](https://redis.io/commands/hdel) + return .init(keyword: "HDEL", arguments: args) + } + + /// [HDEL](https://redis.io/commands/hdel) /// - Parameters: - /// - fields: The list of field names that should be removed from the hash. + /// - fields: The list of field keys that should be removed from the hash. /// - key: The key of the hash to delete from. - /// - Returns: The number of fields that were deleted. - public func hdel(_ fields: String..., from key: RedisKey) -> EventLoopFuture { - return self.hdel(fields, from: key) + public static func hdel(_ fields: RedisHashFieldKey..., from key: RedisKey) -> RedisCommand { + return .hdel(fields, from: key) } - /// Checks if a hash contains the field specified. - /// - /// See [https://redis.io/commands/hexists](https://redis.io/commands/hexists) + /// [HEXISTS](https://redis.io/commands/hexists) /// - Parameters: - /// - field: The field name to look for. + /// - field: The field key to look for. /// - key: The key of the hash to look within. - /// - Returns: `true` if the hash contains the field, `false` if either the key or field do not exist. - public func hexists(_ field: String, in key: RedisKey) -> EventLoopFuture { + public static func hexists(_ field: RedisHashFieldKey, in key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: field) + .init(from: field) ] - return send(command: "HEXISTS", with: args) - .tryConverting(to: Int.self) - .map { return $0 == 1 } + return .init(keyword: "HEXISTS", arguments: args) } - /// Gets the number of fields contained in a hash. - /// - /// See [https://redis.io/commands/hlen](https://redis.io/commands/hlen) - /// - Parameter key: The key of the hash to get field count of. - /// - Returns: The number of fields in the hash, or `0` if the key doesn't exist. - public func hlen(of key: RedisKey) -> EventLoopFuture { - let args = [RESPValue(from: key)] - return send(command: "HLEN", with: args) - .tryConverting() - } - - /// Gets the string length of a hash field's value. - /// - /// See [https://redis.io/commands/hstrlen](https://redis.io/commands/hstrlen) + /// [HGET](https://redis.io/commands/hget) /// - Parameters: - /// - field: The field name whose value is being accessed. - /// - key: The key of the hash. - /// - Returns: The string length of the hash field's value, or `0` if the field or hash do not exist. - public func hstrlen(of field: String, in key: RedisKey) -> EventLoopFuture { + /// - field: The key of the field whose value is being accessed. + /// - key: The key of the hash being accessed. + public static func hget(_ field: RedisHashFieldKey, from key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: field) + .init(from: field) ] - return send(command: "HSTRLEN", with: args) - .tryConverting() + return .init(keyword: "HGET", arguments: args) } - /// Gets all field names in a hash. - /// - /// See [https://redis.io/commands/hkeys](https://redis.io/commands/hkeys) - /// - Parameter key: The key of the hash. - /// - Returns: A list of field names stored within the hash. - public func hkeys(in key: RedisKey) -> EventLoopFuture<[String]> { + /// [HGETALL](https://redis.io/commands/hgetall) + /// - Parameter key: The key of the hash to pull from. + public static func hgetall(from key: RedisKey) -> RedisCommand<[RedisHashFieldKey: RESPValue]> { let args = [RESPValue(from: key)] - return send(command: "HKEYS", with: args) - .tryConverting() + return .init(keyword: "HGETALL", arguments: args) { + let fields = try $0.map(to: [RESPValue].self) + return try Self._mapHashResponse(fields) + } } - /// Gets all values stored in a hash. - /// - /// See [https://redis.io/commands/hvals](https://redis.io/commands/hvals) + /// [HINCRBY](https://redis.io/commands/hincrby) + /// - Parameters: + /// - amount: The amount to increment the value stored in the field by. + /// - field: The key of the field whose value should be incremented. + /// - key: The key of the hash the field is stored in. + @inlinable + public static func hincrby( + _ amount: Value, + field: RedisHashFieldKey, + in key: RedisKey + ) -> RedisCommand { ._hincr(keyword: "HINCRBY", amount, field, key) } + + /// [HINCRBYFLOAT](https://redis.io/commands/hincrbyfloat) + /// - Parameters: + /// - amount: The amount to increment the value stored in the field by. + /// - field: The key of the field whose value should be incremented. + /// - key: The key of the hash the field is stored in. + @inlinable + public static func hincrbyfloat( + _ amount: Value, + field: RedisHashFieldKey, + in key: RedisKey + ) -> RedisCommand { ._hincr(keyword: "HINCRBYFLOAT", amount, field, key) } + + /// [HKEYS](https://redis.io/commands/hkeys) /// - Parameter key: The key of the hash. - /// - Returns: A list of all values stored in a hash. - public func hvals(in key: RedisKey) -> EventLoopFuture<[RESPValue]> { + public static func hkeys(in key: RedisKey) -> RedisCommand<[RedisHashFieldKey]> { let args = [RESPValue(from: key)] - return send(command: "HVALS", with: args) - .tryConverting() + return .init(keyword: "HKEYS", arguments: args) } - - /// Gets all values stored in a hash. - /// - /// See [https://redis.io/commands/hvals](https://redis.io/commands/hvals) - /// - Parameters: - /// - key: The key of the hash. - /// - type: The type to convert the values to. - /// - Returns: A list of all values stored in a hash. - @inlinable - public func hvals(in key: RedisKey, as type: Value.Type) -> EventLoopFuture<[Value?]> { - return self.hvals(in: key) - .map { return $0.map(Value.init(fromRESP:)) } + + /// [HLEN](https://redis.io/commands/hlen) + /// - Parameter key: The key of the hash to get field count of. + public static func hlen(of key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "HLEN", arguments: args) } - /// Incrementally iterates over all fields in a hash. - /// - /// [https://redis.io/commands/scan](https://redis.io/commands/scan) + /// [HMGET](https://redis.io/commands/hmget) /// - Parameters: - /// - key: The key of the hash. - /// - position: The position to start the scan from. - /// - match: A glob-style pattern to filter values to be selected from the result set. - /// - count: The number of elements to advance by. Redis default is 10. - /// - valueType: The type to cast all values to. - /// - Returns: A cursor position for additional invocations with a limited collection of found fields and their values. - @inlinable - public func hscan( - _ key: RedisKey, - startingFrom position: Int = 0, - matching match: String? = nil, - count: Int? = nil, - valueType: Value.Type - ) -> EventLoopFuture<(Int, [String: Value?])> { - return self.hscan(key, startingFrom: position, matching: match, count: count) - .map { (cursor, fields) in - let mappedFields = fields.mapValues(Value.init(fromRESP:)) - return (cursor, mappedFields) - } + /// - fields: A list of field keys to get values for. + /// - key: The key of the hash being accessed. + public static func hmget(_ fields: [RedisHashFieldKey], from key: RedisKey) -> RedisCommand<[RESPValue]> { + assert(fields.count > 0, "at least 1 field key should be provided") + + var args = [RESPValue(from: key)] + args.append(convertingContentsOf: fields) + + return .init(keyword: "HMGET", arguments: args) } - /// Incrementally iterates over all fields in a hash. - /// - /// [https://redis.io/commands/scan](https://redis.io/commands/scan) + /// [HMGET](https://redis.io/commands/hmget) /// - Parameters: - /// - key: The key of the hash. - /// - position: The position to start the scan from. - /// - match: A glob-style pattern to filter values to be selected from the result set. - /// - count: The number of elements to advance by. Redis default is 10. - /// - Returns: A cursor position for additional invocations with a limited collection of found fields and their values. - public func hscan( - _ key: RedisKey, - startingFrom position: Int = 0, - matching match: String? = nil, - count: Int? = nil - ) -> EventLoopFuture<(Int, [String: RESPValue])> { - return _scan(command: "HSCAN", resultType: [RESPValue].self, key, position, match, count) - .flatMapThrowing { - let values = try Self._mapHashResponse($0.1) - return ($0.0, values) - } + /// - fields: A list of field keys to get values for. + /// - key: The key of the hash being accessed. + public static func hmget(_ fields: RedisHashFieldKey..., from key: RedisKey) -> RedisCommand<[RESPValue]> { + return .hmget(fields, from: key) } -} -// MARK: Set + /// [HMSET](https://redis.io/commands/hmset) + /// - Parameters: + /// - fields: The key-value pair of field keys and their respective values to set. + /// - key: The key that holds the hash. + public static func hmset(_ fields: [RedisHashFieldKey: RESPValueConvertible], in key: RedisKey) -> RedisCommand { + assert(fields.count > 0, "at least 1 key-value pair should be provided") + + var args = [RESPValue(from: key)] + args.add(contentsOf: fields, overestimatedCountBeingAdded: fields.count * 2) { array, element in + array.append(.init(from: element.key)) + array.append(element.value.convertedToRESPValue()) + } + + return .init(keyword: "HMSET", arguments: args) + } -extension RedisClient { - /// Sets a hash field to the value specified. + /// [HSET](https://redis.io/commands/hset) /// - Note: If you do not want to overwrite existing values, use `hsetnx(_:field:to:)`. - /// - /// See [https://redis.io/commands/hset](https://redis.io/commands/hset) /// - Parameters: - /// - field: The name of the field in the hash being set. + /// - field: The key of the field in the hash being set. /// - value: The value the hash field should be set to. /// - key: The key that holds the hash. - /// - Returns: `true` if the hash was created, `false` if it was updated. @inlinable - public func hset( - _ field: String, + public static func hset( + _ field: RedisHashFieldKey, to value: Value, in key: RedisKey - ) -> EventLoopFuture { + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: field), + .init(from: field), value.convertedToRESPValue() ] - return send(command: "HSET", with: args) - .tryConverting(to: Int.self) - .map { return $0 == 1 } + return .init(keyword: "HSET", arguments: args) } - /// Sets a hash field to the value specified only if the field does not currently exist. + /// [HSETNX](https://redis.io/commands/hsetnx) /// - Note: If you do not care about overwriting existing values, use `hset(_:field:to:)`. - /// - /// See [https://redis.io/commands/hsetnx](https://redis.io/commands/hsetnx) /// - Parameters: - /// - field: The name of the field in the hash being set. + /// - field: The key of the field in the hash being set. /// - value: The value the hash field should be set to. /// - key: The key that holds the hash. - /// - Returns: `true` if the hash was created. @inlinable - public func hsetnx( - _ field: String, + public static func hsetnx( + _ field: RedisHashFieldKey, to value: Value, in key: RedisKey - ) -> EventLoopFuture { + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: field), + .init(from: field), value.convertedToRESPValue() ] - return send(command: "HSETNX", with: args) - .tryConverting(to: Int.self) - .map { return $0 == 1 } + return .init(keyword: "HSETNX", arguments: args) } - /// Sets the fields in a hash to the respective values provided. - /// - /// See [https://redis.io/commands/hmset](https://redis.io/commands/hmset) - /// - Parameters: - /// - fields: The key-value pair of field names and their respective values to set. - /// - key: The key that holds the hash. - /// - Returns: An `EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - @inlinable - public func hmset( - _ fields: [String: Value], - in key: RedisKey - ) -> EventLoopFuture { - assert(fields.count > 0, "At least 1 key-value pair should be specified") - - var args: [RESPValue] = [.init(from: key)] - args.add(contentsOf: fields, overestimatedCountBeingAdded: fields.count * 2) { (array, element) in - array.append(.init(bulk: element.key)) - array.append(element.value.convertedToRESPValue()) - } - - return send(command: "HMSET", with: args) - .map { _ in () } - } -} - -// MARK: Get - -extension RedisClient { - /// Gets a hash field's value. - /// - /// See [https://redis.io/commands/hget](https://redis.io/commands/hget) + /// [HSTRLEN](https://redis.io/commands/hstrlen) /// - Parameters: - /// - field: The name of the field whose value is being accessed. - /// - key: The key of the hash being accessed. - /// - Returns: The value of the hash field. If the key or field does not exist, it will be `.null`. - public func hget(_ field: String, from key: RedisKey) -> EventLoopFuture { + /// - field: The field key whose value is being accessed. + /// - key: The key of the hash. + public static func hstrlen(of field: RedisHashFieldKey, in key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: field) + .init(from: field) ] - return send(command: "HGET", with: args) - } - - /// Gets a hash field's value as the desired type. - /// - /// See [https://redis.io/commands/hget](https://redis.io/commands/hget) - /// - Parameters: - /// - field: The name of the field whose value is being accessed. - /// - key: The key of the hash being accessed. - /// - type: The type to convert the value to. - /// - Returns: The value of the hash field, or `nil` if the `RESPValue` conversion fails or either the key or field does not exist. - @inlinable - public func hget( - _ field: String, - from key: RedisKey, - as type: Value.Type - ) -> EventLoopFuture { - return self.hget(field, from: key) - .map(Value.init(fromRESP:)) + return .init(keyword: "HSTRLEN", arguments: args) } - /// Gets the values of a hash for the fields specified. - /// - /// See [https://redis.io/commands/hmget](https://redis.io/commands/hmget) - /// - Parameters: - /// - fields: A list of field names to get values for. - /// - key: The key of the hash being accessed. - /// - Returns: A list of values in the same order as the `fields` argument. Non-existent fields return `.null` values. - public func hmget(_ fields: [String], from key: RedisKey) -> EventLoopFuture<[RESPValue]> { - guard fields.count > 0 else { return self.eventLoop.makeSucceededFuture([]) } - - var args: [RESPValue] = [.init(from: key)] - args.append(convertingContentsOf: fields) - - return send(command: "HMGET", with: args) - .tryConverting(to: [RESPValue].self) + /// [HVALS](https://redis.io/commands/hvals) + /// - Parameter key: The key of the hash. + public static func hvals(in key: RedisKey) -> RedisCommand<[RESPValue]> { + let args = [RESPValue(from: key)] + return .init(keyword: "HVALS", arguments: args) } - /// Gets the values of a hash for the fields specified as a specific type. - /// - /// See [https://redis.io/commands/hmget](https://redis.io/commands/hmget) + /// [HSCAN](https://redis.io/commands/hscan) /// - Parameters: - /// - fields: A list of field names to get values for. - /// - key: The key of the hash being accessed. - /// - type: The type to convert the values to. - /// - Returns: A list of values in the same order as the `fields` argument. Non-existent fields and elements that fail the `RESPValue` conversion return `nil` values. + /// - key: The key of the hash. + /// - position: The position to start the scan from. + /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - count: The number of elements to advance by. Redis default is 10. @inlinable - public func hmget( - _ fields: [String], - from key: RedisKey, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.hmget(fields, from: key) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Gets the values of a hash for the fields specified. - /// - /// See [https://redis.io/commands/hmget](https://redis.io/commands/hmget) - /// - Parameters: - /// - fields: A list of field names to get values for. - /// - key: The key of the hash being accessed. - /// - Returns: A list of values in the same order as the `fields` argument. Non-existent fields return `.null` values. - public func hmget(_ fields: String..., from key: RedisKey) -> EventLoopFuture<[RESPValue]> { - return self.hmget(fields, from: key) + public static func hscan( + _ key: RedisKey, + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> RedisCommand<(Int, [RedisHashFieldKey: RESPValue])> { + return ._scan(keyword: "HSCAN", key, position, match, count, { + let values = try $0.map(to: [RESPValue].self) + return try Self._mapHashResponse(values) + }) } +} + +// MARK: - - /// Gets the values of a hash for the fields specified. +extension RedisClient { + /// Incrementally iterates over all fields in a hash. /// - /// See [https://redis.io/commands/hmget](https://redis.io/commands/hmget) + /// See `RedisCommand.hscan(_:startingFrom:matching:count:)` /// - Parameters: - /// - fields: A list of field names to get values for. - /// - key: The key of the hash being accessed. - /// - type: The type to convert the values to. - /// - Returns: A list of values in the same order as the `fields` argument. Non-existent fields and elements that fail the `RESPValue` conversion return `nil` values. - @inlinable - public func hmget( - _ fields: String..., - from key: RedisKey, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.hmget(fields, from: key, as: type) + /// - key: The key of the hash. + /// - position: The position to start the scan from. + /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - count: The number of elements to advance by. Redis default is 10. + /// - valueType: The type to cast all values to. + /// - Returns: A `NIO.EventLoopFuture` that resolves a cursor position for additional scans, + /// with a limited collection of fields and their associated values that were iterated over. + public func scanHashFields( + in key: RedisKey, + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> EventLoopFuture<(Int, [RedisHashFieldKey: RESPValue])> { + return self.send(.hscan(key, startingFrom: position, matching: match, count: count)) } +} - /// Returns all the fields and values stored in a hash. - /// - /// See [https://redis.io/commands/hgetall](https://redis.io/commands/hgetall) - /// - Parameter key: The key of the hash to pull from. - /// - Returns: A key-value pair list of fields and their values. - public func hgetall(from key: RedisKey) -> EventLoopFuture<[String: RESPValue]> { - let args = [RESPValue(from: key)] - return send(command: "HGETALL", with: args) - .tryConverting(to: [RESPValue].self) - .flatMapThrowing(Self._mapHashResponse) +// MARK: - + +/// A representation of a Redis hash field key. +/// +/// `RedisHashFieldKey` is a thin wrapper around `String` to provide stronger type-safety at compile-time with regards to the domain semantics of any +/// give `String` value. +/// +/// It conforms to `ExpressibleByStringLiteral` and `ExpressibleByStringInterpolation`, so creating a hash field key is as simple as: +/// ```swift +/// let fieldKey: RedisHashFieldKey = "foo" // or "\(someVar)" +/// ``` +public struct RedisHashFieldKey: + RESPValueConvertible, + RawRepresentable, + ExpressibleByStringLiteral, ExpressibleByStringInterpolation, + CustomStringConvertible, CustomDebugStringConvertible, + Comparable, Hashable, Codable +{ + public let rawValue: String + + /// Creates a type-safe representation of a key to a Redis hash field. + /// - Parameter key: The key of the Redis hash field. + public init(_ key: String) { self.rawValue = key } + + public var description: String { self.rawValue } + public var debugDescription: String { "\(String(describing: type(of: self))): \(self.rawValue)"} + + public init?(fromRESP value: RESPValue) { + guard let string = value.string else { return nil } + self.rawValue = string + } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.rawValue = value } + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.rawValue = try container.decode(String.self) } - /// Returns all the fields and values stored in a hash. - /// - /// See [https://redis.io/commands/hgetall](https://redis.io/commands/hgetall) - /// - Parameters: - /// - key: The key of the hash to pull from. - /// - type: The type to convert the values to. - /// - Returns: A key-value pair list of fields and their values. Elements that fail the `RESPValue` conversion will be `nil`. - @inlinable - public func hgetall( - from key: RedisKey, - as type: Value.Type - ) -> EventLoopFuture<[String: Value?]> { - return self.hgetall(from: key) - .map { return $0.mapValues(Value.init(fromRESP:)) } + public static func <(lhs: RedisHashFieldKey, rhs: RedisHashFieldKey) -> Bool { lhs.rawValue < rhs.rawValue } + + public func convertedToRESPValue() -> RESPValue { .init(bulk: self.rawValue) } + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) } } -// MARK: Increment +// MARK: - Shared implementations +extension RedisCommand { + @usableFromInline + internal static func _mapHashResponse(_ values: [RESPValue]) throws -> [RedisHashFieldKey: RESPValue] { + guard values.count > 0 else { return [:] } -extension RedisClient { - /// Increments a hash field's value and returns the new value. - /// - /// See [https://redis.io/commands/hincrby](https://redis.io/commands/hincrby) - /// - Parameters: - /// - amount: The amount to increment the value stored in the field by. - /// - field: The name of the field whose value should be incremented. - /// - key: The key of the hash the field is stored in. - /// - Returns: The new value of the hash field. - @inlinable - public func hincrby( - _ amount: Value, - field: String, - in key: RedisKey - ) -> EventLoopFuture { - return _hincr(command: "HINCRBY", amount, field, key) - } + var result: [RedisHashFieldKey: RESPValue] = [:] - /// Increments a hash field's value and returns the new value. - /// - /// See [https://redis.io/commands/hincrbyfloat](https://redis.io/commands/hincrbyfloat) - /// - Parameters: - /// - amount: The amount to increment the value stored in the field by. - /// - field: The name of the field whose value should be incremented. - /// - key: The key of the hash the field is stored in. - /// - Returns: The new value of the hash field. - @inlinable - public func hincrbyfloat( - _ amount: Value, - field: String, - in key: RedisKey - ) -> EventLoopFuture { - return _hincr(command: "HINCRBYFLOAT", amount, field, key) + var index = 0 + repeat { + guard let field = RedisHashFieldKey(fromRESP: values[index]) else { + throw RedisClientError.assertionFailure( + message: "Received non-string value where string hash field key was expected. Raw Value: \(values[index])" + ) + } + let value = values[index + 1] + result[field] = value + index += 2 + } while (index < values.count) + + return result } - + @usableFromInline - internal func _hincr( - command: String, + internal static func _hincr( + keyword: String, _ amount: Value, - _ field: String, + _ field: RedisHashFieldKey, _ key: RedisKey - ) -> EventLoopFuture { + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: field), + .init(from: field), amount.convertedToRESPValue() ] - return send(command: command, with: args) - .tryConverting() + return .init(keyword: keyword, arguments: args) } } diff --git a/Sources/RediStack/Commands/KeyCommands.swift b/Sources/RediStack/Commands/KeyCommands.swift new file mode 100644 index 00000000..55ed5fa5 --- /dev/null +++ b/Sources/RediStack/Commands/KeyCommands.swift @@ -0,0 +1,133 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIO + +// MARK: Key + +extension RedisCommand { + /// [DEL](https://redis.io/commands/del) + /// - Parameter keys: The list of keys to delete from the database. + public static func del(_ keys: [RedisKey]) -> RedisCommand { + let args = keys.map(RESPValue.init(from:)) + return .init(keyword: "DEL", arguments: args) + } + + /// [EXISTS](https://redis.io/commands/exists) + /// - Parameter keys: A list of keys whose existence will be checked for in the database. + public static func exists(_ keys: [RedisKey]) -> RedisCommand { + let args = keys.map(RESPValue.init(from:)) + return .init(keyword: "EXISTS", arguments: args) + } + + /// [EXISTS](https://redis.io/commands/exists) + /// - Parameter keys: A list of keys whose existence will be checked for in the database. + public static func exists(_ keys: RedisKey...) -> RedisCommand { + return .exists(keys) + } + + /// [EXPIRE](https://redis.io/commands/expire) + /// - Note: A key with an associated timeout is often said to be "volatile" in Redis terminology. + /// - Parameters: + /// - key: The key to set the expiration on. + /// - timeout: The time from now the key will expire at. + public static func expire(_ key: RedisKey, after timeout: TimeAmount) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: timeout.seconds) + ] + return .init(keyword: "EXPIRE", arguments: args) + } + + /// [TTL](https://redis.io/commands/ttl) + /// - Parameter key: The key to check the time-to-live on. + public static func ttl(_ key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "TTL", arguments: args) { + return .init(seconds: try $0.map()) + } + } + + /// [PTTL](https://redis.io/commands/pttl) + /// - Parameter key: The key to check the time-to-live on. + public static func pttl(_ key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "PTTL", arguments: args) { + return .init(milliseconds: try $0.map()) + } + } + + /// [SCAN](https://redis.io/commands/scan) + /// - Parameters: + /// - position: The cursor position to start from. + /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - count: The number of elements to advance by. Redis default is 10. + public static func scan( + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> RedisCommand<(Int, [RedisKey])> { + return ._scan(keyword: "SCAN", nil, position, match, count, { try $0.map() }) + } +} + +// MARK: - + +extension RedisClient { + /// Deletes the given keys. Any key that does not exist is ignored. + /// + /// See `RedisCommand.del(keys:)` + /// - Parameter keys: The list of keys to delete from the database. + /// - Returns: A `NIO.EventLoopFuture` that resolves the number of keys that were deleted from the database. + public func delete(_ keys: RedisKey...) -> EventLoopFuture { + return self.delete(keys) + } + + /// Deletes the given keys. Any key that does not exist is ignored. + /// + /// See `RedisCommand.del(keys:)` + /// - Parameter keys: The list of keys to delete from the database. + /// - Returns: A `NIO.EventLoopFuture` that resolves the number of keys that were deleted from the database. + public func delete(_ keys: [RedisKey]) -> EventLoopFuture { + guard keys.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } + return self.send(.del(keys)) + } + + /// Sets a timeout on key. After the timeout has expired, the key will automatically be deleted. + /// + /// See `RedisCommand.expire(_:after:)` + /// - Parameters: + /// - key: The key to set the expiration on. + /// - timeout: The time from now the key will expire at. + /// - Returns: A `NIO.EventLoopFuture` that resolves `true` if the expiration was set and `false` if it wasn't. + public func expire(_ key: RedisKey, after timeout: TimeAmount) -> EventLoopFuture { + return self.send(.expire(key, after: timeout)) + } + + /// Incrementally iterates over all keys in the currently selected database. + /// + /// See `RedisCommand.scan(startingFrom:matching:count:)` + /// - Parameters: + /// - position: The cursor position to start from. + /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - count: The number of elements to advance by. Redis default is 10. + /// - Returns: A cursor position for additional invocations with a limited collection of keys found in the database. + public func scanKeys( + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> EventLoopFuture<(Int, [RedisKey])> { + return self.send(.scan(startingFrom: position, matching: match, count: count)) + } +} diff --git a/Sources/RediStack/Commands/ListCommands.swift b/Sources/RediStack/Commands/ListCommands.swift index 6f05668d..7eb37446 100644 --- a/Sources/RediStack/Commands/ListCommands.swift +++ b/Sources/RediStack/Commands/ListCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -14,1129 +14,558 @@ import NIO -// MARK: General +// MARK: Lists -extension RedisClient { - /// Gets the length of a list. +extension RedisCommand { + /// [BLPOP](https://redis.io/commands/blpop) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the list. /// - /// See [https://redis.io/commands/llen](https://redis.io/commands/llen) - /// - Parameter key: The key of the list. - /// - Returns: The number of elements in the list. - public func llen(of key: RedisKey) -> EventLoopFuture { - let args = [RESPValue(from: key)] - return send(command: "LLEN", with: args) - .tryConverting() + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `lpop` method where possible. + /// - Parameters: + /// - key: The key of the list to pop from. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func blpop(from key: RedisKey, timeout: TimeAmount = .seconds(0)) -> RedisCommand { + return ._bpop(keyword: "BLPOP", [key], timeout, { $0?.1 }) + } + + /// [BLPOP](https://redis.io/commands/blpop) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the list. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `lpop` method where possible. + /// - Parameters: + /// - keys: The list of keys to pop from. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func blpop( + from keys: [RedisKey], + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(RedisKey, RESPValue)?> { + return ._bpop(keyword: "BLPOP", keys, timeout, { $0 }) + } + + /// [BLPOP](https://redis.io/commands/blpop) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the list. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `lpop` method where possible. + /// - Parameters: + /// - keys: The list of keys to pop from. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func blpop( + from keys: RedisKey..., + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(RedisKey, RESPValue)?> { .blpop(from: keys, timeout: timeout) } + + /// [BRPOP](https://redis.io/commands/brpop) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the list. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `rpop` method where possible. + /// - Parameters: + /// - key: The key of the list to pop from. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func brpop(from key: RedisKey, timeout: TimeAmount = .seconds(0)) -> RedisCommand { + return ._bpop(keyword: "BRPOP", [key], timeout, { $0?.1 }) } - /// Gets the element from a list stored at the provided index position. + /// [BRPOP](https://redis.io/commands/brpop) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the list. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `rpop` method where possible. + /// - Parameters: + /// - key: The key of the list to pop from. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func brpop(from keys: [RedisKey], timeout: TimeAmount = .seconds(0)) -> RedisCommand<(RedisKey, RESPValue)?> { + return ._bpop(keyword: "BRPOP", keys, timeout, { $0 }) + } + + /// [BRPOP](https://redis.io/commands/brpop) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the list. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `rpop` method where possible. + /// - Parameters: + /// - key: The key of the list to pop from. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func brpop( + from keys: RedisKey..., + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(RedisKey, RESPValue)?> { .brpop(from: keys, timeout: timeout) } + + /// [BRPOPLPUSH](https://redis.io/commands/brpoplpush) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the source list. /// - /// See [https://redis.io/commands/lindex](https://redis.io/commands/lindex) + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `rpoplpush` method where possible. + /// - Parameters: + /// - source: The key of the list to pop from. + /// - dest: The key of the list to push to. + /// - timeout: The max time to wait for a value to use. `0` seconds means to wait indefinitely. + public static func brpoplpush( + from source: RedisKey, + to dest: RedisKey, + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: source), + .init(from: dest), + .init(from: timeout.seconds) + ] + return .init(keyword: "BRPOPLPUSH", arguments: args) + } + + /// [LINDEX](https://redis.io/commands/lindex) /// - Parameters: /// - index: The 0-based index of the element to get. /// - key: The key of the list. - /// - Returns: The element stored at index, or `.null` if out of bounds. - public func lindex(_ index: Int, from key: RedisKey) -> EventLoopFuture { + public static func lindex(_ index: Int, from key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), .init(bulk: index) ] - return send(command: "LINDEX", with: args) + return .init(keyword: "LINDEX", arguments: args) { try? $0.map() } } - - /// Gets the element from a list stored at the provided index position. - /// - /// See [https://redis.io/commands/lindex](https://redis.io/commands/lindex) + + /// [LINSERT](https://redis.io/commands/linsert) /// - Parameters: - /// - index: The 0-based index of the element to get. + /// - element: The value to insert into the list. /// - key: The key of the list. - /// - type: The type to convert the value to. - /// - Returns: The element stored at index. If the value fails the `RESPValue` conversion or if the index is out of bounds, the returned value will be `nil`. + /// - pivot: The value of the element to insert before. @inlinable - public func lindex( - _ index: Int, - from key: RedisKey, - as type: Value.Type - ) -> EventLoopFuture { - return self.lindex(index, from: key) - .map(Value.init(fromRESP:)) + public static func linsert( + _ element: Value, + into key: RedisKey, + before pivot: Value + ) -> RedisCommand { ._linsert(pivotKeyword: "BEFORE", element, key, pivot) } + + /// [LINSERT](https://redis.io/commands/linsert) + /// - Parameters: + /// - element: The value to insert into the list. + /// - key: The key of the list. + /// - pivot: The value of the element to insert after. + @inlinable + public static func linsert( + _ element: Value, + into key: RedisKey, + after pivot: Value + ) -> RedisCommand { ._linsert(pivotKeyword: "AFTER", element, key, pivot) } + + /// [LLEN](https://redis.io/commands/llen) + /// - Parameter key: The key of the list. + public static func llen(of key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "LLEN", arguments: args) } - /// Sets the value of an element in a list at the provided index position. - /// - /// See [https://redis.io/commands/lset](https://redis.io/commands/lset) + /// [LPOP](https://redis.io/commands/lpop) + /// - Parameter key: The key of the list to pop from. + public static func lpop(from key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "LPOP", arguments: args) { try? $0.map() } + } + + /// [LPUSH](https://redis.io/commands/lpush) + /// - Note: This inserts the elements at the head of the list; for the tail see `rpush(_:into:)`. /// - Parameters: - /// - index: The 0-based index of the element to set. - /// - value: The new value the element should be. - /// - key: The key of the list to update. - /// - Returns: An `EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. + /// - elements: The values to push into the list. + /// - key: The key of the list. @inlinable - public func lset( - index: Int, - to value: Value, - in key: RedisKey - ) -> EventLoopFuture { + public static func lpush(_ elements: [Value], into key: RedisKey) -> RedisCommand { + assert(elements.count > 0, "at least 1 element should be provided") + + var args = [RESPValue(from: key)] + args.append(convertingContentsOf: elements) + + return .init(keyword: "LPUSH", arguments: args) + } + + /// [LPUSH](https://redis.io/commands/lpush) + /// - Note: This inserts the elements at the head of the list; for the tail see `rpush(_:into:)`. + /// - Parameters: + /// - elements: The values to push into the list. + /// - key: The key of the list. + @inlinable + public static func lpush( + _ elements: Value..., + into key: RedisKey + ) -> RedisCommand { .lpush(elements, into: key) } + + /// [LPUSHX](https://redis.io/commands/lpushx) + /// - Note: This inserts the element at the head of the list, for the tail see `rpushx(_:into:)`. + /// - Parameters: + /// - element: The value to try and push into the list. + /// - key: The key of the list. + @inlinable + public static func lpushx(_ element: Value, into key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: index), - value.convertedToRESPValue() + element.convertedToRESPValue() + ] + return .init(keyword: "LPUSHX", arguments: args) + } + + /// [LRANGE](https://redis.io/commands/lrange) + /// - Parameters: + /// - key: The key of the List. + /// - firstIndex: The inclusive index of the first element to include in the range of elements returned. + /// - lastIndex: The inclusive index of the last element to include in the range of elements returned. + public static func lrange(from key: RedisKey, firstIndex: Int, lastIndex: Int) -> RedisCommand<[RESPValue]> { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: firstIndex), + .init(bulk: lastIndex) ] - return send(command: "LSET", with: args) - .map { _ in () } + return .init(keyword: "LRANGE", arguments: args) + } + + /// [LRANGE](https://redis.io/commands/lrange) + /// + /// Example usage: + /// ```swift + /// // elements at indices 4-7 + /// client.send(.lrange(from: "myList", indices: 4...7)) + /// + /// // last 4 elements + /// client.send(.lrange(from: "myList", indices: (-4)...(-1))) + /// + /// // first and last 4 elements + /// client.send(.lrange(from: "myList", indices: (-4)...3)) + /// + /// // first element, and the last 4 + /// client.send(.lrange(from: "myList", indices: (-4)...0)) + /// ``` + /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, + /// `ClosedRange` will trigger a precondition failure. + /// + /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. + /// - Parameters: + /// - key: The key of the List to return elements from. + /// - range: The range of inclusive indices of elements to get. + public static func lrange(from key: RedisKey, indices range: ClosedRange) -> RedisCommand<[RESPValue]> { + return .lrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound) + } + + /// [LRANGE](https://redis.io/commands/lrange) + /// + /// Example usage: + /// ```swift + /// // elements at indicies 4-7 + /// client.send(.lrange(from: "myList", indices: 4..<8)) + /// + /// // last 4 elements + /// client.send(.lrange(from: "myList", indices: (-4)..<0)) + /// + /// // first and last 4 elements + /// client.send(.lrange(from: "myList", indices: (-4)..<4)) + /// + /// // first element and the last 4 + /// client.send(.lrange(from: "myList", indices: (-4)..<1)) + /// ``` + /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, + /// `Range` will trigger a precondition failure. + /// + /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. + /// - Parameters: + /// - key: The key of the List to return elements from. + /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. + public static func lrange(from key: RedisKey, indices range: Range) -> RedisCommand<[RESPValue]> { + return .lrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1) + } + + /// [LRANGE](https://redis.io/commands/lrange) + /// + /// Example usage: + /// ```swift + /// // all except first 2 elements + /// client.send(.lrange(from: "myList", fromIndex: 2)) + /// + /// // last 4 elements + /// client.send(.lrange(from: "myList", fromIndex: -4)) + /// ``` + /// - Parameters: + /// - key: The key of the List to return elements from. + /// - index: The index of the first element that will be in the returned values. + public static func lrange(from key: RedisKey, fromIndex index: Int) -> RedisCommand<[RESPValue]> { + return .lrange(from: key, firstIndex: index, lastIndex: -1) + } + + /// [LRANGE](https://redis.io/commands/lrange) + /// + /// Example usage: + /// ```swift + /// // first 3 elements + /// client.send(.lrange(from: "myList", throughIndex: 2)) + /// + /// // all except last 3 elements + /// client.send(.lrange(from: "myList", throughIndex: -4)) + /// ``` + /// - Parameters: + /// - key: The key of the List to return elements from. + /// - index: The index of the last element that will be in the returned values. + public static func lrange(from key: RedisKey, throughIndex index: Int) -> RedisCommand<[RESPValue]> { + return .lrange(from: key, firstIndex: 0, lastIndex: index) } - /// Removes elements from a list matching the value provided. + /// [LRANGE](https://redis.io/commands/lrange) + /// + /// Example usage: + /// ```swift + /// // first 3 elements + /// client.send(.lrange(from: "myList", upToIndex: 3)) /// - /// See [https://redis.io/commands/lrem](https://redis.io/commands/lrem) + /// // all except last 3 elements + /// client.send(.lrange(from: "myList", upToIndex: -3)) + /// ``` + /// - Parameters: + /// - key: The key of the List to return elements from. + /// - index: The index of the element to not include in the returned values. + public static func lrange(from key: RedisKey, upToIndex index: Int) -> RedisCommand<[RESPValue]> { + return .lrange(from: key, firstIndex: 0, lastIndex: index - 1) + } + + /// [LREM](https://redis.io/commands/lrem) /// - Parameters: /// - value: The value to delete from the list. /// - key: The key of the list to remove from. /// - count: The max number of elements to remove matching the value. See Redis' documentation for more info. - /// - Returns: The number of elements removed from the list. @inlinable - public func lrem( + public static func lrem( _ value: Value, from key: RedisKey, count: Int = 0 - ) -> EventLoopFuture { + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), .init(bulk: count), value.convertedToRESPValue() ] - return send(command: "LREM", with: args) - .tryConverting() + return .init(keyword: "LREM", arguments: args) } -} -// MARK: LTrim + /// [LSET](https://redis.io/commands/lset) + /// - Parameters: + /// - index: The 0-based index of the element to set. + /// - value: The new value the element should be. + /// - key: The key of the list to update. + @inlinable + public static func lset( + index: Int, + to value: Value, + in key: RedisKey + ) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: index), + value.convertedToRESPValue() + ] + return .init(keyword: "LSET", arguments: args) + } -extension RedisClient { - /// Trims a List to only contain elements within the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/ltrim](https://redis.io/commands/ltrim) + /// [LTRIM](https://redis.io/commands/ltrim) /// - Parameters: /// - key: The key of the List to trim. /// - start: The index of the first element to keep. /// - stop: The index of the last element to keep. - /// - Returns: A `NIO.EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - public func ltrim(_ key: RedisKey, before start: Int, after stop: Int) -> EventLoopFuture { + @inlinable + public static func ltrim(_ key: RedisKey, before start: Int, after stop: Int) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), .init(bulk: start), .init(bulk: stop) ] - return send(command: "LTRIM", with: args) - .map { _ in () } + return .init(keyword: "LTRIM", arguments: args) } - /// Trims a List to only contain elements within the specified inclusive bounds of 0-based indices. + /// [LTRIM](https://redis.io/commands/ltrim) /// - /// To keep elements 4 through 7: + /// Example usage: /// ```swift - /// client.ltrim("myList", keepingIndices: 3...6) - /// ``` + /// // keep elements at indices 4-7 + /// client.send(.ltrim("myList", keepingIndices: 3...6)) /// - /// To keep the last 4 through 7 elements: - /// ```swift - /// client.ltrim("myList", keepingIndices: (-7)...(-4)) - /// ``` + /// // keep last 4-7 elements + /// client.send(.ltrim("myList", keepingIndices: (-7)...(-4))) /// - /// To keep the first and last 4 elements: - /// ```swift - /// client.ltrim("myList", keepingIndices: (-4)...3) + /// // keep first element and last 4 + /// client.send(.ltrim("myList", keepingIndices: (-4)...3)) /// ``` - /// - /// See [https://redis.io/commands/ltrim](https://redis.io/commands/ltrim) - /// - Warning: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, + /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, /// `ClosedRange` will trigger a precondition failure. /// /// If you need such a range, use `ltrim(_:before:after:)` instead. /// - Parameters: /// - key: The key of the List to trim. /// - range: The range of indices that should be kept in the List. - /// - Returns: A `NIO.EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - public func ltrim(_ key: RedisKey, keepingIndices range: ClosedRange) -> EventLoopFuture { - return self.ltrim(key, before: range.lowerBound, after: range.upperBound) + public static func ltrim(_ key: RedisKey, keepingIndices range: ClosedRange) -> RedisCommand { + return .ltrim(key, before: range.lowerBound, after: range.upperBound) } - - /// Trims a List to only contain elements starting from the specified index. + + /// [LTRIM](https://redis.io/commands/ltrim) /// - /// To keep all but the first 3 elements: + /// Example usage: /// ```swift - /// client.ltrim("myList", keepingIndices: 3...) - /// ``` + /// // keep all but the first 3 elements + /// client.send(.ltrim("myList", keepingIndices: 3...)) /// - /// To keep the last 4 elements: - /// ```swift - /// client.ltrim("myList", keepingIndices: (-4)...) + /// // keep last 4 elements + /// client.send(.ltrim("myList", keepingIndices: (-4)...)) /// ``` - /// - /// See [https://redis.io/commands/ltrim](https://redis.io/commands/ltrim) /// - Parameters: /// - key: The key of the List to trim. /// - range: The range of indices that should be kept in the List. - /// - Returns: A `NIO.EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - public func ltrim(_ key: RedisKey, keepingIndices range: PartialRangeFrom) -> EventLoopFuture { + public static func ltrim(_ key: RedisKey, keepingIndices range: PartialRangeFrom) -> RedisCommand { return self.ltrim(key, before: range.lowerBound, after: -1) } - - /// Trims a List to only contain elements before the specified index. + + /// [LTRIM](https://redis.io/commands/ltrim) /// - /// To keep the first 3 elements: + /// Example usage: /// ```swift - /// client.ltrim("myList", keepingIndices: ..<3) - /// ``` + /// // keep first 3 elements + /// client.send(.ltrim("myList", keepingIndices: ..<3)) /// - /// To keep all but the last 4 elements: - /// ```swift - /// client.ltrim("myList", keepingIndices: ..<(-4)) + /// // keep all but the last 4 elements + /// client.send(.ltrim("myList", keepingIndices: ..<(-4))) /// ``` - /// - /// See [https://redis.io/commands/ltrim](https://redis.io/commands/ltrim) /// - Parameters: /// - key: The key of the List to trim. /// - range: The range of indices that should be kept in the List. - /// - Returns: A `NIO.EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - public func ltrim(_ key: RedisKey, keepingIndices range: PartialRangeUpTo) -> EventLoopFuture { + public static func ltrim(_ key: RedisKey, keepingIndices range: PartialRangeUpTo) -> RedisCommand { return self.ltrim(key, before: 0, after: range.upperBound - 1) } - - /// Trims a List to only contain elements up to the specified index. + + /// [LTRIM](https://redis.io/commands/ltrim) /// - /// To keep the first 4 elements: + /// Example usage: /// ```swift - /// client.ltrim("myList", keepingIndices: ...3) - /// ``` + /// // keep first 4 elements + /// client.send(.ltrim("myList", keepingIndices: ...3)) /// - /// To keep all but the last 3 elements: - /// ```swift - /// client.ltrim("myList", keepingIndices: ...(-4)) + /// // keep all but the last 3 elements + /// client.send(.ltrim("myList", keepingIndices: ...(-4))) /// ``` - /// - /// See [https://redis.io/commands/ltrim](https://redis.io/commands/ltrim) /// - Parameters: /// - key: The key of the List to trim. /// - range: The range of indices that should be kept in the List. - /// - Returns: A `NIO.EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - public func ltrim(_ key: RedisKey, keepingIndices range: PartialRangeThrough) -> EventLoopFuture { + public static func ltrim(_ key: RedisKey, keepingIndices range: PartialRangeThrough) -> RedisCommand { return self.ltrim(key, before: 0, after: range.upperBound) } - - /// Trims a List to only contain the elements from the specified index up to the index provided. + + /// [LTRIM](https://redis.io/commands/ltrim) /// - /// To keep the first 4 elements: + /// Example usage: /// ```swift - /// client.ltrim("myList", keepingIndices: 0..<4) - /// ``` + /// // keep first 4 elements + /// client.send(.ltrim("myList", keepingIndices: 0..<4)) /// - /// To keep all but the last 3 elements: - /// ```swift - /// client.ltrim("myList", keepingIndices: 0..<(-3)) + /// // keep all but the last 3 elements + /// client.send(.ltrim("myList", keepingIndices: 0..<(-3))) /// ``` - /// - /// See [https://redis.io/commands/ltrim](https://redis.io/commands/ltrim) - /// - Warning: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, - /// `Range` will trigger a precondition failure. - /// - /// If you need such a range, use `ltrim(_:before:after:)` instead. /// - Parameters: /// - key: The key of the List to trim. /// - range: The range of indices that should be kept in the List. - /// - Returns: A `NIO.EventLoopFuture` that resolves when the operation has succeeded, or fails with a `RedisError`. - public func ltrim(_ key: RedisKey, keepingIndices range: Range) -> EventLoopFuture { + public static func ltrim(_ key: RedisKey, keepingIndices range: Range) -> RedisCommand { return self.ltrim(key, before: range.lowerBound, after: range.upperBound - 1) } -} -// MARK: LRange + /// [RPOP](https://redis.io/commands/rpop) + /// - Parameter key: The key of the list to pop from. + public static func rpop(from key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "RPOP", arguments: args) { try? $0.map() } + } -extension RedisClient { - /// Gets all elements from a List within the the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/lrange](https://redis.io/commands/lrange) + /// [RPOPLPUSH](https://redis.io/commands/rpoplpush) /// - Parameters: - /// - key: The key of the List. - /// - firstIndex: The index of the first element to include in the range of elements returned. - /// - lastIndex: The index of the last element to include in the range of elements returned. - /// - Returns: An array of elements found within the range specified. - public func lrange(from key: RedisKey, firstIndex: Int, lastIndex: Int) -> EventLoopFuture<[RESPValue]> { + /// - source: The key of the list to pop from. + /// - dest: The key of the list to push to. + public static func rpoplpush(from source: RedisKey, to dest: RedisKey) -> RedisCommand { let args: [RESPValue] = [ - .init(from: key), - .init(bulk: firstIndex), - .init(bulk: lastIndex) + .init(from: source), + .init(from: dest) ] - return send(command: "LRANGE", with: args) - .tryConverting() + return .init(keyword: "RPOPLPUSH", arguments: args) } - /// Gets all elements from a List within the the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/lrange](https://redis.io/commands/lrange) + /// [RPUSH](https://redis.io/commands/rpush) + /// - Note: This inserts the elements at the tail of the list; for the head see `lpush(_:into:)`. /// - Parameters: - /// - key: The key of the List. - /// - firstIndex: The index of the first element to include in the range of elements returned. - /// - lastIndex: The index of the last element to include in the range of elements returned. - /// - type: The type to convert the values to. - /// - Returns: An array of elements found within the range specified, otherwise `nil` if the `RESPValue` conversion failed. + /// - elements: The values to push into the list. + /// - key: The key of the list. @inlinable - public func lrange( - from key: RedisKey, - firstIndex: Int, - lastIndex: Int, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.lrange(from: key, firstIndex: firstIndex, lastIndex: lastIndex) - .map { return $0.map(Value.init(fromRESP:)) } - } + public static func rpush(_ elements: [Value], into key: RedisKey) -> RedisCommand { + assert(elements.count > 0, "at least 1 element should be provided") - /// Gets all elements from a List within the specified inclusive bounds of 0-based indices. - /// - /// To get the elements at index 4 through 7: - /// ```swift - /// client.lrange(from: "myList", indices: 4...7) - /// ``` - /// - /// To get the last 4 elements: - /// ```swift - /// client.lrange(from: "myList", indices: (-4)...(-1)) - /// ``` - /// - /// To get the first and last 4 elements: - /// ```swift - /// client.lrange(from: "myList", indices: (-4)...3) - /// ``` - /// - /// To get the first element, and the last 4: - /// ```swift - /// client.lrange(from: "myList", indices: (-4)...0)) - /// ``` - /// - /// See [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Warning: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, - /// `ClosedRange` will trigger a precondition failure. - /// - /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - range: The range of inclusive indices of elements to get. - /// - Returns: An array of elements found within the range specified. - public func lrange(from key: RedisKey, indices range: ClosedRange) -> EventLoopFuture<[RESPValue]> { - return self.lrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound) - } - - /// Gets all elements from a List within the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/lrange](https://redis.io/commands/lrange). - /// - Warning: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, - /// `ClosedRange` will trigger a precondition failure. - /// - /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - range: The range of inclusive indices of elements to get. - /// - type: The type to convert the values to. - /// - Returns: An array of elements found within the range specified, otherwise `nil` if the `RESPValue` conversion failed. - @inlinable - public func lrange( - from key: RedisKey, - indices range: ClosedRange, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.lrange(from: key, indices: range) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Gets all the elements from a List starting with the first index bound up to, but not including, the element at the last index bound. - /// - /// To get the elements at index 4 through 7: - /// ```swift - /// client.lrange(from: "myList", indices: 4..<8) - /// ``` - /// - /// To get the last 4 elements: - /// ```swift - /// client.lrange(from: "myList", indices: (-4)..<0) - /// ``` - /// - /// To get the first and last 4 elements: - /// ```swift - /// client.lrange(from: "myList", indices: (-4)..<4) - /// ``` - /// - /// To get the first element, and the last 4: - /// ```swift - /// client.lrange(from: "myList", indices: (-4)..<1) - /// ``` - /// - /// See [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Warning: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, - /// `Range` will trigger a precondition failure. - /// - /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. - /// - Returns: An array of elements found within the range specified. - public func lrange(from key: RedisKey, indices range: Range) -> EventLoopFuture<[RESPValue]> { - return self.lrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1) - } - - /// Gets all the elements from a List starting with the first index bound up to, but not including, the element at the last index bound. - /// - /// See [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Warning: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, - /// `Range` will trigger a precondition failure. - /// - /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. - /// - type: The type to convert the values to. - /// - Returns: An array of elements found within the range specified, otherwise `nil` if the `RESPValue` conversion failed. - @inlinable - public func lrange( - from key: RedisKey, - indices range: Range, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.lrange(from: key, indices: range) - .map { return $0.map(Value.init(fromRESP:)) } + var args = [RESPValue(from: key)] + args.append(convertingContentsOf: elements) + + return .init(keyword: "RPUSH", arguments: args) } - /// Gets all elements from the index specified to the end of a List. - /// - /// To get all except the first 2 elements of a List: - /// ```swift - /// client.lrange(from: "myList", fromIndex: 2) - /// ``` - /// - /// To get the last 4 elements of a List: - /// ```swift - /// client.lrange(from: "myList", fromIndex: -4) - /// ``` - /// - /// See `lrange(from:indices:)`, `lrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - index: The index of the first element that will be in the returned values. - /// - Returns: An array of elements from the List between the index and the end. - public func lrange(from key: RedisKey, fromIndex index: Int) -> EventLoopFuture<[RESPValue]> { - return self.lrange(from: key, firstIndex: index, lastIndex: -1) - } - - /// Gets all elements from the index specified to the end of a List. - /// - /// See `lrange(from:indices:)`, `lrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - index: The index of the first element that will be in the returned values. - /// - type: The type to convert the values to. - /// - Returns: An array of elements from the List between the index and the end, otherwise `nil` if the `RESPValue` conversion failed. - @inlinable - public func lrange( - from key: RedisKey, - fromIndex index: Int, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.lrange(from: key, fromIndex: index) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Gets all elements from the the start of a List up to, and including, the element at the index specified. - /// - /// To get the first 3 elements of a List: - /// ```swift - /// client.lrange(from: "myList", throughIndex: 2) - /// ``` - /// - /// To get all except the last 3 elements of a List: - /// ```swift - /// client.lrange(from: "myList", throughIndex: -4) - /// ``` - /// - /// See `lrange(from:indices:)`, `lrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - index: The index of the last element that will be in the returned values. - /// - Returns: An array of elements from the start of a List to the index. - public func lrange(from key: RedisKey, throughIndex index: Int) -> EventLoopFuture<[RESPValue]> { - return self.lrange(from: key, firstIndex: 0, lastIndex: index) - } - - /// Gets all elements from the the start of a List up to, and including, the element at the index specified. - /// - /// See `lrange(from:indices:)`, `lrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - index: The index of the last element that will be in the returned values. - /// - type: The type to convert the values to. - /// - Returns: An array of elements from the start of a List to the index, otherwise `nil` if the `RESPValue` conversion failed. - @inlinable - public func lrange( - from key: RedisKey, - throughIndex index: Int, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.lrange(from: key, throughIndex: index) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Gets all elements from the the start of a List up to, but not including, the element at the index specified. - /// - /// To get the first 3 elements of a List: - /// ```swift - /// client.lrange(from: "myList", upToIndex: 3) - /// ``` - /// - /// To get all except the last 3 elements of a List: - /// ```swift - /// client.lrange(from: "myList", upToIndex: -3) - /// ``` - /// - /// See `lrange(from:indices:)`, `lrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - index: The index of the element to not include in the returned values. - /// - Returns: An array of elements from the start of the List and up to the index. - public func lrange(from key: RedisKey, upToIndex index: Int) -> EventLoopFuture<[RESPValue]> { - return self.lrange(from: key, firstIndex: 0, lastIndex: index - 1) - } - - /// Gets all elements from the the start of a List up to, but not including, the element at the index specified. - /// - /// See `lrange(from:indices:)`, `lrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/lrange](https://redis.io/commands/lrange) - /// - Parameters: - /// - key: The key of the List to return elements from. - /// - index: The index of the element to not include in the returned values. - /// - type: The type to convert the values to. - /// - Returns: An array of elements from the start of the List and up to the index, otherwise `nil` if the `RESPValue` conversion failed. - @inlinable - public func lrange( - from key: RedisKey, - upToIndex index: Int, - as type: Value.Type - ) -> EventLoopFuture<[Value?]> { - return self.lrange(from: key, upToIndex: index) - .map { return $0.map(Value.init(fromRESP:)) } - } -} - -// MARK: Pop & Push - -extension RedisClient { - /// Pops the last element from a source list and pushes it to a destination list. - /// - /// See [https://redis.io/commands/rpoplpush](https://redis.io/commands/rpoplpush) - /// - Parameters: - /// - source: The key of the list to pop from. - /// - dest: The key of the list to push to. - /// - Returns: The element that was moved. - public func rpoplpush(from source: RedisKey, to dest: RedisKey) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: source), - .init(from: dest) - ] - return send(command: "RPOPLPUSH", with: args) - } - - /// Pops the last element from a source list and pushes it to a destination list. - /// - /// See [https://redis.io/commands/rpoplpush](https://redis.io/commands/rpoplpush) - /// - Parameters: - /// - source: The key of the list to pop from. - /// - dest: The key of the list to push to. - /// - type: The type to convert the value to. - /// - Returns: The element that was moved. This value is `nil` if the `RESPValue` conversion failed. - @inlinable - public func rpoplpush( - from source: RedisKey, - to dest: RedisKey, - valueType: Value.Type - ) -> EventLoopFuture { - return self.rpoplpush(from: source, to: dest) - .map(Value.init(fromRESP:)) - } - - /// Pops the last element from a source list and pushes it to a destination list, blocking until - /// an element is available from the source list. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the source list. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpoplpush` method where possible. - /// - /// See [https://redis.io/commands/brpoplpush](https://redis.io/commands/brpoplpush) - /// - Parameters: - /// - source: The key of the list to pop from. - /// - dest: The key of the list to push to. - /// - timeout: The max time to wait for a value to use. `0` seconds means to wait indefinitely. - /// - Returns: The element popped from the source list and pushed to the destination or `.null` if the timeout was reached. - public func brpoplpush( - from source: RedisKey, - to dest: RedisKey, - timeout: TimeAmount = .seconds(0) - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: source), - .init(from: dest), - .init(from: timeout.seconds) - ] - return send(command: "BRPOPLPUSH", with: args) - } - - - /// Pops the last element from a source list and pushes it to a destination list, blocking until - /// an element is available from the source list. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the source list. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpoplpush` method where possible. - /// - /// See [https://redis.io/commands/brpoplpush](https://redis.io/commands/brpoplpush) - /// - Parameters: - /// - source: The key of the list to pop from. - /// - dest: The key of the list to push to. - /// - timeout: The max time to wait for a value to use. `0` seconds means to wait indefinitely. - /// - type: The type to convert the value to. - /// - Returns: The element popped from the source list and pushed to the destination. - /// If the timeout was reached or `RESPValue` conversion failed, the returned value will be `nil`. - @inlinable - public func brpoplpush( - from source: RedisKey, - to dest: RedisKey, - timeout: TimeAmount = .seconds(0), - valueType: Value.Type - ) -> EventLoopFuture { - return self.brpoplpush(from: source, to: dest, timeout: timeout) - .map(Value.init(fromRESP:)) - } -} - -// MARK: Insert - -extension RedisClient { - /// Inserts the element before the first element matching the "pivot" value specified. - /// - /// See [https://redis.io/commands/linsert](https://redis.io/commands/linsert) - /// - Parameters: - /// - element: The value to insert into the list. - /// - key: The key of the list. - /// - pivot: The value of the element to insert before. - /// - Returns: The size of the list after the insert, or -1 if an element matching the pivot value was not found. - @inlinable - public func linsert( - _ element: Value, - into key: RedisKey, - before pivot: Value - ) -> EventLoopFuture { - return _linsert(pivotKeyword: "BEFORE", element, key, pivot) - } - - /// Inserts the element after the first element matching the "pivot" value provided. - /// - /// See [https://redis.io/commands/linsert](https://redis.io/commands/linsert) - /// - Parameters: - /// - element: The value to insert into the list. - /// - key: The key of the list. - /// - pivot: The value of the element to insert after. - /// - Returns: The size of the list after the insert, or -1 if an element matching the pivot value was not found. - @inlinable - public func linsert( - _ element: Value, - into key: RedisKey, - after pivot: Value - ) -> EventLoopFuture { - return _linsert(pivotKeyword: "AFTER", element, key, pivot) - } - - @usableFromInline - func _linsert( - pivotKeyword: String, - _ element: Value, - _ key: RedisKey, - _ pivot: Value - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: pivotKeyword), - pivot.convertedToRESPValue(), - element.convertedToRESPValue() - ] - return send(command: "LINSERT", with: args) - .tryConverting() - } -} - -// MARK: Head Operations - -extension RedisClient { - /// Removes the first element of a list. - /// - /// See [https://redis.io/commands/lpop](https://redis.io/commands/lpop) - /// - Parameter key: The key of the list to pop from. - /// - Returns: The element that was popped from the list, or `.null`. - public func lpop(from key: RedisKey) -> EventLoopFuture { - let args = [RESPValue(from: key)] - return send(command: "LPOP", with: args) - } - - /// Removes the first element of a list. - /// - /// See [https://redis.io/commands/lpop](https://redis.io/commands/lpop) - /// - Parameters: - /// - key: The key of the list to pop from. - /// - type: The type to convert the value to. - /// - Returns: The element that was popped from the list. If the list is empty or the `RESPValue` conversion failed, this value is `nil`. - @inlinable - public func lpop(from key: RedisKey, as type: Value.Type) -> EventLoopFuture { - return self.lpop(from: key) - .map(Value.init(fromRESP:)) - } - - /// Pushes all of the provided elements into a list. - /// - Note: This inserts the elements at the head of the list; for the tail see `rpush(_:into:)`. - /// - /// See [https://redis.io/commands/lpush](https://redis.io/commands/lpush) - /// - Parameters: - /// - elements: The values to push into the list. - /// - key: The key of the list. - /// - Returns: The length of the list after adding the new elements. - @inlinable - public func lpush(_ elements: [Value], into key: RedisKey) -> EventLoopFuture { - assert(elements.count > 0, "At least 1 element should be provided.") - - var args: [RESPValue] = [.init(from: key)] - args.append(convertingContentsOf: elements) - - return send(command: "LPUSH", with: args) - .tryConverting() - } - - /// Pushes all of the provided elements into a list. - /// - Note: This inserts the elements at the head of the list; for the tail see `rpush(_:into:)`. - /// - /// See [https://redis.io/commands/lpush](https://redis.io/commands/lpush) + /// [RPUSH](https://redis.io/commands/rpush) + /// - Note: This inserts the elements at the tail of the list; for the head see `lpush(_:into:)`. /// - Parameters: /// - elements: The values to push into the list. /// - key: The key of the list. - /// - Returns: The length of the list after adding the new elements. @inlinable - public func lpush(_ elements: Value..., into key: RedisKey) -> EventLoopFuture { - return self.lpush(elements, into: key) + public static func rpush(_ elements: Value..., into key: RedisKey) -> RedisCommand { + return .rpush(elements, into: key) } - /// Pushes an element into a list, but only if the key exists and holds a list. - /// - Note: This inserts the element at the head of the list, for the tail see `rpushx(_:into:)`. - /// - /// See [https://redis.io/commands/lpushx](https://redis.io/commands/lpushx) + /// [RPUSHX](https://redis.io/commands/rpushx) + /// - Note: This inserts the element at the tail of the list; for the head see `lpushx(_:into:)`. /// - Parameters: /// - element: The value to try and push into the list. /// - key: The key of the list. - /// - Returns: The length of the list after adding the new elements. @inlinable - public func lpushx(_ element: Value, into key: RedisKey) -> EventLoopFuture { + public static func rpushx(_ element: Value, into key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), element.convertedToRESPValue() ] - return send(command: "LPUSHX", with: args) - .tryConverting() + return .init(keyword: "RPUSHX", arguments: args) } } -// MARK: Tail Operations +// MARK: - Shared implementations +extension RedisCommand { + fileprivate static func _bpop( + keyword: String, + _ keys: [RedisKey], + _ timeout: TimeAmount, + _ transform: @escaping ((RedisKey, RESPValue)?) throws -> ResultType? + ) -> RedisCommand { + var args = keys.map(RESPValue.init(from:)) + args.append(.init(bulk: timeout.seconds)) + + return .init(keyword: keyword, arguments: args) { value in + guard !value.isNull else { return nil } -extension RedisClient { - /// Removes the last element a list. - /// - /// See [https://redis.io/commands/rpop](https://redis.io/commands/rpop) - /// - Parameter key: The key of the list to pop from. - /// - Returns: The element that was popped from the list, else `.null`. - public func rpop(from key: RedisKey) -> EventLoopFuture { - let args = [RESPValue(from: key)] - return send(command: "RPOP", with: args) - } - - /// Removes the last element a list. - /// - /// See [https://redis.io/commands/rpop](https://redis.io/commands/rpop) - /// - Parameter key: The key of the list to pop from. - /// - Returns: The element that was popped from the list. If the list is empty or the `RESPValue` conversion fails, this value is `nil`. - @inlinable - public func rpop(from key: RedisKey, as type: Value.Type) -> EventLoopFuture { - return self.rpop(from: key) - .map(Value.init(fromRESP:)) - } + let response = try value.map(to: [RESPValue].self) + assert(response.count == 2, "unexpected response size returned: \(response.count)") - /// Pushes all of the provided elements into a list. - /// - Note: This inserts the elements at the tail of the list; for the head see `lpush(_:into:)`. - /// - /// See [https://redis.io/commands/rpush](https://redis.io/commands/rpush) - /// - elements: The values to push into the list. - /// - key: The key of the list. - /// - Returns: The length of the list after adding the new elements. - @inlinable - public func rpush(_ elements: [Value], into key: RedisKey) -> EventLoopFuture { - assert(elements.count > 0, "At least 1 element should be provided.") + let key = try response[0].map(to: String.self) + let initialResult = (RedisKey(key), response[1]) - var args: [RESPValue] = [.init(from: key)] - args.append(convertingContentsOf: elements) - - return send(command: "RPUSH", with: args) - .tryConverting() - } - - /// Pushes all of the provided elements into a list. - /// - Note: This inserts the elements at the tail of the list; for the head see `lpush(_:into:)`. - /// - /// See [https://redis.io/commands/rpush](https://redis.io/commands/rpush) - /// - elements: The values to push into the list. - /// - key: The key of the list. - /// - Returns: The length of the list after adding the new elements. - @inlinable - public func rpush(_ elements: Value..., into key: RedisKey) -> EventLoopFuture { - return self.rpush(elements, into: key) + return try transform(initialResult) + } } - /// Pushes an element into a list, but only if the key exists and holds a list. - /// - Note: This inserts the element at the tail of the list; for the head see `lpushx(_:into:)`. - /// - /// See [https://redis.io/commands/rpushx](https://redis.io/commands/rpushx) - /// - Parameters: - /// - element: The value to try and push into the list. - /// - key: The key of the list. - /// - Returns: The length of the list after adding the new elements. - @inlinable - public func rpushx(_ element: Value, into key: RedisKey) -> EventLoopFuture { + @usableFromInline + internal static func _linsert( + pivotKeyword: String, + _ element: Value, + _ key: RedisKey, + _ pivot: Value + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), + .init(bulk: pivotKeyword), + pivot.convertedToRESPValue(), element.convertedToRESPValue() ] - return send(command: "RPUSHX", with: args) - .tryConverting() - } -} - -// MARK: Blocking Pop - -extension RedisClient { - /// Removes the first element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the list. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `lpop` method where possible. - /// - /// See [https://redis.io/commands/blpop](https://redis.io/commands/blpop) - /// - Parameters: - /// - key: The key of the list to pop from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: The element that was popped from the list, or `.null` if the timeout was reached. - public func blpop(from key: RedisKey, timeout: TimeAmount = .seconds(0)) -> EventLoopFuture { - return blpop(from: [key], timeout: timeout) - .map { return $0?.1 ?? .null } - } - - /// Removes the first element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the list. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `lpop` method where possible. - /// - /// See [https://redis.io/commands/blpop](https://redis.io/commands/blpop) - /// - Parameters: - /// - key: The key of the list to pop from. - /// - type: The type to convert the value to. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: The element that was popped from the list. If the timeout was reached or `RESPValue` conversion failed, `nil`. - @inlinable - public func blpop( - from key: RedisKey, - as type: Value.Type, - timeout: TimeAmount = .seconds(0) - ) -> EventLoopFuture { - return self.blpop(from: key, timeout: timeout) - .map(Value.init(fromRESP:)) - } - - /// Removes the first element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `lpop` method where possible. - /// - /// See [https://redis.io/commands/blpop](https://redis.io/commands/blpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - public func blpop(from keys: [RedisKey], timeout: TimeAmount = .seconds(0)) -> EventLoopFuture<(RedisKey, RESPValue)?> { - return _bpop(command: "BLPOP", keys, timeout) - } - - /// Removes the first element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `lpop` method where possible. - /// - /// See [https://redis.io/commands/blpop](https://redis.io/commands/blpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - valueType: The type to convert the value to. - /// - Returns: - /// If timeout was reached or the `RESPValue` conversion failed, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - @inlinable - public func blpop( - from keys: [RedisKey], - timeout: TimeAmount = .seconds(0), - valueType: Value.Type - ) -> EventLoopFuture<(RedisKey, Value)?> { - return self.blpop(from: keys, timeout: timeout) - .map { - guard - let result = $0, - let value = Value(fromRESP: result.1) - else { return nil } - return (result.0, value) - } - } - - /// Removes the first element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `lpop` method where possible. - /// - /// See [https://redis.io/commands/blpop](https://redis.io/commands/blpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - public func blpop(from keys: RedisKey..., timeout: TimeAmount = .seconds(0)) -> EventLoopFuture<(RedisKey, RESPValue)?> { - return self.blpop(from: keys, timeout: timeout) - } - - /// Removes the first element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `lpop` method where possible. - /// - /// See [https://redis.io/commands/blpop](https://redis.io/commands/blpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - valueType: The type to convert the value to. - /// - Returns: - /// If timeout was reached or `RESPValue` conversion failed, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - @inlinable - public func blpop( - from keys: RedisKey..., - timeout: TimeAmount = .seconds(0), - valueType: Value.Type - ) -> EventLoopFuture<(RedisKey, Value)?> { - return self.blpop(from: keys, timeout: timeout, valueType: valueType) - } - - /// Removes the last element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the list. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpop` method where possible. - /// - /// See [https://redis.io/commands/brpop](https://redis.io/commands/brpop) - /// - Parameters: - /// - key: The key of the list to pop from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: The element that was popped from the list, or `.null` if the timeout was reached. - public func brpop(from key: RedisKey, timeout: TimeAmount = .seconds(0)) -> EventLoopFuture { - return brpop(from: [key], timeout: timeout) - .map { return $0?.1 ?? .null } - } - - /// Removes the last element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the list. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpop` method where possible. - /// - /// See [https://redis.io/commands/brpop](https://redis.io/commands/brpop) - /// - Parameters: - /// - key: The key of the list to pop from. - /// - type: The type to convert the value to. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: The element that was popped from the list. If the timeout was reached or the `RESPValue` conversion fails, this value is `nil`. - @inlinable - public func brpop( - from key: RedisKey, - as type: Value.Type, - timeout: TimeAmount = .seconds(0) - ) -> EventLoopFuture { - return self.brpop(from: key, timeout: timeout) - .map(Value.init(fromRESP:)) - } - - /// Removes the last element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpop` method where possible. - /// - /// See [https://redis.io/commands/brpop](https://redis.io/commands/brpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - public func brpop(from keys: [RedisKey], timeout: TimeAmount = .seconds(0)) -> EventLoopFuture<(RedisKey, RESPValue)?> { - return _bpop(command: "BRPOP", keys, timeout) - } - - /// Removes the last element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpop` method where possible. - /// - /// See [https://redis.io/commands/brpop](https://redis.io/commands/brpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached or `RESPValue` conversion failed, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - @inlinable - public func brpop( - from keys: [RedisKey], - timeout: TimeAmount = .seconds(0), - valueType: Value.Type - ) -> EventLoopFuture<(RedisKey, Value)?> { - return self.brpop(from: keys, timeout: timeout) - .map { - guard - let result = $0, - let value = Value(fromRESP: result.1) - else { return nil } - return (result.0, value) - } - } - - /// Removes the last element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpop` method where possible. - /// - /// See [https://redis.io/commands/brpop](https://redis.io/commands/brpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - public func brpop(from keys: RedisKey..., timeout: TimeAmount = .seconds(0)) -> EventLoopFuture<(RedisKey, RESPValue)?> { - return self.brpop(from: keys, timeout: timeout) - } - - /// Removes the last element of a list, blocking until an element is available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of lists. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `rpop` method where possible. - /// - /// See [https://redis.io/commands/brpop](https://redis.io/commands/brpop) - /// - Parameters: - /// - keys: The keys of lists in Redis that should be popped from. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached or `RESPValue` conversion failed, `nil`. - /// - /// Otherwise, the key of the list the element was removed from and the popped element. - @inlinable - public func brpop( - from keys: RedisKey..., - timeout: TimeAmount = .seconds(0), - valueType: Value.Type - ) -> EventLoopFuture<(RedisKey, Value)?> { - return self.brpop(from: keys, timeout: timeout, valueType: valueType) - } - - @usableFromInline - func _bpop( - command: String, - _ keys: [RedisKey], - _ timeout: TimeAmount - ) -> EventLoopFuture<(RedisKey, RESPValue)?> { - var args = keys.map(RESPValue.init) - args.append(.init(bulk: timeout.seconds)) - - return send(command: command, with: args) - .flatMapThrowing { - guard !$0.isNull else { return nil } - guard let response = $0.array else { - throw RedisClientError.failedRESPConversion(to: [RESPValue].self) - } - assert(response.count == 2, "Unexpected response size returned!") - guard let key = response[0].string else { - throw RedisClientError.assertionFailure(message: "Unexpected structure in response: \(response)") - } - return (.init(key), response[1]) - } + return .init(keyword: "LINSERT", arguments: args) } } diff --git a/Sources/RediStack/Commands/PubSubCommands.swift b/Sources/RediStack/Commands/PubSubCommands.swift index 386bfbd1..168afc93 100644 --- a/Sources/RediStack/Commands/PubSubCommands.swift +++ b/Sources/RediStack/Commands/PubSubCommands.swift @@ -14,89 +14,80 @@ import NIO -// MARK: Publish +// MARK: PubSub -extension RedisClient { - /// Publishes the provided message to a specific Redis channel. - /// +extension RedisCommand { /// See [PUBLISH](https://redis.io/commands/publish) /// - Parameters: /// - message: The "message" value to publish on the channel. /// - channel: The name of the channel to publish the message to. - /// - Returns: The number of subscribed clients that received the message. @inlinable - @discardableResult - public func publish( + public static func publish( _ message: Message, to channel: RedisChannelName - ) -> EventLoopFuture { + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: channel), message.convertedToRESPValue() ] - return self.send(command: "PUBLISH", with: args) - .tryConverting() + return .init(keyword: "PUBLISH", arguments: args) } -} -// MARK: PubSub Sub-commands - -extension RedisClient { - /// Resolves a list of all the channels that have at least 1 (non-pattern) subscriber. - /// - /// See [PUBSUB CHANNELS](https://redis.io/commands/pubsub#pubsub-channels-pattern) - /// - Note: If no `match` pattern is provided, all active channels will be returned. + /// [PUBSUB CHANNELS](https://redis.io/commands/pubsub#pubsub-channels-pattern) + /// - Invariant: If no `match` pattern is provided, all active channels will be returned. /// - Parameter match: An optional pattern of channel names to filter for. - /// - Returns: A list of all active channel names. - public func activeChannels(matching match: String? = nil) -> EventLoopFuture<[RedisChannelName]> { - var args: [RESPValue] = [.init(bulk: "CHANNELS")] - - if let m = match { args.append(.init(bulk: m)) } - - return self.send(command: "PUBSUB", with: args) - .tryConverting() + public static func pubsubChannels(matching match: String? = nil) -> RedisCommand<[RedisChannelName]> { + let args: [RESPValue] = match.map { [.init(bulk: $0)] } ?? [] + return .init(keyword: "PUBSUB CHANNELS", arguments: args) } - - /// Resolves the total count of active subscriptions to channels that were made using patterns. - /// - /// See [PUBSUB NUMPAT](https://redis.io/commands/pubsub#codepubsub-numpatcode) - /// - Returns: The total count of subscriptions made through patterns. - public func patternSubscriberCount() -> EventLoopFuture { - let args: [RESPValue] = [.init(bulk: "NUMPAT")] - return self.send(command: "PUBSUB", with: args) - .tryConverting() + + /// [PUBSUB NUMPAT](https://redis.io/commands/pubsub#codepubsub-numpatcode) + public static func pubsubNumpat() -> RedisCommand { + return .init(keyword: "PUBSUB NUMPAT", arguments: []) } - - /// Resolves a count of (non-pattern) subscribers for each given channel. - /// - /// See [PUBSUB NUMSUB](https://redis.io/commands/pubsub#codepubsub-numsub-channel-1--channel-ncode) + + /// [PUBSUB NUMSUB](https://redis.io/commands/pubsub#codepubsub-numsub-channel-1--channel-ncode) /// - Parameter channels: A list of channel names to collect the subscriber counts for. - /// - Returns: A mapping of channel names and their (non-pattern) subscriber count. - public func subscriberCount(forChannels channels: [RedisChannelName]) -> EventLoopFuture<[RedisChannelName: Int]> { - guard channels.count > 0 else { return self.eventLoop.makeSucceededFuture([:]) } - - var args: [RESPValue] = [.init(bulk: "NUMSUB")] - args.append(convertingContentsOf: channels) - - return self.send(command: "PUBSUB", with: args) - .tryConverting(to: [RESPValue].self) - .flatMapThrowing { response in - assert(response.count == channels.count * 2, "Unexpected response size!") - - // Redis guarantees that the response format is [channel1Name, channel1Count, channel2Name, ...] - // with the order of channels matching the order sent in the request - return try channels - .enumerated() - .reduce(into: [:]) { (result, next) in - assert(next.element.rawValue == response[next.offset].string, "Unexpected value in current index!") - - guard let count = response[next.offset + 1].int else { - throw RedisClientError.assertionFailure( - message: "Unexpected value at position \(next.offset + 1) in \(response)" - ) - } - result[next.element] = count + public static func pubsubNumsub(forChannels channels: [RedisChannelName]) -> RedisCommand<[RedisChannelName: Int]> { + let args = channels.map { $0.convertedToRESPValue() } + return .init(keyword: "PUBSUB NUMSUB", arguments: args) { + let response = try $0.map(to: [RESPValue].self) + assert(response.count == channels.count * 2, "Unexpected response size!") + + // Redis guarantees that the response format is [channel1Name, channel1Count, channel2Name, ...] + // with the order of channels matching the order sent in the request + return try channels + .enumerated() + .reduce(into: [:]) { (result, next) in + assert(next.element.rawValue == response[next.offset].string, "Unexpected value in current index!") + + guard let count = response[next.offset + 1].int else { + throw RedisClientError.assertionFailure( + message: "Unexpected value at position \(next.offset + 1) in \(response)" + ) } - } + result[next.element] = count + } + } + } +} + +// MARK: - + +extension RedisClient { + /// Publishes the provided message to a specific Redis channel. + /// + /// See `RedisCommand.publish(_:to:)` + /// - Parameters: + /// - message: The "message" value to publish on the channel. + /// - channel: The name of the channel to publish the message to. + /// - Returns: The number of subscribed clients that received the message. + @inlinable + @discardableResult + public func publish( + _ message: Message, + to channel: RedisChannelName + ) -> EventLoopFuture { + return self.send(.publish(message, to: channel)) } } diff --git a/Sources/RediStack/Commands/RedisCommand.swift b/Sources/RediStack/Commands/RedisCommand.swift new file mode 100644 index 00000000..74e4df86 --- /dev/null +++ b/Sources/RediStack/Commands/RedisCommand.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An abstract representation of a Redis command that can be sent to a Redis instance. +/// +/// An instance will retain the keyword of the command in plaintext as a `String` for identity purposes, +/// while all the arguments will be stored as `RESPValue` representations. +/// +/// ## ResultType +/// Each `RedisCommand` has a generic type referred to as `ResultType` that is the native Swift representation of the response Redis will send for the command. +/// +/// When creating a `RedisCommand`, a closure will be provided for transforming an arbitrary `RESPValue` instance into the `ResultType`. +public struct RedisCommand { + public let keyword: String + public let arguments: [RESPValue] + + internal let transform: (RESPValue) throws -> ResultType + + /// Creates a command with the given details that represents a Redis command in Swift. + /// - Warning: The `transform` closure is escaping, and will retain references to any scope captures. + /// - Parameters: + /// - keyword: The command keyword as defined by Redis. + /// - arguments: The command arguments to be sent to Redis. + /// - transform: The closure to invoke to transform the value from its raw `RESPValue` instance to the desired final `ResultType`. + public init( + keyword: String, + arguments: [RESPValue], + mapValueToResult transform: @escaping (RESPValue) throws -> ResultType + ) { + self.keyword = keyword + self.arguments = arguments + self.transform = transform + } + + /// Serializes the entire command into a single value for sending to Redis. + /// - Returns: A `RESPValue.array` value of the keyword and its arguments. + public func serialized() -> RESPValue { + var message: [RESPValue] = [.init(bulk: self.keyword)] + message.append(contentsOf: self.arguments) + return .array(message) + } +} + +extension RedisCommand where ResultType == RESPValue { + /// Creates a command with the given keyword and arguments. + public init(keyword: String, arguments: [RESPValue]) { + self.init(keyword: keyword, arguments: arguments, mapValueToResult: { $0 }) + } +} + +extension RedisCommand where ResultType: RESPValueConvertible { + /// Creates a command that tries to map the Redis response to the result type. + public init(keyword: String, arguments: [RESPValue]) { + self.init(keyword: keyword, arguments: arguments, mapValueToResult: { try $0.map(to: ResultType.self) }) + } +} + +extension RedisCommand where ResultType == Bool { + @usableFromInline + internal init(keyword: String, arguments: [RESPValue]) { + self.init(keyword: keyword, arguments: arguments, mapValueToResult: { + let result = try $0.map(to: Int.self) + return result == 1 + }) + } +} + +extension RedisCommand where ResultType == Void { + /// Creates a command that ignores the response from Redis acting as a completion notification. + public init(keyword: String, arguments: [RESPValue]) { + self.init(keyword: keyword, arguments: arguments, mapValueToResult: { _ in }) + } +} + +extension RedisCommand { + @usableFromInline + internal static func _scan( + keyword: String, + _ key: RedisKey?, + _ pos: Int, + _ match: String?, + _ count: Int?, + _ transform: @escaping (RESPValue) throws -> T + ) -> RedisCommand<(Int, T)> { + var args: [RESPValue] = [.init(bulk: pos)] + + if let k = key { args.insert(.init(from: k), at: 0) } + if let m = match { args.append(.init(bulk: "match \(m)")) } + if let c = count { args.append(.init(bulk: "count \(c)")) } + + return .init(keyword: keyword, arguments: args) { + let response = try $0.map(to: [RESPValue].self) + assert(response.count >= 2, "Received response of unexpected size: \(response)") + + let position = try response[0].map(to: Int.self) + let elements = try transform(response[1]) + + return (position, elements) + } + } +} diff --git a/Sources/RediStack/Commands/ServerCommands.swift b/Sources/RediStack/Commands/ServerCommands.swift new file mode 100644 index 00000000..42f9de12 --- /dev/null +++ b/Sources/RediStack/Commands/ServerCommands.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIO + +// MARK: Server + +extension RedisCommand { + /// [SWAPDB](https://redis.io/commands/swapdb) + /// - Parameters: + /// - first: The index of the first database. + /// - second: The index of the second database. + public static func swapdb(_ first: Int, with second: Int) -> RedisCommand { + let args: [RESPValue] = [ + .init(bulk: first), + .init(bulk: second) + ] + return .init(keyword: "SWAPDB", arguments: args) { + return (try $0.map()) == "OK" + } + } +} + +// MARK: - + +extension RedisClient { + /// Swaps the data of two Redis databases by their index IDs. + /// + /// See `RedisCommand.swapdb(_:with:)` + /// - Parameters: + /// - first: The index of the first database. + /// - second: The index of the second database. + /// - Returns: A `NIO.EventLoopFuture` that resolves `true` if the command succeed or `false` if it didn't. + public func swapDatabase(_ first: Int, with second: Int) -> EventLoopFuture { + return self.send(.swapdb(first, with: second)) + } +} diff --git a/Sources/RediStack/Commands/SetCommands.swift b/Sources/RediStack/Commands/SetCommands.swift index a7fd09e1..99a77659 100644 --- a/Sources/RediStack/Commands/SetCommands.swift +++ b/Sources/RediStack/Commands/SetCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -14,479 +14,253 @@ import NIO -// MARK: General +// MARK: Sets -extension RedisClient { - /// Gets all of the elements contained in a set. - /// - Note: Ordering of results are stable between multiple calls of this method to the same set. - /// - /// Results are **UNSTABLE** in regards to the ordering of insertions through the `sadd` command and this method. - /// - /// See [https://redis.io/commands/smembers](https://redis.io/commands/smembers) - /// - Parameter key: The key of the set. - /// - Returns: A list of elements found within the set. - public func smembers(of key: RedisKey) -> EventLoopFuture<[RESPValue]> { - let args = [RESPValue(from: key)] - return send(command: "SMEMBERS", with: args) - .tryConverting() - } - - /// Gets all of the elements contained in a set. - /// - Note: Ordering of results are stable between multiple calls of this method to the same set. - /// - /// Results are **UNSTABLE** in regards to the ordering of insertions through the `sadd` command and this method. - /// - /// See [https://redis.io/commands/smembers](https://redis.io/commands/smembers) +extension RedisCommand { + /// [SADD](https://redis.io/commands/sadd) /// - Parameters: - /// - key: The key of the set. - /// - type: The type to convert the values to. - /// - Returns: A list of elements found within the set. Elements that fail the `RESPValue` conversion will be `nil`. + /// - elements: The values to add to the set. + /// - key: The key of the set to insert into. @inlinable - public func smembers(of key: RedisKey, as type: Value.Type) -> EventLoopFuture<[Value?]> { - return self.smembers(of: key) - .map { return $0.map(Value.init(fromRESP:)) } + public static func sadd(_ elements: [Value], to key: RedisKey) -> RedisCommand { + assert(elements.count > 0, "at least 1 element should be provided") + + var args = [RESPValue(from: key)] + args.append(convertingContentsOf: elements) + + return .init(keyword: "SADD", arguments: args) } - /// Checks if the element is included in a set. - /// - /// See [https://redis.io/commands/sismember](https://redis.io/commands/sismember) + /// [SADD](https://redis.io/commands/sadd) /// - Parameters: - /// - element: The element to look for in the set. - /// - key: The key of the set to look in. - /// - Returns: `true` if the element is in the set. + /// - elements: The values to add to the set. + /// - key: The key of the set to insert into. @inlinable - public func sismember(_ element: Value, of key: RedisKey) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - element.convertedToRESPValue() - ] - return send(command: "SISMEMBER", with: args) - .tryConverting(to: Int.self) - .map { return $0 == 1 } + public static func sadd(_ elements: Value..., to key: RedisKey) -> RedisCommand { + return .sadd(elements, to: key) } - /// Gets the total count of elements within a set. - /// - /// See [https://redis.io/commands/scard](https://redis.io/commands/scard) + /// [SCARD](https://redis.io/commands/scard) /// - Parameter key: The key of the set. - /// - Returns: The total count of elements in the set. - public func scard(of key: RedisKey) -> EventLoopFuture { + public static func scard(of key: RedisKey) -> RedisCommand { let args = [RESPValue(from: key)] - return send(command: "SCARD", with: args) - .tryConverting() + return .init(keyword: "SCARD", arguments: args) } - /// Adds elements to a set. - /// - /// See [https://redis.io/commands/sadd](https://redis.io/commands/sadd) - /// - Parameters: - /// - elements: The values to add to the set. - /// - key: The key of the set to insert into. - /// - Returns: The number of elements that were added to the set. - @inlinable - public func sadd(_ elements: [Value], to key: RedisKey) -> EventLoopFuture { - guard elements.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } - - var args: [RESPValue] = [.init(from: key)] - args.append(convertingContentsOf: elements) + /// [SDIFF](https://redis.io/commands/sdiff) + /// - Parameter keys: The source sets to calculate the difference of. + public static func sdiff(of keys: [RedisKey]) -> RedisCommand<[RESPValue]> { + assert(!keys.isEmpty, "at least 1 key should be provided") - return send(command: "SADD", with: args) - .tryConverting() - } - - /// Adds elements to a set. - /// - /// See [https://redis.io/commands/sadd](https://redis.io/commands/sadd) - /// - Parameters: - /// - elements: The values to add to the set. - /// - key: The key of the set to insert into. - /// - Returns: The number of elements that were added to the set. - @inlinable - public func sadd(_ elements: Value..., to key: RedisKey) -> EventLoopFuture { - return self.sadd(elements, to: key) + let args = keys.map(RESPValue.init(from:)) + return .init(keyword: "SDIFF", arguments: args) } - /// Removes elements from a set. - /// - /// See [https://redis.io/commands/srem](https://redis.io/commands/srem) + /// [SDIFF](https://redis.io/commands/sdiff) + /// - Parameter keys: The source sets to calculate the difference of. + public static func sdiff(of keys: RedisKey...) -> RedisCommand<[RESPValue]> { .sdiff(of: keys) } + + /// [SDIFFSTORE](https://redis.io/commands/sdiffstore) + /// - Warning: If the `destination` key already exists, its value will be overwritten. /// - Parameters: - /// - elements: The values to remove from the set. - /// - key: The key of the set to remove from. - /// - Returns: The number of elements that were removed from the set. - @inlinable - public func srem(_ elements: [Value], from key: RedisKey) -> EventLoopFuture { - guard elements.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } + /// - destination: The key of the new set from the result. + /// - sources: The list of source sets to calculate the difference of. + public static func sdiffstore(as destination: RedisKey, sources keys: [RedisKey]) -> RedisCommand { + assert(!keys.isEmpty, "at least 1 key should be provided") - var args: [RESPValue] = [.init(from: key)] - args.append(convertingContentsOf: elements) - - return send(command: "SREM", with: args) - .tryConverting() + var args = [RESPValue(from: destination)] + args.append(convertingContentsOf: keys) + + return .init(keyword: "SDIFFSTORE", arguments: args) } - - /// Removes elements from a set. - /// - /// See [https://redis.io/commands/srem](https://redis.io/commands/srem) - /// - Parameters: - /// - elements: The values to remove from the set. - /// - key: The key of the set to remove from. - /// - Returns: The number of elements that were removed from the set. - @inlinable - public func srem(_ elements: Value..., from key: RedisKey) -> EventLoopFuture { - return self.srem(elements, from: key) + + /// [SINTER](https://redis.io/commands/sinter) + /// - Parameter keys: The source sets to calculate the intersection of. + public static func sinter(of keys: [RedisKey]) -> RedisCommand<[RESPValue]> { + assert(!keys.isEmpty, "at least 1 key should be provided") + + let args = keys.map(RESPValue.init(from:)) + return .init(keyword: "SINTER", arguments: args) } - /// Randomly selects and removes one or more elements in a set. - /// - /// See [https://redis.io/commands/spop](https://redis.io/commands/spop) + /// [SINTER](https://redis.io/commands/sinter) + /// - Parameter keys: The source sets to calculate the intersection of. + public static func sinter(of keys: RedisKey...) -> RedisCommand<[RESPValue]> { .sinter(of: keys) } + + /// [SINTERSTORE](https://redis.io/commands/sinterstore) + /// - Warning: If the `destination` key already exists, its value will be overwritten. /// - Parameters: - /// - key: The key of the set. - /// - count: The max number of elements to pop from the set. - /// - Returns: The element that was popped from the set. - public func spop(from key: RedisKey, max count: Int = 1) -> EventLoopFuture<[RESPValue]> { - assert(count >= 0, "A negative max count is nonsense.") + /// - destination: The key of the new set from the result. + /// - sources: A list of source sets to calculate the intersection of. + public static func sinterstore(as destination: RedisKey, sources keys: [RedisKey]) -> RedisCommand { + assert(!keys.isEmpty, "at least 1 key should be provided") - guard count > 0 else { return self.eventLoop.makeSucceededFuture([]) } + var args = [RESPValue(from: destination)] + args.append(convertingContentsOf: keys) - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: count) - ] - return send(command: "SPOP", with: args) - .tryConverting() + return .init(keyword: "SINTERSTORE", arguments: args) } - /// Randomly selects and removes one or more elements in a set. - /// - /// See [https://redis.io/commands/spop](https://redis.io/commands/spop) + /// [SISMEMBER](https://redis.io/commands/sismember) /// - Parameters: - /// - key: The key of the set. - /// - type: The type to convert the values to. - /// - count: The max number of elements to pop from the set. - /// - Returns: The element that was popped from the set. Elements that fail the `RESPValue` conversion will be `nil`. + /// - element: The element to look for in the set. + /// - key: The key of the set to look in. @inlinable - public func spop( - from key: RedisKey, - as type: Value.Type, - max count: Int = 1 - ) -> EventLoopFuture<[Value?]> { - return self.spop(from: key, max: count) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Randomly selects one or more elements in a set. - /// - /// connection.srandmember("my_key") // pulls just one random element - /// connection.srandmember("my_key", max: -3) // pulls up to 3 elements, allowing duplicates - /// connection.srandmember("my_key", max: 3) // pulls up to 3 elements, guaranteed unique - /// - /// See [https://redis.io/commands/srandmember](https://redis.io/commands/srandmember) - /// - Parameters: - /// - key: The key of the set. - /// - count: The max number of elements to select from the set. - /// - Returns: The elements randomly selected from the set. - public func srandmember(from key: RedisKey, max count: Int = 1) -> EventLoopFuture<[RESPValue]> { - guard count != 0 else { return self.eventLoop.makeSucceededFuture([]) } - + public static func sismember(_ element: Value, of key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(bulk: count) + element.convertedToRESPValue() ] - return send(command: "SRANDMEMBER", with: args) - .tryConverting() + return .init(keyword: "SISMEMBER", arguments: args) } - - /// Randomly selects one or more elements in a set. + + /// [SMEMBERS](https://redis.io/commands/smembers) + /// - Note: Ordering of results are stable between multiple calls of this method to the same set. /// - /// See [https://redis.io/commands/srandmember](https://redis.io/commands/srandmember) - /// - Parameters: - /// - key: The key of the set. - /// - type; The type to convert the values to. - /// - count: The max number of elements to select from the set. - /// - Returns: The elements randomly selected from the set. Elements that fail the `RESPValue` conversion will be `nil`. - @inlinable - public func srandmember( - from key: RedisKey, - as type: Value.Type, - max count: Int = 1 - ) -> EventLoopFuture<[Value?]> { - return self.srandmember(from: key, max: count) - .map { return $0.map(Value.init(fromRESP:)) } + /// Results are **UNSTABLE** in regards to the ordering of insertions through the `sadd` command and this method. + /// - Parameter key: The key of the set. + public static func smembers(of key: RedisKey) -> RedisCommand<[RESPValue]> { + let args = [RESPValue(from: key)] + return .init(keyword: "SMEMBERS", arguments: args) } - /// Moves an element from one set to another. - /// - /// See [https://redis.io/commands/smove](https://redis.io/commands/smove) + /// [SMOVE](https://redis.io/commands/smove) /// - Parameters: /// - element: The value to move from the source. /// - sourceKey: The key of the source set. /// - destKey: The key of the destination set. - /// - Returns: `true` if the element was successfully removed from the source set. @inlinable - public func smove( + public static func smove( _ element: Value, from sourceKey: RedisKey, to destKey: RedisKey - ) -> EventLoopFuture { - guard sourceKey != destKey else { return self.eventLoop.makeSucceededFuture(true) } + ) -> RedisCommand { + assert(sourceKey != destKey, "same key was provided for a move operation") let args: [RESPValue] = [ .init(from: sourceKey), .init(from: destKey), element.convertedToRESPValue() ] - return send(command: "SMOVE", with: args) - .tryConverting() - .map { return $0 == 1 } + return .init(keyword: "SMOVE", arguments: args) } - /// Incrementally iterates over all values in a set. - /// - /// See [https://redis.io/commands/sscan](https://redis.io/commands/sscan) + /// [SPOP](https://redis.io/commands/spop) /// - Parameters: /// - key: The key of the set. - /// - position: The position to start the scan from. - /// - count: The number of elements to advance by. Redis default is 10. - /// - match: A glob-style pattern to filter values to be selected from the result set. - /// - Returns: A cursor position for additional invocations with a limited collection of elements found in the set. - public func sscan( - _ key: RedisKey, - startingFrom position: Int = 0, - matching match: String? = nil, - count: Int? = nil - ) -> EventLoopFuture<(Int, [RESPValue])> { - return _scan(command: "SSCAN", key, position, match, count) + /// - count: The max number of elements to pop from the set. + public static func spop(from key: RedisKey, max count: Int = 1) -> RedisCommand<[RESPValue]> { + assert(count >= 0, "a negative max count is nonsense") + + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: count) + ] + return .init(keyword: "SPOP", arguments: args) } - - /// Incrementally iterates over all values in a set. + + /// [SRANDMEMBER](https://redis.io/commands/srandmember) /// - /// See [https://redis.io/commands/sscan](https://redis.io/commands/sscan) + /// Example usage: + /// ```swift + /// // pull just 1 random element + /// client.send(.srandmember(from: "my_key")) + /// + /// // pulls up to 3 elements, allowing duplicates + /// client.send(.srandmember(from: "my_key", max: -3)) + /// + /// // pulls up to 3 unique elements + /// client.send(.srandmember(from: "my_key", max: 3)) + /// ``` /// - Parameters: /// - key: The key of the set. - /// - position: The position to start the scan from. - /// - match: A glob-style pattern to filter values to be selected from the result set. - /// - count: The number of elements to advance by. Redis default is 10. - /// - valueType: The type to convert the value to. - /// - Returns: A cursor position for additional invocations with a limited collection of elements found in the set. Elements that fail the `RESPValue` conversion will be `nil`. - @inlinable - public func sscan( - _ key: RedisKey, - startingFrom position: Int = 0, - matching match: String? = nil, - count: Int? = nil, - valueType: Value.Type - ) -> EventLoopFuture<(Int, [Value?])> { - return self.sscan(key, startingFrom: position, matching: match, count: count) - .map { (cursor, rawValues) in - let values = rawValues.map(Value.init(fromRESP:)) - return (cursor, values) - } + /// - count: The max number of elements to select from the set. + public static func srandmember(from key: RedisKey, max count: Int = 1) -> RedisCommand<[RESPValue]> { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: count) + ] + return .init(keyword: "SRANDMEMBER", arguments: args) } -} - -// MARK: Diff -extension RedisClient { - /// Calculates the difference between two or more sets. - /// - /// See [https://redis.io/commands/sdiff](https://redis.io/commands/sdiff) - /// - Parameter keys: The source sets to calculate the difference of. - /// - Returns: A list of elements resulting from the difference. - public func sdiff(of keys: [RedisKey]) -> EventLoopFuture<[RESPValue]> { - guard keys.count > 0 else { return self.eventLoop.makeSucceededFuture([]) } - - let args = keys.map(RESPValue.init) - return send(command: "SDIFF", with: args) - .tryConverting() - } - - /// Calculates the difference between two or more sets. - /// - /// See [https://redis.io/commands/sdiff](https://redis.io/commands/sdiff) + /// [SREM](https://redis.io/commands/srem) /// - Parameters: - /// - keys: The source sets to calculate the difference of. - /// - valueType: The type to convert the values to. - /// - Returns: A list of elements resulting from the difference. Elements that fail the `RESPValue` conversion will be `nil`. + /// - elements: The values to remove from the set. + /// - key: The key of the set to remove from. @inlinable - public func sdiff(of keys: [RedisKey], valueType: Value.Type) -> EventLoopFuture<[Value?]> { - return self.sdiff(of: keys) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Calculates the difference between two or more sets. - /// - /// See [https://redis.io/commands/sdiff](https://redis.io/commands/sdiff) - /// - Parameter keys: The source sets to calculate the difference of. - /// - Returns: A list of elements resulting from the difference. - public func sdiff(of keys: RedisKey...) -> EventLoopFuture<[RESPValue]> { - return self.sdiff(of: keys) + public static func srem(_ elements: [Value], from key: RedisKey) -> RedisCommand { + var args: [RESPValue] = [.init(from: key)] + args.append(convertingContentsOf: elements) + return .init(keyword: "SREM", arguments: args) } - - /// Calculates the difference between two or more sets. - /// - /// See [https://redis.io/commands/sdiff](https://redis.io/commands/sdiff) + + /// [SREM](https://redis.io/commands/srem) /// - Parameters: - /// - keys: The source sets to calculate the difference of. - /// - valueType: The type to convert the values to. - /// - Returns: A list of elements resulting from the difference. Elements that fail the `RESPValue` conversion will be `nil`. + /// - elements: The values to remove from the set. + /// - key: The key of the set to remove from. @inlinable - public func sdiff(of keys: RedisKey..., valueType: Value.Type) -> EventLoopFuture<[Value?]> { - return self.sdiff(of: keys, valueType: valueType) + public static func srem(_ elements: Value..., from key: RedisKey) -> RedisCommand { + return .srem(elements, from: key) } - /// Calculates the difference between two or more sets and stores the result. - /// - Important: If the destination key already exists, it is overwritten. - /// - /// See [https://redis.io/commands/sdiffstore](https://redis.io/commands/sdiffstore) + /// [SUNION](https://redis.io/commands/sunion) + /// - Parameter keys: The source sets to calculate the union of. + public static func sunion(of keys: [RedisKey]) -> RedisCommand<[RESPValue]> { + let args = keys.map(RESPValue.init(from:)) + return .init(keyword: "SUNION", arguments: args) + } + + /// [SUNION](https://redis.io/commands/sunion) + /// - Parameter keys: The source sets to calculate the union of. + public static func sunion(of keys: RedisKey...) -> RedisCommand<[RESPValue]> { .sunion(of: keys) } + + /// [SUNIONSTORE](https://redis.io/commands/sunionstore) /// - Parameters: /// - destination: The key of the new set from the result. - /// - sources: The list of source sets to calculate the difference of. - /// - Returns: The number of elements in the difference result. - public func sdiffstore(as destination: RedisKey, sources keys: [RedisKey]) -> EventLoopFuture { - assert(keys.count > 0, "At least 1 key should be provided.") + /// - sources: A list of source sets to calculate the union of. + public static func sunionstore(as destination: RedisKey, sources keys: [RedisKey]) -> RedisCommand { + assert(!keys.isEmpty, "at least 1 key should be provided") - var args: [RESPValue] = [.init(from: destination)] + var args = [RESPValue(from: destination)] args.append(convertingContentsOf: keys) - - return send(command: "SDIFFSTORE", with: args) - .tryConverting() - } -} - -// MARK: Intersect - -extension RedisClient { - /// Calculates the intersection of two or more sets. - /// - /// See [https://redis.io/commands/sinter](https://redis.io/commands/sinter) - /// - Parameter keys: The source sets to calculate the intersection of. - /// - Returns: A list of elements resulting from the intersection. - public func sinter(of keys: [RedisKey]) -> EventLoopFuture<[RESPValue]> { - guard keys.count > 0 else { return self.eventLoop.makeSucceededFuture([]) } - let args = keys.map(RESPValue.init) - return send(command: "SINTER", with: args) - .tryConverting() - } - - /// Calculates the intersection of two or more sets. - /// - /// See [https://redis.io/commands/sinter](https://redis.io/commands/sinter) - /// - Parameters: - /// - keys: The source sets to calculate the intersection of. - /// - valueType: The type to convert all values to. - /// - Returns: A list of elements resulting from the intersection. Elements that fail the `RESPValue` conversion will be `nil`. - @inlinable - public func sinter(of keys: [RedisKey], valueType: Value.Type) -> EventLoopFuture<[Value?]> { - return self.sinter(of: keys) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Calculates the intersection of two or more sets. - /// - /// See [https://redis.io/commands/sinter](https://redis.io/commands/sinter) - /// - Parameter keys: The source sets to calculate the intersection of. - /// - Returns: A list of elements resulting from the intersection. - public func sinter(of keys: RedisKey...) -> EventLoopFuture<[RESPValue]> { - return self.sinter(of: keys) - } - - /// Calculates the intersection of two or more sets. - /// - /// See [https://redis.io/commands/sinter](https://redis.io/commands/sinter) - /// - Parameters: - /// - keys: The source sets to calculate the intersection of. - /// - valueType: The type to convert all values to. - /// - Returns: A list of elements resulting from the intersection. Elements that fail the `RESPValue` conversion will be `nil`. - @inlinable - public func sinter(of keys: RedisKey..., valueType: Value.Type) -> EventLoopFuture<[Value?]> { - return self.sinter(of: keys, valueType: valueType) + return .init(keyword: "SUNIONSTORE", arguments: args) } - /// Calculates the intersetion of two or more sets and stores the result. - /// - Important: If the destination key already exists, it is overwritten. - /// - /// See [https://redis.io/commands/sinterstore](https://redis.io/commands/sinterstore) + /// [SSCAN](https://redis.io/commands/sscan) /// - Parameters: - /// - destination: The key of the new set from the result. - /// - sources: A list of source sets to calculate the intersection of. - /// - Returns: The number of elements in the intersection result. - public func sinterstore(as destination: RedisKey, sources keys: [RedisKey]) -> EventLoopFuture { - assert(keys.count > 0, "At least 1 key should be provided.") - - var args: [RESPValue] = [.init(from: destination)] - args.append(convertingContentsOf: keys) - - return send(command: "SINTERSTORE", with: args) - .tryConverting() + /// - key: The key of the set. + /// - position: The position to start the scan from. + /// - count: The number of elements to advance by. Redis default is 10. + /// - match: A glob-style pattern to filter values to be selected from the result set. + public static func sscan( + _ key: RedisKey, + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> RedisCommand<(Int, [RESPValue])> { + return ._scan(keyword: "SSCAN", key, position, match, count, { try $0.map() }) } } -// MARK: Union +// MARK: - extension RedisClient { - /// Calculates the union of two or more sets. + /// Incrementally iterates over allv alues in a set. /// - /// See [https://redis.io/commands/sunion](https://redis.io/commands/sunion) - /// - Parameter keys: The source sets to calculate the union of. - /// - Returns: A list of elements resulting from the union. - public func sunion(of keys: [RedisKey]) -> EventLoopFuture<[RESPValue]> { - guard keys.count > 0 else { return self.eventLoop.makeSucceededFuture([]) } - - let args = keys.map(RESPValue.init) - return send(command: "SUNION", with: args) - .tryConverting() - } - - /// Calculates the union of two or more sets. - /// - /// See [https://redis.io/commands/sunion](https://redis.io/commands/sunion) - /// - Parameters: - /// - keys: The source sets to calculate the union of. - /// - valueType: The type to convert all values to. - /// - Returns: A list of elements resulting from the union. Elements that fail the `RESPValue` conversion will be `nil`. - @inlinable - public func sunion(of keys: [RedisKey], valueType: Value.Type) -> EventLoopFuture<[Value?]> { - return self.sunion(of: keys) - .map { return $0.map(Value.init(fromRESP:)) } - } - - /// Calculates the union of two or more sets. - /// - /// See [https://redis.io/commands/sunion](https://redis.io/commands/sunion) - /// - Parameter keys: The source sets to calculate the union of. - /// - Returns: A list of elements resulting from the union. - public func sunion(of keys: RedisKey...) -> EventLoopFuture<[RESPValue]> { - return self.sunion(of: keys) - } - - /// Calculates the union of two or more sets. - /// - /// See [https://redis.io/commands/sunion](https://redis.io/commands/sunion) - /// - Parameters: - /// - keys: The source sets to calculate the union of. - /// - valueType: The type to convert all values to. - /// - Returns: A list of elements resulting from the union. Elements that fail the `RESPValue` conversion will be `nil`. - @inlinable - public func sunion(of keys: RedisKey..., valueType: Value.Type) -> EventLoopFuture<[Value?]> { - return self.sunion(of: keys, valueType: valueType) - } - - /// Calculates the union of two or more sets and stores the result. - /// - Important: If the destination key already exists, it is overwritten. - /// - /// See [https://redis.io/commands/sunionstore](https://redis.io/commands/sunionstore) + /// See `RedisCommand.sscan(_:startingFrom:matching:count:)` /// - Parameters: - /// - destination: The key of the new set from the result. - /// - sources: A list of source sets to calculate the union of. - /// - Returns: The number of elements in the union result. - public func sunionstore(as destination: RedisKey, sources keys: [RedisKey]) -> EventLoopFuture { - assert(keys.count > 0, "At least 1 key should be provided.") - - var args: [RESPValue] = [.init(from: destination)] - args.append(convertingContentsOf: keys) - - return send(command: "SUNIONSTORE", with: args) - .tryConverting() + /// - key: The key of the set. + /// - position: The position to start the scan from. + /// - count: The number of elements to advance by. Redis default is 10. + /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - Returns: A `NIO.EventLoopFuture` that resolves a cursor position for additional scans, with a limited collection of values that were iterated over. + public func scanSetValues( + in key: RedisKey, + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> EventLoopFuture<(Int, [RESPValue])> { + return self.send(.sscan(key, startingFrom: position, matching: match, count: count)) } } diff --git a/Sources/RediStack/Commands/SortedSetCommands.swift b/Sources/RediStack/Commands/SortedSetCommands.swift index 6ab55b96..99b9cb54 100644 --- a/Sources/RediStack/Commands/SortedSetCommands.swift +++ b/Sources/RediStack/Commands/SortedSetCommands.swift @@ -14,1995 +14,1488 @@ import NIO -// MARK: Static Helpers +// MARK: Sorted Sets -extension RedisClient { - static func _mapSortedSetResponse( - _ response: [RESPValue], - scoreIsFirst: Bool - ) throws -> [(RESPValue, Double)] { - guard response.count > 0 else { return [] } - - var result: [(RESPValue, Double)] = [] - - var index = 0 - repeat { - let scoreItem = response[scoreIsFirst ? index : index + 1] - - guard let score = Double(fromRESP: scoreItem) else { - throw RedisClientError.assertionFailure(message: "Unexpected response: '\(scoreItem)'") - } - - let elementIndex = scoreIsFirst ? index + 1 : index - result.append((response[elementIndex], score)) - - index += 2 - } while (index < response.count) - - return result +extension RedisCommand { + /// [BZPOPMIN](https://redis.io/commands/bzpopmin) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the group of sets. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `zpopmin` method where possible. + /// - Parameters: + /// - key: The key identifying the sorted set in Redis. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func bzpopmin( + from key: RedisKey, + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(Double, RESPValue)?> { + return ._bzpop(keyword: "BZPOPMIN", [key], timeout, { result in + result.map { ($0.1, $0.2) } + }) } -} -// MARK: Zadd + /// [BZPOPMIN](https://redis.io/commands/bzpopmin) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the group of sets. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `zpopmin` method where possible. + /// - Parameters: + /// - keys: A list of sorted set keys in Redis. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func bzpopmin( + from keys: [RedisKey], + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(String, Double, RESPValue)?> { ._bzpop(keyword: "BZPOPMIN", keys, timeout, { $0 }) } -/// The supported insert behavior for a `zadd` command with Redis SortedSet types. -/// -/// `zadd` normally inserts all elements (`.allElements`) provided into the SortedSet, updating the score of any element that already exist in the set. -/// -/// However, it supports two other insert behaviors: -/// * `.onlyNewElements` will not update the score of any element already in the SortedSet -/// * `.onlyExistingElements` will not insert any new element into the SortedSet -/// -/// See [https://redis.io/commands/zadd#zadd-options-redis-302-or-greater](https://redis.io/commands/zadd#zadd-options-redis-302-or-greater) -public enum RedisZaddInsertBehavior { - /// Insert new elements and update the score of existing elements. - case allElements - /// Only insert new elements; do not update the score of existing elements. - case onlyNewElements - /// Only update the score of existing elements; do not insert new elements. - case onlyExistingElements - - /// Redis representation of this option. - @usableFromInline - internal var string: String? { - switch self { - case .allElements: return nil - case .onlyNewElements: return "NX" - case .onlyExistingElements: return "XX" - } - } -} + /// [BZPOPMIN](https://redis.io/commands/bzpopmin) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the group of sets. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `zpopmin` method where possible. + /// - Parameters: + /// - keys: A list of sorted set keys in Redis. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func bzpopmin( + from keys: RedisKey..., + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(String, Double, RESPValue)?> { .bzpopmin(from: keys, timeout: timeout) } -/// The supported behavior for what a `zadd` command return value should represent. -/// -/// `zadd` normally returns the number of new elements inserted into the set (`.insertedElementsCount`), -/// but also supports the option (`.changedElementsCount`) to return the number of elements changed as a result of the command. -/// -/// "Changed" in this context refers to both new elements that were inserted and existing elements that had their score updated. -/// -/// See [https://redis.io/commands/zadd](https://redis.io/commands/zadd) -public enum RedisZaddReturnBehavior { - /// Count both new elements that were inserted into the SortedSet and existing elements that had their score updated. - case changedElementsCount - /// Count only new elements that were inserted into the SortedSet. - case insertedElementsCount - - /// Redis representation of this option. - @usableFromInline - internal var string: String? { - switch self { - case .changedElementsCount: return "CH" - case .insertedElementsCount: return nil - } + /// [BZPOPMAX](https://redis.io/commands/bzpopmax) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the set. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `zpopmax` method where possible. + /// - Parameters: + /// - key: The key identifying the sorted set in Redis. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func bzpopmax( + from key: RedisKey, + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(Double, RESPValue)?> { + return ._bzpop(keyword: "BZPOPMAX", [key], timeout, { result in + result.map { ($0.1, $0.2) } + }) } -} -extension RedisClient { - /// Adds elements to a sorted set, assigning their score to the values provided. + /// [BZPOPMAX](https://redis.io/commands/bzpopmax) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the set. /// - /// See [https://redis.io/commands/zadd](https://redis.io/commands/zadd) + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `zpopmax` method where possible. /// - Parameters: - /// - elements: A list of elements and their score to add to the sorted set. + /// - key: The key identifying the sorted set in Redis. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func bzpopmax( + from keys: [RedisKey], + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(String, Double, RESPValue)?> { ._bzpop(keyword: "BZPOPMAX", keys, timeout, { $0 }) } + + /// [BZPOPMAX](https://redis.io/commands/bzpopmax) + /// - Warning: + /// This will block the connection from completing further commands until an element is available to pop from the set. + /// + /// It is **highly** recommended to set a reasonable `timeout` or to use the non-blocking `zpopmax` method where possible. + /// - Parameters: + /// - key: The key identifying the sorted set in Redis. + /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + public static func bzpopmax( + from keys: RedisKey..., + timeout: TimeAmount = .seconds(0) + ) -> RedisCommand<(String, Double, RESPValue)?> { .bzpopmax(from: keys, timeout: timeout) } + + /// [ZADD](https://redis.io/commands/zadd) + /// - Parameters: + /// - element: The element and its score to add to the sorted set. /// - key: The key of the sorted set. /// - insertBehavior: The desired behavior of handling new and existing elements in the SortedSet. /// - returnBehavior: The desired behavior of what the return value should represent. - /// - Returns: If `returning` is `.changedElementsCount`, the number of elements inserted and that had their score updated. Otherwise, just the number of new elements inserted. @inlinable - public func zadd( - _ elements: [(element: Value, score: Double)], + public static func zadd( + _ element: (value: Value, score: Double), to key: RedisKey, inserting insertBehavior: RedisZaddInsertBehavior = .allElements, returning returnBehavior: RedisZaddReturnBehavior = .insertedElementsCount - ) -> EventLoopFuture { - var args: [RESPValue] = [.init(from: key)] - + ) -> RedisCommand { + var args = [RESPValue(from: key)] args.append(convertingContentsOf: [insertBehavior.string, returnBehavior.string].compactMap({ $0 })) - args.add(contentsOf: elements, overestimatedCountBeingAdded: elements.count * 2) { (array, next) in - array.append(.init(bulk: next.score.description)) - array.append(next.element.convertedToRESPValue()) - } - - return self.send(command: "ZADD", with: args) - .tryConverting() + args.append(contentsOf: [.init(bulk: element.score.description), element.value.convertedToRESPValue()]) + return .init(keyword: "ZADD", arguments: args) } - - /// Adds elements to a sorted set, assigning their score to the values provided. - /// - /// See [https://redis.io/commands/zadd](https://redis.io/commands/zadd) + + /// [ZADD](https://redis.io/commands/zadd) /// - Parameters: /// - elements: A list of elements and their score to add to the sorted set. /// - key: The key of the sorted set. /// - insertBehavior: The desired behavior of handling new and existing elements in the SortedSet. /// - returnBehavior: The desired behavior of what the return value should represent. - /// - Returns: If `returning` is `.changedElementsCount`, the number of elements inserted and that had their score updated. Otherwise, just the number of new elements inserted. @inlinable - public func zadd( - _ elements: (element: Value, score: Double)..., + public static func zadd( + _ elements: [(element: Value, score: Double)], to key: RedisKey, inserting insertBehavior: RedisZaddInsertBehavior = .allElements, returning returnBehavior: RedisZaddReturnBehavior = .insertedElementsCount - ) -> EventLoopFuture { - return self.zadd(elements, to: key, inserting: insertBehavior, returning: returnBehavior) + ) -> RedisCommand { + var args = [RESPValue(from: key)] + + args.append(convertingContentsOf: [insertBehavior.string, returnBehavior.string].compactMap({ $0 })) + args.add(contentsOf: elements, overestimatedCountBeingAdded: elements.count * 2) { array, next in + array.append(.init(bulk: next.score.description)) + array.append(next.element.convertedToRESPValue()) + } + + return .init(keyword: "ZADD", arguments: args) } - /// Adds an element to a sorted set, assigning their score to the value provided. - /// - /// See [https://redis.io/commands/zadd](https://redis.io/commands/zadd) + /// [ZADD](https://redis.io/commands/zadd) /// - Parameters: - /// - element: The element and its score to add to the sorted set. + /// - elements: A list of elements and their score to add to the sorted set. /// - key: The key of the sorted set. /// - insertBehavior: The desired behavior of handling new and existing elements in the SortedSet. /// - returnBehavior: The desired behavior of what the return value should represent. - /// - Returns: If `returning` is `.changedElementsCount`, the number of elements inserted and that had their score updated. Otherwise, just the number of new elements inserted. @inlinable - public func zadd( - _ element: (element: Value, score: Double), + public static func zadd( + _ elements: (element: Value, score: Double)..., to key: RedisKey, inserting insertBehavior: RedisZaddInsertBehavior = .allElements, returning returnBehavior: RedisZaddReturnBehavior = .insertedElementsCount - ) -> EventLoopFuture { - return self.zadd(element, to: key, inserting: insertBehavior, returning: returnBehavior) - .map { return $0 == 1 } - } -} + ) -> RedisCommand { .zadd(elements, to: key, inserting: insertBehavior, returning: returnBehavior) } -// MARK: General - -extension RedisClient { - /// Gets the number of elements in a sorted set. - /// - /// See [https://redis.io/commands/zcard](https://redis.io/commands/zcard) + /// [ZCARD](https://redis.io/commands/zcard) /// - Parameter key: The key of the sorted set. - /// - Returns: The number of elements in the sorted set. - public func zcard(of key: RedisKey) -> EventLoopFuture { + public static func zcard(of key: RedisKey) -> RedisCommand { let args = [RESPValue(from: key)] - return send(command: "ZCARD", with: args) - .tryConverting() + return .init(keyword: "ZCARD", arguments: args) } - /// Gets the score of the specified element in a stored set. + /// [ZCOUNT](https://redis.io/commands/zcount) + /// + /// Example usage: + /// To get a count of elements that have at least the score of 3, but no greater than 10: + /// ```swift + /// // count elements with score of 3...10 + /// client.send(.zcount(of: "mySortedSet", withScoresBetween: (3, 10))) /// - /// See [https://redis.io/commands/zscore](https://redis.io/commands/zscore) + /// // count elements with score 3..<10 + /// client.send(.zcount(of: "mySortedSet", withScoresBetween: (3, .exclusive(10)))) + /// ``` /// - Parameters: - /// - element: The element in the sorted set to get the score for. - /// - key: The key of the sorted set. - /// - Returns: The score of the element provided, or `nil` if the element is not found in the set or the set does not exist. - @inlinable - public func zscore(of element: Value, in key: RedisKey) -> EventLoopFuture { + /// - key: The key of the SortedSet that will be counted. + /// - range: The min and max score bounds that an element should have in order to be counted. + public static func zcount( + of key: RedisKey, + withScoresBetween range: (min: RedisZScoreBound, max: RedisZScoreBound) + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - element.convertedToRESPValue() + .init(bulk: range.min.description), + .init(bulk: range.max.description) ] - return send(command: "ZSCORE", with: args) - .map { return Double(fromRESP: $0) } + return .init(keyword: "ZCOUNT", arguments: args) } - /// Incrementally iterates over all elements in a sorted set. + /// [ZCOUNT](https://redis.io/commands/zcount) /// - /// See [https://redis.io/commands/zscan](https://redis.io/commands/zscan) + /// Example usage: + /// ```swift + /// // count of elements with at least score of 3, but no greater than 10 + /// client.send(.zcount(of: "mySortedSet", withScores: 3...10) + /// ``` /// - Parameters: - /// - key: The key identifying the sorted set. - /// - position: The position to start the scan from. - /// - match: A glob-style pattern to filter values to be selected from the result set. - /// - count: The number of elements to advance by. Redis default is 10. - /// - Returns: A cursor position for additional invocations with a limited collection of elements found in the sorted set with their scores. - public func zscan( - _ key: RedisKey, - startingFrom position: Int = 0, - matching match: String? = nil, - count: Int? = nil - ) -> EventLoopFuture<(Int, [(RESPValue, Double)])> { - return self._scan(command: "ZSCAN", resultType: [RESPValue].self, key, position, match, count) - .flatMapThrowing { - let values = try Self._mapSortedSetResponse($0.1, scoreIsFirst: false) - return ($0.0, values) - } + /// - key: The key of the SortedSet that will be counted. + /// - range: The inclusive range of scores to filter elements to count. + public static func zcount(of key: RedisKey, withScores range: ClosedRange) -> RedisCommand { + return .zcount(of: key, withScoresBetween: (.inclusive(range.lowerBound), .inclusive(range.upperBound))) } - - /// Incrementally iterates over all elements in a sorted set. + + /// [ZCOUNT](https://redis.io/commands/zcount) /// - /// See [https://redis.io/commands/zscan](https://redis.io/commands/zscan) + /// Example usage: + /// ```swift + /// // count of elements with at least score of 3, but less than 10 + /// client.send(.zcount(of: "mySortedSet", withScores: 3..<10) + /// ``` /// - Parameters: - /// - key: The key identifying the sorted set. - /// - position: The position to start the scan from. - /// - match: A glob-style pattern to filter values to be selected from the result set. - /// - count: The number of elements to advance by. Redis default is 10. - /// - valueType: The type to convert the values to. - /// - Returns: A cursor position for additional invocations with a limited collection of elements found in the sorted set with their scores. - /// Any element that fails the `RESPValue` conversion will be `nil`. - @inlinable - public func zscan( - _ key: RedisKey, - startingFrom position: Int = 0, - matching match: String? = nil, - count: Int? = nil, - valueType: Value.Type - ) -> EventLoopFuture<(Int, [(Value, Double)?])> { - return self.zscan(key, startingFrom: position, matching: match, count: count) - .map { (cursor, elements) in - let mappedElements = elements.map { next -> (Value, Double)? in - guard let value = Value(fromRESP: next.0) else { return nil } - return (value, next.1) - } - return (cursor, mappedElements) - } + /// - key: The key of the SortedSet that will be counted. + /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements to count. + public static func zcount(of key: RedisKey, withScores range: Range) -> RedisCommand { + return .zcount(of: key, withScoresBetween: (.inclusive(range.lowerBound), .exclusive(range.upperBound))) } -} -// MARK: Rank + /// [ZCOUNT](https://redis.io/commands/zcount) + /// - Parameters: + /// - key: The key of the SortedSet that will be counted. + /// - minScore: The minimum score bound an element in the SortedSet should have in order to be counted. + public static func zcount(of key: RedisKey, withMinimumScoreOf minScore: RedisZScoreBound) -> RedisCommand { + return .zcount(of: key, withScoresBetween: (minScore, .inclusive(.infinity))) + } -extension RedisClient { - /// Returns the rank (index) of the specified element in a sorted set. - /// - Note: This treats the ordered set as ordered from low to high. - /// For the inverse, see `zrevrank(of:in:)`. - /// - /// See [https://redis.io/commands/zrank](https://redis.io/commands/zrank) + /// [ZCOUNT](https://redis.io/commands/zcount) /// - Parameters: - /// - element: The element in the sorted set to search for. - /// - key: The key of the sorted set to search. - /// - Returns: The index of the element, or `nil` if the key was not found. - @inlinable - public func zrank(of element: Value, in key: RedisKey) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - element.convertedToRESPValue() - ] - return send(command: "ZRANK", with: args) - .tryConverting() + /// - key: The key of the SortedSet that will be counted. + /// - maxScore: The maximum score bound an element in the SortedSet should have in order to be counted. + public static func zcount(of key: RedisKey, withMaximumScoreOf maxScore: RedisZScoreBound) -> RedisCommand { + return .zcount(of: key, withScoresBetween: (.inclusive(-.infinity), maxScore)) } - /// Returns the rank (index) of the specified element in a sorted set. - /// - Note: This treats the ordered set as ordered from high to low. - /// For the inverse, see `zrank(of:in:)`. - /// - /// See [https://redis.io/commands/zrevrank](https://redis.io/commands/zrevrank) + /// [ZINCRBY](https://redis.io/commands/zincrby) /// - Parameters: - /// - element: The element in the sorted set to search for. - /// - key: The key of the sorted set to search. - /// - Returns: The index of the element, or `nil` if the key was not found. + /// - element: The element to increment. + /// - key: The key of the sorted set. + /// - amount: The amount to increment this element's score by. @inlinable - public func zrevrank(of element: Value, in key: RedisKey) -> EventLoopFuture { + public static func zincrby( + _ element: Value, + in key: RedisKey, + by amount: Double + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), + .init(bulk: amount.description), element.convertedToRESPValue() ] - return send(command: "ZREVRANK", with: args) - .tryConverting() - } -} - -// MARK: Count - -/// Represents a range bound for use with the Redis SortedSet commands related to element scores. -/// -/// This type conforms to `ExpressibleByFloatLiteral` and `ExpressibleByIntegerLiteral`, which will initialize to an `.inclusive` bound. -/// -/// For example: -/// ```swift -/// let literalBound: RedisZScoreBound = 3 // .inclusive(3) -/// let otherLiteralBound: RedisZScoreBound = 3.0 // .inclusive(3) -/// let exclusiveBound = RedisZScoreBound.exclusive(4) -/// ``` -public enum RedisZScoreBound { - case inclusive(Double) - case exclusive(Double) - - /// The underlying raw score value this bound represents. - public var rawValue: Double { - switch self { - case let .inclusive(v), let .exclusive(v): return v - } - } -} - -extension RedisZScoreBound: CustomStringConvertible { - public var description: String { - switch self { - case let .inclusive(value): return value.description - case let .exclusive(value): return "(\(value.description)" - } - } -} - -extension RedisZScoreBound: ExpressibleByFloatLiteral { - public typealias FloatLiteralType = Double - - public init(floatLiteral value: Double) { - self = .inclusive(value) + return .init(keyword: "ZINCRBY", arguments: args) } -} -extension RedisZScoreBound: ExpressibleByIntegerLiteral { - public typealias IntegerLiteralType = Int64 - - public init(integerLiteral value: Int64) { - self = .inclusive(Double(value)) - } -} + /// [ZINTERSTORE](https://redis.io/commands/zinterstore) + /// - Warning: This operation overwrites any value stored at the `destination` key. + /// - Parameters: + /// - destination: The key of the new sorted set from the result. + /// - sources: The list of sorted set keys to treat as the source of the intersection. + /// - weights: The multiplying factor to apply to the corresponding `sources` key based on index of the two parameters. + /// - aggregateMethod: The method of aggregating the values of the intersection. If one isn't specified, Redis will default to `.sum`. + public static func zinterstore( + as destination: RedisKey, + sources: [RedisKey], + weights: [Int]? = nil, + aggregateMethod aggregate: RedisSortedSetAggregateMethod? = nil + ) -> RedisCommand { ._zstore(keyword: "ZINTERSTORE", sources, destination, weights, aggregate) } -extension RedisClient { - /// Returns the count of elements in a SortedSet with a score within the range specified (inclusive by default). + /// [ZLEXCOUNT](https://redis.io/commands/zlexcount) /// - /// To get a count of elements that have at least the score of 3, but no greater than 10: + /// Example usage: /// ```swift - /// client.zcount(of: "mySortedSet", withScoresBetween: (3, 10)) - /// ``` + /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1. /// - /// To get a count of elements that have at least the score of 3, but less than 10: - /// ```swift - /// client.zcount(of: "mySortedSet", withScoresBetween: (3, .exclusive(10))) + /// client.send(.zlexcount( + /// of: "mySortedSet", + /// withValuesBetween: (.inclusive(1), .inclusive(3)) + /// )) + /// // the response will resolve to 4, as both 10 and 1 have the value "1" /// ``` - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zcount](https://redis.io/commands/zcount) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Parameters: /// - key: The key of the SortedSet that will be counted. - /// - range: The min and max score bounds that an element should have in order to be counted. - /// - Returns: The count of elements in the SortedSet with a score matching the range specified. - public func zcount( + /// - range: The min and max value bounds that an element should have in order to be counted. + @inlinable + public static func zlexcount( of key: RedisKey, - withScoresBetween range: (min: RedisZScoreBound, max: RedisZScoreBound) - ) -> EventLoopFuture { - guard range.min.rawValue <= range.max.rawValue else { return self.eventLoop.makeSucceededFuture(0) } + withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound) + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), .init(bulk: range.min.description), .init(bulk: range.max.description) ] - return self.send(command: "ZCOUNT", with: args) - .tryConverting() - } - - /// Returns the count of elements in a SortedSet with a score within the inclusive range specified. - /// - /// To get a count of elements that have at least the score of 3, but no greater than 10: - /// ```swift - /// client.zcount(of: "mySortedSet", withScores: 3...10) - /// ``` - /// - /// See [https://redis.io/commands/zcount](https://redis.io/commands/zcount) - /// - Parameters: - /// - key: The key of the SortedSet that will be counted. - /// - range: The inclusive range of scores to filter elements to count. - /// - Returns: The count of elements in the SortedSet with a score within the range specified. - public func zcount(of key: RedisKey, withScores range: ClosedRange) -> EventLoopFuture { - return self.zcount(of: key, withScoresBetween: (.inclusive(range.lowerBound), .inclusive(range.upperBound))) + return .init(keyword: "ZLEXCOUNT", arguments: args) } - - /// Returns the count of elements in a SortedSet with a minimum score up to, but not including, a max score. - /// - /// To get a count of elements that have at least the score of 3, but less than 10: - /// ```swift - /// client.zcount(of: "mySortedSet", withScores: 3..<10) - /// ``` - /// - /// See [https://redis.io/commands/zcount](https://redis.io/commands/zcount) - /// - Parameters: - /// - key: The key of the SortedSet that will be counted. - /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements to count. - /// - Returns: The count of elements in the SortedSet with a score within the range specified. - public func zcount(of key: RedisKey, withScores range: Range) -> EventLoopFuture { - return self.zcount(of: key, withScoresBetween: (.inclusive(range.lowerBound), .exclusive(range.upperBound))) - } - - /// Returns the count of elements in a SortedSet whose score is greater than a minimum score value. - /// - /// By default, the value provided will be treated as _inclusive_, meaning any element that has a score matching the value **will** be counted. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zcount](https://redis.io/commands/zcount) - /// - Parameters: - /// - key: The key of the SortedSet that will be counted. - /// - minScore: The minimum score bound an element in the SortedSet should have in order to be counted. - /// - Returns: The count of elements in the SortedSet above the `minScore` threshold. - public func zcount(of key: RedisKey, withMinimumScoreOf minScore: RedisZScoreBound) -> EventLoopFuture { - return self.zcount(of: key, withScoresBetween: (minScore, .inclusive(.infinity))) - } - - /// Returns the count of elements in a SortedSet whose score is less than a maximum score value. - /// - /// By default, the value provided will be treated as _inclusive_, meaning any element that has a score matching the value **will** be counted. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zcount](https://redis.io/commands/zcount) - /// - Parameters: - /// - key: The key of the SortedSet that will be counted. - /// - maxScore: The maximum score bound an element in the SortedSet should have in order to be counted. - /// - exclusive: Should the `maxScore` provided be exclusive? If `true`, scores matching the `maxScore` will **not** be counted. - /// - Returns: The count of elements in the SortedSet below the `maxScore` threshold. - public func zcount(of key: RedisKey, withMaximumScoreOf maxScore: RedisZScoreBound) -> EventLoopFuture { - return self.zcount(of: key, withScoresBetween: (.inclusive(-.infinity), maxScore)) - } -} - -// MARK: Lexiographical Count - -/// Represents a range bound for use with the Redis SortedSet lexiographical commands to compare values. -/// -/// Cases must be explicitly declared, with wrapped values conforming to `CustomStringConvertible`. -/// -/// The cases `.negativeInfinity` and `.positiveInfinity` represent the special characters in Redis of `-` and `+` respectively. -/// These are constants for absolute lower and upper value bounds that are always treated as _inclusive_. -/// -/// See [https://redis.io/commands/zrangebylex#details-on-strings-comparison](https://redis.io/commands/zrangebylex#details-on-strings-comparison) -public enum RedisZLexBound { - case inclusive(Value) - case exclusive(Value) - case positiveInfinity - case negativeInfinity -} - -extension RedisZLexBound: CustomStringConvertible { - public var description: String { - switch self { - case let .inclusive(value): return "[\(value)" - case let .exclusive(value): return "(\(value)" - case .positiveInfinity: return "+" - case .negativeInfinity: return "-" - } - } -} - -extension RedisZLexBound where Value: BinaryFloatingPoint { - public var description: String { - switch self { - case .inclusive(.infinity), .exclusive(.infinity), .positiveInfinity: return "+" - case .inclusive(-.infinity), .exclusive(-.infinity), .negativeInfinity: return "-" - case let .inclusive(value): return "[\(value)" - case let .exclusive(value): return "(\(value)" - } - } -} - -extension RedisClient { - /// Returns the count of elements in a SortedSet whose lexiographical values are between the range specified. - /// - /// For example: - /// ```swift - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1. - /// client.zlexcount(of: "mySortedSet", withValuesBetween: (.inclusive(1), .inclusive(3))) - /// // the response will resolve to 4, as both 10 and 1 have the value "1" - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zlexcount](https://redis.io/commands/zlexcount) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. - /// - Parameters: - /// - key: The key of the SortedSet that will be counted. - /// - range: The min and max value bounds that an element should have in order to be counted. - /// - Returns: The count of elements in the SortedSet with values matching the range specified. - @inlinable - public func zlexcount( - of key: RedisKey, - withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound) - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: range.min.description), - .init(bulk: range.max.description) - ] - return self.send(command: "ZLEXCOUNT", with: args) - .tryConverting() - } - - /// Returns the count of elements in a SortedSet whose lexiographical value is greater than a minimum value. - /// - /// For example with a SortedSet that contains the values [1, 2, 3, 10] and each a score of 1: - /// ```swift - /// client.zlexcount(of: "mySortedSet", withMinimumValueOf: .inclusive(2)) - /// // the response will resolve to 2, as "10" lexiographically comes before element "2" - /// - /// client.zlexcount(of: "mySortedSet", withMinimumValueOf: .inclusive(10)) - /// // the response will resolve to 3, as the set is ordered as ["1", "10", "2", "3"] - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zlexcount](https://redis.io/commands/zlexcount) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. + + /// [ZLEXCOUNT](https://redis.io/commands/zlexcount) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Parameters: /// - key: The key of the SortedSet that will be counted. /// - minValue: The minimum lexiographical value an element in the SortedSet should have in order to be counted. - /// - Returns: The count of elements in the SortedSet above the `minValue` threshold. @inlinable - public func zlexcount( + public static func zlexcount( of key: RedisKey, withMinimumValueOf minValue: RedisZLexBound - ) -> EventLoopFuture { - return self.zlexcount(of: key, withValuesBetween: (minValue, .positiveInfinity)) - } - - /// Returns the count of elements in a SortedSet whose lexiographical value is less than a maximum value. - /// - /// For example with a SortedSet that contains the values [1, 2, 3, 10] and each a score of 1: - /// ```swift - /// client.zlexcount(of: "mySortedSet", withMaximumValueOf: .exclusive(10)) - /// // the response will resolve to 1, as "1" and "10" are sorted into the first 2 elements - /// - /// client.zlexcount(of: "mySortedSet", withMaximumValueOf: .inclusive(3)) - /// // the response will resolve to 4, as the set is ordered as ["1", "10", "2", "3"] - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zlexcount](https://redis.io/commands/zlexcount) + ) -> RedisCommand { .zlexcount(of: key, withValuesBetween: (minValue, .positiveInfinity)) } + + /// [ZLEXCOUNT](https://redis.io/commands/zlexcount) /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Parameters: /// - key: The key of the SortedSet that will be counted. /// - maxValue: The maximum lexiographical value an element in the SortedSet should have in order to be counted. - /// - Returns: The count of elements in the SortedSet below the `maxValue` threshold. @inlinable - public func zlexcount( + public static func zlexcount( of key: RedisKey, withMaximumValueOf maxValue: RedisZLexBound - ) -> EventLoopFuture { - return self.zlexcount(of: key, withValuesBetween: (.negativeInfinity, maxValue)) - } -} + ) -> RedisCommand { .zlexcount(of: key, withValuesBetween: (.negativeInfinity, maxValue)) } -// MARK: Pop + /// [ZPOPMAX](https://redis.io/commands/zpopmax) + /// - Parameter key: The key identifying the sorted set in Redis. + public static func zpopmax(from key: RedisKey) -> RedisCommand<(RESPValue, Double)?> { + return ._zpop(keyword: "ZPOPMAX", nil, key, { $0.isEmpty ? nil : $0[0] }) + } -extension RedisClient { - /// Removes elements from a sorted set with the lowest scores. - /// - /// See [https://redis.io/commands/zpopmin](https://redis.io/commands/zpopmin) + /// [ZPOPMAX](https://redis.io/commands/zpopmax) /// - Parameters: /// - key: The key identifying the sorted set in Redis. /// - count: The max number of elements to pop from the set. - /// - Returns: A list of elements popped from the sorted set with their associated score. - public func zpopmin(from key: RedisKey, max count: Int) -> EventLoopFuture<[(RESPValue, Double)]> { - return _zpop(command: "ZPOPMIN", count, key) + public static func zpopmax(from key: RedisKey, max count: Int) -> RedisCommand<[(RESPValue, Double)]> { + return ._zpop(keyword: "ZPOPMAX", count, key, { $0 }) } - /// Removes the element from a sorted set with the lowest score. - /// - /// See [https://redis.io/commands/zpopmin](https://redis.io/commands/zpopmin) + /// [ZPOPMIN](https://redis.io/commands/zpopmin) /// - Parameter key: The key identifying the sorted set in Redis. - /// - Returns: The element and its associated score that was popped from the sorted set, or `nil` if set was empty. - public func zpopmin(from key: RedisKey) -> EventLoopFuture<(RESPValue, Double)?> { - return _zpop(command: "ZPOPMIN", nil, key) - .map { return $0.count > 0 ? $0[0] : nil } + public static func zpopmin(from key: RedisKey) -> RedisCommand<(RESPValue, Double)?> { + return ._zpop(keyword: "ZPOPMIN", nil, key, { $0.isEmpty ? nil : $0[0] }) } - /// Removes elements from a sorted set with the highest scores. - /// - /// See [https://redis.io/commands/zpopmax](https://redis.io/commands/zpopmax) + /// [ZPOPMIN](https://redis.io/commands/zpopmin) /// - Parameters: /// - key: The key identifying the sorted set in Redis. /// - count: The max number of elements to pop from the set. - /// - Returns: A list of elements popped from the sorted set with their associated score. - public func zpopmax(from key: RedisKey, max count: Int) -> EventLoopFuture<[(RESPValue, Double)]> { - return _zpop(command: "ZPOPMAX", count, key) - } - - /// Removes the element from a sorted set with the highest score. - /// - /// See [https://redis.io/commands/zpopmax](https://redis.io/commands/zpopmax) - /// - Parameter key: The key identifying the sorted set in Redis. - /// - Returns: The element and its associated score that was popped from the sorted set, or `nil` if set was empty. - public func zpopmax(from key: RedisKey) -> EventLoopFuture<(RESPValue, Double)?> { - return _zpop(command: "ZPOPMAX", nil, key) - .map { return $0.count > 0 ? $0[0] : nil } - } - - func _zpop( - command: String, - _ count: Int?, - _ key: RedisKey - ) -> EventLoopFuture<[(RESPValue, Double)]> { - var args: [RESPValue] = [.init(from: key)] - - if let c = count { - guard c != 0 else { return self.eventLoop.makeSucceededFuture([]) } - - args.append(.init(bulk: c)) - } - - return send(command: command, with: args) - .tryConverting(to: [RESPValue].self) - .flatMapThrowing { return try Self._mapSortedSetResponse($0, scoreIsFirst: true) } + public static func zpopmin(from key: RedisKey, max count: Int) -> RedisCommand<[(RESPValue, Double)]> { + return ._zpop(keyword: "ZPOPMIN", count, key, { $0 }) } -} -// MARK: Blocking Pop + /// [ZRANGE](https://redis.io/commands/zrange) + /// - Parameters: + /// - key: The key of the SortedSet + /// - firstIndex: The index of the first element to include in the range of elements returned. + /// - lastIndex: The index of the last element to include in the range of elements returned. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrange( + from key: RedisKey, + firstIndex: Int, + lastIndex: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { ._zrange(keyword: "ZRANGE", key, firstIndex, lastIndex, resultOption) } -extension RedisClient { - /// Removes the element from a sorted set with the lowest score, blocking until an element is - /// available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the set. + /// [ZRANGE](https://redis.io/commands/zrange) + /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, + /// `ClosedRange` will trigger a precondition failure. /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `zpopmin` method where possible. + /// If you need such a range, use `zrange(from:firstIndex:lastIndex:resultOption:)` instead. + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// See [https://redis.io/commands/bzpopmin](https://redis.io/commands/bzpopmin) + /// For the inverse, see `zrevrange(from:indices:returning:)`. /// - Parameters: - /// - key: The key identifying the sorted set in Redis. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// The element and its associated score that was popped from the sorted set, - /// or `nil` if the timeout was reached. - public func bzpopmin( + /// - key: The key of the SortedSet to return elements from. + /// - range: The range of inclusive indices of elements to get. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrange( from key: RedisKey, - timeout: TimeAmount = .seconds(0) - ) -> EventLoopFuture<(Double, RESPValue)?> { - return bzpopmin(from: [key], timeout: timeout) - .map { - guard let response = $0 else { return nil } - return (response.1, response.2) - } + indices range: ClosedRange, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound, returning: resultOption) } - /// Removes the element from a sorted set with the lowest score, blocking until an element is - /// available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of sets. + /// [ZRANGE](https://redis.io/commands/zrange) + /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, + /// `Range` will trigger a precondition failure. /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `zpopmin` method where possible. + /// If you need such a range, use `zrange(from:firstIndex:lastIndex:)` instead. + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// See [https://redis.io/commands/bzpopmin](https://redis.io/commands/bzpopmin) + /// For the inverse, see `zrevrange(from:indices:returning:)`. /// - Parameters: - /// - keys: A list of sorted set keys in Redis. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached, `nil`. - /// - /// Otherwise, the key of the sorted set the element was removed from, the element itself, - /// and its associated score is returned. - public func bzpopmin( - from keys: [RedisKey], - timeout: TimeAmount = .seconds(0) - ) -> EventLoopFuture<(String, Double, RESPValue)?> { - return self._bzpop(command: "BZPOPMIN", keys, timeout) + /// - key: The key of the SortedSet to return elements from. + /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrange( + from key: RedisKey, + indices range: Range, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1, returning: resultOption) } - /// Removes the element from a sorted set with the highest score, blocking until an element is - /// available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the set. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `zpopmax` method where possible. + /// [ZRANGE](https://redis.io/commands/zrange) + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// See [https://redis.io/commands/bzpopmax](https://redis.io/commands/bzpopmax) + /// For the inverse, see `zrevrange(from:fromIndex:returning:)`. /// - Parameters: - /// - key: The key identifying the sorted set in Redis. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// The element and its associated score that was popped from the sorted set, - /// or `nil` if the timeout was reached. - public func bzpopmax( + /// - key: The key of the SortedSet to return elements from. + /// - index: The index of the first element that will be in the returned values. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrange( from key: RedisKey, - timeout: TimeAmount = .seconds(0) - ) -> EventLoopFuture<(Double, RESPValue)?> { - return self.bzpopmax(from: [key], timeout: timeout) - .map { - guard let response = $0 else { return nil } - return (response.1, response.2) - } + fromIndex index: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrange(from: key, firstIndex: index, lastIndex: -1, returning: resultOption) } - /// Removes the element from a sorted set with the highest score, blocking until an element is - /// available. - /// - /// - Important: - /// This will block the connection from completing further commands until an element - /// is available to pop from the group of sets. - /// - /// It is **highly** recommended to set a reasonable `timeout` - /// or to use the non-blocking `zpopmax` method where possible. + /// [ZRANGE](https://redis.io/commands/zrange) + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// See [https://redis.io/commands/bzpopmax](https://redis.io/commands/bzpopmax) + /// For the inverse, see `zrevrange(from:throughIndex:returning:)`. /// - Parameters: - /// - keys: A list of sorted set keys in Redis. - /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. - /// - Returns: - /// If timeout was reached, `nil`. - /// - /// Otherwise, the key of the sorted set the element was removed from, the element itself, - /// and its associated score is returned. - public func bzpopmax( - from keys: [RedisKey], - timeout: TimeAmount = .seconds(0) - ) -> EventLoopFuture<(String, Double, RESPValue)?> { - return self._bzpop(command: "BZPOPMAX", keys, timeout) + /// - key: The key of the SortedSet to return elements from. + /// - index: The index of the last element that will be in the returned values. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrange( + from key: RedisKey, + throughIndex index: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrange(from: key, firstIndex: 0, lastIndex: index, returning: resultOption) } - func _bzpop( - command: String, - _ keys: [RedisKey], - _ timeout: TimeAmount - ) -> EventLoopFuture<(String, Double, RESPValue)?> { - var args = keys.map(RESPValue.init) - args.append(.init(bulk: timeout.seconds)) - - return send(command: command, with: args) - // per the Redis docs, - // we will receive either a nil response, - // or an array with 3 elements in the form [Set Key, Element Score, Element Value] - .flatMapThrowing { - guard !$0.isNull else { return nil } - guard let response = [RESPValue](fromRESP: $0) else { - throw RedisClientError.failedRESPConversion(to: [RESPValue].self) - } - assert(response.count == 3, "Unexpected response size returned!") - guard - let key = response[0].string, - let score = Double(fromRESP: response[1]) - else { - throw RedisClientError.assertionFailure(message: "Unexpected structure in response: \(response)") - } - return (key, score, response[2]) - } + /// [ZRANGE](https://redis.io/commands/zrange) + /// - Important: This treats the SortedSet as ordered from **low** to **high**. + /// + /// For the inverse, see `zrevrange(from:upToIndex:returning:)`. + /// - Parameters: + /// - key: The key of the SortedSet to return elements from. + /// - index: The index of the last element to not include in the returned values. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrange( + from key: RedisKey, + upToIndex index: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrange(from: key, firstIndex: 0, lastIndex: index - 1, returning: resultOption) } -} - -// MARK: Increment -extension RedisClient { - /// Increments the score of the specified element in a sorted set. + /// [ZRANGEBYLEX](https://redis.io/commands/zrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// See [https://redis.io/commands/zincrby](https://redis.io/commands/zincrby) + /// For the inverse, see `zrevrangebylex(from:withValuesBetween:limitBy:)`. /// - Parameters: - /// - amount: The amount to increment this element's score by. - /// - element: The element to increment. - /// - key: The key of the sorted set. - /// - Returns: The new score of the element. + /// - key: The key of the SortedSet that will be counted. + /// - range: The min and max value bounds for filtering elements by. + /// - limitBy: The optional offset and count of elements to query. @inlinable - public func zincrby( - _ amount: Double, - element: Value, - in key: RedisKey - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: amount.description), - element.convertedToRESPValue() - ] - return send(command: "ZINCRBY", with: args) - .tryConverting() + public static func zrangebylex( + from key: RedisKey, + withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound), + limitBy limit: (offset: Int, count: Int)? = nil + ) -> RedisCommand<[RESPValue]> { + return ._zrangebylex(keyword: "ZRANGEBYLEX", key, (range.min.description, range.max.description), limit) } -} - -// MARK: Intersect and Union - -/// The supported methods for aggregating results from the `zunionstore` or `zinterstore` commands in Redis. -/// -/// For more information on these values, see -/// [https://redis.io/commands/zunionstore](https://redis.io/commands/zunionstore) -/// [https://redis.io/commands/zinterstore](https://redis.io/commands/zinterstore) -public enum RedisSortedSetAggregateMethod: String { - /// Add the score of all matching elements in the source SortedSets. - case sum = "SUM" - /// Use the minimum score of the matching elements in the source SortedSets. - case min = "MIN" - /// Use the maximum score of the matching elements in the source SortedSets. - case max = "MAX" -} -extension RedisClient { - /// Calculates the union of two or more sorted sets and stores the result. - /// - Note: This operation overwrites any value stored at the destination key. + /// [ZRANGEBYLEX](https://redis.io/commands/zrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// See [https://redis.io/commands/zunionstore](https://redis.io/commands/zunionstore) + /// For the inverse, see `zrevrangebylex(from:withMinimumValueOf:limitBy:)`. /// - Parameters: - /// - destination: The key of the new sorted set from the result. - /// - sources: The list of sorted set keys to treat as the source of the union. - /// - weights: The multiplying factor to apply to the corresponding `sources` key based on index of the two parameters. - /// - aggregateMethod: The method of aggregating the values of the union. If one isn't specified, Redis will default to `.sum`. - /// - Returns: The number of elements in the new sorted set. - public func zunionstore( - as destination: RedisKey, - sources: [RedisKey], - weights: [Int]? = nil, - aggregateMethod aggregate: RedisSortedSetAggregateMethod? = nil - ) -> EventLoopFuture { - return _zopstore(command: "ZUNIONSTORE", sources, destination, weights, aggregate) + /// - key: The key of the SortedSet. + /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be included in the result set. + /// - limit: The optional offset and count of elements to query + @inlinable + public static func zrangebylex( + from key: RedisKey, + withMinimumValueOf minValue: RedisZLexBound, + limitBy limit: (offset: Int, count: Int)? = nil + ) -> RedisCommand<[RESPValue]> { + return .zrangebylex(from: key, withValuesBetween: (minValue, .positiveInfinity), limitBy: limit) } - /// Calculates the intersection of two or more sorted sets and stores the result. - /// - Note: This operation overwrites any value stored at the destination key. + /// [ZRANGEBYLEX](https://redis.io/commands/zrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// See [https://redis.io/commands/zinterstore](https://redis.io/commands/zinterstore) + /// For the inverse, see `zrevrangebylex(from:withMaximumValueOf:limitBy:)`. /// - Parameters: - /// - destination: The key of the new sorted set from the result. - /// - sources: The list of sorted set keys to treat as the source of the intersection. - /// - weights: The multiplying factor to apply to the corresponding `sources` key based on index of the two parameters. - /// - aggregateMethod: The method of aggregating the values of the intersection. If one isn't specified, Redis will default to `.sum`. - /// - Returns: The number of elements in the new sorted set. - public func zinterstore( - as destination: RedisKey, - sources: [RedisKey], - weights: [Int]? = nil, - aggregateMethod aggregate: RedisSortedSetAggregateMethod? = nil - ) -> EventLoopFuture { - return _zopstore(command: "ZINTERSTORE", sources, destination, weights, aggregate) + /// - key: The key of the SortedSet. + /// - minValue: The maximum lexiographical value an element in the SortedSet should have to be included in the result set. + /// - limit: The optional offset and count of elements to query + @inlinable + public static func zrangebylex( + from key: RedisKey, + withMaximumValueOf maxValue: RedisZLexBound, + limitBy limit: (offset: Int, count: Int)? = nil + ) -> RedisCommand<[RESPValue]> { + return .zrangebylex(from: key, withValuesBetween: (.negativeInfinity, maxValue), limitBy: limit) } - func _zopstore( - command: String, - _ sources: [RedisKey], - _ destination: RedisKey, - _ weights: [Int]?, - _ aggregate: RedisSortedSetAggregateMethod? - ) -> EventLoopFuture { - assert(sources.count > 0, "At least 1 source key should be provided.") - - var args: [RESPValue] = [ - .init(from: destination), - .init(bulk: sources.count) - ] - args.append(convertingContentsOf: sources) - - if let w = weights { - assert(w.count > 0, "When passing a value for 'weights', at least 1 value should be provided.") - assert(w.count <= sources.count, "Weights should be no larger than the amount of source keys.") - - args.append(.init(bulk: "WEIGHTS")) - args.append(convertingContentsOf: w) - } - - if let a = aggregate { - args.append(.init(bulk: "AGGREGATE")) - args.append(.init(bulk: a.rawValue)) - } - - return send(command: command, with: args) - .tryConverting() + /// [ZREVRANGEBYLEX](https://redis.io/commands/zrevrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. + /// - Important: This treats the SortedSet as ordered from **high** to **low**. + /// + /// For the inverse, see `zrangebylex(from:withValuesBetween:limitBy:)`. + /// - Parameters: + /// - key: The key of the SortedSet that will be counted. + /// - range: The min and max value bounds for filtering elements by. + /// - limitBy: The optional offset and count of elements to query. + @inlinable + public static func zrevrangebylex( + from key: RedisKey, + withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound), + limitBy limit: (offset: Int, count: Int)? = nil + ) -> RedisCommand<[RESPValue]> { + return ._zrangebylex(keyword: "ZREVRANGEBYLEX", key, (range.max.description, range.min.description), limit) } -} - -// MARK: Range -extension RedisClient { - /// Gets all elements from a SortedSet within the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/zrange](https://redis.io/commands/zrange) - /// - Important: This treats the SortedSet as ordered from **low** to **high**. + /// [ZREVRANGEBYLEX](https://redis.io/commands/zrevrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrevrange(from:firstIndex:lastIndex:includeScoresInResponse:)`. + /// For the inverse, see `zrangebylex(from:withMinimumValueOf:limitBy:)`. /// - Parameters: - /// - key: The key of the SortedSet - /// - firstIndex: The index of the first element to include in the range of elements returned. - /// - lastIndex: The index of the last element to include in the range of elements returned. - /// - Returns: An array of elements found within the range specified. - public func zrange( + /// - key: The key of the SortedSet. + /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be included in the result set. + /// - limit: The optional offset and count of elements to query + @inlinable + public static func zrevrangebylex( from key: RedisKey, - firstIndex: Int, - lastIndex: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self._zrange(command: "ZRANGE", key, firstIndex, lastIndex, includeScores) + withMinimumValueOf minValue: RedisZLexBound, + limitBy limit: (offset: Int, count: Int)? = nil + ) -> RedisCommand<[RESPValue]> { + return .zrevrangebylex(from: key, withValuesBetween: (minValue, .positiveInfinity), limitBy: limit) } - - /// Gets all elements from a SortedSet within the specified inclusive bounds of 0-based indices. - /// - /// To get the elements at index 4 through 7: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: 4...7) - /// ``` - /// - /// To get the last 4 elements: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: (-4)...(-1)) - /// ``` - /// - /// To get the first and last 4 elements: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: (-4)...3) - /// ``` - /// - /// To get the first element, and the last 4: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: (-4)...0)) - /// ``` - /// - /// See [https://redis.io/commands/zrange](https://redis.io/commands/zrange) - /// - Warning: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, - /// `ClosedRange` will trigger a precondition failure. - /// - /// If you need such a range, use `zrange(from:firstIndex:lastIndex:)` instead. - /// - Important: This treats the SortedSet as ordered from **low** to **high**. + + /// [ZREVRANGEBYLEX](https://redis.io/commands/zrevrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrevrange(from:indices:includeScoresInResponse:)`. + /// For the inverse, see `zrangebylex(from:withMaximumValueOf:limitBy:)`. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - range: The range of inclusive indices of elements to get. - /// - Returns: An array of elements found within the range specified. - public func zrange( + /// - key: The key of the SortedSet. + /// - minValue: The maximum lexiographical value an element in the SortedSet should have to be included in the result set. + /// - limit: The optional offset and count of elements to query + @inlinable + public static func zrevrangebylex( from key: RedisKey, - indices range: ClosedRange, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound, includeScoresInResponse: includeScores) + withMaximumValueOf maxValue: RedisZLexBound, + limitBy limit: (offset: Int, count: Int)? = nil + ) -> RedisCommand<[RESPValue]> { + return .zrevrangebylex(from: key, withValuesBetween: (.negativeInfinity, maxValue), limitBy: limit) } - - /// Gets all the elements from a SortedSet starting with the first index bound up to, but not including, the element at the last index bound. - /// - /// To get the elements at index 4 through 7: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: 4..<8) - /// ``` - /// - /// To get the last 4 elements: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: (-4)..<0) - /// ``` - /// - /// To get the first and last 4 elements: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: (-4)..<4) - /// ``` - /// - /// To get the first element, and the last 4: - /// ```swift - /// client.zrange(from: "mySortedSet", indices: (-4)..<1) - /// ``` - /// - /// See [https://redis.io/commands/zrange](https://redis.io/commands/zrange) - /// - Warning: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, - /// `Range` will trigger a precondition failure. - /// - /// If you need such a range, use `zrange(from:firstIndex:lastIndex:)` instead. + + /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrange(from:indices:includeScoresInResponse:)`. + /// For the inverse, see `zrevrangebyscore(from:withScoresBetween:limitBy:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. - /// - Returns: An array of elements found within the range specified. - public func zrange( + /// - key: The key of the SortedSet. + /// - range: The min and max score bounds to filter elements by. + /// - limit: The optional offset and count of elements to query. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrangebyscore( from key: RedisKey, - indices range: Range, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1, includeScoresInResponse: includeScores) + withScoresBetween range: (min: RedisZScoreBound, max: RedisZScoreBound), + limitBy limit: (offset: Int, count: Int)? = nil, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return ._zrangebyscore(keyword: "ZRANGEBYSCORE", key, (range.min.description, range.max.description), limit, resultOption) } - - /// Gets all elements from the index specified to the end of a SortedSet. - /// - /// To get all except the first 2 elements of a SortedSet: - /// ```swift - /// client.zrange(from: "mySortedSet", fromIndex: 2) - /// ``` - /// - /// To get the last 4 elements of a SortedSet: - /// ```swift - /// client.zrange(from: "mySortedSet", fromIndex: -4) - /// ``` - /// - /// See `zrange(from:indices:)`, `zrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/zrange](https://redis.io/commands/zrange) + + /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrange(from:fromIndex:includeScoresInResponse:)`. + /// For the inverse, see `zrevrangebyscore(from:withScores:limitBy:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - index: The index of the first element that will be in the returned values. - /// - Returns: An array of elements from the SortedSet between the index and the end. - public func zrange( + /// - key: The key of the SortedSet. + /// - range: The inclusive range of scores to filter elements by. + /// - limit: The optional offset and count of elements to query. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrangebyscore( from key: RedisKey, - fromIndex index: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: index, lastIndex: -1, includeScoresInResponse: includeScores) + withScores range: ClosedRange, + limitBy limit: (offset: Int, count: Int)? = nil, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrangebyscore( + from: key, + withScoresBetween: (.inclusive(range.lowerBound), .inclusive(range.upperBound)), + limitBy: limit, + returning: resultOption + ) } - - /// Gets all elements from the start of a SortedSet up to, and including, the element at the index specified. - /// - /// To get the first 3 elements of a SortedSet: - /// ```swift - /// client.zrange(from: "mySortedSet", throughIndex: 2) - /// ``` - /// - /// To get all except the last 3 elements of a SortedSet: - /// ```swift - /// client.zrange(from: "mySortedSet", throughIndex: -4) - /// ``` - /// - /// See `zrange(from:indices:)`, `zrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/zrange](https://redis.io/commands/zrange) + + /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrange(from:throughIndex:includeScoresInResponse:)`. + /// For the inverse, see `zrevrangebyscore(from:withScores:limitBy:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - index: The index of the last element that will be in the returned values. - /// - Returns: An array of elements from the start of a SortedSet to the index. - public func zrange( + /// - key: The key of the SortedSet. + /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. + /// - limit: The optional offset and count of elements to query. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrangebyscore( from key: RedisKey, - throughIndex index: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: 0, lastIndex: index, includeScoresInResponse: includeScores) + withScores range: Range, + limitBy limit: (offset: Int, count: Int)? = nil, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrangebyscore( + from: key, + withScoresBetween: (.inclusive(range.lowerBound), .exclusive(range.upperBound)), + limitBy: limit, + returning: resultOption + ) } - - /// Gets all elements from the start of a SortedSet up to, but not including, the element at the index specified. - /// - /// To get the first 3 elements of a List: - /// ```swift - /// client.zrange(from: "myList", upToIndex: 3) - /// ``` - /// - /// To get all except the last 3 elements of a List: - /// ```swift - /// client.zrange(from: "myList", upToIndex: -3) - /// ``` - /// - /// See `zrange(from:indices:)`, `zrange(from:upToIndex:lastIndex:)`, and [https://redis.io/commands/zrange](https://redis.io/commands/zrange) + + /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrange(from:upToIndex:includeScoresInResponse:)`. + /// For the inverse, see `zrevrangebyscore(from:withMinimumScoreOf:limitBy:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - index: The index of the last element to not include in the returned values. - /// - Returns: An array of elements from the start of the SortedSet and up to the index. - public func zrange( + /// - key: The key of the SortedSet. + /// - range: The minimum score bound an element in the SortedSet should have to be included in the response. + /// - limit: The optional offset and count of elements to query. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrangebyscore( from key: RedisKey, - upToIndex index: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: 0, lastIndex: index - 1, includeScoresInResponse: includeScores) + withMinimumScoreOf minScore: RedisZScoreBound, + limitBy limit: (offset: Int, count: Int)? = nil, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrangebyscore( + from: key, + withScoresBetween: (minScore, .inclusive(.infinity)), + limitBy: limit, + returning: resultOption + ) } - - /// Gets all elements from a SortedSet within the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/zrevrange](https://redis.io/commands/zrevrange) - /// - Important: This treats the SortedSet as ordered from **high** to **low**. + + /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) + /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrange(from:firstIndex:lastIndex:includeScoresInResponse:)`. + /// For the inverse, see `zrevrangebyscore(from:withMaximumScoreOf:limitBy:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet - /// - firstIndex: The index of the first element to include in the range of elements returned. - /// - lastIndex: The index of the last element to include in the range of elements returned. - /// - Returns: An array of elements found within the range specified. - public func zrevrange( + /// - key: The key of the SortedSet. + /// - range: The maximum score bound an element in the SortedSet should have to be included in the response. + /// - limit: The optional offset and count of elements to query. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrangebyscore( from key: RedisKey, - firstIndex: Int, - lastIndex: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self._zrange(command: "ZREVRANGE", key, firstIndex, lastIndex, includeScores) + withMaximumScoreOf maxScore: RedisZScoreBound, + limitBy limit: (offset: Int, count: Int)? = nil, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrangebyscore( + from: key, + withScoresBetween: (.inclusive(-.infinity), maxScore), + limitBy: limit, + returning: resultOption + ) } - - /// Gets all elements from a SortedSet within the specified inclusive bounds of 0-based indices. - /// - /// To get the elements at index 4 through 7: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: 4...7) - /// ``` - /// - /// To get the last 4 elements: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: (-4)...(-1)) - /// ``` - /// - /// To get the first and last 4 elements: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: (-4)...3) - /// ``` - /// - /// To get the first element, and the last 4: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: (-4)...0)) - /// ``` - /// - /// See [https://redis.io/commands/zrevrange](https://redis.io/commands/zrevrange) - /// - Warning: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, - /// `ClosedRange` will trigger a precondition failure. - /// - /// If you need such a range, use `zrevrange(from:firstIndex:lastIndex:)` instead. - /// - Important: This treats the SortedSet as ordered from **high** to **low**. + + /// [ZRANK](https://redis.io/commands/zrank) + /// - Important: This treats the ordered set as ordered from low to high. /// - /// For the inverse, see `zrange(from:indices:includeScoresInResponse:)`. + /// For the inverse, see `zrevrank(of:in:)`. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - range: The range of inclusive indices of elements to get. - /// - Returns: An array of elements found within the range specified. - public func zrevrange( - from key: RedisKey, - indices range: ClosedRange, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound, includeScoresInResponse: includeScores) + /// - element: The element in the sorted set to search for. + /// - key: The key of the sorted set to search. + @inlinable + public static func zrank(of element: Value, in key: RedisKey) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + element.convertedToRESPValue() + ] + return .init(keyword: "ZRANK", arguments: args) } - - /// Gets all the elements from a SortedSet starting with the first index bound up to, but not including, the element at the last index bound. - /// - /// To get the elements at index 4 through 7: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: 4..<8) - /// ``` - /// - /// To get the last 4 elements: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: (-4)..<0) - /// ``` - /// - /// To get the first and last 4 elements: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: (-4)..<4) - /// ``` - /// - /// To get the first element, and the last 4: - /// ```swift - /// client.zrevrange(from: "mySortedSet", indices: (-4)..<1) - /// ``` - /// - /// See [https://redis.io/commands/zrevrange](https://redis.io/commands/zrevrange) - /// - Warning: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, - /// `Range` will trigger a precondition failure. - /// - /// If you need such a range, use `zrevrange(from:firstIndex:lastIndex:)` instead. - /// - Important: This treats the SortedSet as ordered from **high** to **low**. - /// - /// For the inverse, see `zrange(from:indices:includeScoresInResponse:)`. + + /// [ZREM](https://redis.io/commands/zrem) /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. - /// - Returns: An array of elements found within the range specified. - public func zrevrange( - from key: RedisKey, - indices range: Range, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1, includeScoresInResponse: includeScores) + /// - elements: The values to remove from the sorted set. + /// - key: The key of the sorted set. + @inlinable + public static func zrem(_ elements: [Value], from key: RedisKey) -> RedisCommand { + var args = [RESPValue(from: key)] + args.append(convertingContentsOf: elements) + return .init(keyword: "ZREM", arguments: args) } - - /// Gets all elements from the index specified to the end of a SortedSet. - /// - /// To get all except the first 2 elements of a SortedSet: - /// ```swift - /// client.zrevrange(from: "mySortedSet", fromIndex: 2) - /// ``` - /// - /// To get the last 4 elements of a SortedSet: - /// ```swift - /// client.zrevrange(from: "mySortedSet", fromIndex: -4) - /// ``` - /// - /// See `zrevrange(from:indices:)`, `zrevrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/zrevrange](https://redis.io/commands/zrevrange) - /// - Important: This treats the SortedSet as ordered from **high** to **low**. - /// - /// For the inverse, see `zrange(from:fromIndex:includeScoresInResponse:)`. + + /// [ZREM](https://redis.io/commands/zrem) /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - index: The index of the first element that will be in the returned values. - /// - Returns: An array of elements from the SortedSet between the index and the end. - public func zrevrange( + /// - elements: The values to remove from the sorted set. + /// - key: The key of the sorted set. + @inlinable + public static func zrem(_ elements: Value..., from key: RedisKey) -> RedisCommand { + return .zrem(elements, from: key) + } + + /// [ZREMRANGEBYLEX](https://redis.io/commands/zremrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the elements removed are unspecified. + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - range: The min and max value bounds that an element should have to be removed. + @inlinable + public static func zremrangebylex( from key: RedisKey, - fromIndex index: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: index, lastIndex: -1, includeScoresInResponse: includeScores) + withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound) + ) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: range.min.description), + .init(bulk: range.max.description) + ] + return .init(keyword: "ZREMRANGEBYLEX", arguments: args) } - - /// Gets all elements from the start of a SortedSet up to, and including, the element at the index specified. - /// - /// To get the first 3 elements of a SortedSet: - /// ```swift - /// client.zrevrange(from: "mySortedSet", throughIndex: 2) - /// ``` - /// - /// To get all except the last 3 elements of a SortedSet: - /// ```swift - /// client.zrevrange(from: "mySortedSet", throughIndex: -4) - /// ``` - /// - /// See `zrevrange(from:indices:)`, `zrevrange(from:firstIndex:lastIndex:)`, and [https://redis.io/commands/zrevrange](https://redis.io/commands/zrevrange) - /// - Important: This treats the SortedSet as ordered from **high** to **low**. - /// - /// For the inverse, see `zrange(from:throughIndex:includeScoresInResponse:)`. + + /// [ZREMRANGEBYLEX](https://redis.io/commands/zremrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the elements removed are unspecified. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - index: The index of the last element that will be in the returned values. - /// - Returns: An array of elements from the start of a SortedSet to the index. - public func zrevrange( + /// - key: The key of the SortedSet to remove elements from. + /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be removed. + @inlinable + public static func zremrangebylex( from key: RedisKey, - throughIndex index: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: 0, lastIndex: index, includeScoresInResponse: includeScores) + withMinimumValueOf minValue: RedisZLexBound + ) -> RedisCommand { .zremrangebylex(from: key, withValuesBetween: (minValue, .positiveInfinity)) } + + /// [ZREMRANGEBYLEX](https://redis.io/commands/zremrangebylex) + /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the elements removed are unspecified. + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - maxValue: The maximum lexiographical value and element in the SortedSet should have to be removed. + @inlinable + public static func zremrangebylex( + from key: RedisKey, + withMaximumValueOf maxValue: RedisZLexBound + ) -> RedisCommand { .zremrangebylex(from: key, withValuesBetween: (.negativeInfinity, maxValue)) } + + /// [ZREMRANGEBYRANK](https://redis.io/commands/zremrangebyrank) + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - firstIndex: The index of the first element to remove. + /// - lastIndex: The index of the last element to remove. + public static func zremrangebyrank(from key: RedisKey, firstIndex: Int, lastIndex: Int) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: firstIndex), + .init(bulk: lastIndex) + ] + return .init(keyword: "ZREMRANGEBYRANK", arguments: args) } - - /// Gets all elements from the start of a SortedSet up to, but not including, the element at the index specified. - /// - /// To get the first 3 elements of a List: - /// ```swift - /// client.zrevrange(from: "myList", upToIndex: 3) - /// ``` - /// - /// To get all except the last 3 elements of a List: - /// ```swift - /// client.zrevrange(from: "myList", upToIndex: -3) - /// ``` + + /// [ZREMRANGEBYRANK](https://redis.io/commands/zremrangebyrank) + /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, + /// `ClosedRange` will trigger a precondition failure. /// - /// See `zrevrange(from:indices:)`, `zrevrange(from:upToIndex:lastIndex:)`, and [https://redis.io/commands/zrevrange](https://redis.io/commands/zrevrange) - /// - Important: This treats the SortedSet as ordered from **high** to **low**. + /// If you need such a range, use `zremrangebyrank(from:firstIndex:lastIndex:)` instead. + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - range: The range of inclusive indices of elements to remove. + public static func zremrangebyrank(from key: RedisKey, indices range: ClosedRange) -> RedisCommand { + return .zremrangebyrank(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound) + } + + /// [ZREMRANGEBYRANK](https://redis.io/commands/zremrangebyrank) + /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, + /// `Range` will trigger a precondition failure. /// - /// For the inverse, see `zrange(from:upToIndex:includeScoresInResponse:)`. + /// If you need such a range, use `zremrangebyrank(from:firstIndex:lastIndex:)` instead. /// - Parameters: - /// - key: The key of the SortedSet to return elements from. - /// - index: The index of the last element to not include in the returned values. - /// - Returns: An array of elements from the start of the SortedSet and up to the index. - public func zrevrange( - from key: RedisKey, - upToIndex index: Int, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrange(from: key, firstIndex: 0, lastIndex: index - 1, includeScoresInResponse: includeScores) + /// - key: The key of the SortedSet to remove elements from. + /// - range: The range of indices (inclusive lower, exclusive upper) elements to remove. + public static func zremrangebyrank(from key: RedisKey, indices range: Range) -> RedisCommand { + return .zremrangebyrank(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1) } - func _zrange( - command: String, - _ key: RedisKey, - _ start: Int, - _ stop: Int, - _ withScores: Bool - ) -> EventLoopFuture<[RESPValue]> { - var args: [RESPValue] = [ + /// [ZREMRANGEBYRANK](https://redis.io/commands/zremrangebyrank) + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - index: The index of the first element that will be removed. + public static func zremrangebyrank(from key: RedisKey, fromIndex index: Int) -> RedisCommand { + return .zremrangebyrank(from: key, firstIndex: index, lastIndex: -1) + } + + /// [ZREMRANGEBYRANK](https://redis.io/commands/zremrangebyrank) + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - index: The index of the last element that will be removed. + public static func zremrangebyrank(from key: RedisKey, throughIndex index: Int) -> RedisCommand { + return .zremrangebyrank(from: key, firstIndex: 0, lastIndex: index) + } + + /// [ZREMRANGEBYRANK](https://redis.io/commands/zremrangebyrank) + /// - Warning: Providing an index of `0` will remove all elements from the SortedSet. + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - index: The index of the last element to not remove. + public static func zremrangebyrank(from key: RedisKey, upToIndex index: Int) -> RedisCommand { + return .zremrangebyrank(from: key, firstIndex: 0, lastIndex: index - 1) + } + + /// [ZREMRANGEBYSCORE](https://redis.io/commands/zremrangebyscore) + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - range: The min and max score bounds to filter elements by. + public static func zremrangebyscore( + from key: RedisKey, + withScoresBetween range: (min: RedisZScoreBound, max: RedisZScoreBound) + ) -> RedisCommand { + let args: [RESPValue] = [ .init(from: key), - .init(bulk: start), - .init(bulk: stop) + .init(bulk: range.min.description), + .init(bulk: range.max.description) ] + return .init(keyword: "ZREMRANGEBYSCORE", arguments: args) + } - if withScores { args.append(.init(bulk: "WITHSCORES")) } + /// [ZREMRANGEBYSCORE](https://redis.io/commands/zremrangebyscore) + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - range: The inclusive range of scores to filter elements by. + public static func zremrangebyscore(from key: RedisKey, withScores range: ClosedRange) -> RedisCommand { + return .zremrangebyscore(from: key, withScoresBetween: (.inclusive(range.lowerBound), .inclusive(range.upperBound))) + } - return send(command: command, with: args) - .tryConverting() + /// [ZREMRANGEBYSCORE](https://redis.io/commands/zremrangebyscore) + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. + public static func zremrangebyscore(from key: RedisKey, withScores range: Range) -> RedisCommand { + return .zremrangebyscore(from: key, withScoresBetween: (.inclusive(range.lowerBound), .exclusive(range.upperBound))) } -} -// MARK: Range by Score + /// [ZREMRANGEBYSCORE](https://redis.io/commands/zremrangebyscore) + /// - Parameters: + /// - key: The key of the SortedSet to remove elements from. + /// - minScore: The minimum score bound an element in the SortedSet should have to be removed. + public static func zremrangebyscore(from key: RedisKey, withMinimumScoreOf minScore: RedisZScoreBound) -> RedisCommand { + return .zremrangebyscore(from: key, withScoresBetween: (minScore, .inclusive(.infinity))) + } -extension RedisClient { - /// Gets all elements from a SortedSet whose score is within the range specified. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) - /// - Important: This treats the SortedSet as ordered from **low** to **high**. - /// - /// For the inverse, see `zrevrangebyscore(from:withScoresBetween:limitBy:includeScoresInResponse:)`. + /// [ZREMRANGEBYSCORE](https://redis.io/commands/zremrangebyscore) /// - Parameters: - /// - key: The key of the SortedSet. - /// - range: The min and max score bounds to filter elements by. - /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrangebyscore( - from key: RedisKey, - withScoresBetween range: (min: RedisZScoreBound, max: RedisZScoreBound), - limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return _zrangebyscore(command: "ZRANGEBYSCORE", key, (range.min.description, range.max.description), includeScores, limit) + /// - key: The key of the SortedSet to remove elements from. + /// - minScore: The maximum score bound an element in the SortedSet should have to be removed. + public static func zremrangebyscore(from key: RedisKey, withMaximumScoreOf maxScore: RedisZScoreBound) -> RedisCommand { + return .zremrangebyscore(from: key, withScoresBetween: (.inclusive(-.infinity), maxScore)) } - - /// Gets all elements from a SortedSet whose score is within the inclusive range specified. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) - /// - Important: This treats the SortedSet as ordered from **low** to **high**. + + /// [ZREVRANGE](https://redis.io/commands/zrevrange) + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrevrangebyscore(from:withScores:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrange(from:firstIndex:lastIndex:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet. - /// - range: The inclusive range of scores to filter elements by. - /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrangebyscore( + /// - key: The key of the SortedSet + /// - firstIndex: The index of the first element to include in the range of elements returned. + /// - lastIndex: The index of the last element to include in the range of elements returned. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrange( from key: RedisKey, - withScores range: ClosedRange, - limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrangebyscore( - from: key, - withScoresBetween: (.inclusive(range.lowerBound), .inclusive(range.upperBound)), - limitBy: limit, - includeScoresInResponse: includeScores - ) - } - - /// Gets all elements from a SortedSet whose score is at least a minimum score up to, but not including, a max score. + firstIndex: Int, + lastIndex: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { ._zrange(keyword: "ZREVRANGE", key, firstIndex, lastIndex, resultOption) } + + /// [ZREVRANGE](https://redis.io/commands/zrevrange) + /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, + /// `ClosedRange` will trigger a precondition failure. /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) - /// - Important: This treats the SortedSet as ordered from **low** to **high**. + /// If you need such a range, use `zrevrange(from:firstIndex:lastIndex:)` instead. + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrevrangebyscore(from:withScores:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrange(from:indices:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet. - /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. - /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrangebyscore( + /// - key: The key of the SortedSet to return elements from. + /// - range: The range of inclusive indices of elements to get. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrange( from key: RedisKey, - withScores range: Range, - limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrangebyscore( - from: key, - withScoresBetween: (.inclusive(range.lowerBound), .exclusive(range.upperBound)), - limitBy: limit, - includeScoresInResponse: includeScores - ) - } - - /// Gets all elements from a SortedSet whose score is greater than a minimum score value. + indices range: ClosedRange, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { .zrevrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound, returning: resultOption) } + + /// [ZREVRANGE](https://redis.io/commands/zrevrange) + /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, + /// `Range` will trigger a precondition failure. /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) - /// - Important: This treats the SortedSet as ordered from **low** to **high**. + /// If you need such a range, use `zrevrange(from:firstIndex:lastIndex:)` instead. + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrevrangebyscore(from:withMinimumScoreOf:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrange(from:indices:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet. - /// - range: The minimum score bound an element in the SortedSet should have to be included in the response. - /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrangebyscore( + /// - key: The key of the SortedSet to return elements from. + /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrange( from key: RedisKey, - withMinimumScoreOf minScore: RedisZScoreBound, - limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrangebyscore( - from: key, - withScoresBetween: (minScore, .inclusive(.infinity)), - limitBy: limit, - includeScoresInResponse: includeScores - ) - } + indices range: Range, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { .zrevrange(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1, returning: resultOption) } - /// Gets all elements from a SortedSet whose score is less than a maximum score value. + /// [ZREVRANGE](https://redis.io/commands/zrevrange) + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) - /// - Important: This treats the SortedSet as ordered from **low** to **high**. + /// For the inverse, see `zrange(from:fromIndex:returning:)`. + /// - Parameters: + /// - key: The key of the SortedSet to return elements from. + /// - index: The index of the first element that will be in the returned values. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrange( + from key: RedisKey, + fromIndex index: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { .zrevrange(from: key, firstIndex: index, lastIndex: -1, returning: resultOption) } + + /// [ZREVRANGE](https://redis.io/commands/zrevrange) + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrevrangebyscore(from:withMaximumScoreOf:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrange(from:throughIndex:returning:)`. /// - Parameters: - /// - key: The key of the SortedSet. - /// - range: The maximum score bound an element in the SortedSet should have to be included in the response. - /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrangebyscore( + /// - key: The key of the SortedSet to return elements from. + /// - index: The index of the last element that will be in the returned values. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrange( from key: RedisKey, - withMaximumScoreOf maxScore: RedisZScoreBound, - limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrangebyscore( - from: key, - withScoresBetween: (.inclusive(-.infinity), maxScore), - limitBy: limit, - includeScoresInResponse: includeScores - ) - } - - /// Gets all elements from a SortedSet whose score is within the range specified. + throughIndex index: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { .zrevrange(from: key, firstIndex: 0, lastIndex: index, returning: resultOption) } + + /// [ZREVRANGE](https://redis.io/commands/zrevrange) + /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) + /// For the inverse, see `zrange(from:upToIndex:returning:)`. + /// - Parameters: + /// - key: The key of the SortedSet to return elements from. + /// - index: The index of the last element to not include in the returned values. + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrange( + from key: RedisKey, + upToIndex index: Int, + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { .zrevrange(from: key, firstIndex: 0, lastIndex: index - 1, returning: resultOption) } + + /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withScoresBetween:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrangebyscore(from:withScoresBetween:limitBy:returning:)`. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The min and max score bounds to filter elements by. /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrevrangebyscore( + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrangebyscore( from key: RedisKey, withScoresBetween range: (min: RedisZScoreBound, max: RedisZScoreBound), limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return _zrangebyscore(command: "ZREVRANGEBYSCORE", key, (range.max.description, range.min.description), includeScores, limit) + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return ._zrangebyscore(keyword: "ZREVRANGEBYSCORE", key, (range.max.description, range.min.description), limit, resultOption) } - - /// Gets all elements from a SortedSet whose score is within the inclusive range specified. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) + + /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withScores:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrangebyscore(from:withScores:limitBy:returning:)`. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The inclusive range of scores to filter elements by. /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrevrangebyscore( + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrangebyscore( from key: RedisKey, withScores range: ClosedRange, limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrevrangebyscore( + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrevrangebyscore( from: key, withScoresBetween: (.inclusive(range.lowerBound), .inclusive(range.upperBound)), limitBy: limit, - includeScoresInResponse: includeScores + returning: resultOption ) } - - /// Gets all elements from a SortedSet whose score is at least a minimum score up to, but not including, a max score. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) + + /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withScores:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrangebyscore(from:withScores:limitBy:returning:)`. /// - Parameters: /// - key: The key of the SortedSet. /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrevrangebyscore( + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrangebyscore( from key: RedisKey, withScores range: Range, limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrevrangebyscore( + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrevrangebyscore( from: key, withScoresBetween: (.inclusive(range.lowerBound), .exclusive(range.upperBound)), limitBy: limit, - includeScoresInResponse: includeScores + returning: resultOption ) } - - /// Gets all elements from a SortedSet whose score is greater than a minimum score value. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) + + /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withMinimumScoreOf:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrangebyscore(from:withMinimumScoreOf:limitBy:returning:)`. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The minimum score bound an element in the SortedSet should have to be included in the response. /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrevrangebyscore( + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrangebyscore( from key: RedisKey, withMinimumScoreOf minScore: RedisZScoreBound, limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrevrangebyscore( + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrevrangebyscore( from: key, withScoresBetween: (minScore, .inclusive(.infinity)), limitBy: limit, - includeScoresInResponse: includeScores + returning: resultOption ) } - - /// Gets all elements from a SortedSet whose score is less than a maximum score value. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zrangebyscore](https://redis.io/commands/zrangebyscore) + + /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withMaximumScoreOf:limitBy:includeScoresInResponse:)`. + /// For the inverse, see `zrangebyscore(from:withMaximumScoreOf:limitBy:returning:)`. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The maximum score bound an element in the SortedSet should have to be included in the response. /// - limit: The optional offset and count of elements to query. - /// - includeScores: Should the response array contain the elements AND their scores? If `true`, the response array will follow the pattern [Item_1, Score_1, Item_2, ...] - /// - Returns: An array of elements from the SortedSet that were within the range provided, and optionally their scores. - public func zrevrangebyscore( + /// - resultOption: What information should be returned in the result? + @inlinable + public static func zrevrangebyscore( from key: RedisKey, withMaximumScoreOf maxScore: RedisZScoreBound, limitBy limit: (offset: Int, count: Int)? = nil, - includeScoresInResponse includeScores: Bool = false - ) -> EventLoopFuture<[RESPValue]> { - return self.zrevrangebyscore( + returning resultOption: RedisZRangeResultOption + ) -> RedisCommand { + return .zrevrangebyscore( from: key, withScoresBetween: (.inclusive(-.infinity), maxScore), limitBy: limit, - includeScoresInResponse: includeScores + returning: resultOption ) } - func _zrangebyscore( - command: String, - _ key: RedisKey, - _ range: (min: String, max: String), - _ withScores: Bool, - _ limit: (offset: Int, count: Int)? - ) -> EventLoopFuture<[RESPValue]> { - var args: [RESPValue] = [ - .init(from: key), - .init(bulk: range.min), - .init(bulk: range.max) - ] - - if withScores { args.append(.init(bulk: "WITHSCORES")) } - - if let l = limit { - args.append(.init(bulk: "LIMIT")) - args.append(convertingContentsOf: [l.offset, l.count]) - } - - return send(command: command, with: args) - .tryConverting() - } -} - -// MARK: Range by Lexiographical - -extension RedisClient { - /// Gets all elements from a SortedSet whose lexiographical values are between the range specified. - /// - /// For example: - /// ``` - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1 - /// client.zrangebylex(of: "mySortedSet", withValuesBetween: (.inclusive(1), .exclusive(3))) - /// // the response resolves to [1, 10, 2] - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zrangebylex](https://redis.io/commands/zrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. - /// - Important: This treats the SortedSet as ordered from **low** to **high**. - /// - /// For the inverse, see `zrevrangebylex(from:withValuesBetween:limitBy:)`. - /// - Parameters: - /// - key: The key of the SortedSet that will be counted. - /// - range: The min and max value bounds for filtering elements by. - /// - limitBy: The optional offset and count of elements to query. - /// - Returns: An array of elements from the SortedSet that were within the range provided. - @inlinable - public func zrangebylex( - from key: RedisKey, - withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound), - limitBy limit: (offset: Int, count: Int)? = nil - ) -> EventLoopFuture<[RESPValue]> { - return self._zrangebylex(command: "ZRANGEBYLEX", key, (range.min.description, range.max.description), limit) - } - - /// Gets all elements from a SortedSet whose lexiographical value is greater than a minimum value. - /// - /// ``` - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1 - /// client.zrangebylex(of: "mySortedSet", withMinimumValueOf: .inclusive(1)) - /// // the response resolves to [1, 10, 2, 3] - /// ``` + /// [ZREVRANK](https://redis.io/commands/zrevrank) + /// - Important: This treats the ordered set as ordered from high to low. /// - /// See `RedisZLexBound` and [https://redis.io/commands/zrangebylex](https://redis.io/commands/zrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. - /// - Important: This treats the SortedSet as ordered from **low** to **high**. - /// - /// For the inverse, see `zrevrangebylex(from:withMinimumValueOf:limitBy:)`. + /// For the inverse, see `zrank(of:in:)`. /// - Parameters: - /// - key: The key of the SortedSet. - /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be included in the result set. - /// - limit: The optional offset and count of elements to query - /// - Returns: An array of elements from the SortedSet above the `minValue` threshold. + /// - element: The element in the sorted set to search for. + /// - key: The key of the sorted set to search. @inlinable - public func zrangebylex( - from key: RedisKey, - withMinimumValueOf minValue: RedisZLexBound, - limitBy limit: (offset: Int, count: Int)? = nil - ) -> EventLoopFuture<[RESPValue]> { - return self.zrangebylex(from: key, withValuesBetween: (minValue, .positiveInfinity), limitBy: limit) + public static func zrevrank(of element: Value, in key: RedisKey) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + element.convertedToRESPValue() + ] + return .init(keyword: "ZREVRANK", arguments: args) } - - /// Gets all elements from a SortedSet whose lexiographical value is less than a maximum value. - /// - /// ``` - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1 - /// client.zlexcount(of: "mySortedSet", withMaximumValueOf: .exclusive(2)) - /// // the response resolves to [1, 10] - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zrangebylex](https://redis.io/commands/zrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. - /// - Important: This treats the SortedSet as ordered from **low** to **high**. - /// - /// For the inverse, see `zrevrangebylex(from:withMaximumValueOf:limitBy:)`. + + /// [ZSCORE](https://redis.io/commands/zscore) /// - Parameters: - /// - key: The key of the SortedSet. - /// - minValue: The maximum lexiographical value an element in the SortedSet should have to be included in the result set. - /// - limit: The optional offset and count of elements to query - /// - Returns: An array of elements from the SortedSet below the `maxValue` threshold. + /// - element: The element in the sorted set to get the score for. + /// - key: The key of the sorted set. @inlinable - public func zrangebylex( - from key: RedisKey, - withMaximumValueOf maxValue: RedisZLexBound, - limitBy limit: (offset: Int, count: Int)? = nil - ) -> EventLoopFuture<[RESPValue]> { - return self.zrangebylex(from: key, withValuesBetween: (.negativeInfinity, maxValue), limitBy: limit) + public static func zscore(of element: Value, in key: RedisKey) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + element.convertedToRESPValue() + ] + return .init(keyword: "ZSCORE", arguments: args) { try? $0.map() } } - - /// Gets all elements from a SortedSet whose lexiographical values are between the range specified. - /// - /// For example: - /// ``` - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1 - /// client.zrevrangebylex(of: "mySortedSet", withValuesBetween: (.inclusive(1), .exclusive(3))) - /// // the response resolves to [2, 10 1] - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zrevrangebylex](https://redis.io/commands/zrevrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. - /// - Important: This treats the SortedSet as ordered from **high** to **low**. - /// - /// For the inverse, see `zrangebylex(from:withValuesBetween:limitBy:)`. + + /// [ZUNIONSTORE](https://redis.io/commands/zunionstore) + /// - Warning: This operation overwrites any value stored at the destination key. /// - Parameters: - /// - key: The key of the SortedSet that will be counted. - /// - range: The min and max value bounds for filtering elements by. - /// - limitBy: The optional offset and count of elements to query. - /// - Returns: An array of elements from the SortedSet that were within the range provided. - @inlinable - public func zrevrangebylex( - from key: RedisKey, - withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound), - limitBy limit: (offset: Int, count: Int)? = nil - ) -> EventLoopFuture<[RESPValue]> { - return self._zrangebylex(command: "ZREVRANGEBYLEX", key, (range.max.description, range.min.description), limit) - } - - /// Gets all elements from a SortedSet whose lexiographical value is greater than a minimum value. - /// - /// ``` - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1 - /// client.zrevrangebylex(of: "mySortedSet", withMinimumValueOf: .inclusive(1)) - /// // the response resolves to [3, 2, 10, 1] - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zrevrangebylex](https://redis.io/commands/zrevrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. - /// - Important: This treats the SortedSet as ordered from **high** to **low**. - /// - /// For the inverse, see `zrangebylex(from:withMinimumValueOf:limitBy:)`. + /// - destination: The key of the new sorted set from the result. + /// - sources: The list of sorted set keys to treat as the source of the union. + /// - weights: The multiplying factor to apply to the corresponding `sources` key based on index of the two parameters. + /// - aggregateMethod: The method of aggregating the values of the union. If one isn't specified, Redis will default to `.sum`. + public static func zunionstore( + as destination: RedisKey, + sources: [RedisKey], + weights: [Int]? = nil, + aggregateMethod aggregate: RedisSortedSetAggregateMethod? = nil + ) -> RedisCommand { ._zstore(keyword: "ZUNIONSTORE", sources, destination, weights, aggregate) } + + /// [ZSCAN](https://redis.io/commands/zscan) /// - Parameters: - /// - key: The key of the SortedSet. - /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be included in the result set. - /// - limit: The optional offset and count of elements to query - /// - Returns: An array of elements from the SortedSet above the `minValue` threshold. - @inlinable - public func zrevrangebylex( - from key: RedisKey, - withMinimumValueOf minValue: RedisZLexBound, - limitBy limit: (offset: Int, count: Int)? = nil - ) -> EventLoopFuture<[RESPValue]> { - return self.zrevrangebylex(from: key, withValuesBetween: (minValue, .positiveInfinity), limitBy: limit) + /// - key: The key identifying the sorted set. + /// - position: The position to start the scan from. + /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - count: The number of elements to advance by. Redis default is 10. + public static func zscan( + _ key: RedisKey, + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> RedisCommand<(Int, [(RESPValue, Double)])> { + return ._scan(keyword: "ZSCAN", key, position, match, count, { + let response = try $0.map(to: [RESPValue].self) + return try Self._mapSortedSetResponse(response, scoreIsFirst: false) + }) } - - /// Gets all elements from a SortedSet whose lexiographical value is less than a maximum value. - /// - /// ``` - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1 - /// client.zrevrangebylex(of: "mySortedSet", withMaximumValueOf: .exclusive(2)) - /// // the response resolves to [10, 1] - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zrevrangebylex](https://redis.io/commands/zrevrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. - /// - Important: This treats the SortedSet as ordered from **high** to **low**. +} + +// MARK: - + +extension RedisClient { + /// Incrementally iterates over all elements in a sorted set. /// - /// For the inverse, see `zrangebylex(from:withMaximumValueOf:limitBy:)`. + /// See `RedisCommand.zscan(_:startingFrom:matching:count:)` /// - Parameters: - /// - key: The key of the SortedSet. - /// - minValue: The maximum lexiographical value an element in the SortedSet should have to be included in the result set. - /// - limit: The optional offset and count of elements to query - /// - Returns: An array of elements from the SortedSet below the `maxValue` threshold. - @inlinable - public func zrevrangebylex( - from key: RedisKey, - withMaximumValueOf maxValue: RedisZLexBound, - limitBy limit: (offset: Int, count: Int)? = nil - ) -> EventLoopFuture<[RESPValue]> { - return self.zrevrangebylex(from: key, withValuesBetween: (.negativeInfinity, maxValue), limitBy: limit) + /// - key: The key identifying the sorted set. + /// - position: The position to start the scan from. + /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - count: The number of elements to advance by. Redis default is 10. + /// - Returns: A `NIO.EventLoopFuture` that resolves a cursor position for additional scans, + /// with a limited collection of elements with their scores found in the Sorted Set. + public func scanSortedSetValues( + in key: RedisKey, + startingFrom position: Int = 0, + matching match: String? = nil, + count: Int? = nil + ) -> EventLoopFuture<(Int, [(RESPValue, Double)])> { + return self.send(.zscan(key, startingFrom: position, matching: match, count: count)) } +} - @usableFromInline - func _zrangebylex( - command: String, - _ key: RedisKey, - _ range: (min: String, max: String), - _ limit: (offset: Int, count: Int)? - ) -> EventLoopFuture<[RESPValue]> { - var args: [RESPValue] = [ - .init(from: key), - .init(bulk: range.min), - .init(bulk: range.max) - ] +// MARK: - - if let l = limit { - args.reserveCapacity(6) // 3 above, plus 3 being added - args.append(.init(bulk: "LIMIT")) - args.append(.init(bulk: l.offset)) - args.append(.init(bulk: l.count)) - } +/// The supported insert behavior for a `zadd` command with Redis SortedSet types. +/// +/// `zadd` normally inserts all given elements into the SortedSet, updating the score of any element that already exist in the set. +/// +/// However, it other behaviors are available: [ZADD Options](https://redis.io/commands/zadd#zadd-options). +public struct RedisZaddInsertBehavior { + /// Insert new elements and update the score of existing elements. + public static let allElements = RedisZaddInsertBehavior(nil) + /// Only insert new elements; do not update the score of existing elements. + public static let onlyNewElements = RedisZaddInsertBehavior(.nx) + /// Only update the score of existing elements; do not insert new elements. + public static let onlyExistingElements = RedisZaddInsertBehavior(.xx) + + @usableFromInline + internal var string: String? { self.option?.rawValue } - return send(command: command, with: args) - .tryConverting() + /// Redis representation of this option. + private enum Option: String { + case nx = "NX" + case xx = "XX" } + + private let option: Option? + private init(_ option: Option?) { self.option = option } } -// MARK: Remove +/// The supported behavior for what a `zadd` command return value should represent. +/// +/// `zadd` normally returns the number of new elements inserted into the set, +/// but also supports the option to return the number of elements changed as a result of the command. +/// +/// "Changed" in this context refers to both new elements that were inserted and existing elements that had their score updated. +/// +/// See [ZADD Options](https://redis.io/commands/zadd#zadd-options) +public struct RedisZaddReturnBehavior { + /// Count both new elements that were inserted into the SortedSet and existing elements that had their score updated. + public static let changedElementsCount = RedisZaddReturnBehavior(.ch) + /// Count only new elements that were inserted into the SortedSet. + public static let insertedElementsCount = RedisZaddReturnBehavior(nil) -extension RedisClient { - /// Removes the specified elements from a sorted set. - /// - /// See [https://redis.io/commands/zrem](https://redis.io/commands/zrem) - /// - Parameters: - /// - elements: The values to remove from the sorted set. - /// - key: The key of the sorted set. - /// - Returns: The number of elements removed from the set. - @inlinable - public func zrem(_ elements: [Value], from key: RedisKey) -> EventLoopFuture { - guard elements.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } + @usableFromInline + internal var string: String? { self.option?.rawValue } - var args: [RESPValue] = [.init(from: key)] - args.append(convertingContentsOf: elements) - - return send(command: "ZREM", with: args) - .tryConverting() - } - - /// Removes the specified elements from a sorted set. - /// - /// See [https://redis.io/commands/zrem](https://redis.io/commands/zrem) - /// - Parameters: - /// - elements: The values to remove from the sorted set. - /// - key: The key of the sorted set. - /// - Returns: The number of elements removed from the set. - @inlinable - public func zrem(_ elements: Value..., from key: RedisKey) -> EventLoopFuture { - return self.zrem(elements, from: key) + /// Redis representation of this option. + private enum Option: String { + case ch = "CH" } -} -// MARK: Remove by Lexiographical + private let option: Option? + private init(_ option: Option?) { self.option = option } +} -extension RedisClient { - /// Removes elements from a SortedSet whose lexiographical values are between the range specified. - /// - /// For example: - /// ```swift - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1. - /// client.zremrangebylex(from: "mySortedSet", withValuesBetween: (.inclusive(10), .exclusive(3)) - /// // elements 10 and 2 were removed - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zremrangebylex](https://redis.io/commands/zremrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the elements removed are unspecified. - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - range: The min and max value bounds that an element should have to be removed. - /// - Returns: The count of elements that were removed from the SortedSet. - @inlinable - public func zremrangebylex( - from key: RedisKey, - withValuesBetween range: (min: RedisZLexBound, max: RedisZLexBound) - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: range.min.description), - .init(bulk: range.max.description) - ] - return send(command: "ZREMRANGEBYLEX", with: args) - .tryConverting() - } +/// Represents a range bound for use with the Redis SortedSet commands related to element scores. +/// +/// This type conforms to `ExpressibleByFloatLiteral` and `ExpressibleByIntegerLiteral`, which will initialize to an `.inclusive` bound. +/// +/// For example: +/// ```swift +/// let literalBound: RedisZScoreBound = 3 // .inclusive(3) +/// let otherLiteralBound: RedisZScoreBound = 3.0 // .inclusive(3) +/// let exclusiveBound = RedisZScoreBound.exclusive(4) +/// ``` +public enum RedisZScoreBound: + CustomStringConvertible, + ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral +{ + case inclusive(Double) + case exclusive(Double) - /// Removes elements from a SortedSet whose lexiographical values are greater than a minimum value. - /// - /// For example: - /// ```swift - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1. - /// client.zremrangebylex(from: "mySortedSet", withMinimumValueOf: .inclusive(10)) - /// // elements 10, 2, and 3 are removed - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zremrangebylex](https://redis.io/commands/zremrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the elements removed are unspecified. - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be removed. - /// - Returns: The count of elements that were removed from the SortedSet. - @inlinable - public func zremrangebylex( - from key: RedisKey, - withMinimumValueOf minValue: RedisZLexBound - ) -> EventLoopFuture { - return self.zremrangebylex(from: key, withValuesBetween: (minValue, .positiveInfinity)) + /// The underlying raw score value this bound represents. + public var rawValue: Double { + switch self { + case let .inclusive(v), let .exclusive(v): return v + } } - - /// Removes elements from a SortedSet whose lexiographical values are less than a maximum value. - /// - /// For example: - /// ```swift - /// // "mySortedSet" contains the values [1, 2, 3, 10] each with a score of 1. - /// client.zremrangebylex(from: "mySortedSet", withMaximumValueOf: .exclusive(2)) - /// // elements 1 and 10 are removed - /// ``` - /// - /// See `RedisZLexBound` and [https://redis.io/commands/zremrangebylex](https://redis.io/commands/zremrangebylex) - /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the elements removed are unspecified. - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - maxValue: The maximum lexiographical value and element in the SortedSet should have to be removed. - /// - Returns: The count of elements that were removed from the SortedSet. - @inlinable - public func zremrangebylex( - from key: RedisKey, - withMaximumValueOf maxValue: RedisZLexBound - ) -> EventLoopFuture { - return self.zremrangebylex(from: key, withValuesBetween: (.negativeInfinity, maxValue)) + + public var description: String { + switch self { + case let .inclusive(value): return value.description + case let .exclusive(value): return "(\(value.description)" + } } + + public init(floatLiteral value: Double) { self = .inclusive(value) } + public init(integerLiteral value: Int64) { self = .inclusive(Double(value)) } } -// MARK: Remove by Rank +/// The supported methods for aggregating results from various Sorted Set algorithm commands in Redis. +/// +/// See the documentation for each individual command that uses this object for more details. +public struct RedisSortedSetAggregateMethod { + /// Add the score of all matching elements in the source SortedSets. + public static let sum = RedisSortedSetAggregateMethod(.sum) + /// Use the minimum score of the matching elements in the source SortedSets. + public static let min = RedisSortedSetAggregateMethod(.min) + /// Use the maximum score of the matching elements in the source SortedSets. + public static let max = RedisSortedSetAggregateMethod(.max) -extension RedisClient { - /// Removes all elements from a SortedSet within the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/zremrangebyrank](https://redis.io/commands/zremrangebyrank) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - firstIndex: The index of the first element to remove. - /// - lastIndex: The index of the last element to remove. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyrank(from key: RedisKey, firstIndex: Int, lastIndex: Int) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: firstIndex), - .init(bulk: lastIndex) - ] - return self.send(command: "ZREMRANGEBYRANK", with: args) - .tryConverting() + internal var string: String { self.option.rawValue } + + /// Redis representation of this option. + private enum Option: String { + case sum = "SUM" + case min = "MIN" + case max = "MAX" } - - /// Removes all elements from a SortedSet within the specified inclusive bounds of 0-based indices. - /// - /// See [https://redis.io/commands/zremrangebyrank](https://redis.io/commands/zremrangebyrank) - /// - Warning: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, - /// `ClosedRange` will trigger a precondition failure. - /// - /// If you need such a range, use `zremrangebyrank(from:firstIndex:lastIndex:)` instead. - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - range: The range of inclusive indices of elements to remove. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyrank(from key: RedisKey, indices range: ClosedRange) -> EventLoopFuture { - return self.zremrangebyrank(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound) + + private let option: Option + private init(_ option: Option) { self.option = option } +} + +/// Represents a range bound for use with the Redis SortedSet lexiographical commands to compare values. +/// +/// Cases must be explicitly declared, with wrapped values conforming to `CustomStringConvertible`. +/// +/// The cases `.negativeInfinity` and `.positiveInfinity` represent the special characters in Redis of `-` and `+` respectively. +/// These are constants for absolute lower and upper value bounds that are always treated as _inclusive_. +/// +/// See [Redis' string comparison documentation](https://redis.io/commands/zrangebylex#details-on-strings-comparison). +public enum RedisZLexBound: CustomStringConvertible { + case inclusive(Value) + case exclusive(Value) + case positiveInfinity + case negativeInfinity + + public var description: String { + switch self { + case let .inclusive(value): return "[\(value)" + case let .exclusive(value): return "(\(value)" + case .positiveInfinity: return "+" + case .negativeInfinity: return "-" + } } - - /// Removes all elements from a SortedSet starting with the first index bound up to, but not including, the element at the last index bound. - /// - /// See [https://redis.io/commands/zremrangebyrank](https://redis.io/commands/zremrangebyrank) - /// - Warning: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, - /// `Range` will trigger a precondition failure. - /// - /// If you need such a range, use `zremrangebyrank(from:firstIndex:lastIndex:)` instead. - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - range: The range of indices (inclusive lower, exclusive upper) elements to remove. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyrank(from key: RedisKey, indices range: Range) -> EventLoopFuture { - return self.zremrangebyrank(from: key, firstIndex: range.lowerBound, lastIndex: range.upperBound - 1) +} + +extension RedisZLexBound where Value: BinaryFloatingPoint { + public var description: String { + switch self { + case .inclusive(.infinity), .exclusive(.infinity), .positiveInfinity: return "+" + case .inclusive(-.infinity), .exclusive(-.infinity), .negativeInfinity: return "-" + case let .inclusive(value): return "[\(value)" + case let .exclusive(value): return "(\(value)" + } } - - /// Removes all elements from the index specified to the end of a SortedSet. - /// - /// See [https://redis.io/commands/zremrangebyrank](https://redis.io/commands/zremrangebyrank) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - index: The index of the first element that will be removed. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyrank(from key: RedisKey, fromIndex index: Int) -> EventLoopFuture { - return self.zremrangebyrank(from: key, firstIndex: index, lastIndex: -1) +} + +/// A representation of the range of options for the results that can be returned from Sorted Set range operations. +/// +/// This correlates to the `WITHSCORES` option in Redis terminology. +public struct RedisZRangeResultOption { + /// Returns the scores in addition to the values of elements in a Sorted Set. + public static var valuesAndScores: RedisZRangeResultOption<[(RESPValue, Double)]> { + return .init(true) { + return try RedisCommand._mapSortedSetResponse($0, scoreIsFirst: false) + } } - - /// Removes all elements from the start of a SortedSet up to, and including, the element at the index specified. - /// - /// See [https://redis.io/commands/zremrangebyrank](https://redis.io/commands/zremrangebyrank) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - index: The index of the last element that will be removed. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyrank(from key: RedisKey, throughIndex index: Int) -> EventLoopFuture { - return self.zremrangebyrank(from: key, firstIndex: 0, lastIndex: index) + /// Returns only the values of elements in a Sorted Set. + public static var valuesOnly: RedisZRangeResultOption<[RESPValue]> { + return .init(false, { $0 }) } - /// Removes all elements from the start of a SortedSet up to, but not including, the element at the index specified. - /// - /// See [https://redis.io/commands/zremrangebyrank](https://redis.io/commands/zremrangebyrank) - /// - Warning: Providing an index of `0` will remove all elements from the SortedSet. - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - index: The index of the last element to not remove. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyrank(from key: RedisKey, upToIndex index: Int) -> EventLoopFuture { - return self.zremrangebyrank(from: key, firstIndex: 0, lastIndex: index - 1) + fileprivate let includeScores: Bool + fileprivate let transform: ([RESPValue]) throws -> ResultType + private init(_ includeScores: Bool, _ transform: @escaping ([RESPValue]) throws -> ResultType) { + self.includeScores = includeScores + self.transform = transform } } -// MARK: Remove by Score +// MARK: - Shared implementations +extension RedisCommand { + fileprivate static func _bzpop( + keyword: String, + _ keys: [RedisKey], + _ timeout: TimeAmount, + _ transform: @escaping ((String, Double, RESPValue)?) throws -> ResultType? + ) -> RedisCommand { + var args = keys.map(RESPValue.init(from:)) + args.append(.init(bulk: timeout.seconds)) + return .init(keyword: keyword, arguments: args) { + guard !$0.isNull else { return nil } + + let response = try $0.map(to: [RESPValue].self) + assert(response.count == 3, "unexpected response size returned") + guard + let key = response[0].string, + let score = Double(fromRESP: response[1]) + else { + throw RedisClientError.assertionFailure(message: "unexpected structure in response: \(response)") + } + + return try transform((key, score, response[2])) + } + } -extension RedisClient { - /// Removes elements from a SortedSet whose score is within the range specified. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zremrangebyscore](https://redis.io/commands/zremrangebyscore) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - range: The min and max score bounds to filter elements by. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyscore( - from key: RedisKey, - withScoresBetween range: (min: RedisZScoreBound, max: RedisZScoreBound) - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: range.min.description), - .init(bulk: range.max.description) + fileprivate static func _zstore( + keyword: String, + _ sources: [RedisKey], + _ destination: RedisKey, + _ weights: [Int]?, + _ aggregate: RedisSortedSetAggregateMethod? + ) -> RedisCommand { + assert(sources.count > 0, "at least 1 source key should be provided") + + var args: [RESPValue] = [ + .init(from: destination), + .init(bulk: sources.count) ] - return self.send(command: "ZREMRANGEBYSCORE", with: args) - .tryConverting() + args.append(convertingContentsOf: sources) + + if let w = weights { + assert(w.count > 0, "when passing a value for 'weights', at least 1 value should be provided") + assert(w.count <= sources.count, "weights should be no larger than the amount of source keys") + + args.append(.init(bulk: "WEIGHTS")) + args.append(convertingContentsOf: w) + } + if let a = aggregate { + args.append(.init(bulk: "AGGREGATE")) + args.append(.init(bulk: a.string)) + } + + return .init(keyword: keyword, arguments: args) } - - /// Removes elements from a SortedSet whose score is within the inclusive range specified. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zremrangebyscore](https://redis.io/commands/zremrangebyscore) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - range: The inclusive range of scores to filter elements by. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyscore(from key: RedisKey, withScores range: ClosedRange) -> EventLoopFuture { - return self.zremrangebyscore(from: key, withScoresBetween: (.inclusive(range.lowerBound), .inclusive(range.upperBound))) + + fileprivate static func _zpop( + keyword: String, + _ count: Int?, + _ key: RedisKey, + _ transform: @escaping ([(RESPValue, Double)]) -> ResultType + ) -> RedisCommand { + var args = [RESPValue(from: key)] + + if let c = count { args.append(.init(bulk: c)) } + + return .init(keyword: keyword, arguments: args) { + let response = try $0.map(to: [RESPValue].self) + let result = try Self._mapSortedSetResponse(response, scoreIsFirst: true) + return transform(result) + } } - - /// Removes elements from a SortedSet whose score is at least a minimum score up to, but not including, a max score. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zremrangebyscore](https://redis.io/commands/zremrangebyscore) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyscore(from key: RedisKey, withScores range: Range) -> EventLoopFuture { - return self.zremrangebyscore(from: key, withScoresBetween: (.inclusive(range.lowerBound), .exclusive(range.upperBound))) + + @usableFromInline + internal static func _zrange( + keyword: String, + _ key: RedisKey, + _ start: Int, + _ stop: Int, + _ resultOption: RedisZRangeResultOption + ) -> RedisCommand { + var args: [RESPValue] = [ + .init(from: key), + .init(bulk: start), + .init(bulk: stop) + ] + if resultOption.includeScores { args.append(.init(bulk: "WITHSCORES")) } + return .init(keyword: keyword, arguments: args) { + let response = try $0.map(to: [RESPValue].self) + return try resultOption.transform(response) + } } - - /// Removes elements from a SortedSet whose score is greater than a minimum score value. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zremrangebyscore](https://redis.io/commands/zremrangebyscore) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - minScore: The minimum score bound an element in the SortedSet should have to be removed. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyscore(from key: RedisKey, withMinimumScoreOf minScore: RedisZScoreBound) -> EventLoopFuture { - return self.zremrangebyscore(from: key, withScoresBetween: (minScore, .inclusive(.infinity))) + + @usableFromInline + internal static func _zrangebylex( + keyword: String, + _ key: RedisKey, + _ range: (min: String, max: String), + _ limit: (offset: Int, count: Int)? + ) -> RedisCommand<[RESPValue]> { + var args: [RESPValue] = [ + .init(from: key), + .init(bulk: range.min), + .init(bulk: range.max) + ] + if let l = limit { + args.append(.init(bulk: "LIMIT")) + args.append(.init(bulk: l.offset)) + args.append(.init(bulk: l.count)) + } + return .init(keyword: keyword, arguments: args) } - - /// Removes elements from a SortedSet whose score is less than a maximum score value. - /// - /// See `RedisZScoreBound` and [https://redis.io/commands/zremrangebyscore](https://redis.io/commands/zremrangebyscore) - /// - Parameters: - /// - key: The key of the SortedSet to remove elements from. - /// - minScore: The maximum score bound an element in the SortedSet should have to be removed. - /// - Returns: The count of elements that were removed from the SortedSet. - public func zremrangebyscore(from key: RedisKey, withMaximumScoreOf maxScore: RedisZScoreBound) -> EventLoopFuture { - return self.zremrangebyscore(from: key, withScoresBetween: (.inclusive(-.infinity), maxScore)) + + @usableFromInline + internal static func _zrangebyscore( + keyword: String, + _ key: RedisKey, + _ range: (min: String, max: String), + _ limit: (offset: Int, count: Int)?, + _ resultOption: RedisZRangeResultOption + ) -> RedisCommand { + var args: [RESPValue] = [ + .init(from: key), + .init(bulk: range.min), + .init(bulk: range.max) + ] + if resultOption.includeScores { args.append(.init(bulk: "WITHSCORES")) } + if let l = limit { + args.append(.init(bulk: "LIMIT")) + args.append(.init(bulk: l.offset)) + args.append(.init(bulk: l.count)) + } + return .init(keyword: keyword, arguments: args) { + let response = try $0.map(to: [RESPValue].self) + return try resultOption.transform(response) + } + } + + fileprivate static func _mapSortedSetResponse(_ response: [RESPValue], scoreIsFirst: Bool) throws -> [(RESPValue, Double)] { + let responseCount = response.count + guard responseCount > 0 else { return [] } + + var result: [(RESPValue, Double)] = [] + result.reserveCapacity(responseCount / 2) // every other RESPValue is the count + + var index = 0 + repeat { + let scoreItem = response[scoreIsFirst ? index : index + 1] + + guard let score = Double(fromRESP: scoreItem) else { + throw RedisClientError.assertionFailure(message: "unexpected response: '\(scoreItem)'") + } + + let elementIndex = scoreIsFirst ? index + 1 : index + result.append((response[elementIndex], score)) + + index += 2 + } while (index < responseCount) + + return result } } diff --git a/Sources/RediStack/Commands/StringCommands.swift b/Sources/RediStack/Commands/StringCommands.swift index 48500c02..e68f59f8 100644 --- a/Sources/RediStack/Commands/StringCommands.swift +++ b/Sources/RediStack/Commands/StringCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,472 +12,467 @@ // //===----------------------------------------------------------------------===// +import Foundation import NIO -// MARK: Get +// MARK: Strings -extension RedisClient { - /// Get the value of a key. - /// - /// [https://redis.io/commands/get](https://redis.io/commands/get) - /// - Parameter key: The key to fetch the value from. - /// - Returns: The value stored at the key provided. If the key does not exist, the value will be `.null`. - public func get(_ key: RedisKey) -> EventLoopFuture { - let args = [RESPValue(from: key)] - return self.send(command: "GET", with: args) - } - - /// Get the value of a key, converting it to the desired type. - /// - /// [https://redis.io/commands/get](https://redis.io/commands/get) +extension RedisCommand { + /// [APPEND](https://redis.io/commands/append) /// - Parameters: - /// - key: The key to fetch the value from. - /// - type: The desired type to convert the stored data to. - /// - Returns: The converted value stored at the key provided, otherwise `nil` if the key does not exist or fails the type conversion. + /// - value: The value to append onto the value stored at the key. + /// - key: The key to use to uniquely identify this value. @inlinable - public func get( - _ key: RedisKey, - as type: StoredType.Type - ) -> EventLoopFuture { - return self.get(key) - .map { return StoredType(fromRESP: $0) } + public static func append(_ value: Value, to key: RedisKey) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + value.convertedToRESPValue() + ] + return .init(keyword: "APPEND", arguments: args) } - /// Gets the values of all specified keys, using `.null` to represent non-existant values. - /// - /// See [https://redis.io/commands/mget](https://redis.io/commands/mget) - /// - Parameter keys: The list of keys to fetch the values from. - /// - Returns: The values stored at the keys provided, matching the same order. - public func mget(_ keys: [RedisKey]) -> EventLoopFuture<[RESPValue]> { - guard keys.count > 0 else { return self.eventLoop.makeSucceededFuture([]) } - - let args = keys.map(RESPValue.init) - return send(command: "MGET", with: args) - .tryConverting() + /// [DECR](https://redis.io/commands/decr) + /// - Parameter key: The key whose value should be decremented. + public static func decr(_ key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "DECR", arguments: args) } - /// Gets the values of all specified keys, using `.null` to represent non-existant values. - /// - /// See [https://redis.io/commands/mget](https://redis.io/commands/mget) + /// [DECRBY](https://redis.io/commands/decrby) /// - Parameters: - /// - keys: The list of keys to fetch the values from. - /// - type: The type to convert the values to. - /// - Returns: The values stored at the keys provided, matching the same order. Values that fail the `RESPValue` conversion will be `nil`. + /// - key: The key whose value should be decremented. + /// - count: The amount that this value should be decremented, supporting both positive and negative values. @inlinable - public func mget(_ keys: [RedisKey], as type: Value.Type) -> EventLoopFuture<[Value?]> { - return self.mget(keys) - .map { return $0.map(Value.init(fromRESP:)) } + public static func decrby( + _ key: RedisKey, + by count: Value + ) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: count) + ] + return .init(keyword: "DECRBY", arguments: args) + } + + /// [GET](https://redis.io/commands/get) + /// - Parameter key: The key to fetch the value from. + public static func get(_ key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "GET", arguments: args) { try? $0.map() } } - /// Gets the values of all specified keys, using `.null` to represent non-existant values. - /// - /// See [https://redis.io/commands/mget](https://redis.io/commands/mget) - /// - Parameter keys: The list of keys to fetch the values from. - /// - Returns: The values stored at the keys provided, matching the same order. - public func mget(_ keys: RedisKey...) -> EventLoopFuture<[RESPValue]> { - return self.mget(keys) + /// [INCR](https://redis.io/commands/incr) + /// - Parameter key: The key whose value should be incremented. + public static func incr(_ key: RedisKey) -> RedisCommand { + let args = [RESPValue(from: key)] + return .init(keyword: "INCR", arguments: args) } - /// Gets the values of all specified keys, using `.null` to represent non-existant values. - /// - /// See [https://redis.io/commands/mget](https://redis.io/commands/mget) + /// [INCRBY](https://redis.io/commands/incrby) /// - Parameters: - /// - keys: The list of keys to fetch the values from. - /// - type: The type to convert the values to. - /// - Returns: The values stored at the keys provided, matching the same order. Values that fail the `RESPValue` conversion will be `nil`. + /// - key: The key whose value should be incremented. + /// - count: The amount that this value should be incremented, supporting both positive and negative values. @inlinable - public func mget(_ keys: RedisKey..., as type: Value.Type) -> EventLoopFuture<[Value?]> { - return self.mget(keys, as: type) - } -} - -// MARK: Set - -/// A condition which must hold true in order for a key to be set. -/// -/// See [https://redis.io/commands/set](https://redis.io/commands/set) -public struct RedisSetCommandCondition: Hashable { - private enum Condition: String, Hashable { - case keyExists = "XX" - case keyDoesNotExist = "NX" - } - - private let condition: Condition? - private init(_ condition: Condition?) { - self.condition = condition - } - - /// The `RESPValue` representation of the condition. - @usableFromInline - internal var commandArgument: RESPValue? { - return self.condition.map { RESPValue(from: $0.rawValue) } + public static func incrby( + _ key: RedisKey, + by count: Value + ) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + .init(bulk: count) + ] + return .init(keyword: "INCRBY", arguments: args) } -} - -extension RedisSetCommandCondition { - /// No condition is required to be met in order to set the key's value. - public static let none = RedisSetCommandCondition(.none) - - /// Only set the key if it already exists. - /// - /// Redis documentation refers to this as the option "XX". - public static let keyExists = RedisSetCommandCondition(.keyExists) - - /// Only set the key if it does not already exist. - /// - /// Redis documentation refers to this as the option "NX". - public static let keyDoesNotExist = RedisSetCommandCondition(.keyDoesNotExist) -} -/// The expiration to apply when setting a key. -/// -/// See [https://redis.io/commands/set](https://redis.io/commands/set) -public struct RedisSetCommandExpiration: Hashable { - private enum Expiration: Hashable { - case keepExisting - case seconds(Int) - case milliseconds(Int) + /// [INCRBYFLOAT](https://redis.io/commands/incrbyfloat) + /// - Parameters: + /// - key: The key whose value should be incremented. + /// - count: The amount that this value should be incremented, supporting both positive and negative values. + @inlinable + public static func incrbyfloat( + _ key: RedisKey, + by count: Value + ) -> RedisCommand { + let args: [RESPValue] = [ + .init(from: key), + count.convertedToRESPValue() + ] + return .init(keyword: "INCRBYFLOAT", arguments: args) } - private let expiration: Expiration - private init(_ expiration: Expiration) { - self.expiration = expiration - } + /// [MGET](https://redis.io/commands/mget) + /// - Parameter keys: The list of keys to fetch the values from. + public static func mget(_ keys: RedisKey...) -> RedisCommand<[RESPValue?]> { .mget(keys) } - /// An array of `RESPValue`s representing this expiration. - @usableFromInline - internal func asCommandArguments() -> [RESPValue] { - switch self.expiration { - case .keepExisting: - return [RESPValue(from: "KEEPTTL")] - case .seconds(let amount): - return [RESPValue(from: "EX"), amount.convertedToRESPValue()] - case .milliseconds(let amount): - return [RESPValue(from: "PX"), amount.convertedToRESPValue()] + /// [MGET](https://redis.io/commands/mget) + /// - Parameter keys: The list of keys to fetch the values from. + public static func mget(_ keys: [RedisKey]) -> RedisCommand<[RESPValue?]> { + let args = keys.map(RESPValue.init(from:)) + return .init(keyword: "MGET", arguments: args) { + // Redis will represent non-existant values as `.null` + // we want to represent that natively in Swift with an optional + let values = try $0.map(to: [RESPValue].self) + return values.map { $0 == .null ? nil : $0 } } } -} - -extension RedisSetCommandExpiration { - /// Retain the existing expiration associated with the key, if one exists. - /// - /// Redis documentation refers to this as "KEEPTTL". - /// - Important: This is option is only available in Redis 6.0+. An error will be returned if this value is sent in lower versions of Redis. - public static let keepExisting = RedisSetCommandExpiration(.keepExisting) - /// Expire the key after the given number of seconds. - /// - /// Redis documentation refers to this as the option "EX". - /// - Important: The actual amount used will be the specified value or `1`, whichever is larger. - public static func seconds(_ amount: Int) -> RedisSetCommandExpiration { - return RedisSetCommandExpiration(.seconds(max(amount, 1))) + /// [MSET](https://redis.io/commands/mset) + /// - Note: Use `msetnx(_:)` if you don't want to overwrite values. + /// - Parameter operations: The key-value list of SET operations to execute. + public static func mset(_ operations: [RedisKey: RESPValueConvertible]) -> RedisCommand { + return ._mset(keyword: "MSET", operations) { _ in } } - /// Expire the key after the given number of milliseconds. - /// - /// Redis documentation refers to this as the option "PX". - /// - Important: The actual amount used will be the specified value or `1`, whichever is larger. - public static func milliseconds(_ amount: Int) -> RedisSetCommandExpiration { - return RedisSetCommandExpiration(.milliseconds(max(amount, 1))) + /// [MSETNX](https://redis.io/commands/msetnx) + /// - Note: Use `mset(_:)` if you don't care about overwriting values. + /// - Parameter operations: The key-value list of SET operations to execute. + public static func msetnx(_ operations: [RedisKey: RESPValueConvertible]) -> RedisCommand { + return ._mset(keyword: "MSETNX", operations) { + let result = try $0.map(to: Int.self) + return result == 1 + } } -} - -/// The result of a `SET` command. -public enum RedisSetCommandResult: Hashable { - /// The command completed successfully. - case ok - - /// The command was not performed because a condition was not met. - /// - /// See `RedisSetCommandCondition`. - case conditionNotMet -} -extension RedisClient { - /// Append a value to the end of an existing entry. - /// - Note: If the key does not exist, it is created and set as an empty string, so `APPEND` will be similar to `SET` in this special case. - /// - /// See [https://redis.io/commands/append](https://redis.io/commands/append) + /// [PSETEX](https://redis.io/commands/psetex) + /// - Invariant: The actual `expiration` used will be the given value or `1`, whichever is larger. /// - Parameters: - /// - value: The value to append onto the value stored at the key. /// - key: The key to use to uniquely identify this value. - /// - Returns: The length of the key's value after appending the additional value. + /// - value: The value to set the key to. + /// - expiration: The number of milliseconds after which to expire the key. @inlinable - public func append(_ value: Value, to key: RedisKey) -> EventLoopFuture { + public static func psetex( + _ key: RedisKey, + to value: Value, + expirationInMilliseconds expiration: Int + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), + .init(from: max(1, expiration)), value.convertedToRESPValue() ] - return send(command: "APPEND", with: args) - .tryConverting() + return .init(keyword: "PSETEX", arguments: args) } - /// Sets the value stored in the key provided, overwriting the previous value. - /// - /// Any previous expiration set on the key is discarded if the SET operation was successful. - /// - /// - Important: Regardless of the type of value stored at the key, it will be overwritten to a string value. - /// - /// [https://redis.io/commands/set](https://redis.io/commands/set) + /// [SET](https://redis.io/commands/set) /// - Parameters: /// - key: The key to use to uniquely identify this value. /// - value: The value to set the key to. - /// - Returns: An `EventLoopFuture` that resolves if the operation was successful. @inlinable - public func set(_ key: RedisKey, to value: Value) -> EventLoopFuture { + public static func set(_ key: RedisKey, to value: Value) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), value.convertedToRESPValue() ] - return send(command: "SET", with: args) - .map { _ in () } + return .init(keyword: "SET", arguments: args) } - /// Sets the key to the provided value with options to control how it is set. - /// - /// [https://redis.io/commands/set](https://redis.io/commands/set) - /// - Important: Regardless of the type of data stored at the key, it will be overwritten to a "string" data type. - /// - /// ie. If the key is a reference to a Sorted Set, its value will be overwritten to be a "string" data type. - /// + /// [SET](https://redis.io/commands/set) /// - Parameters: /// - key: The key to use to uniquely identify this value. /// - value: The value to set the key to. /// - condition: The condition under which the key should be set. /// - expiration: The expiration to use when setting the key. No expiration is set if `nil`. - /// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation; - /// `.ok` if the operation was successful and `.conditionNotMet` if the specified `condition` was not met. - /// - /// If the condition `.none` was used, then the result value will always be `.ok`. - public func set( + @inlinable + public static func set( _ key: RedisKey, to value: Value, onCondition condition: RedisSetCommandCondition, expiration: RedisSetCommandExpiration? = nil - ) -> EventLoopFuture { + ) -> RedisCommand { var args: [RESPValue] = [ .init(from: key), value.convertedToRESPValue() ] - if let conditionArgument = condition.commandArgument { - args.append(conditionArgument) - } - - if let expiration = expiration { - args.append(contentsOf: expiration.asCommandArguments()) - } + if let arg = condition.commandArgument { args.append(arg) } + if let e = expiration { args.append(contentsOf: e.asCommandArguments()) } - return self.send(command: "SET", with: args) - .map { return $0.isNull ? .conditionNotMet : .ok } + return .init(keyword: "SET", arguments: args) { $0.isNull ? .conditionNotMet : .ok } } - /// Sets the key to the provided value if the key does not exist. - /// - /// [https://redis.io/commands/setnx](https://redis.io/commands/setnx) - /// - Important: Regardless of the type of data stored at the key, it will be overwritten to a "string" data type. - /// - /// ie. If the key is a reference to a Sorted Set, its value will be overwritten to be a "string" data type. - /// - Parameters: - /// - key: The key to use to uniquely identify this value. - /// - value: The value to set the key to. - /// - Returns: `true` if the operation successfully completed. - @inlinable - public func setnx(_ key: RedisKey, to value: Value) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - value.convertedToRESPValue() - ] - return self.send(command: "SETNX", with: args) - .tryConverting(to: Int.self) - .map { $0 == 1 } - } - - /// Sets a key to the provided value and an expiration timeout in seconds. - /// - /// See [https://redis.io/commands/setex](https://redis.io/commands/setex) - /// - Important: Regardless of the type of data stored at the key, it will be overwritten to a "string" data type. - /// - /// ie. If the key is a reference to a Sorted Set, its value will be overwritten to be a "string" data type. - /// - Important: The actual expiration used will be the specified value or `1`, whichever is larger. + /// [SETEX](https://redis.io/commands/setex) + /// - Invariant: The actual expiration used will be the specified value or `1`, whichever is larger. /// - Parameters: /// - key: The key to use to uniquely identify this value. /// - value: The value to set the key to. /// - expiration: The number of seconds after which to expire the key. - /// - Returns: A `NIO.EventLoopFuture` that resolves if the operation was successful. @inlinable - public func setex( + public static func setex( _ key: RedisKey, to value: Value, expirationInSeconds expiration: Int - ) -> EventLoopFuture { + ) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), .init(from: max(1, expiration)), value.convertedToRESPValue() ] - return self.send(command: "SETEX", with: args) - .map { _ in () } + return .init(keyword: "SETEX", arguments: args) } - /// Sets a key to the provided value and an expiration timeout in milliseconds. - /// - /// See [https://redis.io/commands/psetex](https://redis.io/commands/psetex) - /// - Important: Regardless of the type of data stored at the key, it will be overwritten to a "string" data type. - /// - /// ie. If the key is a reference to a Sorted Set, its value will be overwritten to be a "string" data type. - /// - Important: The actual expiration used will be the specified value or `1`, whichever is larger. + ///[SETNX](https://redis.io/commands/setnx) /// - Parameters: /// - key: The key to use to uniquely identify this value. /// - value: The value to set the key to. - /// - expiration: The number of milliseconds after which to expire the key. - /// - Returns: A `NIO.EventLoopFuture` that resolves if the operation was successful. @inlinable - public func psetex( - _ key: RedisKey, - to value: Value, - expirationInMilliseconds expiration: Int - ) -> EventLoopFuture { + public static func setnx(_ key: RedisKey, to value: Value) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), - .init(from: max(1, expiration)), value.convertedToRESPValue() ] - return self.send(command: "PSETEX", with: args) - .map { _ in () } + return .init(keyword: "SETNX", arguments: args) } +} - /// Sets each key to their respective new value, overwriting existing values. - /// - Note: Use `msetnx(_:)` if you don't want to overwrite values. +// MARK: - + +extension RedisClient { + /// Gets the value of the given key. /// - /// See [https://redis.io/commands/mset](https://redis.io/commands/mset) - /// - Parameter operations: The key-value list of SET operations to execute. - /// - Returns: An `EventLoopFuture` that resolves if the operation was successful. - @inlinable - public func mset(_ operations: [RedisKey: Value]) -> EventLoopFuture { - return _mset(command: "MSET", operations) - .map { _ in () } + /// See `RedisCommand.get(_:)` + /// - Parameter key: The key to fetch the value from. + /// - Returns: A `NIO.EventLoopFuture` that resolves the value stored at the given key, otherwise `nil`. + public func get(_ key: RedisKey) -> EventLoopFuture { + return self.send(.get(key)) } - /// Sets each key to their respective new value, only if all keys do not currently exist. - /// - Note: Use `mset(_:)` if you don't care about overwriting values. + /// Gets the value of the given key, converting it to the desired type. /// - /// See [https://redis.io/commands/msetnx](https://redis.io/commands/msetnx) - /// - Parameter operations: The key-value list of SET operations to execute. - /// - Returns: `true` if the operation successfully completed. + /// See `RedisCommand.get(_:)` + /// - Parameters: + /// - key: The key to fetch the value from. + /// - type: The desired type to convert the stored data to. + /// - Returns: A `NIO.EventLoopFuture` that resolves the converted value stored at the given key, otherwise `nil` if the key does not exist or fails the type conversion. @inlinable - public func msetnx(_ operations: [RedisKey: Value]) -> EventLoopFuture { - return _mset(command: "MSETNX", operations) - .tryConverting(to: Int.self) - .map { return $0 == 1 } + public func get( + _ key: RedisKey, + as type: Value.Type = Value.self + ) -> EventLoopFuture { + return self.get(key) + .flatMapThrowing { $0.flatMap(Value.init(fromRESP:)) } } - @usableFromInline - func _mset( - command: String, - _ operations: [RedisKey: Value] - ) -> EventLoopFuture { - assert(operations.count > 0, "At least 1 key-value pair should be provided.") - - let args: [RESPValue] = operations.reduce( - into: .init(initialCapacity: operations.count * 2), - { (array, element) in - array.append(.init(from: element.key)) - array.append(element.value.convertedToRESPValue()) + /// Gets the value of the given key, decoding it as a JSON data structure. + /// + /// See `RedisCommand.get(_:)` + /// - Parameters: + /// - key: The key to fetch the value from. + /// - type: The JSON type to decode to. + /// - decoder: The optional JSON decoder instance to use. Defaults to `.init()`. + /// - Returns: A `NIO.EventLoopFuture` that resolves the decoded JSON value at the given key, otherwise `nil` if the key does not exist or JSON decoding fails. + @inlinable + public func get( + _ key: RedisKey, + asJSON type: D.Type = D.self, + decoder: JSONDecoder = .init() + ) -> EventLoopFuture { + return self.get(key, as: Data.self) + .flatMapThrowing { data in + return try data.map { try decoder.decode(D.self, from: $0) } } - ) - - return send(command: command, with: args) } -} -// MARK: Increment + /// Sets the value stored at the given key, overwriting the previous value. + /// + /// Any previous expiration set on the key is discarded if the `SET` operation was successful. + /// + /// See `RedisCommand.set(_:to:)` + /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. + /// - Parameters: + /// - key: The key to use to uniquely identify this value in Redis. + /// - value: The value to set the `key` to. + /// - Returns: A `NIO.EventLoopFuture` that resolves if the operation was successful. + @inlinable + public func set(_ key: RedisKey, to value: Value) -> EventLoopFuture { + return self.send(.set(key, to: value)) + } -extension RedisClient { - /// Increments the stored value by 1. + /// Sets the value stored at the given key with options to control how to set it. /// - /// See [https://redis.io/commands/incr](https://redis.io/commands/incr) - /// - Parameter key: The key whose value should be incremented. - /// - Returns: The new value after the operation. - public func increment(_ key: RedisKey) -> EventLoopFuture { - let args = [RESPValue(from: key)] - return send(command: "INCR", with: args) - .tryConverting() + /// See `RedisCommand.set(_:to:onCondition:expiration:)` + /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. + /// - Parameters: + /// - key: The key to use to uniquely identify this value. + /// - value: The value to set the `key` to. + /// - condition: The condition under which the `key` should be set. + /// - expiration: The expiration to set on the `key` when setting the value. If `nil`, no expiration will be set. + /// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation; `.ok` if successful and `.conditionNotMet` if the given `condition` was not meth. + /// + /// If the condition `.none` was used, then the result value will always be `.ok`. + @inlinable + public func set( + _ key: RedisKey, + to value: Value, + onCondition condition: RedisSetCommandCondition, + expiration: RedisSetCommandExpiration? = nil + ) -> EventLoopFuture { + return self.send(.set(key, to: value, onCondition: condition, expiration: expiration)) } - /// Increments the stored value by the amount desired . + /// Sets the value stored at the given key to the given value as JSON data. /// - /// See [https://redis.io/commands/incrby](https://redis.io/commands/incrby) + /// See `RedisCommand.set(_:to:)` + /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. /// - Parameters: - /// - key: The key whose value should be incremented. - /// - count: The amount that this value should be incremented, supporting both positive and negative values. - /// - Returns: The new value after the operation. + /// - key: The key to use to uniquely identify this value in Redis. + /// - value: The value to convert to JSON data and set the `key` to. + /// - encoder: The optional JSON encoder instance to use. Defaults to `.init()`. + /// - Returns: A `NIO.EventLoopFuture` that resolves if the operation was successful. @inlinable - public func increment( + public func set( _ key: RedisKey, - by count: Value - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: count) - ] - return send(command: "INCRBY", with: args) - .tryConverting() + toJSON value: E, + encoder: JSONEncoder = .init() + ) -> EventLoopFuture { + do { + return try self.set(key, to: encoder.encode(value)) + } catch { + return self.eventLoop.makeFailedFuture(error) + } } - /// Increments the stored value by the amount desired. + /// Sets the value stored at the given key as JSON data with options to control how to set it. /// - /// See [https://redis.io/commands/incrbyfloat](https://redis.io/commands/incrbyfloat) + /// See `RedisCommand.set(_:to:onCondition:expiration:)` + /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. /// - Parameters: - /// - key: The key whose value should be incremented. - /// - count: The amount that this value should be incremented, supporting both positive and negative values. - /// - Returns: The new value after the operation. + /// - key: The key to use to uniquely identify this value in Redis. + /// - value: The value to convert to JSON data set the `key` to. + /// - condition: The condition under which the `key` should be set. + /// - expiration: The expiration to set on the `key` when setting the value. If `nil`, no expiration will be set. + /// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation; `.ok` if successful and `.conditionNotMet` if the given `condition` was not meth. + /// + /// If the condition `.none` was used, then the result value will always be `.ok`. @inlinable - public func increment( + public func set( _ key: RedisKey, - by count: Value - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - count.convertedToRESPValue() - ] - return send(command: "INCRBYFLOAT", with: args) - .tryConverting() + toJSON value: E, + onCondition condition: RedisSetCommandCondition, + expiration: RedisSetCommandExpiration? = nil, + encoder: JSONEncoder = .init() + ) -> EventLoopFuture { + do { + return try self.send(.set(key, to: encoder.encode(value), onCondition: condition, expiration: expiration)) + } catch { + return self.eventLoop.makeFailedFuture(error) + } } } -// MARK: Decrement +// MARK: - -extension RedisClient { - /// Decrements the stored value by 1. +/// A condition which must hold true in order for a key to be set with the `SET` command. +/// +/// See [SET](https://redis.io/commands/set) +public struct RedisSetCommandCondition: Hashable { + /// No condition is required to be met in order to set the key's value. + public static let none = RedisSetCommandCondition(.none) + + /// Only set the key if it already exists. /// - /// See [https://redis.io/commands/decr](https://redis.io/commands/decr) - /// - Parameter key: The key whose value should be decremented. - /// - Returns: The new value after the operation. - @inlinable - public func decrement(_ key: RedisKey) -> EventLoopFuture { - let args = [RESPValue(from: key)] - return send(command: "DECR", with: args) - .tryConverting() + /// Redis documentation refers to this as the option "XX". + public static let keyExists = RedisSetCommandCondition(.keyExists) + + /// Only set the key if it does not already exist. + /// + /// Redis documentation refers to this as the option "NX". + public static let keyDoesNotExist = RedisSetCommandCondition(.keyDoesNotExist) + + private enum Condition: String, Hashable { + case keyExists = "XX" + case keyDoesNotExist = "NX" } - /// Decrements the stored valye by the amount desired. + private let condition: Condition? + private init(_ condition: Condition?) { + self.condition = condition + } + + /// The `RESPValue` representation of the condition. + @usableFromInline + internal var commandArgument: RESPValue? { + return self.condition.map { RESPValue(from: $0.rawValue) } + } +} + +/// The expiration to apply when setting a key with the `SET` command. +/// +/// See [SET](https://redis.io/commands/set) +public struct RedisSetCommandExpiration: Hashable { + /// Retain the existing expiration associated with the key, if one exists. + /// + /// Redis documentation refers to this as "KEEPTTL". + /// - Important: This is option is only available in Redis 6.0+. An error will be returned if this value is sent in lower versions of Redis. + public static let keepExisting = RedisSetCommandExpiration(.keepExisting) + + /// Expire the key after the given number of seconds. /// - /// See [https://redis.io/commands/decrby](https://redis.io/commands/decrby) - /// - Parameters: - /// - key: The key whose value should be decremented. - /// - count: The amount that this value should be decremented, supporting both positive and negative values. - /// - Returns: The new value after the operation. - @inlinable - public func decrement( - _ key: RedisKey, - by count: Value - ) -> EventLoopFuture { - let args: [RESPValue] = [ - .init(from: key), - .init(bulk: count) - ] - return send(command: "DECRBY", with: args) - .tryConverting() + /// Redis documentation refers to this as the option "EX". + /// - Important: The actual amount used will be the specified value or `1`, whichever is larger. + public static func seconds(_ amount: Int) -> RedisSetCommandExpiration { + return .init(.seconds(max(amount, 1))) + } + + /// Expire the key after the given number of milliseconds. + /// + /// Redis documentation refers to this as the option "PX". + /// - Important: The actual amount used will be the specified value or `1`, whichever is larger. + public static func milliseconds(_ amount: Int) -> RedisSetCommandExpiration { + return .init(.milliseconds(max(amount, 1))) + } + + private enum Expiration: Hashable { + case keepExisting + case seconds(Int) + case milliseconds(Int) + } + + private let expiration: Expiration + private init(_ expiration: Expiration) { + self.expiration = expiration + } + + /// An array of `RESPValue`s representing this expiration. + @usableFromInline + internal func asCommandArguments() -> [RESPValue] { + switch self.expiration { + case .keepExisting: + return [RESPValue(from: "KEEPTTL")] + case .seconds(let amount): + return [RESPValue(from: "EX"), amount.convertedToRESPValue()] + case .milliseconds(let amount): + return [RESPValue(from: "PX"), amount.convertedToRESPValue()] + } + } +} + +/// The result of a `SET` command. +public enum RedisSetCommandResult: Hashable { + /// The command completed successfully. + case ok + /// The command was not performed because a condition was not met. + /// + /// See `RedisSetCommandCondition`. + case conditionNotMet +} + +// MARK: - Shared implementation + +extension RedisCommand { + fileprivate static func _mset( + keyword: String, + _ operations: [RedisKey: RESPValueConvertible], + _ transform: @escaping (RESPValue) throws -> ResultType + ) -> RedisCommand { + assert(operations.count > 0, "at least 1 key-value pari should be provided") + + let args: [RESPValue ] = operations.reduce( + into: .init(initialCapacity: operations.count * 2), + { array, element in + array.append(.init(from: element.key)) + array.append(element.value.convertedToRESPValue()) + } + ) + return .init(keyword: keyword, arguments: args, mapValueToResult: transform) } } diff --git a/Sources/RediStack/RESP/RESPValue.swift b/Sources/RediStack/RESP/RESPValue.swift index 5e94f3f2..e3196a59 100644 --- a/Sources/RediStack/RESP/RESPValue.swift +++ b/Sources/RediStack/RESP/RESPValue.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -166,30 +166,12 @@ extension RESPValue: RESPValueConvertible { } } -// MARK: EventLoopFuture Extensions - -import NIO - -extension EventLoopFuture where Value == RESPValue { - /// Attempts to convert the resolved RESPValue to the desired type. - /// - /// This method is intended to be used much like a precondition in synchronous code, where a value is expected to be available from the `RESPValue`. - /// - Important: If the `RESPValueConvertible` initializer fails, then the `NIO.EventLoopFuture` will fail. - /// - Parameter to: The desired type to convert to. - /// - Throws: `RedisClientError.failedRESPConversion(to:)` - /// - Returns: A `NIO.EventLoopFuture` that resolves a value of the desired type or fails if the conversion does. +// MARK: RESPValue Conversion +extension RESPValue { @usableFromInline - internal func tryConverting( - to type: T.Type = T.self, - file: StaticString = #file, - line: UInt = #line - ) -> EventLoopFuture { - return self.flatMapThrowing(file: file, line: line) { - guard let value = T(fromRESP: $0) else { - throw RedisClientError.failedRESPConversion(to: type) - } - return value - } + internal func map(to type: T.Type = T.self) throws -> T { + guard let value = T(fromRESP: self) else { throw RedisClientError.failedRESPConversion(to: type) } + return value } } diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index 149a025e..ce1aa0df 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -29,12 +29,12 @@ public protocol RedisClient { /// The `NIO.EventLoop` that this client operates on. var eventLoop: EventLoop { get } - /// Sends the desired command with the specified arguments. - /// - Parameters: - /// - command: The command to execute. - /// - arguments: The arguments, if any, to be sent with the command. - /// - Returns: A `NIO.EventLoopFuture` that will resolve with the Redis command response. - func send(command: String, with arguments: [RESPValue]) -> EventLoopFuture + /// Sends the given command to Redis. + /// - Parameter command: The command to send to Redis for execution. + /// - Returns: A `NIO.EventLoopFuture` that will resolve when the Redis command receives a response. + /// + /// If a `RedisError` is returned, the future will be failed instead. + func send(_ command: RedisCommand) -> EventLoopFuture /// Temporarily overrides the default logger for command logs to the provided instance. /// - Parameter logger: The `Logging.Logger` instance to use for command logs. @@ -117,13 +117,6 @@ public protocol RedisClient { // MARK: Extension Methods extension RedisClient { - /// Sends the desired command without arguments. - /// - Parameter command: The command keyword to execute. - /// - Returns: A `NIO.EventLoopFuture` that will resolve with the Redis command response. - public func send(command: String) -> EventLoopFuture { - return self.send(command: command, with: []) - } - /// Unsubscribes the client from all active Redis channel name subscriptions. /// - Returns: A `NIO.EventLoopFuture` that resolves when the subscriptions have been removed. public func unsubscribe() -> EventLoopFuture { diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index ad5afbdf..7f093f87 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -209,47 +209,27 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { // MARK: Sending Commands extension RedisConnection { - /// Sends the command with the provided arguments to Redis. - /// - /// See `RedisClient.send(command:with:)`. - /// - Note: The timing of when commands are actually sent to Redis can be controlled with the `RedisConnection.sendCommandsImmediately` property. - /// - Returns: A `NIO.EventLoopFuture` that resolves with the command's result stored in a `RESPValue`. - /// If a `RedisError` is returned, the future will be failed instead. - public func send(command: String, with arguments: [RESPValue]) -> EventLoopFuture { + public func send(_ command: RedisCommand) -> EventLoopFuture { self.eventLoop.flatSubmit { - return self.send(command: command, with: arguments, context: nil) + return self.send(command, context: nil) } } - internal func send( - command: String, - with arguments: [RESPValue], - context: Context? - ) -> EventLoopFuture { - self.eventLoop.preconditionInEventLoop() - + internal func send(_ command: RedisCommand, context: Context?) -> EventLoopFuture { let logger = self.prepareLoggerForUse(context) - + guard self.isConnected else { let error = RedisClientError.connectionClosed logger.warning("\(error.localizedDescription)") - return self.channel.eventLoop.makeFailedFuture(error) + return self.eventLoop.makeFailedFuture(error) } logger.trace("received command request") logger.debug("sending command", metadata: [ - RedisLogging.MetadataKeys.commandKeyword: "\(command)", - RedisLogging.MetadataKeys.commandArguments: "\(arguments)" + RedisLogging.MetadataKeys.command: "\(command)" ]) - var message: [RESPValue] = [.init(bulk: command)] - message.append(contentsOf: arguments) - - let promise = channel.eventLoop.makePromise(of: RESPValue.self) - let command = RedisCommand( - message: .array(message), - responsePromise: promise - ) + let promise = self.eventLoop.makePromise(of: RESPValue.self) let startTime = DispatchTime.now().uptimeNanoseconds promise.futureResult.whenComplete { result in @@ -269,14 +249,25 @@ extension RedisConnection { ]) } } - + defer { logger.trace("command sent") } + + let outboundData: RedisCommandHandler.OutboundCommandPayload = (command.serialized(), promise) + let writeFuture: EventLoopFuture = self.sendCommandsImmediately + ? self.channel.writeAndFlush(outboundData) + : self.channel.write(outboundData) - if self.sendCommandsImmediately { - return channel.writeAndFlush(command).flatMap { promise.futureResult } - } else { - return channel.write(command).flatMap { promise.futureResult } - } + return writeFuture + .flatMap { promise.futureResult } + .flatMapThrowing { try command.transform($0) } + } + + internal func send( + command: String, + with arguments: [RESPValue], + context: Context? + ) -> EventLoopFuture { + self.eventLoop.makeFailedFuture(RedisClientError.connectionClosed) } } @@ -325,16 +316,15 @@ extension RedisConnection { /// Bypasses everything for a normal command and explicitly just sends a "QUIT" command to Redis. /// - Note: If the command fails, the `NIO.EventLoopFuture` will still succeed - as it's not critical for the command to succeed. private func sendQuitCommand(logger: Logger) -> EventLoopFuture { - let promise = channel.eventLoop.makePromise(of: RESPValue.self) - let command = RedisCommand( - message: .array([RESPValue(bulk: "QUIT")]), - responsePromise: promise + let payload: RedisCommandHandler.OutboundCommandPayload = ( + RedisCommand(keyword: "QUIT", arguments: []).serialized(), + self.eventLoop.makePromise() ) logger.trace("sending QUIT command") - return channel.writeAndFlush(command) // write the command - .flatMap { promise.futureResult } // chain the callback to the response's + return channel.writeAndFlush(payload) // write the command + .flatMap { payload.responsePromise.futureResult } // chain the callback to the response's .map { _ in logger.trace("sent QUIT command") } // ignore the result's value .recover { _ in logger.debug("recovered from error sending QUIT") } // if there's an error, just return to void } diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 6f37d3b0..9040c492 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -226,9 +226,9 @@ extension RedisConnectionPool: RedisClient { public func logging(to logger: Logger) -> RedisClient { return UserContextRedisClient(client: self, context: self.prepareLoggerForUse(logger)) } - - public func send(command: String, with arguments: [RESPValue]) -> EventLoopFuture { - return self.send(command: command, with: arguments, context: nil) + + public func send(_ command: RedisCommand) -> EventLoopFuture { + return self.send(command, context: nil) } public func subscribe( @@ -272,14 +272,14 @@ extension RedisConnectionPool: RedisClient { // MARK: RedisClientWithUserContext conformance extension RedisConnectionPool: RedisClientWithUserContext { - internal func send(command: String, with arguments: [RESPValue], context: Logger?) -> EventLoopFuture { + internal func send(_ command: RedisCommand, context: Logger?) -> EventLoopFuture { return self.forwardOperationToConnection( { (connection, returnConnection, context) in connection.sendCommandsImmediately = true return connection - .send(command: command, with: arguments, context: context) + .send(command, context: context) .always { _ in returnConnection(connection, context) } }, preferredConnection: nil, diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index fe7c4786..99a90ef6 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -41,8 +41,7 @@ public enum RedisLogging { // Internal keys can be as long as they want, but still should have the `rdstk` prefix to avoid clashes - internal static var commandKeyword: String { "rdstk_command" } - internal static var commandArguments: String { "rdstk_args" } + internal static var command: String { "rdstk_command" } internal static var commandResult: String { "rdstk_result" } internal static var connectionCount: String { "rdstk_conn_count" } internal static var poolConnectionRetryBackoff: String { "rdstk_conn_retry_prev_backoff" } @@ -71,7 +70,7 @@ extension Logger { /// /// An execution context includes things like a `Logging.Logger` instance for command activity logs. internal protocol RedisClientWithUserContext: RedisClient { - func send(command: String, with arguments: [RESPValue], context: Context?) -> EventLoopFuture + func send(_ command: RedisCommand, context: Context?) -> EventLoopFuture func subscribe( to channels: [RedisChannelName], @@ -113,9 +112,9 @@ internal struct UserContextRedisClient: Redi // Forward the commands to the underlying client - internal func send(command: String, with arguments: [RESPValue]) -> EventLoopFuture { + internal func send(_ command: RedisCommand) -> EventLoopFuture { return self.eventLoop.flatSubmit { - return self.client.send(command: command, with: arguments, context: self.context) + return self.client.send(command, context: self.context) } } diff --git a/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift b/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift index f535ac10..d694e5a2 100644 --- a/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift +++ b/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift @@ -45,7 +45,7 @@ internal final class EmbeddedMockRedisServer { func pumpChannel(_ channel: EmbeddedChannel) throws -> Bool { var didRead = false - while let nextRead = try channel.readOutbound(as: RedisCommand.self) { + while let nextRead = try channel.readOutbound(as: RedisCommandHandler.OutboundCommandPayload.self) { didRead = true try self.processChannelRead(nextRead, channel) } @@ -53,7 +53,7 @@ internal final class EmbeddedMockRedisServer { return didRead } - func processChannelRead(_ data: RedisCommand, _ channel: Channel) throws { + func processChannelRead(_ data: RedisCommandHandler.OutboundCommandPayload, _ channel: Channel) throws { switch data.message { case .array([RESPValue(from: "QUIT")]): // We always allow this. diff --git a/Sources/RediStackTestUtils/Extensions/RediStack.swift b/Sources/RediStackTestUtils/Extensions/RediStack.swift index a9ed062d..baac9172 100644 --- a/Sources/RediStackTestUtils/Extensions/RediStack.swift +++ b/Sources/RediStackTestUtils/Extensions/RediStack.swift @@ -28,3 +28,9 @@ extension RedisConnection.Configuration { try self.init(hostname: host, port: port, password: password) } } + +extension RedisCommand { + /// Erases all data on the Redis instance. + /// - Warning: **ONLY** use this on your test Redis instances! + public static var flushall: RedisCommand { .init(keyword: "FLUSHALL", arguments: []) } +} diff --git a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift index 00f7d5cf..3240b401 100644 --- a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift @@ -63,7 +63,7 @@ open class RedisConnectionPoolIntegrationTestCase: XCTestCase { /// See `XCTest.XCTestCase.tearDown()` open override func tearDown() { do { - _ = try self.pool.send(command: "FLUSHALL").wait() + _ = try self.pool.send(.flushall).wait() } catch let err as RedisConnectionPoolError where err == .poolClosed { // Ok, this is fine. } catch { diff --git a/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift index d3297531..7f7b3b74 100644 --- a/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift @@ -64,7 +64,7 @@ open class RedisIntegrationTestCase: XCTestCase { open override func tearDown() { do { if self.connection.isConnected { - _ = try self.connection.send(command: "FLUSHALL") + _ = try self.connection.send(.flushall) .flatMap { _ in self.connection.close() } .wait() } diff --git a/Sources/RedisTypes/RedisSet.swift b/Sources/RedisTypes/RedisSet.swift index c8b45168..7796d4cb 100644 --- a/Sources/RedisTypes/RedisSet.swift +++ b/Sources/RedisTypes/RedisSet.swift @@ -75,7 +75,7 @@ public struct RedisSet where Element: RESPValueConvertible { /// Resolves the number of elements in the set. /// /// See `RediStack.RedisClient.scard(of:)` - public var count: EventLoopFuture { return self.client.scard(of: self.id) } + public var count: EventLoopFuture { return self.client.send(.scard(of: self.id)) } /// Resolves a Boolean value that indicates whether the set is empty. public var isEmpty: EventLoopFuture { return self.count.map { $0 == 0 } } /// Resolves all of elements in the set. @@ -85,7 +85,7 @@ public struct RedisSet where Element: RESPValueConvertible { /// /// See `RediStack.RedisClient.smembers(of:)` public var allElements: EventLoopFuture<[Element]> { - return self.client.smembers(of: self.id) + return self.client.send(.smembers(of: self.id)) .map { $0.compactMap(Element.init) } } @@ -95,7 +95,7 @@ public struct RedisSet where Element: RESPValueConvertible { /// - Parameter member: An element to look for in the set. /// - Returns: A `NIO.EventLoopFuture` resolving `true` if `member` exists in the set; otherwise, `false`. public func contains(_ member: Element) -> EventLoopFuture { - return self.client.sismember(member, of: self.id) + return self.client.send(.sismember(member, of: self.id)) } } @@ -119,7 +119,7 @@ extension RedisSet { /// - Returns: A `NIO.EventLoopFuture` resolving the number of elements inserted into the set. public func insert(contentsOf newMembers: [Element]) -> EventLoopFuture { guard newMembers.count > 0 else { return self.client.eventLoop.makeSucceededFuture(0) } - return self.client.sadd(newMembers, to: self.id) + return self.client.send(.sadd(newMembers, to: self.id)) } } @@ -134,7 +134,7 @@ extension RedisSet { /// - other:A set of the same type as the current set. /// - Returns: A `NIO.EventLoopFuture` resolving `true` if the element was moved; otherwise, `false`. public func move(_ member: Element, to other: RedisSet) -> EventLoopFuture { - return self.client.smove(member, from: self.id, to: other.id) + return self.client.send(.smove(member, from: self.id, to: other.id)) } /// Removes the given element from the set. @@ -154,7 +154,7 @@ extension RedisSet { /// - Returns: A `NIO.EventLoopFuture` resolving the number of elements removed from the set. public func remove(_ members: [Element]) -> EventLoopFuture { guard members.count > 0 else { return self.client.eventLoop.makeSucceededFuture(0) } - return self.client.srem(members, from: self.id) + return self.client.send(.srem(members, from: self.id)) } /// Removes all elements from the array. @@ -179,7 +179,7 @@ extension RedisSet { /// /// - Returns: A `NIO.EventLoopFuture` resolving a randomly popped element from the set, or `nil` if the set was empty. public func popRandomElement() -> EventLoopFuture { - return self.client.spop(from: self.id) + return self.client.send(.spop(from: self.id)) .map { response in guard response.count > 0 else { return nil } return Element(fromRESP: response[0]) @@ -197,7 +197,7 @@ extension RedisSet { public func popRandomElements(max count: Int) -> EventLoopFuture<[Element]> { guard count >= 0 else { return self.client.eventLoop.makeFailedFuture(RedisError.indexOutOfRange) } guard count >= 1 else { return self.client.eventLoop.makeSucceededFuture([]) } - return self.client.spop(from: self.id, max: count) + return self.client.send(.spop(from: self.id, max: count)) .map { return $0.compactMap(Element.init) } } @@ -210,7 +210,7 @@ extension RedisSet { /// /// - Returns: A `NIO.EventLoopFuture` resolving a randoml element from the set, or `nil` if the set was empty. public func randomElement() -> EventLoopFuture { - return self.client.srandmember(from: self.id) + return self.client.send(.srandmember(from: self.id)) .map { response in guard response.count > 0 else { return nil } return Element(fromRESP: response[0]) @@ -239,7 +239,7 @@ extension RedisSet { assert(max > 0, "Max should be a positive value. Use 'allowDuplicates' to handle proper value signing.") let count = allowDuplicates ? -max : max - return self.client.srandmember(from: self.id, max: count) + return self.client.send(.srandmember(from: self.id, max: count)) .map { $0.compactMap(Element.init) } } } diff --git a/Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift new file mode 100644 index 00000000..64dee20b --- /dev/null +++ b/Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import RediStack +import RediStackTestUtils +import XCTest + +final class ConnectionCommandsTests: RediStackIntegrationTestCase { + func test_ping() throws { + let first = try connection.ping().wait() + XCTAssertEqual(first, "PONG") + + let second = try connection.ping(with: "My message").wait() + XCTAssertEqual(second, "My message") + } + + func test_echo() throws { + let response = try connection.send(.echo("FIZZ_BUZZ")).wait() + XCTAssertEqual(response, "FIZZ_BUZZ") + } + + func test_select() { + XCTAssertNoThrow(try connection.select(database: 3).wait()) + } +} diff --git a/Tests/RediStackIntegrationTests/Commands/HashCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/HashCommandsTests.swift index 8d3abb68..b5724bc6 100644 --- a/Tests/RediStackIntegrationTests/Commands/HashCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/HashCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -18,120 +18,120 @@ import XCTest final class HashCommandsTests: RediStackIntegrationTestCase { func test_hset() throws { - var result = try connection.hset("test", to: "\(#line)", in: #function).wait() + var result = try connection.send(.hset("test", to: "\(#line)", in: #function)).wait() XCTAssertTrue(result) - result = try connection.hset("test", to: "\(#line)", in: #function).wait() + result = try connection.send(.hset("test", to: "\(#line)", in: #function)).wait() XCTAssertFalse(result) } func test_hmset() throws { - XCTAssertNoThrow(try connection.hmset(["field": 30], in: #function).wait()) - let value = try connection.hget("field", from: #function, as: String.self).wait() - XCTAssertEqual(value, "30") + XCTAssertNoThrow(try connection.send(.hmset(["field": 30], in: #function)).wait()) + let value = try connection.send(.hget("field", from: #function)).wait() + XCTAssertEqual(value?.string, "30") } func test_hsetnx() throws { - var success = try connection.hsetnx("field", to: "foo", in: #function).wait() + var success = try connection.send(.hsetnx("field", to: "foo", in: #function)).wait() XCTAssertTrue(success) - success = try connection.hsetnx("field", to: 30, in: #function).wait() + success = try connection.send(.hsetnx("field", to: 30, in: #function)).wait() XCTAssertFalse(success) - let value = try connection.hget("field", from: #function, as: String.self).wait() - XCTAssertEqual(value, "foo") + let value = try connection.send(.hget("field", from: #function)).wait() + XCTAssertEqual(value?.string, "foo") } func test_hget() throws { - _ = try connection.hset("test", to: 30, in: #function).wait() - let value = try connection.hget("test", from: #function, as: String.self).wait() - XCTAssertEqual(value, "30") + _ = try connection.send(.hset("test", to: 30, in: #function)).wait() + let value = try connection.send(.hget("test", from: #function)).wait() + XCTAssertEqual(value?.string, "30") } func test_hmget() throws { - _ = try connection.hmset(["first": "foo", "second": "bar"], in: #function).wait() - let values = try connection.hmget("first", "second", "fake", from: #function, as: String.self).wait() + _ = try connection.send(.hmset(["first": "foo", "second": "bar"], in: #function)).wait() + let values = try connection.send(.hmget("first", "second", "fake", from: #function)).wait().map { $0.string } XCTAssertEqual(values[0], "foo") XCTAssertEqual(values[1], "bar") XCTAssertNil(values[2]) } func test_hgetall() throws { - let dataset = ["first": "foo", "second": "bar"] - _ = try connection.hmset(dataset, in: #function).wait() - let hashes = try connection.hgetall(from: #function, as: String.self).wait() + let dataset: [RedisHashFieldKey: RESPValue] = ["first": .init(bulk: "foo"), "second": .init(bulk: "bar")] + _ = try connection.send(.hmset(dataset, in: #function)).wait() + let hashes = try connection.send(.hgetall(from: #function)).wait() XCTAssertEqual(hashes, dataset) } func test_hdel() throws { - _ = try connection.hmset(["first": "foo", "second": "bar"], in: #function).wait() - let count = try connection.hdel("first", "second", "fake", from: #function).wait() + _ = try connection.send(.hmset(["first": "foo", "second": "bar"], in: #function)).wait() + let count = try connection.send(.hdel("first", "second", "fake", from: #function)).wait() XCTAssertEqual(count, 2) } func test_hexists() throws { - var exists = try connection.hexists("foo", in: #function).wait() + var exists = try connection.send(.hexists("foo", in: #function)).wait() XCTAssertFalse(exists) - _ = try connection.hset("foo", to: "\(#line)", in: #function).wait() - exists = try connection.hexists("foo", in: #function).wait() + _ = try connection.send(.hset("foo", to: "\(#line)", in: #function)).wait() + exists = try connection.send(.hexists("foo", in: #function)).wait() XCTAssertTrue(exists) } func test_hlen() throws { - var count = try connection.hlen(of: #function).wait() + var count = try connection.send(.hlen(of: #function)).wait() XCTAssertEqual(count, 0) - _ = try connection.hset("first", to: "\(#line)", in: #function).wait() - count = try connection.hlen(of: #function).wait() + _ = try connection.send(.hset("first", to: "\(#line)", in: #function)).wait() + count = try connection.send(.hlen(of: #function)).wait() XCTAssertEqual(count, 1) - _ = try connection.hset("second", to: "\(#line)", in: #function).wait() - count = try connection.hlen(of: #function).wait() + _ = try connection.send(.hset("second", to: "\(#line)", in: #function)).wait() + count = try connection.send(.hlen(of: #function)).wait() XCTAssertEqual(count, 2) } func test_hstrlen() throws { - _ = try connection.hset("first", to: "foo", in: #function).wait() - var size = try connection.hstrlen(of: "first", in: #function).wait() + _ = try connection.send(.hset("first", to: "foo", in: #function)).wait() + var size = try connection.send(.hstrlen(of: "first", in: #function)).wait() XCTAssertEqual(size, 3) - _ = try connection.hset("second", to: 300, in: #function).wait() - size = try connection.hstrlen(of: "second", in: #function).wait() + _ = try connection.send(.hset("second", to: 300, in: #function)).wait() + size = try connection.send(.hstrlen(of: "second", in: #function)).wait() XCTAssertEqual(size, 3) } func test_hkeys() throws { - let dataset: [String: String] = [ + let dataset: [RedisHashFieldKey: String] = [ "first": "3", "second": "foo" ] - _ = try connection.hmset(dataset, in: #function).wait() - let keys = try connection.hkeys(in: #function).wait() + _ = try connection.send(.hmset(dataset, in: #function)).wait() + let keys = try connection.send(.hkeys(in: #function)).wait() XCTAssertEqual(keys.count, 2) XCTAssertTrue(keys.allSatisfy(dataset.keys.contains)) } func test_hvals() throws { - let dataset = [ + let dataset: [RedisHashFieldKey: String] = [ "first": "3", "second": "foo" ] - _ = try connection.hmset(dataset, in: #function).wait() - let values = try connection.hvals(in: #function).wait().compactMap { String(fromRESP: $0) } + _ = try connection.send(.hmset(dataset, in: #function)).wait() + let values = try connection.send(.hvals(in: #function)).wait().compactMap { String(fromRESP: $0) } XCTAssertEqual(values.count, 2) XCTAssertTrue(values.allSatisfy(dataset.values.contains)) } func test_hincrby() throws { - _ = try connection.hset("first", to: 3, in: #function).wait() - var value = try connection.hincrby(10, field: "first", in: #function).wait() + _ = try connection.send(.hset("first", to: 3, in: #function)).wait() + var value = try connection.send(.hincrby(10, field: "first", in: #function)).wait() XCTAssertEqual(value, 13) - value = try connection.hincrby(-15, field: "first", in: #function).wait() + value = try connection.send(.hincrby(-15, field: "first", in: #function)).wait() XCTAssertEqual(value, -2) } func test_hincrbyfloat() throws { - _ = try connection.hset("first", to: 3.14, in: #function).wait() + _ = try connection.send(.hset("first", to: 3.14, in: #function)).wait() - let double = try connection.hincrbyfloat(Double(3.14), field: "first", in: #function).wait() + let double = try connection.send(.hincrbyfloat(Double(3.14), field: "first", in: #function)).wait() XCTAssertEqual(double, 6.28) - let float = try connection.hincrbyfloat(Float(-10.23523), field: "first", in: #function).wait() + let float = try connection.send(.hincrbyfloat(Float(-10.23523), field: "first", in: #function)).wait() XCTAssertEqual(float, -3.95523) } diff --git a/Tests/RediStackIntegrationTests/Commands/BasicCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift similarity index 65% rename from Tests/RediStackIntegrationTests/Commands/BasicCommandsTests.swift rename to Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift index b9261871..536af770 100644 --- a/Tests/RediStackIntegrationTests/Commands/BasicCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,20 +12,16 @@ // //===----------------------------------------------------------------------===// -@testable import RediStack +import RediStack import RediStackTestUtils import XCTest -final class BasicCommandsTests: RediStackIntegrationTestCase { - func test_select() { - XCTAssertNoThrow(try connection.select(database: 3).wait()) - } - +final class KeyCommandsTests: RediStackIntegrationTestCase { func test_delete() throws { let keys = [ #function + "1", #function + "2", #function + "3" ].map(RedisKey.init(_:)) - try connection.set(keys[0], to: "value").wait() - try connection.set(keys[1], to: "value").wait() - try connection.set(keys[2], to: "value").wait() + try connection.send(.set(keys[0], to: "value")).wait() + try connection.send(.set(keys[1], to: "value")).wait() + try connection.send(.set(keys[2], to: "value")).wait() let first = try connection.delete([keys[0]]).wait() XCTAssertEqual(first, 1) @@ -38,18 +34,18 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { } func test_exists() throws { - try self.connection.set("first", to: "1").wait() - let first = try connection.exists("first").wait() + try self.connection.send(.set("first", to: "1")).wait() + let first = try connection.send(.exists("first")).wait() XCTAssertEqual(first, 1) - try self.connection.set("second", to: "2").wait() - let firstAndSecond = try connection.exists("first", "second").wait() + try self.connection.send(.set("second", to: "2")).wait() + let firstAndSecond = try connection.send(.exists("first", "second")).wait() XCTAssertEqual(firstAndSecond, 2) - let secondAndThird = try connection.exists("second", "third").wait() + let secondAndThird = try connection.send(.exists("second", "third")).wait() XCTAssertEqual(secondAndThird, 1) - let third = try connection.exists("third").wait() + let third = try connection.send(.exists("third")).wait() XCTAssertEqual(third, 0) } @@ -57,7 +53,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { try connection.set(#function, to: "value").wait() XCTAssertNotNil(try connection.get(#function).wait()) XCTAssertTrue(try connection.expire(#function, after: .nanoseconds(1)).wait()) - XCTAssertEqual(try connection.get(#function).wait(), .null) + XCTAssertNil(try connection.get(#function).wait()) try connection.set(#function, to: "new value").wait() XCTAssertNotNil(try connection.get(#function).wait()) @@ -70,7 +66,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { let expire = try self.connection.expire("first", after: .minutes(1)).wait() XCTAssertTrue(expire) - let ttl = try self.connection.ttl("first").wait() + let ttl = try self.connection.send(.ttl("first")).wait() switch ttl { case .keyDoesNotExist, .unlimited: XCTFail("Expected an expiry to be set on key 'first'") @@ -78,7 +74,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { XCTAssertGreaterThanOrEqual(lifetime.timeAmount.nanoseconds, 0) } - let doesNotExist = try self.connection.ttl("second").wait() + let doesNotExist = try self.connection.send(.ttl("second")).wait() switch doesNotExist { case .keyDoesNotExist: () // Expected @@ -87,7 +83,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { } try self.connection.set("second", to: "value").wait() - let hasNoExpire = try self.connection.ttl("second").wait() + let hasNoExpire = try self.connection.send(.ttl("second")).wait() switch hasNoExpire { case .unlimited: () // Expected @@ -101,7 +97,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { let expire = try self.connection.expire("first", after: .minutes(1)).wait() XCTAssertTrue(expire) - let pttl = try self.connection.pttl("first").wait() + let pttl = try self.connection.send(.pttl("first")).wait() switch pttl { case .keyDoesNotExist, .unlimited: XCTFail("Expected an expiry to be set on key 'first'") @@ -109,7 +105,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { XCTAssertGreaterThanOrEqual(lifetime.timeAmount.nanoseconds, 0) } - let doesNotExist = try self.connection.ttl("second").wait() + let doesNotExist = try self.connection.send(.ttl("second")).wait() switch doesNotExist { case .keyDoesNotExist: () // Expected @@ -118,7 +114,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { } try self.connection.set("second", to: "value").wait() - let hasNoExpire = try self.connection.ttl("second").wait() + let hasNoExpire = try self.connection.send(.ttl("second")).wait() switch hasNoExpire { case .unlimited: () // Expected @@ -126,44 +122,7 @@ final class BasicCommandsTests: RediStackIntegrationTestCase { XCTFail("Expected '.noExpiry' but lifetime was \(hasNoExpire)") } } - - func test_ping() throws { - let first = try connection.ping().wait() - XCTAssertEqual(first, "PONG") - - let second = try connection.ping(with: "My message").wait() - XCTAssertEqual(second, "My message") - } - - func test_echo() throws { - let response = try connection.echo("FIZZ_BUZZ").wait() - XCTAssertEqual(response, "FIZZ_BUZZ") - } - - func test_swapDatabase() throws { - try connection.set("first", to: "3").wait() - var first = try connection.get("first", as: String.self).wait() - XCTAssertEqual(first, "3") - - try connection.select(database: 1).wait() - var second = try connection.get("first", as: String.self).wait() - XCTAssertEqual(second, nil) - - try connection.set("second", to: "100").wait() - second = try connection.get("second", as: String.self).wait() - XCTAssertEqual(second, "100") - - let success = try connection.swapDatabase(0, with: 1).wait() - XCTAssertEqual(success, true) - - second = try connection.get("first", as: String.self).wait() - XCTAssertEqual(second, "3") - - try connection.select(database: 0).wait() - first = try connection.get("second", as: String.self).wait() - XCTAssertEqual(first, "100") - } - + // TODO: #23 -- Rework Scan Unit Test // This is extremely flakey, and causes non-deterministic failures because of the assert on key counts // func test_scan() throws { diff --git a/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift index 99127fbb..30af2c4a 100644 --- a/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -18,89 +18,89 @@ import XCTest final class ListCommandsTests: RediStackIntegrationTestCase { func test_llen() throws { - var length = try connection.llen(of: #function).wait() + var length = try connection.send(.llen(of: #function)).wait() XCTAssertEqual(length, 0) - _ = try connection.lpush([30], into: #function).wait() - length = try connection.llen(of: #function).wait() + _ = try connection.send(.lpush([30], into: #function)).wait() + length = try connection.send(.llen(of: #function)).wait() XCTAssertEqual(length, 1) } func test_lindex() throws { - var element = try connection.lindex(0, from: #function).wait() - XCTAssertTrue(element.isNull) + var element = try connection.send(.lindex(0, from: #function)).wait() + XCTAssertNil(element) - _ = try connection.lpush([10], into: #function).wait() + _ = try connection.send(.lpush([10], into: #function)).wait() - element = try connection.lindex(0, from: #function).wait() - XCTAssertFalse(element.isNull) - XCTAssertEqual(Int(fromRESP: element), 10) + element = try connection.send(.lindex(0, from: #function)).wait() + XCTAssertNotNil(element) + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 10) } func test_lset() throws { - XCTAssertThrowsError(try connection.lset(index: 0, to: 30, in: #function).wait()) - _ = try connection.lpush([10], into: #function).wait() - XCTAssertNoThrow(try connection.lset(index: 0, to: 30, in: #function).wait()) - let element = try connection.lindex(0, from: #function).wait() - XCTAssertEqual(Int(fromRESP: element), 30) + XCTAssertThrowsError(try connection.send(.lset(index: 0, to: 30, in: #function)).wait()) + _ = try connection.send(.lpush([10], into: #function)).wait() + XCTAssertNoThrow(try connection.send(.lset(index: 0, to: 30, in: #function)).wait()) + let element = try connection.send(.lindex(0, from: #function)).wait() + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 30) } func test_lrem() throws { - _ = try connection.lpush([10, 10, 20, 30, 10], into: #function).wait() - var count = try connection.lrem(10, from: #function, count: 2).wait() + _ = try connection.send(.lpush([10, 10, 20, 30, 10], into: #function)).wait() + var count = try connection.send(.lrem(10, from: #function, count: 2)).wait() XCTAssertEqual(count, 2) - count = try connection.lrem(10, from: #function, count: 2).wait() + count = try connection.send(.lrem(10, from: #function, count: 2)).wait() XCTAssertEqual(count, 1) } func test_lrange() throws { - var elements = try connection.lrange(from: #function, indices: 0...10).wait() + var elements = try connection.send(.lrange(from: #function, indices: 0...10)).wait() XCTAssertEqual(elements.count, 0) - _ = try connection.lpush([5, 4, 3, 2, 1], into: #function).wait() + _ = try connection.send(.lpush([5, 4, 3, 2, 1], into: #function)).wait() - elements = try connection.lrange(from: #function, throughIndex: 4).wait() + elements = try connection.send(.lrange(from: #function, throughIndex: 4)).wait() XCTAssertEqual(elements.count, 5) XCTAssertEqual(Int(fromRESP: elements[0]), 1) XCTAssertEqual(Int(fromRESP: elements[4]), 5) - elements = try connection.lrange(from: #function, fromIndex: 1).wait() + elements = try connection.send(.lrange(from: #function, fromIndex: 1)).wait() XCTAssertEqual(elements.count, 4) - elements = try connection.lrange(from: #function, fromIndex: -3).wait() + elements = try connection.send(.lrange(from: #function, fromIndex: -3)).wait() XCTAssertEqual(elements.count, 3) - elements = try connection.lrange(from: #function, firstIndex: 2, lastIndex: 0).wait() + elements = try connection.send(.lrange(from: #function, firstIndex: 2, lastIndex: 0)).wait() XCTAssertEqual(elements.count, 0) - elements = try connection.lrange(from: #function, indices: 4...5).wait() + elements = try connection.send(.lrange(from: #function, indices: 4...5)).wait() XCTAssertEqual(elements.count, 1) - elements = try connection.lrange(from: #function, upToIndex: -3).wait() + elements = try connection.send(.lrange(from: #function, upToIndex: -3)).wait() XCTAssertEqual(elements.count, 2) } func test_rpoplpush() throws { - _ = try connection.lpush([10], into: "first").wait() - _ = try connection.lpush([30], into: "second").wait() + _ = try connection.send(.lpush([10], into: "first")).wait() + _ = try connection.send(.lpush([30], into: "second")).wait() - var element = try connection.rpoplpush(from: "first", to: "second").wait() - XCTAssertEqual(Int(fromRESP: element), 10) - XCTAssertEqual(try connection.llen(of: "first").wait(), 0) - XCTAssertEqual(try connection.llen(of: "second").wait(), 2) + var element = try connection.send(.rpoplpush(from: "first", to: "second")).wait() + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 10) + XCTAssertEqual(try connection.send(.llen(of: "first")).wait(), 0) + XCTAssertEqual(try connection.send(.llen(of: "second")).wait(), 2) - element = try connection.rpoplpush(from: "second", to: "first").wait() - XCTAssertEqual(Int(fromRESP: element), 30) - XCTAssertEqual(try connection.llen(of: "second").wait(), 1) + element = try connection.send(.rpoplpush(from: "second", to: "first")).wait() + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 30) + XCTAssertEqual(try connection.send(.llen(of: "second")).wait(), 1) } func test_brpoplpush() throws { - _ = try connection.lpush([10], into: "first").wait() + _ = try connection.send(.lpush([10], into: "first")).wait() - let element = try connection.brpoplpush(from: "first", to: "second").wait() - XCTAssertEqual(Int(fromRESP: element), 10) + let element = try connection.send(.brpoplpush(from: "first", to: "second")).wait() + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 10) let blockingConnection = try self.makeNewConnection() let expectation = XCTestExpectation(description: "brpoplpush should never return") - _ = blockingConnection.bzpopmin(from: #function) + _ = blockingConnection.send(.bzpopmin(from: #function)) .always { _ in expectation.fulfill() blockingConnection.close() @@ -111,46 +111,46 @@ final class ListCommandsTests: RediStackIntegrationTestCase { } func test_linsert() throws { - _ = try connection.lpush([10], into: #function).wait() + _ = try connection.send(.lpush([10], into: #function)).wait() - _ = try connection.linsert(20, into: #function, after: 10).wait() - var elements = try connection.lrange(from: #function, throughIndex: 1) + _ = try connection.send(.linsert(20, into: #function, after: 10)).wait() + var elements = try connection.send(.lrange(from: #function, throughIndex: 1)) .map { response in response.compactMap { Int(fromRESP: $0) } } .wait() XCTAssertEqual(elements, [10, 20]) - _ = try connection.linsert(30, into: #function, before: 10).wait() - elements = try connection.lrange(from: #function, throughIndex: 2) + _ = try connection.send(.linsert(30, into: #function, before: 10)).wait() + elements = try connection.send(.lrange(from: #function, throughIndex: 2)) .map { response in response.compactMap { Int(fromRESP: $0) } } .wait() XCTAssertEqual(elements, [30, 10, 20]) } func test_lpop() throws { - var element = try connection.lpop(from: #function).wait() - XCTAssertTrue(element.isNull) + var element = try connection.send(.lpop(from: #function)).wait() + XCTAssertNil(element) - _ = try connection.lpush([10, 20, 30], into: #function).wait() + _ = try connection.send(.lpush([10, 20, 30], into: #function)).wait() - element = try connection.lpop(from: #function).wait() - XCTAssertFalse(element.isNull) - XCTAssertEqual(Int(fromRESP: element), 30) + element = try connection.send(.lpop(from: #function)).wait() + XCTAssertNotNil(element) + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 30) } func test_blpop() throws { - let nilPop = try connection.blpop(from: #function, timeout: .seconds(1)).wait() - XCTAssertEqual(nilPop, .null) + let nilPop = try connection.send(.blpop(from: #function, timeout: .seconds(1))).wait() + XCTAssertNil(nilPop) - _ = try connection.lpush([10, 20, 30], into: "first").wait() - let pop1 = try connection.blpop(from: "first").wait() - XCTAssertEqual(Int(fromRESP: pop1), 30) + _ = try connection.send(.lpush([10, 20, 30], into: "first")).wait() + let pop1 = try connection.send(.blpop(from: "first")).wait() + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(pop1)), 30) - let pop2 = try connection.blpop(from: "fake", "first").wait() + let pop2 = try connection.send(.blpop(from: "fake", "first")).wait() XCTAssertEqual(pop2?.0, "first") let blockingConnection = try self.makeNewConnection() let expectation = XCTestExpectation(description: "blpop should never return") - _ = blockingConnection.bzpopmin(from: #function) + _ = blockingConnection.send(.bzpopmin(from: #function)) .always { _ in expectation.fulfill() blockingConnection.close() @@ -161,55 +161,55 @@ final class ListCommandsTests: RediStackIntegrationTestCase { } func test_lpush() throws { - _ = try connection.rpush([10, 20, 30], into: #function).wait() + _ = try connection.send(.rpush([10, 20, 30], into: #function)).wait() - let size = try connection.lpush(100, into: #function).wait() - let element = try connection.lindex(0, from: #function).wait() + let size = try connection.send(.lpush(100, into: #function)).wait() + let element = try connection.send(.lindex(0, from: #function)).wait() XCTAssertEqual(size, 4) - XCTAssertEqual(Int(fromRESP: element), 100) + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 100) } func test_lpushx() throws { - var size = try connection.lpushx(10, into: #function).wait() + var size = try connection.send(.lpushx(10, into: #function)).wait() XCTAssertEqual(size, 0) - _ = try connection.lpush([10], into: #function).wait() + _ = try connection.send(.lpush([10], into: #function)).wait() - size = try connection.lpushx(30, into: #function).wait() + size = try connection.send(.lpushx(30, into: #function)).wait() XCTAssertEqual(size, 2) - let element = try connection.rpop(from: #function) - .map { return Int(fromRESP: $0) } + let element = try connection.send(.rpop(from: #function)) + .flatMapThrowing { return Int(fromRESP: try XCTUnwrap($0)) } .wait() XCTAssertEqual(element, 10) } func test_rpop() throws { - _ = try connection.lpush([10, 20, 30], into: #function).wait() + _ = try connection.send(.lpush([10, 20, 30], into: #function)).wait() - let element = try connection.rpop(from: #function).wait() + let element = try connection.send(.rpop(from: #function)).wait() XCTAssertNotNil(element) - XCTAssertEqual(Int(fromRESP: element), 10) + XCTAssertEqual(Int(fromRESP: element!), 10) _ = try connection.delete([#function]).wait() - let result = try connection.rpop(from: #function).wait() - XCTAssertTrue(result.isNull) + let result = try connection.send(.rpop(from: #function)).wait() + XCTAssertNil(result) } func test_brpop() throws { - let nilPop = try connection.brpop(from: #function, timeout: .seconds(1)).wait() - XCTAssertEqual(nilPop, .null) + let nilPop = try connection.send(.brpop(from: #function, timeout: .seconds(1))).wait() + XCTAssertNil(nilPop) - _ = try connection.lpush([10, 20, 30], into: "first").wait() - let pop1 = try connection.brpop(from: "first").wait() - XCTAssertEqual(Int(fromRESP: pop1), 10) + _ = try connection.send(.lpush([10, 20, 30], into: "first")).wait() + let pop1 = try connection.send(.brpop(from: "first")).wait() + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(pop1)), 10) - let pop2 = try connection.brpop(from: "fake", "first").wait() + let pop2 = try connection.send(.brpop(from: "fake", "first")).wait() XCTAssertEqual(pop2?.0, "first") let blockingConnection = try self.makeNewConnection() let expectation = XCTestExpectation(description: "brpop should never return") - _ = blockingConnection.bzpopmin(from: #function) + _ = blockingConnection.send(.bzpopmin(from: #function)) .always { _ in expectation.fulfill() blockingConnection.close() @@ -220,24 +220,24 @@ final class ListCommandsTests: RediStackIntegrationTestCase { } func test_rpush() throws { - _ = try connection.lpush([10, 20, 30], into: #function).wait() + _ = try connection.send(.lpush([10, 20, 30], into: #function)).wait() - let size = try connection.rpush(100, into: #function).wait() - let element = try connection.lindex(3, from: #function).wait() + let size = try connection.send(.rpush(100, into: #function)).wait() + let element = try connection.send(.lindex(3, from: #function)).wait() XCTAssertEqual(size, 4) - XCTAssertEqual(Int(fromRESP: element), 100) + XCTAssertEqual(Int(fromRESP: try XCTUnwrap(element)), 100) } func test_rpushx() throws { - var size = try connection.rpushx(10, into: #function).wait() + var size = try connection.send(.rpushx(10, into: #function)).wait() XCTAssertEqual(size, 0) - _ = try connection.rpush([10], into: #function).wait() + _ = try connection.send(.rpush([10], into: #function)).wait() - size = try connection.rpushx(30, into: #function).wait() + size = try connection.send(.rpushx(30, into: #function)).wait() XCTAssertEqual(size, 2) - let element = try connection.lpop(from: #function) - .map { return Int(fromRESP: $0) } + let element = try connection.send(.lpop(from: #function)) + .flatMapThrowing { return Int(fromRESP: try XCTUnwrap($0)) } .wait() XCTAssertEqual(element, 10) } @@ -245,31 +245,31 @@ final class ListCommandsTests: RediStackIntegrationTestCase { func test_ltrim() throws { let setup = { _ = try self.connection.delete(#function).wait() - _ = try self.connection.lpush([5, 4, 3, 2, 1], into: #function).wait() + _ = try self.connection.send(.lpush([5, 4, 3, 2, 1], into: #function)).wait() } - let getElements = { return try self.connection.lrange(from: #function, fromIndex: 0).wait() } + let getElements = { return try self.connection.send(.lrange(from: #function, fromIndex: 0)).wait() } try setup() - XCTAssertNoThrow(try connection.ltrim(#function, before: 1, after: 3).wait()) - XCTAssertNoThrow(try connection.ltrim(#function, keepingIndices: 0...1).wait()) + XCTAssertNoThrow(try connection.send(.ltrim(#function, before: 1, after: 3)).wait()) + XCTAssertNoThrow(try connection.send(.ltrim(#function, keepingIndices: 0...1)).wait()) var elements = try getElements() XCTAssertEqual(elements.count, 2) try setup() - XCTAssertNoThrow(try connection.ltrim(#function, keepingIndices: (-3)...).wait()) + XCTAssertNoThrow(try connection.send(.ltrim(#function, keepingIndices: (-3)...)).wait()) elements = try getElements() XCTAssertEqual(elements.count, 3) try setup() - XCTAssertNoThrow(try connection.ltrim(#function, keepingIndices: ...(-4)).wait()) + XCTAssertNoThrow(try connection.send(.ltrim(#function, keepingIndices: ...(-4))).wait()) elements = try getElements() XCTAssertEqual(elements.count, 2) try setup() - XCTAssertNoThrow(try connection.ltrim(#function, keepingIndices: ..<(-2)).wait()) + XCTAssertNoThrow(try connection.send(.ltrim(#function, keepingIndices: ..<(-2))).wait()) elements = try getElements() XCTAssertEqual(elements.count, 3) } diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index 1aea602c..92b3e0c4 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -85,7 +85,7 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { try self.connection.subscribe(to: #function) { (_, _) in }.wait() defer { try? self.connection.unsubscribe(from: #function).wait() } - XCTAssertThrowsError(try self.connection.lpush("value", into: "List").wait()) { + XCTAssertThrowsError(try self.connection.send(.lpush("value", into: "List")).wait()) { XCTAssertTrue($0 is RedisError) } } @@ -105,8 +105,9 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { try self.connection.subscribe(to: #function) { (_, _) in }.wait() defer { try? self.connection.unsubscribe(from: #function).wait() } - let value = try self.connection.send(command: "QUIT").wait() - XCTAssertEqual(value.string, "OK") + let quit = RedisCommand(keyword: "QUIT", arguments: []) + let result = try self.connection.send(quit).wait() + XCTAssertEqual(result, "OK") } func test_unsubscribeFromAllChannels() throws { diff --git a/Tests/RediStackIntegrationTests/Commands/ServerCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/ServerCommandsTests.swift new file mode 100644 index 00000000..a660d6d5 --- /dev/null +++ b/Tests/RediStackIntegrationTests/Commands/ServerCommandsTests.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import RediStack +import RediStackTestUtils +import XCTest + +final class ServerCommandsTests: RediStackIntegrationTestCase { + func test_swapDatabase() throws { + try connection.set("first", to: "3").wait() + var first = try connection.get("first", as: String.self).wait() + XCTAssertEqual(first, "3") + + try connection.select(database: 1).wait() + var second = try connection.get("first", as: String.self).wait() + XCTAssertEqual(second, nil) + + try connection.set("second", to: "100").wait() + second = try connection.get("second", as: String.self).wait() + XCTAssertEqual(second, "100") + + let success = try connection.swapDatabase(0, with: 1).wait() + XCTAssertEqual(success, true) + + second = try connection.get("first", as: String.self).wait() + XCTAssertEqual(second, "3") + + try connection.select(database: 0).wait() + first = try connection.get("second", as: String.self).wait() + XCTAssertEqual(first, "100") + } +} diff --git a/Tests/RediStackIntegrationTests/Commands/SetCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/SetCommandsTests.swift index 350d5c2a..7e20ad8d 100644 --- a/Tests/RediStackIntegrationTests/Commands/SetCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/SetCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -18,9 +18,9 @@ import XCTest final class SetCommandsTests: RediStackIntegrationTestCase { func test_sadd() throws { - var insertCount = try connection.sadd(1, 2, 3, to: #function).wait() + var insertCount = try connection.send(.sadd(1, 2, 3, to: #function)).wait() XCTAssertEqual(insertCount, 3) - insertCount = try connection.sadd([3, 4, 5], to: #function).wait() + insertCount = try connection.send(.sadd([3, 4, 5], to: #function)).wait() XCTAssertEqual(insertCount, 2) } @@ -28,63 +28,63 @@ final class SetCommandsTests: RediStackIntegrationTestCase { let first = ["Hello", ","] let second = ["World", "!"] - _ = try connection.sadd(first, to: #function).wait() - var set = try connection.smembers(of: #function).wait() + _ = try connection.send(.sadd(first, to: #function)).wait() + var set = try connection.send(.smembers(of: #function)).wait() XCTAssertEqual(set.count, 2) - _ = try connection.sadd(first, to: #function).wait() - set = try connection.smembers(of: #function).wait() + _ = try connection.send(.sadd(first, to: #function)).wait() + set = try connection.send(.smembers(of: #function)).wait() XCTAssertEqual(set.count, 2) - _ = try connection.sadd(second, to: #function).wait() - set = try connection.smembers(of: #function).wait() + _ = try connection.send(.sadd(second, to: #function)).wait() + set = try connection.send(.smembers(of: #function)).wait() XCTAssertEqual(set.count, 4) } func test_sismember() throws { - _ = try connection.sadd(["Hello"], to: #function).wait() - XCTAssertTrue(try connection.sismember("Hello", of: #function).wait()) + _ = try connection.send(.sadd(["Hello"], to: #function)).wait() + XCTAssertTrue(try connection.send(.sismember("Hello", of: #function)).wait()) - XCTAssertFalse(try connection.sismember(3, of: #function).wait()) - _ = try connection.sadd([3], to: #function).wait() - XCTAssertTrue(try connection.sismember(3, of: #function).wait()) + XCTAssertFalse(try connection.send(.sismember(3, of: #function)).wait()) + _ = try connection.send(.sadd([3], to: #function)).wait() + XCTAssertTrue(try connection.send(.sismember(3, of: #function)).wait()) } func test_scard() throws { - XCTAssertEqual(try connection.scard(of: #function).wait(), 0) - _ = try connection.sadd([1, 2, 3], to: #function).wait() - XCTAssertEqual(try connection.scard(of: #function).wait(), 3) + XCTAssertEqual(try connection.send(.scard(of: #function)).wait(), 0) + _ = try connection.send(.sadd([1, 2, 3], to: #function)).wait() + XCTAssertEqual(try connection.send(.scard(of: #function)).wait(), 3) } func test_srem() throws { - var removedCount = try connection.srem(1, from: #function).wait() + var removedCount = try connection.send(.srem(1, from: #function)).wait() XCTAssertEqual(removedCount, 0) - _ = try connection.sadd([1], to: #function).wait() - removedCount = try connection.srem([1], from: #function).wait() + _ = try connection.send(.sadd([1], to: #function)).wait() + removedCount = try connection.send(.srem([1], from: #function)).wait() XCTAssertEqual(removedCount, 1) } func test_spop() throws { - var count = try connection.scard(of: #function).wait() - var result = try connection.spop(from: #function).wait() + var count = try connection.send(.scard(of: #function)).wait() + var result = try connection.send(.spop(from: #function)).wait() XCTAssertEqual(count, 0) XCTAssertEqual(result.count, 0) - _ = try connection.sadd(["Hello"], to: #function).wait() + _ = try connection.send(.sadd(["Hello"], to: #function)).wait() - result = try connection.spop(from: #function).wait() + result = try connection.send(.spop(from: #function)).wait() XCTAssertEqual(result.count, 1) XCTAssertEqual(result[0].string, "Hello") - count = try connection.scard(of: #function).wait() + count = try connection.send(.scard(of: #function)).wait() XCTAssertEqual(count, 0) } func test_srandmember() throws { - _ = try connection.sadd([1, 2, 3], to: #function).wait() - XCTAssertEqual(try connection.srandmember(from: #function).wait().count, 1) - XCTAssertEqual(try connection.srandmember(from: #function, max: 4).wait().count, 3) - XCTAssertEqual(try connection.srandmember(from: #function, max: -4).wait().count, 4) + _ = try connection.send(.sadd([1, 2, 3], to: #function)).wait() + XCTAssertEqual(try connection.send(.srandmember(from: #function)).wait().count, 1) + XCTAssertEqual(try connection.send(.srandmember(from: #function, max: 4)).wait().count, 3) + XCTAssertEqual(try connection.send(.srandmember(from: #function, max: -4)).wait().count, 4) } func test_sdiff() throws { @@ -92,20 +92,20 @@ final class SetCommandsTests: RediStackIntegrationTestCase { let key2: RedisKey = #file let key3 = RedisKey(key1.rawValue + key2.rawValue) - _ = try connection.sadd([1, 2, 3], to: key1).wait() - _ = try connection.sadd([3, 4, 5], to: key2).wait() - _ = try connection.sadd([2, 4], to: key3).wait() + _ = try connection.send(.sadd([1, 2, 3], to: key1)).wait() + _ = try connection.send(.sadd([3, 4, 5], to: key2)).wait() + _ = try connection.send(.sadd([2, 4], to: key3)).wait() - let diff1 = try connection.sdiff(of: key1, key2).wait() + let diff1 = try connection.send(.sdiff(of: key1, key2)).wait() XCTAssertEqual(diff1.count, 2) - let diff2 = try connection.sdiff(of: key1, key3).wait() + let diff2 = try connection.send(.sdiff(of: key1, key3)).wait() XCTAssertEqual(diff2.count, 2) - let diff3 = try connection.sdiff(of: [key1, key2, key3]).wait() + let diff3 = try connection.send(.sdiff(of: [key1, key2, key3])).wait() XCTAssertEqual(diff3.count, 1) - let diff4 = try connection.sdiff(of: [key3, key1, key2]).wait() + let diff4 = try connection.send(.sdiff(of: [key3, key1, key2])).wait() XCTAssertEqual(diff4.count, 0) } @@ -114,12 +114,12 @@ final class SetCommandsTests: RediStackIntegrationTestCase { let key2: RedisKey = #file let key3 = RedisKey(key1.rawValue + key2.rawValue) - _ = try connection.sadd([1, 2, 3], to: key1).wait() - _ = try connection.sadd([3, 4, 5], to: key2).wait() + _ = try connection.send(.sadd([1, 2, 3], to: key1)).wait() + _ = try connection.send(.sadd([3, 4, 5], to: key2)).wait() - let diffCount = try connection.sdiffstore(as: key3, sources: [key1, key2]).wait() + let diffCount = try connection.send(.sdiffstore(as: key3, sources: [key1, key2])).wait() XCTAssertEqual(diffCount, 2) - let members = try connection.smembers(of: key3).wait() + let members = try connection.send(.smembers(of: key3)).wait() XCTAssertEqual(members[0].string, "1") XCTAssertEqual(members[1].string, "2") } @@ -129,20 +129,20 @@ final class SetCommandsTests: RediStackIntegrationTestCase { let key2: RedisKey = #file let key3 = RedisKey(key1.rawValue + key2.rawValue) - _ = try connection.sadd([1, 2, 3], to: key1).wait() - _ = try connection.sadd([3, 4, 5], to: key2).wait() - _ = try connection.sadd([2, 4], to: key3).wait() + _ = try connection.send(.sadd([1, 2, 3], to: key1)).wait() + _ = try connection.send(.sadd([3, 4, 5], to: key2)).wait() + _ = try connection.send(.sadd([2, 4], to: key3)).wait() - let diff1 = try connection.sinter(of: key1, key2).wait() + let diff1 = try connection.send(.sinter(of: key1, key2)).wait() XCTAssertEqual(diff1.count, 1) - let diff2 = try connection.sinter(of: key1, key3).wait() + let diff2 = try connection.send(.sinter(of: key1, key3)).wait() XCTAssertEqual(diff2.count, 1) - let diff3 = try connection.sinter(of: [key1, key2, key3]).wait() + let diff3 = try connection.send(.sinter(of: [key1, key2, key3])).wait() XCTAssertEqual(diff3.count, 0) - let diff4 = try connection.sinter(of: [key3, key1, key2]).wait() + let diff4 = try connection.send(.sinter(of: [key3, key1, key2])).wait() XCTAssertEqual(diff4.count, 0) } @@ -151,29 +151,29 @@ final class SetCommandsTests: RediStackIntegrationTestCase { let key2: RedisKey = #file let key3 = RedisKey(key1.rawValue + key2.rawValue) - _ = try connection.sadd([1, 2, 3], to: key1).wait() - _ = try connection.sadd([3, 4, 5], to: key2).wait() + _ = try connection.send(.sadd([1, 2, 3], to: key1)).wait() + _ = try connection.send(.sadd([3, 4, 5], to: key2)).wait() - let diffCount = try connection.sinterstore(as: key3, sources: [key1, key2]).wait() + let diffCount = try connection.send(.sinterstore(as: key3, sources: [key1, key2])).wait() XCTAssertEqual(diffCount, 1) - XCTAssertEqual(try connection.smembers(of: key3).wait()[0].string, "3") + XCTAssertEqual(try connection.send(.smembers(of: key3)).wait()[0].string, "3") } func test_smove() throws { - _ = try connection.sadd([1, 2, 3], to: #function).wait() - _ = try connection.sadd([3, 4, 5], to: #file).wait() + _ = try connection.send(.sadd([1, 2, 3], to: #function)).wait() + _ = try connection.send(.sadd([3, 4, 5], to: #file)).wait() - var didMove = try connection.smove(3, from: #function, to: #file).wait() + var didMove = try connection.send(.smove(3, from: #function, to: #file)).wait() XCTAssertTrue(didMove) - XCTAssertEqual(try connection.scard(of: #function).wait(), 2) - XCTAssertEqual(try connection.scard(of: #file).wait(), 3) + XCTAssertEqual(try connection.send(.scard(of: #function)).wait(), 2) + XCTAssertEqual(try connection.send(.scard(of: #file)).wait(), 3) - didMove = try connection.smove(2, from: #function, to: #file).wait() + didMove = try connection.send(.smove(2, from: #function, to: #file)).wait() XCTAssertTrue(didMove) - XCTAssertEqual(try connection.scard(of: #function).wait(), 1) - XCTAssertEqual(try connection.scard(of: #file).wait(), 4) + XCTAssertEqual(try connection.send(.scard(of: #function)).wait(), 1) + XCTAssertEqual(try connection.send(.scard(of: #file)).wait(), 4) - didMove = try connection.smove(6, from: #file, to: #function).wait() + didMove = try connection.send(.smove(6, from: #file, to: #function)).wait() XCTAssertFalse(didMove) } @@ -182,17 +182,17 @@ final class SetCommandsTests: RediStackIntegrationTestCase { let key2: RedisKey = #file let key3 = RedisKey(key1.rawValue + key2.rawValue) - _ = try connection.sadd([1, 2, 3], to: key1).wait() - _ = try connection.sadd([3, 4, 5], to: key2).wait() - _ = try connection.sadd([2, 4], to: key3).wait() + _ = try connection.send(.sadd([1, 2, 3], to: key1)).wait() + _ = try connection.send(.sadd([3, 4, 5], to: key2)).wait() + _ = try connection.send(.sadd([2, 4], to: key3)).wait() - let union1 = try connection.sunion(of: key1, key2).wait() + let union1 = try connection.send(.sunion(of: key1, key2)).wait() XCTAssertEqual(union1.count, 5) - let union2 = try connection.sunion(of: [key2, key3]).wait() + let union2 = try connection.send(.sunion(of: [key2, key3])).wait() XCTAssertEqual(union2.count, 4) - let diff3 = try connection.sunion(of: [key1, key2, key3]).wait() + let diff3 = try connection.send(.sunion(of: [key1, key2, key3])).wait() XCTAssertEqual(diff3.count, 5) } @@ -201,12 +201,12 @@ final class SetCommandsTests: RediStackIntegrationTestCase { let key2: RedisKey = #file let key3 = RedisKey(key1.rawValue + key2.rawValue) - _ = try connection.sadd([1, 2, 3], to: key1).wait() - _ = try connection.sadd([2, 3, 4], to: key2).wait() + _ = try connection.send(.sadd([1, 2, 3], to: key1)).wait() + _ = try connection.send(.sadd([2, 3, 4], to: key2)).wait() - let unionCount = try connection.sunionstore(as: key3, sources: [key1, key2]).wait() + let unionCount = try connection.send(.sunionstore(as: key3, sources: [key1, key2])).wait() XCTAssertEqual(unionCount, 4) - let results = try connection.smembers(of: key3).wait() + let results = try connection.send(.smembers(of: key3)).wait() XCTAssertEqual(results[0].string, "1") XCTAssertEqual(results[1].string, "2") XCTAssertEqual(results[2].string, "3") diff --git a/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift index 42aa44b1..cd84ae01 100644 --- a/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -30,59 +30,59 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { dataset.append((index, Double(index))) } - _ = try connection.zadd(dataset, to: SortedSetCommandsTests.testKey).wait() + _ = try connection.send(.zadd(dataset, to: SortedSetCommandsTests.testKey)).wait() } catch { XCTFail("Failed to create RedisConnection! \(error)") } } func test_zadd() throws { - _ = try connection.send(command: "FLUSHALL").wait() + _ = try connection.send(.flushall).wait() - var count = try connection.zadd([(30, 2)], to: #function).wait() + var count = try connection.send(.zadd([(30, 2)], to: #function)).wait() XCTAssertEqual(count, 1) - count = try connection.zadd([(30, 5)], to: #function).wait() + count = try connection.send(.zadd([(30, 5)], to: #function)).wait() XCTAssertEqual(count, 0) - count = try connection.zadd((30, 6), (31, 0), (32, 1), to: #function, inserting: .onlyNewElements).wait() + count = try connection.send(.zadd((30, 6), (31, 0), (32, 1), to: #function, inserting: .onlyNewElements)).wait() XCTAssertEqual(count, 2) - count = try connection.zadd( + count = try connection.send(.zadd( [(32, 2), (33, 3)], to: #function, inserting: .onlyExistingElements, returning: .changedElementsCount - ).wait() + )).wait() XCTAssertEqual(count, 1) - var success = try connection.zadd((30, 7), to: #function, returning: .changedElementsCount).wait() + var success = try connection.send(.zadd((30, 7), to: #function, returning: .changedElementsCount)).wait() XCTAssertTrue(success) - success = try connection.zadd((30, 8), to: #function, inserting: .onlyNewElements).wait() + success = try connection.send(.zadd((30, 8), to: #function, inserting: .onlyNewElements)).wait() XCTAssertFalse(success) } func test_zcard() throws { - var count = try connection.zcard(of: key).wait() + var count = try connection.send(.zcard(of: key)).wait() XCTAssertEqual(count, 10) - _ = try connection.zadd(("foo", 0), to: key).wait() + _ = try connection.send(.zadd(("foo", 0), to: key)).wait() - count = try connection.zcard(of: key).wait() + count = try connection.send(.zcard(of: key)).wait() XCTAssertEqual(count, 11) } func test_zscore() throws { - _ = try connection.send(command: "FLUSHALL").wait() + _ = try connection.send(.flushall).wait() - var score = try connection.zscore(of: 30, in: #function).wait() + var score = try connection.send(.zscore(of: 30, in: #function)).wait() XCTAssertEqual(score, nil) - _ = try connection.zadd((30, 1), to: #function).wait() + _ = try connection.send(.zadd((30, 1), to: #function)).wait() - score = try connection.zscore(of: 30, in: #function).wait() + score = try connection.send(.zscore(of: 30, in: #function)).wait() XCTAssertEqual(score, 1) - _ = try connection.zincrby(10, element: 30, in: #function).wait() + _ = try connection.send(.zincrby(30, in: #function, by: 10)).wait() - score = try connection.zscore(of: 30, in: #function).wait() + score = try connection.send(.zscore(of: 30, in: #function)).wait() XCTAssertEqual(score, 11) } @@ -109,9 +109,9 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { func test_zrank() throws { let futures = [ - connection.zrank(of: 1, in: key), - connection.zrank(of: 2, in: key), - connection.zrank(of: 3, in: key), + connection.send(.zrank(of: 1, in: key)), + connection.send(.zrank(of: 2, in: key)), + connection.send(.zrank(of: 3, in: key)), ] let scores = try EventLoopFuture.whenAllSucceed(futures, on: connection.eventLoop).wait() XCTAssertEqual(scores, [0, 1, 2]) @@ -119,77 +119,77 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { func test_zrevrank() throws { let futures = [ - connection.zrevrank(of: 1, in: key), - connection.zrevrank(of: 2, in: key), - connection.zrevrank(of: 3, in: key), + connection.send(.zrevrank(of: 1, in: key)), + connection.send(.zrevrank(of: 2, in: key)), + connection.send(.zrevrank(of: 3, in: key)), ] let scores = try EventLoopFuture.whenAllSucceed(futures, on: connection.eventLoop).wait() XCTAssertEqual(scores, [9, 8, 7]) } func test_zcount() throws { - var count = try connection.zcount(of: key, withScores: 1...3).wait() + var count = try connection.send(.zcount(of: key, withScores: 1...3)).wait() XCTAssertEqual(count, 3) - count = try connection.zcount(of: key, withScoresBetween: (.exclusive(1), .exclusive(3))).wait() + count = try connection.send(.zcount(of: key, withScoresBetween: (.exclusive(1), .exclusive(3)))).wait() XCTAssertEqual(count, 1) - count = try connection.zcount(of: key, withScores: 3..<8).wait() + count = try connection.send(.zcount(of: key, withScores: 3..<8)).wait() XCTAssertEqual(count, 5) - count = try connection.zcount(of: key, withMinimumScoreOf: .exclusive(7)).wait() + count = try connection.send(.zcount(of: key, withMinimumScoreOf: .exclusive(7))).wait() XCTAssertEqual(count, 3) - count = try connection.zcount(of: key, withMaximumScoreOf: 10).wait() + count = try connection.send(.zcount(of: key, withMaximumScoreOf: 10)).wait() XCTAssertEqual(count, 10) - count = try connection.zcount(of: key, withScoresBetween: (3, 0)).wait() + count = try connection.send(.zcount(of: key, withScoresBetween: (3, 0))).wait() XCTAssertEqual(count, 0) } func test_zlexcount() throws { for i in 1...10 { - _ = try connection.zadd((i, 1), to: #function).wait() + _ = try connection.send(.zadd((i, 1), to: #function)).wait() } - var count = try connection.zlexcount(of: #function, withValuesBetween: (.inclusive(1), .inclusive(3))).wait() + var count = try connection.send(.zlexcount(of: #function, withValuesBetween: (.inclusive(1), .inclusive(3)))).wait() XCTAssertEqual(count, 4) - count = try connection.zlexcount(of: #function, withValuesBetween: (.exclusive(1), .exclusive(3))).wait() + count = try connection.send(.zlexcount(of: #function, withValuesBetween: (.exclusive(1), .exclusive(3)))).wait() XCTAssertEqual(count, 2) - count = try connection.zlexcount(of: #function, withMinimumValueOf: .inclusive(2)).wait() + count = try connection.send(.zlexcount(of: #function, withMinimumValueOf: .inclusive(2))).wait() XCTAssertEqual(count, 8) - count = try connection.zlexcount(of: #function, withMaximumValueOf: .exclusive(3)).wait() + count = try connection.send(.zlexcount(of: #function, withMaximumValueOf: .exclusive(3))).wait() XCTAssertEqual(count, 3) } func test_zpopmin() throws { - let min = try connection.zpopmin(from: key).wait() + let min = try connection.send(.zpopmin(from: key)).wait() XCTAssertEqual(min?.1, 1) - _ = try connection.zpopmin(from: key, max: 7).wait() + _ = try connection.send(.zpopmin(from: key, max: 7)).wait() - let results = try connection.zpopmin(from: key, max: 3).wait() + let results = try connection.send(.zpopmin(from: key, max: 3)).wait() XCTAssertEqual(results.count, 2) XCTAssertEqual(results[0].1, 9) XCTAssertEqual(results[1].1, 10) } func test_bzpopmin() throws { - let nilMin = try connection.bzpopmin(from: #function, timeout: .seconds(1)).wait() + let nilMin = try connection.send(.bzpopmin(from: #function, timeout: .seconds(1))).wait() XCTAssertNil(nilMin) - let min1 = try connection.bzpopmin(from: key).wait() + let min1 = try connection.send(.bzpopmin(from: key)).wait() XCTAssertEqual(min1?.0, 1) - let min2 = try connection.bzpopmin(from: [#function, key]).wait() + let min2 = try connection.send(.bzpopmin(from: [#function, key])).wait() XCTAssertEqual(min2?.0, key.rawValue) XCTAssertEqual(min2?.1, 2) let blockingConnection = try self.makeNewConnection() let expectation = XCTestExpectation(description: "bzpopmin should never return") - _ = blockingConnection.bzpopmin(from: #function) + _ = blockingConnection.send(.bzpopmin(from: #function)) .always { _ in expectation.fulfill() blockingConnection.close() @@ -200,30 +200,30 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { } func test_zpopmax() throws { - let min = try connection.zpopmax(from: key).wait() + let min = try connection.send(.zpopmax(from: key)).wait() XCTAssertEqual(min?.1, 10) - _ = try connection.zpopmax(from: key, max: 7).wait() + _ = try connection.send(.zpopmax(from: key, max: 7)).wait() - let results = try connection.zpopmax(from: key, max: 3).wait() + let results = try connection.send(.zpopmax(from: key, max: 3)).wait() XCTAssertEqual(results.count, 2) XCTAssertEqual(results[0].1, 2) XCTAssertEqual(results[1].1, 1) } func test_bzpopmax() throws { - let nilMax = try connection.bzpopmax(from: #function, timeout: .seconds(1)).wait() + let nilMax = try connection.send(.bzpopmax(from: #function, timeout: .seconds(1))).wait() XCTAssertNil(nilMax) - let max1 = try connection.bzpopmax(from: key).wait() + let max1 = try connection.send(.bzpopmax(from: key)).wait() XCTAssertEqual(max1?.0, 10) - let max2 = try connection.bzpopmax(from: [#function, key]).wait() + let max2 = try connection.send(.bzpopmax(from: [#function, key])).wait() XCTAssertEqual(max2?.0, key.rawValue) XCTAssertEqual(max2?.1, 9) let blockingConnection = try self.makeNewConnection() let expectation = XCTestExpectation(description: "bzpopmax should never return") - _ = blockingConnection.bzpopmax(from: #function) + _ = blockingConnection.send(.bzpopmax(from: #function)) .always { _ in expectation.fulfill() blockingConnection.close() @@ -234,148 +234,140 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { } func test_zincrby() throws { - var score = try connection.zincrby(3_00_1398.328923, element: 1, in: key).wait() + var score = try connection.send(.zincrby(1, in: key, by: 3_00_1398.328923)).wait() XCTAssertEqual(score, 3_001_399.328923) - score = try connection.zincrby(-201_309.1397318, element: 1, in: key).wait() + score = try connection.send(.zincrby(1, in: key, by: -201_309.1397318)).wait() XCTAssertEqual(score, 2_800_090.1891912) - score = try connection.zincrby(20, element: 1, in: key).wait() + score = try connection.send(.zincrby(1, in: key, by: 20)).wait() XCTAssertEqual(score, 2_800_110.1891912) } func test_zunionstore() throws { let testKey = RedisKey(#function + #file) - _ = try connection.zadd([(1, 1), (2, 2)], to: #function).wait() - _ = try connection.zadd([(3, 3), (4, 4)], to: #file).wait() + _ = try connection.send(.zadd([(1, 1), (2, 2)], to: #function)).wait() + _ = try connection.send(.zadd([(3, 3), (4, 4)], to: #file)).wait() - let unionCount = try connection.zunionstore( + let unionCount = try connection.send(.zunionstore( as: testKey, sources: [key, #function, #file], weights: [3, 2, 1], aggregateMethod: .max - ).wait() + )).wait() XCTAssertEqual(unionCount, 10) - let rank = try connection.zrank(of: 10, in: testKey).wait() + let rank = try connection.send(.zrank(of: 10, in: testKey)).wait() XCTAssertEqual(rank, 9) - let score = try connection.zscore(of: 10, in: testKey).wait() + let score = try connection.send(.zscore(of: 10, in: testKey)).wait() XCTAssertEqual(score, 30) } func test_zinterstore() throws { - _ = try connection.zadd([(3, 3), (10, 10), (11, 11)], to: #function).wait() + _ = try connection.send(.zadd([(3, 3), (10, 10), (11, 11)], to: #function)).wait() - let unionCount = try connection.zinterstore( + let unionCount = try connection.send(.zinterstore( as: #file, sources: [key, #function], weights: [3, 2], aggregateMethod: .min - ).wait() + )).wait() XCTAssertEqual(unionCount, 2) - let rank = try connection.zrank(of: 10, in: #file).wait() + let rank = try connection.send(.zrank(of: 10, in: #file)).wait() XCTAssertEqual(rank, 1) - let score = try connection.zscore(of: 10, in: #file).wait() + let score = try connection.send(.zscore(of: 10, in: #file)).wait() XCTAssertEqual(score, 20.0) } func test_zrange() throws { - var elements = try connection.zrange(from: key, indices: 1...3).wait() + var elements = try connection.send(.zrange(from: key, indices: 1...3, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 3) - elements = try connection.zrange(from: key, indices: 3..<9).wait() + elements = try connection.send(.zrange(from: key, indices: 3..<9, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 6) - elements = try connection.zrange(from: key, upToIndex: 4).wait() + elements = try connection.send(.zrange(from: key, upToIndex: 4, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 4) - elements = try connection.zrange(from: key, throughIndex: 4).wait() + elements = try connection.send(.zrange(from: key, throughIndex: 4, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 5) - elements = try connection.zrange(from: key, fromIndex: 7).wait() + elements = try connection.send(.zrange(from: key, fromIndex: 7, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 3) - elements = try connection.zrange(from: key, firstIndex: 1, lastIndex: 3, includeScoresInResponse: true).wait() - XCTAssertEqual(elements.count, 6) - - let values = try RedisConnection._mapSortedSetResponse(elements, scoreIsFirst: false) - .map { (value, _) in return Int(fromRESP: value) } + let elementsAndScores = try connection.send(.zrange(from: key, firstIndex: 1, lastIndex: 3, returning: .valuesAndScores)).wait() + XCTAssertEqual(elementsAndScores.count, 3) + let values = elementsAndScores.map { Int(fromRESP: $0.0) } XCTAssertEqual(values[0], 2) XCTAssertEqual(values[1], 3) XCTAssertEqual(values[2], 4) } func test_zrevrange() throws { - var elements = try connection.zrevrange(from: key, indices: 1...3).wait() + var elements = try connection.send(.zrevrange(from: key, indices: 1...3, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 3) - elements = try connection.zrevrange(from: key, indices: 3..<9).wait() + elements = try connection.send(.zrevrange(from: key, indices: 3..<9, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 6) - elements = try connection.zrevrange(from: key, upToIndex: 4).wait() + elements = try connection.send(.zrevrange(from: key, upToIndex: 4, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 4) - elements = try connection.zrevrange(from: key, throughIndex: 4).wait() + elements = try connection.send(.zrevrange(from: key, throughIndex: 4, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 5) - elements = try connection.zrevrange(from: key, fromIndex: 7).wait() + elements = try connection.send(.zrevrange(from: key, fromIndex: 7, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 3) - elements = try connection.zrevrange(from: key, firstIndex: 1, lastIndex: 3, includeScoresInResponse: true).wait() - XCTAssertEqual(elements.count, 6) - - let values = try RedisConnection._mapSortedSetResponse(elements, scoreIsFirst: false) - .map { (value, _) in return Int(fromRESP: value) } + let elementsAndScores = try connection.send(.zrevrange(from: key, firstIndex: 1, lastIndex: 3, returning: .valuesAndScores)).wait() + XCTAssertEqual(elementsAndScores.count, 3) + let values = elementsAndScores.map { value, _ in Int(fromRESP: value) } XCTAssertEqual(values[0], 9) XCTAssertEqual(values[1], 8) XCTAssertEqual(values[2], 7) } func test_zrangebyscore() throws { - var elements = try connection.zrangebyscore(from: key, withScoresBetween: (.exclusive(1), 3)).wait() + var elements = try connection.send(.zrangebyscore(from: key, withScoresBetween: (.exclusive(1), 3), returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 2) - elements = try connection.zrangebyscore(from: key, withScores: 7..<10, limitBy: (offset: 2, count: 3)).wait() + elements = try connection.send(.zrangebyscore(from: key, withScores: 7..<10, limitBy: (offset: 2, count: 3), returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 1) - elements = try connection.zrangebyscore(from: key, withMinimumScoreOf: .exclusive(5)).wait() + elements = try connection.send(.zrangebyscore(from: key, withMinimumScoreOf: .exclusive(5), returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 5) - elements = try connection.zrangebyscore(from: key, withMaximumScoreOf: 5).wait() + elements = try connection.send(.zrangebyscore(from: key, withMaximumScoreOf: 5, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 5) - elements = try connection.zrangebyscore(from: key, withScores: 1...3, includeScoresInResponse: true).wait() - XCTAssertEqual(elements.count, 6) - - let values = try RedisConnection._mapSortedSetResponse(elements, scoreIsFirst: false) - .map { (_, score) in return score } + let elementsAndScores = try connection.send(.zrangebyscore(from: key, withScores: 1...3, returning: .valuesAndScores)).wait() + XCTAssertEqual(elementsAndScores.count, 3) + let values = elementsAndScores.map { value, _ in Double(fromRESP: value) } XCTAssertEqual(values[0], 1.0) XCTAssertEqual(values[1], 2.0) XCTAssertEqual(values[2], 3.0) } func test_zrevrangebyscore() throws { - var elements = try connection.zrevrangebyscore(from: key, withScoresBetween: (.exclusive(1), 3)).wait() + var elements = try connection.send(.zrevrangebyscore(from: key, withScoresBetween: (.exclusive(1), 3), returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 2) - elements = try connection.zrevrangebyscore(from: key, withScores: 7..<10, limitBy: (offset: 2, count: 3)).wait() + elements = try connection.send(.zrevrangebyscore(from: key, withScores: 7..<10, limitBy: (offset: 2, count: 3), returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 1) - elements = try connection.zrevrangebyscore(from: key, withMinimumScoreOf: .exclusive(5)).wait() + elements = try connection.send(.zrevrangebyscore(from: key, withMinimumScoreOf: .exclusive(5), returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 5) - elements = try connection.zrevrangebyscore(from: key, withMaximumScoreOf: 5).wait() + elements = try connection.send(.zrevrangebyscore(from: key, withMaximumScoreOf: 5, returning: .valuesOnly)).wait() XCTAssertEqual(elements.count, 5) - elements = try connection.zrevrangebyscore(from: key, withScores: 1...3, includeScoresInResponse: true).wait() - XCTAssertEqual(elements.count, 6) - - let values = try RedisConnection._mapSortedSetResponse(elements, scoreIsFirst: false) - .map { (_, score) in return score } + let elementsAndScores = try connection.send(.zrevrangebyscore(from: key, withScores: 1...3, returning: .valuesAndScores)).wait() + XCTAssertEqual(elementsAndScores.count, 3) + let values = elementsAndScores.map(\.1) XCTAssertEqual(values[0], 3.0) XCTAssertEqual(values[1], 2.0) XCTAssertEqual(values[2], 1.0) @@ -383,20 +375,20 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { func test_zrangebylex() throws { for i in 1...10 { - _ = try connection.zadd((i, 1), to: #function).wait() + _ = try connection.send(.zadd((i, 1), to: #function)).wait() } - var elements = try connection.zrangebylex(from: #function, withMinimumValueOf: .exclusive(10)) + var elements = try connection.send(.zrangebylex(from: #function, withMinimumValueOf: .exclusive(10))) .wait() .map(Int.init(fromRESP:)) XCTAssertEqual(elements.count, 8) - elements = try connection.zrangebylex(from: #function, withMaximumValueOf: .inclusive(5)) + elements = try connection.send(.zrangebylex(from: #function, withMaximumValueOf: .inclusive(5))) .wait() .map(Int.init(fromRESP:)) XCTAssertEqual(elements.count, 6) - elements = try connection.zrangebylex(from: #function, withValuesBetween: (.inclusive(1), .inclusive(2))) + elements = try connection.send(.zrangebylex(from: #function, withValuesBetween: (.inclusive(1), .inclusive(2)))) .wait() .map(Int.init(fromRESP:)) @@ -405,11 +397,12 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { XCTAssertEqual(elements[1], 10) XCTAssertEqual(elements[2], 2) - elements = try connection - .zrangebylex( - from: #function, - withValuesBetween: (.inclusive(1), .exclusive(4)), - limitBy: (offset: 1, count: 1) + elements = try connection.send( + .zrangebylex( + from: #function, + withValuesBetween: (.inclusive(1), .exclusive(4)), + limitBy: (offset: 1, count: 1) + ) ) .wait() .map(Int.init(fromRESP:)) @@ -419,34 +412,35 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { func test_zrevrangebylex() throws { for i in 1...10 { - _ = try connection.zadd((i, 1), to: #function).wait() + _ = try connection.send(.zadd((i, 1), to: #function)).wait() } - var elements = try connection.zrevrangebylex(from: #function, withMinimumValueOf: .inclusive(1)) + var elements = try connection.send(.zrevrangebylex(from: #function, withMinimumValueOf: .inclusive(1))) .wait() .map(Int.init(fromRESP:)) XCTAssertEqual(elements.count, 10) XCTAssertEqual(elements[0], 9) XCTAssertEqual(elements[9], 1) - elements = try connection.zrevrangebylex(from: #function, withMaximumValueOf: .exclusive(2)) + elements = try connection.send(.zrevrangebylex(from: #function, withMaximumValueOf: .exclusive(2))) .wait() .map(Int.init(fromRESP:)) XCTAssertEqual(elements.count, 2) XCTAssertEqual(elements[0], 10) - elements = try connection.zrevrangebylex(from: #function, withValuesBetween: (.exclusive(2), .inclusive(4))) + elements = try connection.send(.zrevrangebylex(from: #function, withValuesBetween: (.exclusive(2), .inclusive(4)))) .wait() .map(Int.init(fromRESP:)) XCTAssertEqual(elements.count, 2) XCTAssertEqual(elements[0], 4) XCTAssertEqual(elements[1], 3) - elements = try connection - .zrevrangebylex( - from: #function, - withValuesBetween: (.inclusive(1), .exclusive(4)), - limitBy: (offset: 1, count: 2) + elements = try connection.send( + .zrevrangebylex( + from: #function, + withValuesBetween: (.inclusive(1), .exclusive(4)), + limitBy: (offset: 1, count: 2) + ) ) .wait() .map(Int.init(fromRESP:)) @@ -455,66 +449,66 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { } func test_zrem() throws { - var count = try connection.zrem(1, from: key).wait() + var count = try connection.send(.zrem(1, from: key)).wait() XCTAssertEqual(count, 1) - count = try connection.zrem([1], from: key).wait() + count = try connection.send(.zrem([1], from: key)).wait() XCTAssertEqual(count, 0) - count = try connection.zrem(2, 3, 4, 5, from: key).wait() + count = try connection.send(.zrem(2, 3, 4, 5, from: key)).wait() XCTAssertEqual(count, 4) - count = try connection.zrem([5, 6, 7], from: key).wait() + count = try connection.send(.zrem([5, 6, 7], from: key)).wait() XCTAssertEqual(count, 2) } func test_zremrangebylex() throws { for value in ["bar", "car", "tar"] { - _ = try connection.zadd((value, 0), to: #function).wait() + _ = try connection.send(.zadd((value, 0), to: #function)).wait() } - var count = try connection.zremrangebylex(from: #function, withValuesBetween: (.exclusive("a"), .inclusive("t"))).wait() + var count = try connection.send(.zremrangebylex(from: #function, withValuesBetween: (.exclusive("a"), .inclusive("t")))).wait() XCTAssertEqual(count, 2) - count = try connection.zremrangebylex(from: #function, withMaximumValueOf: .inclusive("t")).wait() + count = try connection.send(.zremrangebylex(from: #function, withMaximumValueOf: .inclusive("t"))).wait() XCTAssertEqual(count, 0) - count = try connection.zremrangebylex(from: #function, withMinimumValueOf: .inclusive("t")).wait() + count = try connection.send(.zremrangebylex(from: #function, withMinimumValueOf: .inclusive("t"))).wait() XCTAssertEqual(count, 1) } func test_zremrangebyrank() throws { - var count = try connection.zremrangebyrank(from: key, fromIndex: 9).wait() + var count = try connection.send(.zremrangebyrank(from: key, fromIndex: 9)).wait() XCTAssertEqual(count, 1) - count = try connection.zremrangebyrank(from: key, indices: 0...1).wait() + count = try connection.send(.zremrangebyrank(from: key, indices: 0...1)).wait() XCTAssertEqual(count, 2) - count = try connection.zremrangebyrank(from: key, indices: 0..<2).wait() + count = try connection.send(.zremrangebyrank(from: key, indices: 0..<2)).wait() XCTAssertEqual(count, 2) - count = try connection.zremrangebyrank(from: key, upToIndex: 1).wait() + count = try connection.send(.zremrangebyrank(from: key, upToIndex: 1)).wait() XCTAssertEqual(count, 1) - count = try connection.zremrangebyrank(from: key, throughIndex: 1).wait() + count = try connection.send(.zremrangebyrank(from: key, throughIndex: 1)).wait() XCTAssertEqual(count, 2) - count = try connection.zremrangebyrank(from: key, upToIndex: 0).wait() + count = try connection.send(.zremrangebyrank(from: key, upToIndex: 0)).wait() XCTAssertEqual(count, 2) } func test_zremrangebyscore() throws { - var count = try connection.zremrangebyscore(from: key, withScoresBetween: (.exclusive(8), 10)).wait() + var count = try connection.send(.zremrangebyscore(from: key, withScoresBetween: (.exclusive(8), 10))).wait() XCTAssertEqual(count, 2) - count = try connection.zremrangebyscore(from: key, withScores: 4..<7).wait() + count = try connection.send(.zremrangebyscore(from: key, withScores: 4..<7)).wait() XCTAssertEqual(count, 3) - count = try connection.zremrangebyscore(from: key, withScores: 2...3).wait() + count = try connection.send(.zremrangebyscore(from: key, withScores: 2...3)).wait() XCTAssertEqual(count, 2) - count = try connection.zremrangebyscore(from: key, withMinimumScoreOf: .exclusive(1)).wait() + count = try connection.send(.zremrangebyscore(from: key, withMinimumScoreOf: .exclusive(1))).wait() XCTAssertEqual(count, 2) - count = try connection.zremrangebyscore(from: key, withMaximumScoreOf: .inclusive(1)).wait() + count = try connection.send(.zremrangebyscore(from: key, withMaximumScoreOf: .inclusive(1))).wait() XCTAssertEqual(count, 1) } } diff --git a/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift index 6c04cab6..e86032e6 100644 --- a/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2020 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -29,21 +29,21 @@ final class StringCommandsTests: RediStackIntegrationTestCase { XCTAssertEqual(r2, 30) _ = try connection.delete(#function).wait() - let r3: RESPValue = try connection.get(#function).wait() - XCTAssertEqual(r3, .null) + let r3: RESPValue? = try connection.get(#function).wait() + XCTAssertNil(r3) } func test_mget() throws { let keys = ["one", "two"].map(RedisKey.init(_:)) try keys.forEach { _ = try connection.set($0, to: $0).wait() } - let values = try connection.mget(keys + ["empty"]).wait() + let values = try connection.send(.mget(keys + ["empty"])).wait() XCTAssertEqual(values.count, 3) - XCTAssertEqual(values[0].string, "one") - XCTAssertEqual(values[1].string, "two") - XCTAssertEqual(values[2].isNull, true) + XCTAssertEqual(values[0]?.string, "one") + XCTAssertEqual(values[1]?.string, "two") + XCTAssertNil(values[2]) - XCTAssertEqual(try connection.mget("empty", #function).wait().count, 2) + XCTAssertEqual(try connection.send(.mget("empty", #function)).wait().count, 2) } func test_set() throws { @@ -70,7 +70,7 @@ final class StringCommandsTests: RediStackIntegrationTestCase { ) XCTAssertEqual(try expireInSecondsResult.wait(), .ok) - let ttl = try connection.ttl(expireInSecondsKey).wait() + let ttl = try connection.send(.ttl(expireInSecondsKey)).wait() switch ttl { case .keyDoesNotExist, .unlimited: XCTFail("Unexpected TTL for key \(expireInSecondsKey)") @@ -89,7 +89,7 @@ final class StringCommandsTests: RediStackIntegrationTestCase { XCTAssertEqual(try expireInMillisecondsResult.wait(), .ok) - let pttl = try connection.ttl(expireInMillisecondsKey).wait() + let pttl = try connection.send(.ttl(expireInMillisecondsKey)).wait() switch pttl { case .keyDoesNotExist, .unlimited: XCTFail("Unexpected TTL for key \(expireInMillisecondsKey)") @@ -106,7 +106,7 @@ final class StringCommandsTests: RediStackIntegrationTestCase { let setResult = connection.set(#function, to: "value", onCondition: .keyDoesNotExist, expiration: .seconds(42)) XCTAssertEqual(try setResult.wait(), .ok) - let ttl = try connection.ttl(#function).wait() + let ttl = try connection.send(.ttl(#function)).wait() switch ttl { case .keyDoesNotExist, .unlimited: XCTFail("Unexpected TTL for key \(#function)") @@ -117,13 +117,13 @@ final class StringCommandsTests: RediStackIntegrationTestCase { } func test_setnx() throws { - XCTAssertTrue(try connection.setnx(#function, to: "value").wait()) - XCTAssertFalse(try connection.setnx(#function, to: "value").wait()) + XCTAssertTrue(try connection.send(.setnx(#function, to: "value")).wait()) + XCTAssertFalse(try connection.send(.setnx(#function, to: "value")).wait()) } func test_setex() throws { - XCTAssertNoThrow(try connection.setex(#function, to: "value", expirationInSeconds: 42).wait()) - let ttl = try connection.ttl(#function).wait() + XCTAssertNoThrow(try connection.send(.setex(#function, to: "value", expirationInSeconds: 42)).wait()) + let ttl = try connection.send(.ttl(#function)).wait() switch ttl { case .keyDoesNotExist, .unlimited: XCTFail("Unexpected TTL for \(#function)") @@ -134,8 +134,8 @@ final class StringCommandsTests: RediStackIntegrationTestCase { } func test_psetex() throws { - XCTAssertNoThrow(try connection.psetex(#function, to: "value", expirationInMilliseconds: 42_000).wait()) - let ttl = try connection.pttl(#function).wait() + XCTAssertNoThrow(try connection.send(.psetex(#function, to: "value", expirationInMilliseconds: 42_000)).wait()) + let ttl = try connection.send(.pttl(#function)).wait() switch ttl { case .keyDoesNotExist, .unlimited: XCTFail("Unexpected TTL for \(#function)") @@ -147,8 +147,8 @@ final class StringCommandsTests: RediStackIntegrationTestCase { func test_append() throws { let result = "value appended" - XCTAssertNoThrow(try connection.append("value", to: #function).wait()) - let length = try connection.append(" appended", to: #function).wait() + XCTAssertNoThrow(try connection.send(.append("value", to: #function)).wait()) + let length = try connection.send(.append(" appended", to: #function)).wait() XCTAssertEqual(length, result.count) let val = try connection.get(#function, as: String.self).wait() XCTAssertEqual(val, result) @@ -159,13 +159,13 @@ final class StringCommandsTests: RediStackIntegrationTestCase { "first": 1, "second": 2 ] - XCTAssertNoThrow(try connection.mset(data).wait()) - let values = try connection.mget(["first", "second"]).wait().compactMap { $0.string } + XCTAssertNoThrow(try connection.send(.mset(data)).wait()) + let values = try connection.send(.mget(["first", "second"])).wait().compactMap { $0?.string } XCTAssertEqual(values.count, 2) XCTAssertEqual(values[0], "1") XCTAssertEqual(values[1], "2") - XCTAssertNoThrow(try connection.mset(["first": 10]).wait()) + XCTAssertNoThrow(try connection.send(.mset(["first": 10])).wait()) let val = try connection.get("first", as: String.self).wait() XCTAssertEqual(val, "10") } @@ -175,58 +175,58 @@ final class StringCommandsTests: RediStackIntegrationTestCase { "first": 1, "second": 2 ] - var success = try connection.msetnx(data).wait() + var success = try connection.send(.msetnx(data)).wait() XCTAssertEqual(success, true) - success = try connection.msetnx(["first": 10, "second": 20]).wait() + success = try connection.send(.msetnx(["first": 10, "second": 20])).wait() XCTAssertEqual(success, false) - let values = try connection.mget(["first", "second"]).wait().compactMap { $0.string } + let values = try connection.send(.mget(["first", "second"])).wait().compactMap { $0?.string } XCTAssertEqual(values[0], "1") XCTAssertEqual(values[1], "2") } func test_increment() throws { - var result = try connection.increment(#function).wait() + var result = try connection.send(.incr(#function)).wait() XCTAssertEqual(result, 1) - result = try connection.increment(#function).wait() + result = try connection.send(.incr(#function)).wait() XCTAssertEqual(result, 2) } func test_incrementBy() throws { - var result = try connection.increment(#function, by: 10).wait() + var result = try connection.send(.incrby(#function, by: 10)).wait() XCTAssertEqual(result, 10) - result = try connection.increment(#function, by: -3).wait() + result = try connection.send(.incrby(#function, by: -3)).wait() XCTAssertEqual(result, 7) - result = try connection.increment(#function, by: 0).wait() + result = try connection.send(.incrby(#function, by: 0)).wait() XCTAssertEqual(result, 7) } func test_incrementByFloat() throws { - var float = try connection.increment(#function, by: Float(3.0)).wait() + var float = try connection.send(.incrbyfloat(#function, by: Float(3.0))).wait() XCTAssertEqual(float, 3.0) - float = try connection.increment(#function, by: Float(-10.135901)).wait() + float = try connection.send(.incrbyfloat(#function, by: Float(-10.135901))).wait() XCTAssertEqual(float, -7.135901) - var double = try connection.increment(#function, by: Double(10.2839)).wait() + var double = try connection.send(.incrbyfloat(#function, by: Double(10.2839))).wait() XCTAssertEqual(double, 3.147999) - double = try connection.increment(#function, by: Double(15.2938)).wait() + double = try connection.send(.incrbyfloat(#function, by: Double(15.2938))).wait() XCTAssertEqual(double, 18.441799) } func test_decrement() throws { - var result = try connection.decrement(#function).wait() + var result = try connection.send(.decr(#function)).wait() XCTAssertEqual(result, -1) - result = try connection.decrement(#function).wait() + result = try connection.send(.decr(#function)).wait() XCTAssertEqual(result, -2) } func test_decrementBy() throws { - var result = try connection.decrement(#function, by: -10).wait() + var result = try connection.send(.decrby(#function, by: -10)).wait() XCTAssertEqual(result, 10) - result = try connection.decrement(#function, by: 3).wait() + result = try connection.send(.decrby(#function, by: 3)).wait() XCTAssertEqual(result, 7) - result = try connection.decrement(#function, by: 0).wait() + result = try connection.send(.decrby(#function, by: 0)).wait() XCTAssertEqual(result, 7) } } diff --git a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift index cffd4ab1..79d9c6b3 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift @@ -23,17 +23,20 @@ final class RedisConnectionPoolTests: RediStackConnectionPoolIntegrationTestCase // We're going to insert a bunch of elements into a set, and then when all is done confirm that every // element exists. let operations = (0..<50).map { number in - self.pool.sadd([number], to: #function) + self.pool.send(.sadd([number], to: #function)) } let results = try EventLoopFuture.whenAllSucceed(operations, on: self.eventLoopGroup.next()).wait() XCTAssertEqual(results, Array(repeating: 1, count: 50)) - let whatRedisThinks = try self.pool.smembers(of: #function, as: Int.self).wait() + let whatRedisThinks = try self.pool + .send(.smembers(of: #function)) + .flatMapThrowing { result in try result.map { try $0.map(to: Int.self) } } + .wait() XCTAssertEqual(whatRedisThinks.compactMap { $0 }.sorted(), Array(0..<50)) } func test_closedPoolDoesNothing() throws { self.pool.close() - XCTAssertThrowsError(try self.pool.increment(#function).wait()) { error in + XCTAssertThrowsError(try self.pool.send(.incr(#function)).wait()) { error in XCTAssertEqual(error as? RedisConnectionPoolError, .poolClosed) } } From edb550f23392d86eea7b0c97b80daf05a41c53aa Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sat, 6 Feb 2021 19:56:50 -0800 Subject: [PATCH 05/63] [CI] Simplify config setup --- .gitlab/ci/amazonlinux-2.yml | 14 ------------ .gitlab/ci/centos-7.yml | 20 ++++++++++++----- .gitlab/ci/centos-8.yml | 14 ------------ .gitlab/ci/main.yml | 40 ++++++++++++++++----------------- .gitlab/ci/platform-test.yml | 43 ++++++++++++++++++++++++++++++++++++ .gitlab/ci/test-template.yml | 17 -------------- .gitlab/ci/ubuntu-bionic.yml | 14 ------------ .gitlab/ci/ubuntu-focal.yml | 14 ------------ .gitlab/ci/ubuntu-xenial.yml | 14 ------------ 9 files changed, 77 insertions(+), 113 deletions(-) delete mode 100644 .gitlab/ci/amazonlinux-2.yml delete mode 100644 .gitlab/ci/centos-8.yml create mode 100644 .gitlab/ci/platform-test.yml delete mode 100644 .gitlab/ci/test-template.yml delete mode 100644 .gitlab/ci/ubuntu-bionic.yml delete mode 100644 .gitlab/ci/ubuntu-focal.yml delete mode 100644 .gitlab/ci/ubuntu-xenial.yml diff --git a/.gitlab/ci/amazonlinux-2.yml b/.gitlab/ci/amazonlinux-2.yml deleted file mode 100644 index 703f8b86..00000000 --- a/.gitlab/ci/amazonlinux-2.yml +++ /dev/null @@ -1,14 +0,0 @@ -include: '/.gitlab/ci/test-template.yml' - -swift trunk: - extends: .unit-test - image: swiftlang/swift:nightly-master-amazonlinux2 - allow_failure: true - -swift 5.3: - extends: .unit-test - image: swift:5.3-amazonlinux2 - -swift 5.2: - extends: .unit-test - image: swift:5.2-amazonlinux2 diff --git a/.gitlab/ci/centos-7.yml b/.gitlab/ci/centos-7.yml index 81c8ab65..af23b55b 100644 --- a/.gitlab/ci/centos-7.yml +++ b/.gitlab/ci/centos-7.yml @@ -1,7 +1,10 @@ -include: '/.gitlab/ci/test-template.yml' +include: '/.gitlab/ci/platform-test.yml' -.centos7-test-workaround: - extends: .unit-test +variables: + SWIFT_STANDARD_PLATFORM_TESTS: "false" + +.centos7: + extends: .platform-test before_script: - yum install -y make libcurl-devel - git clone https://github.com/git/git -bv2.28.0 --depth 1 @@ -9,14 +12,19 @@ include: '/.gitlab/ci/test-template.yml' - make prefix=/usr -j all install NO_OPENSSL=1 NO_EXPAT=1 NO_TCLTK=1 NO_GETTEXT=1 NO_PERL=1 swift trunk: - extends: .centos7-test-workaround + extends: .centos7 image: swiftlang/swift:nightly-master-centos7 allow_failure: true +# 5.4 nightlies are not available yet +# swift 5.4: +# extends: .centos7 +# image: swiftlang/swift:nightly-5.4-centos7 + swift 5.3: - extends: .centos7-test-workaround + extends: .centos7 image: swift:5.3-centos7 swift 5.2: - extends: .centos7-test-workaround + extends: .centos7 image: swift:5.2-centos7 diff --git a/.gitlab/ci/centos-8.yml b/.gitlab/ci/centos-8.yml deleted file mode 100644 index cfda4131..00000000 --- a/.gitlab/ci/centos-8.yml +++ /dev/null @@ -1,14 +0,0 @@ -include: '/.gitlab/ci/test-template.yml' - -swift trunk: - extends: .unit-test - image: swiftlang/swift:nightly-master-centos8 - allow_failure: true - -swift 5.3: - extends: .unit-test - image: swift:5.3-centos8 - -swift 5.2: - extends: .unit-test - image: swift:5.2-centos8 diff --git a/.gitlab/ci/main.yml b/.gitlab/ci/main.yml index c92595c8..d2340c31 100644 --- a/.gitlab/ci/main.yml +++ b/.gitlab/ci/main.yml @@ -58,38 +58,38 @@ Code Climate: reports: codequality: gl-code-quality-report.json -Ubuntu Bionic: +.platform-test: stage: Platform Tests trigger: strategy: depend - include: '/.gitlab/ci/ubuntu-bionic.yml' + include: '/.gitlab/ci/platform-test.yml' + +Ubuntu Bionic: + extends: .platform-test + variables: + SWIFT_PLATFORM_NAME: bionic Ubuntu Xenial: - stage: Platform Tests - trigger: - strategy: depend - include: '/.gitlab/ci/ubuntu-xenial.yml' + extends: .platform-test + variables: + SWIFT_PLATFORM_NAME: xenial Ubuntu Focal: - stage: Platform Tests - trigger: - strategy: depend - include: '/.gitlab/ci/ubuntu-focal.yml' + extends: .platform-test + variables: + SWIFT_PLATFORM_NAME: focal CentOS 7: - stage: Platform Tests + extends: .platform-test trigger: - strategy: depend include: '/.gitlab/ci/centos-7.yml' CentOS 8: - stage: Platform Tests - trigger: - strategy: depend - include: '/.gitlab/ci/centos-8.yml' + extends: .platform-test + variables: + SWIFT_PLATFORM_NAME: centos8 Amazon Linux 2: - stage: Platform Tests - trigger: - strategy: depend - include: '/.gitlab/ci/amazonlinux-2.yml' + extends: .platform-test + variables: + SWIFT_PLATFORM_NAME: amazonlinux2 diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml new file mode 100644 index 00000000..66428a0f --- /dev/null +++ b/.gitlab/ci/platform-test.yml @@ -0,0 +1,43 @@ +stages: + - Test + +variables: + SWIFT_STANDARD_PLATFORM_TESTS: "true" + +.platform-test: + stage: Test + tags: + - docker + variables: + REDIS_URL: 'redis' + REDIS_PW: 'password' + services: + - name: redis:5 + alias: 'redis' + command: ["redis-server", "--requirepass", "password"] + script: + - swift build --build-tests --enable-test-discovery --sanitize=thread -v + - swift test --skip-build + +.standard-platform: + extends: .platform-test + rules: + - if: '$SWIFT_STANDARD_PLATFORM_TESTS == "true"' + +swift trunk: + extends: .standard-platform + image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} + allow_failure: true + +# 5.4 nightlies are not available yet +# swift 5.4: +# extends: .standard-platform +# image: swiftlang/swift:nightly-5.4-${SWIFT_PLATFORM_NAME} + +swift 5.3: + extends: .standard-platform + image: swift:5.3-${SWIFT_PLATFORM_NAME} + +swift 5.2: + extends: .standard-platform + image: swift:5.2-${SWIFT_PLATFORM_NAME} diff --git a/.gitlab/ci/test-template.yml b/.gitlab/ci/test-template.yml deleted file mode 100644 index 76314da4..00000000 --- a/.gitlab/ci/test-template.yml +++ /dev/null @@ -1,17 +0,0 @@ -stages: - - Test - -.unit-test: - stage: Test - tags: - - docker - variables: - REDIS_URL: 'redis' - REDIS_PW: 'password' - services: - - name: redis:5 - alias: 'redis' - command: ["redis-server", "--requirepass", "password"] - script: - - swift build --build-tests --enable-test-discovery --sanitize=thread -v - - swift test --skip-build diff --git a/.gitlab/ci/ubuntu-bionic.yml b/.gitlab/ci/ubuntu-bionic.yml deleted file mode 100644 index a508064f..00000000 --- a/.gitlab/ci/ubuntu-bionic.yml +++ /dev/null @@ -1,14 +0,0 @@ -include: '/.gitlab/ci/test-template.yml' - -swift trunk: - extends: .unit-test - image: swiftlang/swift:nightly-master-bionic - allow_failure: true - -swift 5.3: - extends: .unit-test - image: swift:5.3-bionic - -swift 5.2: - extends: .unit-test - image: swift:5.2-bionic diff --git a/.gitlab/ci/ubuntu-focal.yml b/.gitlab/ci/ubuntu-focal.yml deleted file mode 100644 index d16ef9d7..00000000 --- a/.gitlab/ci/ubuntu-focal.yml +++ /dev/null @@ -1,14 +0,0 @@ -include: '/.gitlab/ci/test-template.yml' - -swift trunk: - extends: .unit-test - image: swiftlang/swift:nightly-master-focal - allow_failure: true - -swift 5.3: - extends: .unit-test - image: swift:5.3-focal - -swift 5.2: - extends: .unit-test - image: swift:5.2-focal diff --git a/.gitlab/ci/ubuntu-xenial.yml b/.gitlab/ci/ubuntu-xenial.yml deleted file mode 100644 index 2e3e3856..00000000 --- a/.gitlab/ci/ubuntu-xenial.yml +++ /dev/null @@ -1,14 +0,0 @@ -include: '/.gitlab/ci/test-template.yml' - -swift trunk: - extends: .unit-test - image: swiftlang/swift:nightly-master-xenial - allow_failure: true - -swift 5.3: - extends: .unit-test - image: swift:5.3-xenial - -swift 5.2: - extends: .unit-test - image: swift:5.2-xenial From e2e8890cbf82028377460f8246d7b51f0a2859b3 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 16 Feb 2021 11:01:44 -0800 Subject: [PATCH 06/63] [CI] Add definition for 5.4 docker images, update README --- .gitlab/ci/centos-7.yml | 7 +++---- .gitlab/ci/platform-test.yml | 7 +++---- README.md | 6 +++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.gitlab/ci/centos-7.yml b/.gitlab/ci/centos-7.yml index af23b55b..134d57cd 100644 --- a/.gitlab/ci/centos-7.yml +++ b/.gitlab/ci/centos-7.yml @@ -16,10 +16,9 @@ swift trunk: image: swiftlang/swift:nightly-master-centos7 allow_failure: true -# 5.4 nightlies are not available yet -# swift 5.4: -# extends: .centos7 -# image: swiftlang/swift:nightly-5.4-centos7 +swift 5.4: + extends: .centos7 + image: swiftlang/swift:nightly-5.4-centos7 swift 5.3: extends: .centos7 diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml index 66428a0f..f615038e 100644 --- a/.gitlab/ci/platform-test.yml +++ b/.gitlab/ci/platform-test.yml @@ -29,10 +29,9 @@ swift trunk: image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} allow_failure: true -# 5.4 nightlies are not available yet -# swift 5.4: -# extends: .standard-platform -# image: swiftlang/swift:nightly-5.4-${SWIFT_PLATFORM_NAME} +swift 5.4: + extends: .standard-platform + image: swiftlang/swift:nightly-5.4-${SWIFT_PLATFORM_NAME} swift 5.3: extends: .standard-platform diff --git a/README.md b/README.md index a6bd9b2b..33659975 100644 --- a/README.md +++ b/README.md @@ -120,13 +120,13 @@ receive regular unit testing (either in development, or with CI) against the **c | Platform | Swift 5.2 | 5.3 | 5.4 | Trunk | |:----------------------|:------------------:|:------------------:|:------------------:|:------------------:| -| macOS Latest (Intel) | | :white_check_mark: | | | +| macOS Latest (Intel) | | | :white_check_mark: | | | Ubuntu 20.04 (Focal) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Amazon Linux 2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| CentOS 7 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | CentOS 8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| CentOS 7 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | For older versions of **RediStack**, view each summary below. @@ -135,7 +135,7 @@ For older versions of **RediStack**, view each summary below. | Platform | Swift 5.1 | 5.2 | 5.3 | |:----------------------|:------------------:|:------------------:|:------------------:| -| macOS Latest (Intel) | | | :white_check_mark: | +| macOS Latest (Intel) | | | | | Ubuntu 20.04 (Focal) | | :white_check_mark: | :white_check_mark: | | Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | From 40d521c14883038577a780f1a00e3302a5e93fea Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 16 Feb 2021 11:07:12 -0800 Subject: [PATCH 07/63] [Docs] Update README to better reflect the reality of the library's development --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 33659975..3f159dc4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

SSWG Maturity Apache 2 License - Swift 5.1+ + Swift 5.2+ Redis 5.0

@@ -32,7 +32,7 @@ The table below lists the major releases alongside their compatible language, de | RediStack Release | [Swift](https://swift.org/download) | [Redis](https://redis.io) | [SwiftNIO](https://github.com/apple/swift-nio) | [SwiftLog](https://github.com/apple/swift-log) | [SwiftMetrics](https://github.com/apple/swift-metrics) | |:-----------------:|:-----------------------------------:|:-------------------------:|:----------------------------------------------:|:----------------------------------------------:|:------------------------------:| | `from: "1.0.0"` | 5.1+ | 3.x**¹** ..< 6.x | 2.x | 1.x | 1.x ..< 3.0 | -| `from: "2.0.0"` | 5.2+ | 3.x**¹** ... 6.x | 2.x | 1.x | 1.x ..< 3.0 | +|`.branch("master")`| 5.2+ | 3.x**¹** ... 6.x | 2.x | 1.x | 1.x ..< 3.0 | > **¹** _Use of newer Redis features on older Redis versions is done at your own risk. See Redis' release notes for [v6](https://raw.githubusercontent.com/antirez/redis/6.0/00-RELEASENOTES), [v5](https://raw.githubusercontent.com/antirez/redis/5.0/00-RELEASENOTES), [v4](https://raw.githubusercontent.com/antirez/redis/4.0/00-RELEASENOTES), and [v3](https://raw.githubusercontent.com/antirez/redis/3.0/00-RELEASENOTES) for what is supported for each version of Redis._ @@ -48,7 +48,7 @@ To install **RediStack**, just add the package as a dependency in your **Package ```swift dependencies: [ - .package(url: "https://gitlab.com/mordil/RediStack.git", from: "2.0.0") + .package(url: "https://gitlab.com/mordil/RediStack.git", .branch("master")) ] ``` From 79d0fd86521edb0be8feefe6a2cf9cb2f2856aa8 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 16 Feb 2021 11:14:36 -0800 Subject: [PATCH 08/63] [Docs] Update README to use emoji instead of markdown for docsgen --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3f159dc4..0696a8b5 100644 --- a/README.md +++ b/README.md @@ -118,30 +118,30 @@ This policy is to balance the desire for as much backwards compatibility as poss The following table shows the combination of Swift language versions and operating systems that receive regular unit testing (either in development, or with CI) against the **current version** of **RediStack**. -| Platform | Swift 5.2 | 5.3 | 5.4 | Trunk | -|:----------------------|:------------------:|:------------------:|:------------------:|:------------------:| -| macOS Latest (Intel) | | | :white_check_mark: | | -| Ubuntu 20.04 (Focal) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Amazon Linux 2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| CentOS 8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| CentOS 7 | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Platform | Swift 5.2 | 5.3 | 5.4 | Trunk | +|:----------------------|:---------:|:---:|:---:|:-----:| +| macOS Latest (Intel) | | | ✅ | | +| Ubuntu 20.04 (Focal) | ✅ | ✅ | ✅ | ✅ | +| Ubuntu 18.04 (Bionic) | ✅ | ✅ | ✅ | ✅ | +| Ubuntu 16.04 (Xenial) | ✅ | ✅ | ✅ | ✅ | +| Amazon Linux 2 | ✅ | ✅ | ✅ | ✅ | +| CentOS 8 | ✅ | ✅ | ✅ | ✅ | +| CentOS 7 | ✅ | ✅ | ✅ | ✅ | For older versions of **RediStack**, view each summary below.
RediStack 1.x -| Platform | Swift 5.1 | 5.2 | 5.3 | -|:----------------------|:------------------:|:------------------:|:------------------:| -| macOS Latest (Intel) | | | | -| Ubuntu 20.04 (Focal) | | :white_check_mark: | :white_check_mark: | -| Ubuntu 18.04 (Bionic) | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Ubuntu 16.04 (Xenial) | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Amazon Linux 2 | | :white_check_mark: | :white_check_mark: | -| CentOS 7 | | :white_check_mark: | :white_check_mark: | -| CentOS 8 | | :white_check_mark: | :white_check_mark: | +| Platform | Swift 5.1 | 5.2 | 5.3 | +|:----------------------|:---------:|:---:|:---:| +| macOS Latest (Intel) | | | | +| Ubuntu 20.04 (Focal) | | ✅ | ✅ | +| Ubuntu 18.04 (Bionic) | ✅ | ✅ | ✅ | +| Ubuntu 16.04 (Xenial) | ✅ | ✅ | ✅ | +| Amazon Linux 2 | | ✅ | ✅ | +| CentOS 7 | | ✅ | ✅ | +| CentOS 8 | | ✅ | ✅ |
From 5b05e263007fa13db65405af649e3704f5430953 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Mon, 22 Feb 2021 11:08:04 -0800 Subject: [PATCH 09/63] Remove 1.x deprecation support --- Sources/RediStack/_Deprecations.swift | 130 +----------------- .../RediStackTestUtils/_Deprecations.swift | 37 +---- 2 files changed, 2 insertions(+), 165 deletions(-) diff --git a/Sources/RediStack/_Deprecations.swift b/Sources/RediStack/_Deprecations.swift index da3e33be..1ee13179 100644 --- a/Sources/RediStack/_Deprecations.swift +++ b/Sources/RediStack/_Deprecations.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2021 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -11,131 +11,3 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// - -import Logging -import NIO - -extension RedisConnection { - /// The documented default port that Redis connects through. - /// - /// See [https://redis.io/topics/quickstart](https://redis.io/topics/quickstart) - @available(*, deprecated, message: "Use RedisConnection.Configuration.defaultPort") - public static var defaultPort: Int { Configuration.defaultPort } - - /// Creates a new connection to a Redis instance. - /// - /// If you would like to specialize the `NIO.ClientBootstrap` that the connection communicates on, override the default by passing it in as `tcpClient`. - /// - /// let eventLoopGroup: EventLoopGroup = ... - /// var customTCPClient = ClientBootstrap.makeRedisTCPClient(group: eventLoopGroup) - /// customTCPClient.channelInitializer { channel in - /// // channel customizations - /// } - /// let connection = RedisConnection.connect( - /// to: ..., - /// on: eventLoopGroup.next(), - /// password: ..., - /// tcpClient: customTCPClient - /// ).wait() - /// - /// It is recommended that you be familiar with `ClientBootstrap.makeRedisTCPClient(group:)` and `NIO.ClientBootstrap` in general before doing so. - /// - /// Note: Use of `wait()` in the example is for simplicity. Never call `wait()` on an event loop. - /// - /// - Important: Call `close()` on the connection before letting the instance deinit to properly cleanup resources. - /// - Note: If a `password` is provided, the connection will send an "AUTH" command to Redis as soon as it has been opened. - /// - /// - Parameters: - /// - socket: The `NIO.SocketAddress` information of the Redis instance to connect to. - /// - eventLoop: The `NIO.EventLoop` that this connection will execute all tasks on. - /// - password: The optional password to use for authorizing the connection with Redis. - /// - logger: The `Logging.Logger` instance to use for all client logging purposes. If one is not provided, one will be created. - /// A `Foundation.UUID` will be attached to the metadata to uniquely identify this connection instance's logs. - /// - tcpClient: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `makeRedisTCPClient` instance. - /// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection after it has been opened, and if a `password` is provided, authenticated. - @available(*, deprecated, message: "Use make(configuration:boundEventLoop:configuredTCPClient:) instead") - public static func connect( - to socket: SocketAddress, - on eventLoop: EventLoop, - password: String? = nil, - logger: Logger = .redisBaseConnectionLogger, - tcpClient: ClientBootstrap? = nil - ) -> EventLoopFuture { - let config: Configuration - do { - config = try .init( - address: socket, - password: password, - defaultLogger: logger - ) - } catch { - return eventLoop.makeFailedFuture(error) - } - - return self.make(configuration: config, boundEventLoop: eventLoop, configuredTCPClient: tcpClient) - } -} - -extension RedisConnectionPool { - /// Create a new `RedisConnectionPool`. - /// - /// - parameters: - /// - serverConnectionAddresses: The set of Redis servers to which this pool is initially willing to connect. - /// This set can be updated over time. - /// - loop: The event loop to which this pooled client is tied. - /// - maximumConnectionCount: The maximum number of connections to for this pool, either to be preserved or as a hard limit. - /// - minimumConnectionCount: The minimum number of connections to preserve in the pool. If the pool is mostly idle - /// and the Redis servers close these idle connections, the `RedisConnectionPool` will initiate new outbound - /// connections proactively to avoid the number of available connections dropping below this number. Defaults to `1`. - /// - connectionPassword: The password to use to connect to the Redis servers in this pool. - /// - connectionLogger: The `Logger` to pass to each connection in the pool. - /// - connectionTCPClient: The base `ClientBootstrap` to use to create pool connections, if a custom one is in use. - /// - poolLogger: The `Logger` used by the connection pool itself. - /// - connectionBackoffFactor: Used when connection attempts fail to control the exponential backoff. This is a multiplicative - /// factor, each connection attempt will be delayed by this amount times the previous delay. - /// - initialConnectionBackoffDelay: If a TCP connection attempt fails, this is the first backoff value on the reconnection attempt. - /// Subsequent backoffs are computed by compounding this value by `connectionBackoffFactor`. - /// - connectionRetryTimeout: The max time to wait for a connection to be available before failing a particular command or connection operation. - /// The default is 60 seconds. - @available(*, deprecated, message: "Use .init(configuration:boundEventLoop:) instead.") - public convenience init( - serverConnectionAddresses: [SocketAddress], - loop: EventLoop, - maximumConnectionCount: RedisConnectionPoolSize, - minimumConnectionCount: Int = 1, - connectionPassword: String? = nil, // config - connectionLogger: Logger = .redisBaseConnectionLogger, // config - connectionTCPClient: ClientBootstrap? = nil, - poolLogger: Logger = .redisBaseConnectionPoolLogger, - connectionBackoffFactor: Float32 = 2, - initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), - connectionRetryTimeout: TimeAmount? = .seconds(60) - ) { - self.init( - configuration: Configuration( - initialServerConnectionAddresses: serverConnectionAddresses, - maximumConnectionCount: maximumConnectionCount, - connectionFactoryConfiguration: ConnectionFactoryConfiguration( - connectionPassword: connectionPassword, - connectionDefaultLogger: connectionLogger, - tcpClient: connectionTCPClient - ), - minimumConnectionCount: minimumConnectionCount, - connectionBackoffFactor: connectionBackoffFactor, - initialConnectionBackoffDelay: initialConnectionBackoffDelay, - connectionRetryTimeout: connectionRetryTimeout, - poolDefaultLogger: poolLogger - ), - boundEventLoop: loop - ) - } -} - -// MARK: - RedisKeyLifetime -@available(*, deprecated, message: "renamed to RedisKey.Lifetime") -public typealias RedisKeyLifetime = RedisKey.Lifetime - -extension RedisKey.Lifetime { - @available(*, deprecated, message: "renamed to Duration") - public typealias Lifetime = Duration -} diff --git a/Sources/RediStackTestUtils/_Deprecations.swift b/Sources/RediStackTestUtils/_Deprecations.swift index c77f24a5..1ee13179 100644 --- a/Sources/RediStackTestUtils/_Deprecations.swift +++ b/Sources/RediStackTestUtils/_Deprecations.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2021 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -11,38 +11,3 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// - -import NIO -import RediStack - -extension RedisConnection { - /// A default hostname of `localhost` to try and connect to Redis at. - @available(*, deprecated, message: "Use RedisConnection.Configuration.defaultHostname") - public static let defaultHostname = "localhost" - - /// Creates a connection intended for tests using `REDIS_URL` and `REDIS_PW` environment variables if available. - /// - /// The default URL is `127.0.0.1` while the default port is `RedisConnection.defaultPort`. - /// - /// If `REDIS_PW` is not defined, no authentication will happen on the connection. - /// - Parameters: - /// - eventLoop: The event loop that the connection should execute on. - /// - port: The port to connect on. - /// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection. - @available(*, deprecated, message: "Use RedisConnection.make(configuration:boundEventLoop:) method") - public static func connect( - on eventLoop: EventLoop, - host: String = RedisConnection.defaultHostname, - port: Int = RedisConnection.Configuration.defaultPort, - password: String? = nil - ) -> EventLoopFuture { - let address: SocketAddress - do { - address = try SocketAddress.makeAddressResolvingHost(host, port: port) - } catch { - return eventLoop.makeFailedFuture(error) - } - - return RedisConnection.connect(to: address, on: eventLoop, password: password) - } -} From 338a6f4aa137644e3013d19c5648bc9731bdfc4a Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Tue, 23 Feb 2021 10:13:18 +0000 Subject: [PATCH 10/63] Delay connection attempts without addresses. In some circumstances users may have connection pools configured without any SocketAddresses ready to go. This is particularly likely in service discovery configurations. Right now, the effect of attempting to use such a pool is two fold. First, we'll emit a bunch of error level logs telling users we have no addresses. Second, we'll fall into the exponential backoff phase of connection establishment. The first property is annoying, but the second one is actively harmful. If your construction is timed incorrectly, we'll have the awkward problem of burning a bunch of CPU trying to create connections we know we cannot, and then a lengthy delay after the addresses are actually configured before we start trying to use them. That's the worst of all worlds. This patch adds logic to detect the attempt to create connections when we don't have any configured addresses and delays them. This path should improve performance and ergonomics when in this mode. --- .../ConnectionPool/ConnectionPool.swift | 2 +- Sources/RediStack/RedisConnectionPool.swift | 34 ++++++++++++- ...disConnectionPoolIntegrationTestCase.swift | 8 ++- .../RedisConnectionPoolTests.swift | 51 +++++++++++++++++++ 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/Sources/RediStack/ConnectionPool/ConnectionPool.swift b/Sources/RediStack/ConnectionPool/ConnectionPool.swift index ce7fd139..d83152c5 100644 --- a/Sources/RediStack/ConnectionPool/ConnectionPool.swift +++ b/Sources/RediStack/ConnectionPool/ConnectionPool.swift @@ -385,7 +385,7 @@ extension ConnectionPool { } self.connectionWaiters.append(waiter) - // What are we going to wait for? Well, now we check. If the number of active connections is + // Ok, we have connection targets. If the number of active connections is // below the max, or the pool is leaky, we can create a new connection. Otherwise, we just have // to wait for a connection to come back. if self.activeConnectionCount < self.maximumConnectionCount || self.leaky { diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 9040c492..aafde581 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -47,6 +47,14 @@ public class RedisConnectionPool { private var serverConnectionAddresses: ConnectionAddresses // This needs to be a var because its value changes as the pool enters/leaves pubsub mode to reuse the same connection. private var pubsubConnection: RedisConnection? + // This array buffers any request for a connection that cannot be succeeded right away in the case where we have no target. + // We never allow this to get larger than a specific bound, to resist DoS attacks. Past that bound we will fast-fail. + private var requestsForConnections: [EventLoopPromise] = [] + + /// The maximum number of connection requests we'll buffer in `requestsForConnections` before we start fast-failing. These + /// are buffered only when there are no available addresses to connect to, so in practice it's highly unlikely this will be + /// hit, but either way, 100 concurrent connection requests ought to be plenty in this case. + private static let maximumBufferedConnectionRequests = 100 public init(configuration: Configuration, boundEventLoop: EventLoop) { var config = configuration @@ -109,6 +117,11 @@ extension RedisConnectionPool { // This breaks the cycle between us and the pool. self.pool = nil + + // Drop all pending connection attempts. No need to empty this manually, it'll get dropped regardless. + for request in self.requestsForConnections { + request.fail(RedisConnectionPoolError.poolClosed) + } } } @@ -171,6 +184,14 @@ extension RedisConnectionPool { self.loop.execute { self.serverConnectionAddresses.update(newAddresses) + + // Shiny, we can unbuffer any pending connections and pass them on as they now have somewhere to go. + let unbufferedRequests = self.requestsForConnections + self.requestsForConnections = [] + + for request in unbufferedRequests { + request.completeWith(self.connectionFactory(self.loop)) + } } } @@ -182,8 +203,17 @@ extension RedisConnectionPool { let factoryConfig = self.configuration.factoryConfiguration guard let nextTarget = self.serverConnectionAddresses.nextTarget() else { - // No valid connection target, we'll fail. - return targetLoop.makeFailedFuture(RedisConnectionPoolError.noAvailableConnectionTargets) + // No valid connection target, we'll keep track of the request and attempt to satisfy it later. + // First, confirm we have space to keep track of this. If not, fast-fail. + guard self.requestsForConnections.count < RedisConnectionPool.maximumBufferedConnectionRequests else { + return targetLoop.makeFailedFuture(RedisConnectionPoolError.noAvailableConnectionTargets) + } + + // Ok, we can buffer, let's do that. + self.prepareLoggerForUse(nil).notice("waiting for target addresses") + let promise = targetLoop.makePromise(of: RedisConnection.self) + self.requestsForConnections.append(promise) + return promise.futureResult } let connectionConfig: RedisConnection.Configuration diff --git a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift index 3240b401..4aab3a64 100644 --- a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import Logging import NIO import RediStack import XCTest @@ -75,16 +76,19 @@ open class RedisConnectionPoolIntegrationTestCase: XCTestCase { } public func makeNewPool( + initialAddresses: [SocketAddress]? = nil, + initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), connectionRetryTimeout: TimeAmount? = .seconds(5), minimumConnectionCount: Int = 0 ) throws -> RedisConnectionPool { - let address = try SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort) + let addresses = try initialAddresses ?? [SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort)] let pool = RedisConnectionPool( configuration: .init( - initialServerConnectionAddresses: [address], + initialServerConnectionAddresses: addresses, maximumConnectionCount: .maximumActiveConnections(4), connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword), minimumConnectionCount: minimumConnectionCount, + initialConnectionBackoffDelay: initialConnectionBackoffDelay, connectionRetryTimeout: connectionRetryTimeout ), boundEventLoop: self.eventLoopGroup.next() diff --git a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift index 79d9c6b3..1e5e3107 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift @@ -43,8 +43,59 @@ final class RedisConnectionPoolTests: RediStackConnectionPoolIntegrationTestCase func test_nilConnectionRetryTimeoutStillWorks() throws { let pool = try self.makeNewPool(connectionRetryTimeout: nil) + defer { pool.close() } XCTAssertNoThrow(try pool.get(#function).wait()) } + + func test_noConnectionAttemptsUntilAddressesArePresent() throws { + // Note the config here: we have no initial addresses, the connecton backoff delay is 10 seconds, and the retry timeout is only 5 seconds. + // The effect of this config is that if we fail a connection attempt, we'll fail it forever. + let pool = try self.makeNewPool(initialAddresses: [], initialConnectionBackoffDelay: .seconds(10), connectionRetryTimeout: .seconds(5), minimumConnectionCount: 0) + defer { pool.close() } + + // As above we're gonna try to insert a bunch of elements into a set. This time, + // the pool has no addresses yet. We expect that when we add an address later everything will work nicely. + // We do fewer here. + let operations = (0..<10).map { number in + pool.send(.sadd([number], to: #function)) + } + + // Now that we've kicked those off, let's hand over a new address. + try pool.updateConnectionAddresses([SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort)]) + + // We should get the results. + let results = try EventLoopFuture.whenAllSucceed(operations, on: self.eventLoopGroup.next()).wait() + XCTAssertEqual(results, Array(repeating: 1, count: 10)) + } + + func testDelayedConnectionsFailOnClose() throws { + // Note the config here: we have no initial addresses, the connecton backoff delay is 10 seconds, and the retry timeout is only 5 seconds. + // The effect of this config is that if we fail a connection attempt, we'll fail it forever. + let pool = try self.makeNewPool(initialAddresses: [], initialConnectionBackoffDelay: .seconds(10), connectionRetryTimeout: .seconds(5), minimumConnectionCount: 0) + defer { pool.close() } + + // As above we're gonna try to insert a bunch of elements into a set. This time, + // the pool has no addresses yet. We expect that when we add an address later everything will work nicely. + // We do fewer here. + let operations = (0..<10).map { number in + pool.send(.sadd([number], to: #function)) + } + + // Now that we've kicked those off, let's close. + pool.close() + + let results = try EventLoopFuture.whenAllComplete(operations, on: self.eventLoopGroup.next()).wait() + for result in results { + switch result { + case .success: + XCTFail("Request succeeded") + case .failure(let error) where error as? RedisConnectionPoolError == .poolClosed: + () // Pass + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + } } // MARK: Leasing a connection From 1168ed09f7d667accb54be07d735027064de49f9 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Mon, 1 Jun 2020 13:14:19 +0100 Subject: [PATCH 11/63] Add support for service discovery. The newly-released Service Discovery framework gives us the interesting opportunity to make RediStack aware of complex service discovery tools. This patch supplies a simple adaptor to integrat Service Discovery with RediStack's pooled client, allowing users to work with arbitrary service discovery systems. --- Package.swift | 9 ++- Sources/RediStack/RedisConnectionPool.swift | 70 +++++++++++++++++++ Sources/RediStack/RedisLogging.swift | 2 + .../RedisLoggingTests.swift | 34 +++++++++ .../RedisServiceDiscoveryTests.swift | 56 +++++++++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift diff --git a/Package.swift b/Package.swift index d2fa54b0..c21086cc 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0") + .package(url: "https://github.com/apple/swift-nio.git", from: "2.18.0"), + .package(url: "https://github.com/apple/swift-service-discovery", from: "1.0.0"), ], targets: [ .target( @@ -33,7 +34,8 @@ let package = Package( dependencies: [ .product(name: "NIO", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), - .product(name: "Metrics", package: "swift-metrics") + .product(name: "Metrics", package: "swift-metrics"), + .product(name: "ServiceDiscovery", package: "swift-service-discovery") ] ), .testTarget( @@ -66,7 +68,8 @@ let package = Package( name: "RediStackIntegrationTests", dependencies: [ "RediStack", "RediStackTestUtils", - .product(name: "NIO", package: "swift-nio") + .product(name: "NIO", package: "swift-nio"), + .product(name: "ServiceDiscovery", package: "swift-service-discovery") ] ) ] diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index aafde581..6932b2ce 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -15,6 +15,7 @@ import struct Foundation.UUID import NIO import NIOConcurrencyHelpers import Logging +import ServiceDiscovery /// A `RedisConnectionPool` is an implementation of `RedisClient` backed by a pool of connections to Redis, /// rather than a single one. @@ -50,6 +51,15 @@ public class RedisConnectionPool { // This array buffers any request for a connection that cannot be succeeded right away in the case where we have no target. // We never allow this to get larger than a specific bound, to resist DoS attacks. Past that bound we will fast-fail. private var requestsForConnections: [EventLoopPromise] = [] + // This is var because if we're using service discovery, we don't start doing that until activate is called. + private var cancellationToken: CancellationToken? { + willSet { + guard let token = self.cancellationToken, !token.isCancelled, token !== newValue else { + return + } + token.cancel() + } + } /// The maximum number of connection requests we'll buffer in `requestsForConnections` before we start fast-failing. These /// are buffered only when there are no available addresses to connect to, so in practice it's highly unlikely this will be @@ -88,6 +98,33 @@ public class RedisConnectionPool { } } +// MARK: Alternative initializers +extension RedisConnectionPool { + /// Constructs a `RedisConnectionPool` that updates its addresses based on information from + /// service discovery. + /// + /// This constructor behaves similarly to the regular constructor. However, it also activates the + /// connection pool before returning it to the user. This is necessary because the act of subscribing + /// to service discovery forms a reference cycle between the service discovery instance and the + /// `RedisConnectionPool`. Pools constructed via this constructor _must_ always have `close` called + /// on them. + /// + /// Pools created via this constructor will be auto-closed when the service discovery instance is completed for + /// any reason, including on error. Users should still always call `close` in their own code during teardown. + public static func activatedServiceDiscoveryPool( + service: Discovery.Service, + discovery: Discovery, + configuration: Configuration, + boundEventLoop: EventLoop, + logger: Logger? = nil + ) -> RedisConnectionPool where Discovery.Instance == SocketAddress { + let pool = RedisConnectionPool(configuration: configuration, boundEventLoop: boundEventLoop) + pool.beginSubscribingToServiceDiscovery(service: service, discovery: discovery, logger: logger) + pool.activate(logger: logger) + return pool + } +} + // MARK: General helpers. extension RedisConnectionPool { /// Starts the connection pool. @@ -122,6 +159,9 @@ extension RedisConnectionPool { for request in self.requestsForConnections { request.fail(RedisConnectionPoolError.poolClosed) } + + // This cancels service discovery. + self.cancellationToken = nil } } @@ -247,6 +287,36 @@ extension RedisConnectionPool { logger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" return logger } + + /// A private helper function used for the service discovery constructor. + private func beginSubscribingToServiceDiscovery( + service: Discovery.Service, + discovery: Discovery, + logger: Logger? + ) where Discovery.Instance == SocketAddress { + self.loop.execute { + let logger = self.prepareLoggerForUse(logger) + + self.cancellationToken = discovery.subscribe( + to: service, + onNext: { result in + // This closure may execute on any thread. + self.loop.execute { + switch result { + case .success(let targets): + self.updateConnectionAddresses(targets, logger: logger) + case .failure(let error): + logger.error("Service discovery error", metadata: [RedisLogging.MetadataKeys.error: "\(error)"]) + } + } + }, + onComplete: { (_: CompletionReason) in + // We don't really care about the reason, we just want to brick this client. + self.close(logger: logger) + } + ) + } + } } // MARK: RedisClient conformance diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index 99a90ef6..659635df 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -21,6 +21,7 @@ public enum RedisLogging { public struct Labels { public static var connection: String { "RediStack.RedisConnection" } public static var connectionPool: String { "RediStack.RedisConnectionPool" } + public static var serviceDiscovery: String { "RediStack.RedisServiceDiscoveryClient" } } /// The key values used in RediStack for storing `Logging.Logger.Metadata` in log messages. /// @@ -53,6 +54,7 @@ public enum RedisLogging { public static let baseConnectionLogger = Logger(label: Labels.connection) public static let baseConnectionPoolLogger = Logger(label: Labels.connectionPool) + public static let baseServiceDiscoveryLogger = Logger(label: Labels.serviceDiscovery) } // MARK: Logger integration diff --git a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift index 26452202..5cfc3f30 100644 --- a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift +++ b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift @@ -13,6 +13,8 @@ //===----------------------------------------------------------------------===// import Logging +import ServiceDiscovery +import NIO import RediStack import RediStackTestUtils import XCTest @@ -67,6 +69,38 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { .string(pool.id.uuidString) ) } + + func test_serviceDiscoveryMetadata() throws { + let handler = TestLogHandler() + let logger = Logger(label: #function, factory: { _ in return handler }) + let hosts = InMemoryServiceDiscovery(configuration: .init()) + let config = RedisConnectionPool.Configuration( + initialServerConnectionAddresses: [], + maximumConnectionCount: .maximumActiveConnections(1), + connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword) + ) + let client = RedisConnectionPool.activatedServiceDiscoveryPool( + service: "default.local", + discovery: hosts, + configuration: config, + boundEventLoop: self.connection.eventLoop) + defer { + client.close() + } + + let address = try SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort) + hosts.register("default.local", instances: [address]) + + _ = try client + .logging(to: logger) + .ping() + .wait() + XCTAssertTrue(handler.metadata.keys.contains(RedisLogging.MetadataKeys.connectionID)) + XCTAssertEqual( + handler.metadata[RedisLogging.MetadataKeys.connectionPoolID], + .string(client.id.uuidString) + ) + } } final class TestLogHandler: LogHandler { diff --git a/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift b/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift new file mode 100644 index 00000000..3b769f33 --- /dev/null +++ b/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2020 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import ServiceDiscovery +import NIO +import Logging +@testable import RediStack +import RediStackTestUtils +import XCTest + +final class RedisServiceDiscoveryTests: RediStackConnectionPoolIntegrationTestCase { + func test_basicServiceDiscovery() throws { + let hosts = InMemoryServiceDiscovery(configuration: .init()) + let config = RedisConnectionPool.Configuration( + initialServerConnectionAddresses: [], + maximumConnectionCount: .maximumActiveConnections(5), + connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword), + minimumConnectionCount: 1 + ) + let client = RedisConnectionPool.activatedServiceDiscoveryPool( + service: "default.local", + discovery: hosts, + configuration: config, + boundEventLoop: self.eventLoopGroup.next() + ) + defer { + client.close() + } + + let address = try SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort) + hosts.register("default.local", instances: [address]) + hosts.register("another.local", instances: []) + + // Now we try to make a bunch of requests. + // We're going to insert a bunch of elements into a set, and then when all is done confirm that every + // element exists. + let operations = (0..<50).map { number in + client.send(.sadd([number], to: #function)) + } + let results = try EventLoopFuture.whenAllSucceed(operations, on: self.eventLoopGroup.next()).wait() + XCTAssertEqual(results, Array(repeating: 1, count: 50)) + let whatRedisThinks = try client.send(.smembers(of: #function)).wait() + XCTAssertEqual(whatRedisThinks.compactMap { $0.int }.sorted(), Array(0..<50)) + } +} From c8637c1ffa5ce222ddce82c862dca7a02309c1ff Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Mon, 8 Mar 2021 10:51:15 -0800 Subject: [PATCH 12/63] [Docs] Update README to include new dependency --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0696a8b5..2fd87d9c 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ This library is primarily developed for Redis v5, but is backwards compatible to The table below lists the major releases alongside their compatible language, dependency, and Redis versions. -| RediStack Release | [Swift](https://swift.org/download) | [Redis](https://redis.io) | [SwiftNIO](https://github.com/apple/swift-nio) | [SwiftLog](https://github.com/apple/swift-log) | [SwiftMetrics](https://github.com/apple/swift-metrics) | -|:-----------------:|:-----------------------------------:|:-------------------------:|:----------------------------------------------:|:----------------------------------------------:|:------------------------------:| -| `from: "1.0.0"` | 5.1+ | 3.x**¹** ..< 6.x | 2.x | 1.x | 1.x ..< 3.0 | -|`.branch("master")`| 5.2+ | 3.x**¹** ... 6.x | 2.x | 1.x | 1.x ..< 3.0 | +| RediStack Release | [Swift](https://swift.org/download) | [Redis](https://redis.io) | [SwiftNIO](https://github.com/apple/swift-nio) | [SwiftLog](https://github.com/apple/swift-log) | [SwiftMetrics](https://github.com/apple/swift-metrics) | [ServiceDiscovery](https://github.com/apple/swift-service-discovery) | +|:--------------------|:----:|:----------------:|:---:|:---:|:-----------:|:---:| +| `from: "1.0.0"` | 5.1+ | 3.x**¹** ..< 6.x | 2.x | 1.x | 1.x ..< 3.0 | - | +| `.branch("master")` | 5.2+ | 3.x**¹** ... 6.x | 2.x | 1.x | 1.x ..< 3.0 | 1.x | > **¹** _Use of newer Redis features on older Redis versions is done at your own risk. See Redis' release notes for [v6](https://raw.githubusercontent.com/antirez/redis/6.0/00-RELEASENOTES), [v5](https://raw.githubusercontent.com/antirez/redis/5.0/00-RELEASENOTES), [v4](https://raw.githubusercontent.com/antirez/redis/4.0/00-RELEASENOTES), and [v3](https://raw.githubusercontent.com/antirez/redis/3.0/00-RELEASENOTES) for what is supported for each version of Redis._ From 328ef17c2c92a9ff40937c4cc5bc6af8b21710e6 Mon Sep 17 00:00:00 2001 From: Peter Adams Date: Tue, 16 Mar 2021 15:48:41 +0000 Subject: [PATCH 13/63] Correct minor typo in documentation --- Sources/RediStack/Commands/SetCommands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/RediStack/Commands/SetCommands.swift b/Sources/RediStack/Commands/SetCommands.swift index 99a77659..29195e24 100644 --- a/Sources/RediStack/Commands/SetCommands.swift +++ b/Sources/RediStack/Commands/SetCommands.swift @@ -246,7 +246,7 @@ extension RedisCommand { // MARK: - extension RedisClient { - /// Incrementally iterates over allv alues in a set. + /// Incrementally iterates over all values in a set. /// /// See `RedisCommand.sscan(_:startingFrom:matching:count:)` /// - Parameters: From 03a066f8f5cabc095218d8ffaeb923387b141615 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Thu, 8 Apr 2021 20:15:42 -0700 Subject: [PATCH 14/63] Audit log message severity levels Motivation: Following the SSWG guidelines for libraries and log levels, because much of the library's behavior is expressed in the language and NIO framework as errors and failed ELFs, logging at error is "verbose" and takes away control from developers. Modifications: Log messages have been adjusted to more accurately represent when and how the log message should be used, especially when ELFs are failed or errors are thrown. Result: Developers won't have log messages at error or critical unless they opt-in from their own code, unless the library has no way of expressing the failure condition through the language. --- Sources/RediStack/ConnectionPool/ConnectionPool.swift | 2 +- Sources/RediStack/RedisConnection.swift | 6 +++--- docs/api-design/Logging.md | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/RediStack/ConnectionPool/ConnectionPool.swift b/Sources/RediStack/ConnectionPool/ConnectionPool.swift index d83152c5..738c334b 100644 --- a/Sources/RediStack/ConnectionPool/ConnectionPool.swift +++ b/Sources/RediStack/ConnectionPool/ConnectionPool.swift @@ -246,7 +246,7 @@ extension ConnectionPool { private func connectionCreationFailed(_ error: Error, backoff: TimeAmount, logger: Logger) { self.loop.assertInEventLoop() - logger.error("failed to create connection for pool", metadata: [ + logger.warning("failed to create connection for pool", metadata: [ RedisLogging.MetadataKeys.error: "\(error)" ]) diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index 7f093f87..b94da9f9 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -180,7 +180,7 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { guard self.state.isConnected else { return } self.state = .closed - self.logger.error("connection was closed unexpectedly") + self.logger.warning("connection was closed unexpectedly") RedisMetrics.activeConnectionCount.decrement() } @@ -239,7 +239,7 @@ extension RedisConnection { // log data based on the result switch result { case let .failure(error): - logger.error("command failed", metadata: [ + logger.debug("command failed", metadata: [ RedisLogging.MetadataKeys.error: "\(error.localizedDescription)" ]) @@ -300,7 +300,7 @@ extension RedisConnection { .flatMap { self.closeChannel() } // close the channel from our end notification.whenFailure { - logger.error("error while closing connection", metadata: [ + logger.warning("failed to close connection", metadata: [ RedisLogging.MetadataKeys.error: "\($0)" ]) } diff --git a/docs/api-design/Logging.md b/docs/api-design/Logging.md index 81c6f0ce..da13265a 100644 --- a/docs/api-design/Logging.md +++ b/docs/api-design/Logging.md @@ -57,11 +57,11 @@ connection 1. All log metadata keys should be added to the `RedisLogging` namespace 1. Log messages should be in all lowercase, with no punctuation preferred - if a Redis command keyword (such as `QUIT`) is in the log message, it should be in all caps -1. Log a `critical` message before any `precondition` failure -1. Prefer single locations of `error` messages - - for example, only the top level `send` command on `RedisConnection` should log the error returned from Redis or from a failed `EventLoopFuture` -1. `warning` logs should be reserved for situations that could lead to `critical` conditions +1. `warning` logs should be reserved for situations that could lead to `error` or `critical` conditions - this may include leaks or bad state +1. Only use `error` in situations where the error cannot be expressed by the language, such as by throwing an error or failing `EventLoopFuture`s. + - this is to avoid high severity logs that developers cannot control and must create filtering mechanisms if they want to ignore emitted logs from **RediStack** +1. Log a `critical` message before any `preconditionFailure` or `fatalError` ### Metadata From 0c13e4f26cc901d21832540cf636eb8d4b62e167 Mon Sep 17 00:00:00 2001 From: Peter Adams Date: Wed, 28 Apr 2021 13:45:06 +0100 Subject: [PATCH 15/63] Get scan working on the same as redis-cli 6.2.1 --- Sources/RediStack/Commands/RedisCommand.swift | 4 +- .../Commands/KeyCommandsTests.swift | 50 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Sources/RediStack/Commands/RedisCommand.swift b/Sources/RediStack/Commands/RedisCommand.swift index 74e4df86..c9c180cc 100644 --- a/Sources/RediStack/Commands/RedisCommand.swift +++ b/Sources/RediStack/Commands/RedisCommand.swift @@ -96,8 +96,8 @@ extension RedisCommand { var args: [RESPValue] = [.init(bulk: pos)] if let k = key { args.insert(.init(from: k), at: 0) } - if let m = match { args.append(.init(bulk: "match \(m)")) } - if let c = count { args.append(.init(bulk: "count \(c)")) } + if let m = match { args.append(contentsOf: [.init(bulk: "match"), .init(bulk: "\(m)")]) } + if let c = count { args.append(contentsOf: [.init(bulk: "count"), .init(bulk: "\(c)")]) } return .init(keyword: keyword, arguments: args) { let response = try $0.map(to: [RESPValue].self) diff --git a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift index 536af770..277e1f5a 100644 --- a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift @@ -125,29 +125,29 @@ final class KeyCommandsTests: RediStackIntegrationTestCase { // TODO: #23 -- Rework Scan Unit Test // This is extremely flakey, and causes non-deterministic failures because of the assert on key counts -// func test_scan() throws { -// var dataset: [RedisKey] = .init(repeating: "", count: 10) -// for index in 1...15 { -// let key = RedisKey("key\(index)\(index % 2 == 0 ? "_even" : "_odd")") -// dataset.append(key) -// _ = try connection.set(key, to: "\(index)").wait() -// } -// -// var (cursor, keys) = try connection.scan(count: 5).wait() -// XCTAssertGreaterThanOrEqual(cursor, 0) -// XCTAssertGreaterThanOrEqual(keys.count, 5) -// -// (_, keys) = try connection.scan(startingFrom: cursor, count: 8).wait() -// XCTAssertGreaterThanOrEqual(keys.count, 8) -// -// (cursor, keys) = try connection.scan(matching: "*_odd").wait() -// XCTAssertGreaterThanOrEqual(cursor, 0) -// XCTAssertGreaterThanOrEqual(keys.count, 1) -// XCTAssertLessThanOrEqual(keys.count, 7) -// -// (cursor, keys) = try connection.scan(matching: "*_even*").wait() -// XCTAssertGreaterThanOrEqual(cursor, 0) -// XCTAssertGreaterThanOrEqual(keys.count, 1) -// XCTAssertLessThanOrEqual(keys.count, 7) -// } + func test_scan() throws { + var dataset: [RedisKey] = .init(repeating: "", count: 10) + for index in 1...15 { + let key = RedisKey("key\(index)\(index % 2 == 0 ? "_even" : "_odd")") + dataset.append(key) + _ = try connection.set(key, to: "\(index)").wait() + } + + var (cursor, keys) = try connection.scanKeys(count: 5).wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(keys.count, 5) + + (_, keys) = try connection.scanKeys(startingFrom: cursor, count: 8).wait() + XCTAssertGreaterThanOrEqual(keys.count, 8) + + (cursor, keys) = try connection.scanKeys(matching: "*_odd").wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(keys.count, 1) + XCTAssertLessThanOrEqual(keys.count, 7) + + (cursor, keys) = try connection.scanKeys(matching: "*_even*").wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(keys.count, 1) + XCTAssertLessThanOrEqual(keys.count, 7) + } } From f0d123fdaa6626accad5d413dcd01f2556244614 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Thu, 29 Apr 2021 09:31:38 -0700 Subject: [PATCH 16/63] Disable scan unit tests again as they are still flaky --- .../Commands/KeyCommandsTests.swift | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift index 277e1f5a..3afb25d9 100644 --- a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift @@ -125,29 +125,29 @@ final class KeyCommandsTests: RediStackIntegrationTestCase { // TODO: #23 -- Rework Scan Unit Test // This is extremely flakey, and causes non-deterministic failures because of the assert on key counts - func test_scan() throws { - var dataset: [RedisKey] = .init(repeating: "", count: 10) - for index in 1...15 { - let key = RedisKey("key\(index)\(index % 2 == 0 ? "_even" : "_odd")") - dataset.append(key) - _ = try connection.set(key, to: "\(index)").wait() - } - - var (cursor, keys) = try connection.scanKeys(count: 5).wait() - XCTAssertGreaterThanOrEqual(cursor, 0) - XCTAssertGreaterThanOrEqual(keys.count, 5) - - (_, keys) = try connection.scanKeys(startingFrom: cursor, count: 8).wait() - XCTAssertGreaterThanOrEqual(keys.count, 8) - - (cursor, keys) = try connection.scanKeys(matching: "*_odd").wait() - XCTAssertGreaterThanOrEqual(cursor, 0) - XCTAssertGreaterThanOrEqual(keys.count, 1) - XCTAssertLessThanOrEqual(keys.count, 7) - - (cursor, keys) = try connection.scanKeys(matching: "*_even*").wait() - XCTAssertGreaterThanOrEqual(cursor, 0) - XCTAssertGreaterThanOrEqual(keys.count, 1) - XCTAssertLessThanOrEqual(keys.count, 7) - } +// func test_scan() throws { +// var dataset: [RedisKey] = .init(repeating: "", count: 10) +// for index in 1...15 { +// let key = RedisKey("key\(index)\(index % 2 == 0 ? "_even" : "_odd")") +// dataset.append(key) +// _ = try connection.set(key, to: "\(index)").wait() +// } +// +// var (cursor, keys) = try connection.scanKeys(count: 5).wait() +// XCTAssertGreaterThanOrEqual(cursor, 0) +// XCTAssertGreaterThanOrEqual(keys.count, 5) +// +// (_, keys) = try connection.scanKeys(startingFrom: cursor, count: 8).wait() +// XCTAssertGreaterThanOrEqual(keys.count, 8) +// +// (cursor, keys) = try connection.scanKeys(matching: "*_odd").wait() +// XCTAssertGreaterThanOrEqual(cursor, 0) +// XCTAssertGreaterThanOrEqual(keys.count, 1) +// XCTAssertLessThanOrEqual(keys.count, 7) +// +// (cursor, keys) = try connection.scanKeys(matching: "*_even*").wait() +// XCTAssertGreaterThanOrEqual(cursor, 0) +// XCTAssertGreaterThanOrEqual(keys.count, 1) +// XCTAssertLessThanOrEqual(keys.count, 7) +// } } From 9958e2d13b245f0682e2854905801a02e6a2b6e6 Mon Sep 17 00:00:00 2001 From: Peter Adams Date: Sun, 2 May 2021 20:39:18 +0100 Subject: [PATCH 17/63] Fix pubsub channels --- .../RediStack/Commands/PubSubCommands.swift | 7 ++-- .../Commands/PubSubCommandsTests.swift | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Sources/RediStack/Commands/PubSubCommands.swift b/Sources/RediStack/Commands/PubSubCommands.swift index 168afc93..0e1f4f9b 100644 --- a/Sources/RediStack/Commands/PubSubCommands.swift +++ b/Sources/RediStack/Commands/PubSubCommands.swift @@ -37,8 +37,11 @@ extension RedisCommand { /// - Invariant: If no `match` pattern is provided, all active channels will be returned. /// - Parameter match: An optional pattern of channel names to filter for. public static func pubsubChannels(matching match: String? = nil) -> RedisCommand<[RedisChannelName]> { - let args: [RESPValue] = match.map { [.init(bulk: $0)] } ?? [] - return .init(keyword: "PUBSUB CHANNELS", arguments: args) + var args: [RESPValue] = [.init(bulk: "CHANNELS")] + if let match = match { + args.append(.init(bulk: match)) + } + return .init(keyword: "PUBSUB", arguments: args) } /// [PUBSUB NUMPAT](https://redis.io/commands/pubsub#codepubsub-numpatcode) diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index 92b3e0c4..5e55fb81 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -189,6 +189,41 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { self.waitForExpectations(timeout: 1) } + + func test_pubSubChannels() throws { + let fn = #function + let subscriber = try self.makeNewConnection() + defer { try? subscriber.close().wait() } + + let channelNames = (1...10).map { + RedisChannelName("\(fn)\($0)\($0 % 2 == 0 ? "_even" : "_odd")") + } + + for channelName in channelNames { + try subscriber.subscribe( + to: channelName, + messageReceiver: { _, _ in }, + onSubscribe: nil, + onUnsubscribe: nil + ).wait() + } + XCTAssertTrue(subscriber.isSubscribed) + defer { + // Unsubscribe (clean up) + try? subscriber.unsubscribe(from: channelNames).wait() + XCTAssertFalse(subscriber.isSubscribed) + } + + // Make another connection to query on. + let queryConnection = try self.makeNewConnection() + defer { try? queryConnection.close().wait() } + + let oddChannels = try queryConnection.send(.pubsubChannels(matching: "\(fn)*_odd")).wait() + XCTAssertEqual(oddChannels.count, channelNames.count / 2) + + let allChannels = try queryConnection.send(.pubsubChannels()).wait() + XCTAssertGreaterThanOrEqual(allChannels.count, channelNames.count) + } } final class RedisPubSubCommandsPoolTests: RediStackConnectionPoolIntegrationTestCase { From 410a5b2d030506127dc8d47e2552b1aa1d48243d Mon Sep 17 00:00:00 2001 From: Peter Adams Date: Mon, 3 May 2021 20:43:04 +0000 Subject: [PATCH 18/63] Change the scan test to accept up to 8 odd keys This attempts to fix #23 --- .../Commands/KeyCommandsTests.swift | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift index 3afb25d9..1c47b230 100644 --- a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift @@ -122,32 +122,30 @@ final class KeyCommandsTests: RediStackIntegrationTestCase { XCTFail("Expected '.noExpiry' but lifetime was \(hasNoExpire)") } } - - // TODO: #23 -- Rework Scan Unit Test - // This is extremely flakey, and causes non-deterministic failures because of the assert on key counts -// func test_scan() throws { -// var dataset: [RedisKey] = .init(repeating: "", count: 10) -// for index in 1...15 { -// let key = RedisKey("key\(index)\(index % 2 == 0 ? "_even" : "_odd")") -// dataset.append(key) -// _ = try connection.set(key, to: "\(index)").wait() -// } -// -// var (cursor, keys) = try connection.scanKeys(count: 5).wait() -// XCTAssertGreaterThanOrEqual(cursor, 0) -// XCTAssertGreaterThanOrEqual(keys.count, 5) -// -// (_, keys) = try connection.scanKeys(startingFrom: cursor, count: 8).wait() -// XCTAssertGreaterThanOrEqual(keys.count, 8) -// -// (cursor, keys) = try connection.scanKeys(matching: "*_odd").wait() -// XCTAssertGreaterThanOrEqual(cursor, 0) -// XCTAssertGreaterThanOrEqual(keys.count, 1) -// XCTAssertLessThanOrEqual(keys.count, 7) -// -// (cursor, keys) = try connection.scanKeys(matching: "*_even*").wait() -// XCTAssertGreaterThanOrEqual(cursor, 0) -// XCTAssertGreaterThanOrEqual(keys.count, 1) -// XCTAssertLessThanOrEqual(keys.count, 7) -// } + + func test_scan() throws { + var dataset: [RedisKey] = .init(repeating: "", count: 10) + for index in 1...15 { + let key = RedisKey("key\(index)\(index % 2 == 0 ? "_even" : "_odd")") + dataset.append(key) + _ = try connection.set(key, to: "\(index)").wait() + } + + var (cursor, keys) = try connection.scanKeys(count: 5).wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(keys.count, 5) + + (_, keys) = try connection.scanKeys(startingFrom: cursor, count: 8).wait() + XCTAssertGreaterThanOrEqual(keys.count, 8) + + (cursor, keys) = try connection.scanKeys(matching: "*_odd").wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(keys.count, 1) + XCTAssertLessThanOrEqual(keys.count, 8) + + (cursor, keys) = try connection.scanKeys(matching: "*_even*").wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(keys.count, 1) + XCTAssertLessThanOrEqual(keys.count, 7) + } } From e08b42616be52a9fac37fc4ec65126cca4e5a2ee Mon Sep 17 00:00:00 2001 From: Peter Adams Date: Mon, 3 May 2021 08:53:38 +0100 Subject: [PATCH 19/63] Get pubsub numpat working --- Sources/RediStack/Commands/PubSubCommands.swift | 2 +- .../Commands/PubSubCommandsTests.swift | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/RediStack/Commands/PubSubCommands.swift b/Sources/RediStack/Commands/PubSubCommands.swift index 0e1f4f9b..d78bac4f 100644 --- a/Sources/RediStack/Commands/PubSubCommands.swift +++ b/Sources/RediStack/Commands/PubSubCommands.swift @@ -46,7 +46,7 @@ extension RedisCommand { /// [PUBSUB NUMPAT](https://redis.io/commands/pubsub#codepubsub-numpatcode) public static func pubsubNumpat() -> RedisCommand { - return .init(keyword: "PUBSUB NUMPAT", arguments: []) + return .init(keyword: "PUBSUB", arguments: [.init(bulk: "NUMPAT")]) } /// [PUBSUB NUMSUB](https://redis.io/commands/pubsub#codepubsub-numsub-channel-1--channel-ncode) diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index 5e55fb81..c6d0e6d4 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -190,6 +190,14 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { self.waitForExpectations(timeout: 1) } + func test_pubSubNumpat() throws { + let queryConnection = try self.makeNewConnection() + defer { try? queryConnection.close().wait() } + + let numPat = try queryConnection.send(.pubsubNumpat()).wait() + XCTAssertGreaterThanOrEqual(numPat, 0) + } + func test_pubSubChannels() throws { let fn = #function let subscriber = try self.makeNewConnection() From 3ca471b226903fd80a86a971b8792396738983e6 Mon Sep 17 00:00:00 2001 From: Peter Adams Date: Tue, 4 May 2021 16:26:35 +0000 Subject: [PATCH 20/63] Get pubsub numsub working --- .../RediStack/Commands/PubSubCommands.swift | 12 ++++--- .../Commands/PubSubCommandsTests.swift | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Sources/RediStack/Commands/PubSubCommands.swift b/Sources/RediStack/Commands/PubSubCommands.swift index d78bac4f..cf057e2b 100644 --- a/Sources/RediStack/Commands/PubSubCommands.swift +++ b/Sources/RediStack/Commands/PubSubCommands.swift @@ -52,8 +52,9 @@ extension RedisCommand { /// [PUBSUB NUMSUB](https://redis.io/commands/pubsub#codepubsub-numsub-channel-1--channel-ncode) /// - Parameter channels: A list of channel names to collect the subscriber counts for. public static func pubsubNumsub(forChannels channels: [RedisChannelName]) -> RedisCommand<[RedisChannelName: Int]> { - let args = channels.map { $0.convertedToRESPValue() } - return .init(keyword: "PUBSUB NUMSUB", arguments: args) { + var args: [RESPValue] = [.init(bulk: "NUMSUB")] + args.append(convertingContentsOf: channels) + return .init(keyword: "PUBSUB", arguments: args) { let response = try $0.map(to: [RESPValue].self) assert(response.count == channels.count * 2, "Unexpected response size!") @@ -62,11 +63,12 @@ extension RedisCommand { return try channels .enumerated() .reduce(into: [:]) { (result, next) in - assert(next.element.rawValue == response[next.offset].string, "Unexpected value in current index!") + let responseOffset = next.offset * 2 + assert(next.element.rawValue == response[responseOffset].string, "Unexpected value in current index!") - guard let count = response[next.offset + 1].int else { + guard let count = response[responseOffset + 1].int else { throw RedisClientError.assertionFailure( - message: "Unexpected value at position \(next.offset + 1) in \(response)" + message: "Unexpected value at position \(responseOffset + 1) in \(response)" ) } result[next.element] = count diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index c6d0e6d4..d7f9f27c 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -232,6 +232,42 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { let allChannels = try queryConnection.send(.pubsubChannels()).wait() XCTAssertGreaterThanOrEqual(allChannels.count, channelNames.count) } + + func test_pubSubNumsub() throws { + let fn = #function + let subscriber = try self.makeNewConnection() + defer { try? subscriber.close().wait() } + + let channelNames = (1...5).map { + RedisChannelName("\(fn)\($0)") + } + + for channelName in channelNames { + try subscriber.subscribe( + to: channelName, + messageReceiver: { _, _ in }, + onSubscribe: nil, + onUnsubscribe: nil + ).wait() + } + XCTAssertTrue(subscriber.isSubscribed) + defer { + // Unsubscribe (clean up) + try? subscriber.unsubscribe(from: channelNames).wait() + XCTAssertFalse(subscriber.isSubscribed) + } + + // Make another connection to query on. + let queryConnection = try self.makeNewConnection() + defer { try? queryConnection.close().wait() } + + let notSubscribedChannel = RedisChannelName("\(fn)_notsubbed") + let numSubs = try queryConnection.send(.pubsubNumsub(forChannels: [channelNames[0], notSubscribedChannel])).wait() + XCTAssertEqual(numSubs.count, 2) + + XCTAssertGreaterThanOrEqual(numSubs[channelNames[0]] ?? 0, 1) + XCTAssertEqual(numSubs[notSubscribedChannel], 0) + } } final class RedisPubSubCommandsPoolTests: RediStackConnectionPoolIntegrationTestCase { From 2cc63ec8f2c14a6c34f2bafbd56de70baea5e17f Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 1 Jun 2021 13:59:43 -0700 Subject: [PATCH 21/63] [Docs] Add security policy --- README.md | 6 ++++++ SECURITY.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 2fd87d9c..0271d414 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,12 @@ For bugs or feature requests, file a new [issue](https://gitlab.com/mordil/RediS For all other support requests, please email [support@redistack.info](mailto:support@redistack.info). +## Security Vulnerabilities + +If you think you have found a security flaw in the library, please report it following this project's [Security Policy](https://gitlab.com/Mordil/RediStack/blob/master/SECURITY.md). + +Project contributors will treat your report as top priority. + ## Changelog [SemVer](https://semver.org/) changes are documented for each release on the [releases page](https://gitlab.com/Mordil/RediStack/-/releases). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..fe549b45 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +Security is the top priority for this library and any report will be treated as urgent. + +After sending a report, you should expect a response within **7 calendar days**. If you have not, please file a secondary report with the SSWG using [sswg-security-reports@forums.swift.org](mailto:sswg-security-reports@forums.swift.org). + +Once a report has been received, and determined to be a valid issue, a fix should be released no later than **14 calendar days** from the date it was determined as valid. + +After a fix has been implemented, a [CVE](https://cve.mitre.org/index.html) request will be filed with GitLab and issued according to [GitLab's CVE policies](https://about.gitlab.com/security/cve/). + +Once the fix has been released, the original report may become public. + +## Reporting Issues + +If you have discovered a vulnerability in the project, please send your report directly to [support@redistack.info](mailto:support@redistack.info) + +> Please prefix your subject line with `[SECURITY]` + +These reports are immediately filed as confidential and only you and those with [report access](#report-access) will see any conversation from your initial report. + +Example: + +``` +To: support@redistack.info +From: reporter@email.com +Subject: [SECURITY] DDOS Potential with PubSub +Body: +The current way that PubSub is implemented leaves the opportunity for a bad actor to cause a denial-of-service by... +``` + +> For tips on writing your vulnerability reports, refer to [How to Write a Better Vulnerability Report](https://medium.com/swlh/how-to-write-a-better-vulnerability-report-20163ab913fb), by Vickie Li + +## Report Access + +All [project members](https://gitlab.com/mordil/redistack/-/project_members), which includes [SSWG](https://swift.org/sswg/) representatives, are able to view confidential issues reported by following this security policy. From ad316a97acef9d406c9445ee5f2fd288d313b902 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Mon, 16 Aug 2021 21:29:35 -0700 Subject: [PATCH 22/63] 95 -- Add callback closure on RedisConnection invoked on channel close --- Sources/RediStack/RedisConnection.swift | 7 ++- .../RediStackTests/RedisConnectionTests.swift | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 Tests/RediStackTests/RedisConnectionTests.swift diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index b94da9f9..2f1227d1 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2021 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -139,6 +139,10 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { ], on: self.eventLoop) } } + /// A closure to invoke when the connection closes unexpectedly. + /// + /// An unexpected closure is when the connection is closed by any other method than by calling `close(logger:)`. + public var onUnexpectedClosure: (() -> Void)? internal let channel: Channel private let systemContext: Context @@ -182,6 +186,7 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { self.state = .closed self.logger.warning("connection was closed unexpectedly") RedisMetrics.activeConnectionCount.decrement() + self.onUnexpectedClosure?() } self.logger.trace("connection created") diff --git a/Tests/RediStackTests/RedisConnectionTests.swift b/Tests/RediStackTests/RedisConnectionTests.swift new file mode 100644 index 00000000..65727748 --- /dev/null +++ b/Tests/RediStackTests/RedisConnectionTests.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2021 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import NIO +@testable import RediStack +import XCTest + +final class RedisConnectionTests: XCTestCase { + +} + +// MARK: Unexpected Closures +extension RedisConnectionTests { + func test_connectionUnexpectedlyCloses_invokesCallback() throws { + let loop = EmbeddedEventLoop() + + let expectedClosureConnection = RedisConnection(configuredRESPChannel: EmbeddedChannel(loop: loop), context: Logger(label: "")) + let expectedClosureExpectation = self.expectation(description: "this should not be fulfilled") + expectedClosureExpectation.isInverted = true + + expectedClosureConnection.onUnexpectedClosure = { expectedClosureExpectation.fulfill() } + _ = expectedClosureConnection.close(logger: nil) + + let channel = EmbeddedChannel(loop: loop) + let notExpectedClosureConnection = RedisConnection(configuredRESPChannel: channel, context: Logger(label: "")) + let notExpectedClosureExpectation = self.expectation(description: "this should be fulfilled") + notExpectedClosureConnection.onUnexpectedClosure = { notExpectedClosureExpectation.fulfill() } + + _ = try channel.finish(acceptAlreadyClosed: true) + + self.waitForExpectations(timeout: 0.5) + } +} From 5ed6375e431954d500d28c0a82e5e3b509427c8d Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 2 Nov 2021 23:23:13 -0700 Subject: [PATCH 23/63] Set Swift 5.3 as the minimum version --- .gitlab/ci/centos-7.yml | 10 +++--- .gitlab/ci/platform-test.yml | 10 +++--- Package.swift | 4 +-- Sources/RediStack/RedisClient.swift | 56 ----------------------------- 4 files changed, 12 insertions(+), 68 deletions(-) diff --git a/.gitlab/ci/centos-7.yml b/.gitlab/ci/centos-7.yml index 134d57cd..47316797 100644 --- a/.gitlab/ci/centos-7.yml +++ b/.gitlab/ci/centos-7.yml @@ -16,14 +16,14 @@ swift trunk: image: swiftlang/swift:nightly-master-centos7 allow_failure: true +swift 5.5: + extends: .centos7 + image: swift:5.5-centos7 + swift 5.4: extends: .centos7 - image: swiftlang/swift:nightly-5.4-centos7 + image: swift:5.4-centos7 swift 5.3: extends: .centos7 image: swift:5.3-centos7 - -swift 5.2: - extends: .centos7 - image: swift:5.2-centos7 diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml index f615038e..0ba1187b 100644 --- a/.gitlab/ci/platform-test.yml +++ b/.gitlab/ci/platform-test.yml @@ -29,14 +29,14 @@ swift trunk: image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} allow_failure: true +swift 5.5: + extends: .standard-platform + image: swift:5.5-${SWIFT_PLATFORM_NAME} + swift 5.4: extends: .standard-platform - image: swiftlang/swift:nightly-5.4-${SWIFT_PLATFORM_NAME} + image: swift:5.4-${SWIFT_PLATFORM_NAME} swift 5.3: extends: .standard-platform image: swift:5.3-${SWIFT_PLATFORM_NAME} - -swift 5.2: - extends: .standard-platform - image: swift:5.2-${SWIFT_PLATFORM_NAME} diff --git a/Package.swift b/Package.swift index c21086cc..f3481285 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,9 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.3 //===----------------------------------------------------------------------===// // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2021 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index ce1aa0df..fdc7053b 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -141,7 +141,6 @@ extension RedisClient { return self.punsubscribe(from: patterns) } - #if swift(>=5.3) public func subscribe( to channels: [RedisChannelName], messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, @@ -177,61 +176,6 @@ extension RedisClient { ) -> EventLoopFuture { return self.psubscribe(to: patterns, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } - #else - public func subscribe( - to channels: RedisChannelName..., - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - return self.subscribe(to: channels, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - - public func subscribe( - to channels: RedisChannelName..., - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver - ) -> EventLoopFuture { - return self.subscribe(to: channels, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - - public func subscribe( - to channels: [RedisChannelName], - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver - ) -> EventLoopFuture { - return self.subscribe(to: channels, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - - public func psubscribe( - to patterns: String..., - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - return self.psubscribe(to: patterns, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - - public func psubscribe( - to patterns: [String], - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver - ) -> EventLoopFuture { - return self.psubscribe(to: patterns, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - - public func psubscribe( - to patterns: String..., - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver - ) -> EventLoopFuture { - return self.psubscribe(to: patterns, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) - } - #endif } // MARK: Errors From 9def1b9aabbb9bbc82d4db18ca5bb5ce9458ebe5 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 2 Nov 2021 23:44:46 -0700 Subject: [PATCH 24/63] [CI] Restructure CI to be simpler with overrides --- .gitlab/ci/centos-7.yml | 3 --- .gitlab/ci/main.yml | 16 ++++++++-------- .gitlab/ci/platform-test.yml | 26 +------------------------- .gitlab/ci/standard-platforms.yml | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 36 deletions(-) create mode 100644 .gitlab/ci/standard-platforms.yml diff --git a/.gitlab/ci/centos-7.yml b/.gitlab/ci/centos-7.yml index 47316797..c5ae557a 100644 --- a/.gitlab/ci/centos-7.yml +++ b/.gitlab/ci/centos-7.yml @@ -1,8 +1,5 @@ include: '/.gitlab/ci/platform-test.yml' -variables: - SWIFT_STANDARD_PLATFORM_TESTS: "false" - .centos7: extends: .platform-test before_script: diff --git a/.gitlab/ci/main.yml b/.gitlab/ci/main.yml index d2340c31..6e56d50b 100644 --- a/.gitlab/ci/main.yml +++ b/.gitlab/ci/main.yml @@ -58,38 +58,38 @@ Code Climate: reports: codequality: gl-code-quality-report.json -.platform-test: +.standard-platform-test: stage: Platform Tests trigger: strategy: depend - include: '/.gitlab/ci/platform-test.yml' + include: '/.gitlab/ci/standard-platforms.yml' Ubuntu Bionic: - extends: .platform-test + extends: .standard-platform-test variables: SWIFT_PLATFORM_NAME: bionic Ubuntu Xenial: - extends: .platform-test + extends: .standard-platform-test variables: SWIFT_PLATFORM_NAME: xenial Ubuntu Focal: - extends: .platform-test + extends: .standard-platform-test variables: SWIFT_PLATFORM_NAME: focal CentOS 7: - extends: .platform-test + extends: .standard-platform-test trigger: include: '/.gitlab/ci/centos-7.yml' CentOS 8: - extends: .platform-test + extends: .standard-platform-test variables: SWIFT_PLATFORM_NAME: centos8 Amazon Linux 2: - extends: .platform-test + extends: .standard-platform-test variables: SWIFT_PLATFORM_NAME: amazonlinux2 diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml index 0ba1187b..238011ff 100644 --- a/.gitlab/ci/platform-test.yml +++ b/.gitlab/ci/platform-test.yml @@ -1,9 +1,6 @@ stages: - Test -variables: - SWIFT_STANDARD_PLATFORM_TESTS: "true" - .platform-test: stage: Test tags: @@ -18,25 +15,4 @@ variables: script: - swift build --build-tests --enable-test-discovery --sanitize=thread -v - swift test --skip-build - -.standard-platform: - extends: .platform-test - rules: - - if: '$SWIFT_STANDARD_PLATFORM_TESTS == "true"' - -swift trunk: - extends: .standard-platform - image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} - allow_failure: true - -swift 5.5: - extends: .standard-platform - image: swift:5.5-${SWIFT_PLATFORM_NAME} - -swift 5.4: - extends: .standard-platform - image: swift:5.4-${SWIFT_PLATFORM_NAME} - -swift 5.3: - extends: .standard-platform - image: swift:5.3-${SWIFT_PLATFORM_NAME} + \ No newline at end of file diff --git a/.gitlab/ci/standard-platforms.yml b/.gitlab/ci/standard-platforms.yml new file mode 100644 index 00000000..9974ffb7 --- /dev/null +++ b/.gitlab/ci/standard-platforms.yml @@ -0,0 +1,18 @@ +include: '/.gitlab/ci/platform-test.yml' + +swift trunk: + extends: .platform-test + image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} + allow_failure: true + +swift 5.5: + extends: .platform-test + image: swift:5.5-${SWIFT_PLATFORM_NAME} + +swift 5.4: + extends: .platform-test + image: swift:5.4-${SWIFT_PLATFORM_NAME} + +swift 5.3: + extends: .platform-test + image: swift:5.3-${SWIFT_PLATFORM_NAME} From fed62a64b87c92e60f169af8cbbf77a35cfb8f7f Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 2 Nov 2021 23:47:39 -0700 Subject: [PATCH 25/63] [CI] Only run CI builds against nightly images --- .gitlab/ci/centos-7.yml | 3 ++- .gitlab/ci/platform-test.yml | 6 ++++++ .gitlab/ci/standard-platforms.yml | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.gitlab/ci/centos-7.yml b/.gitlab/ci/centos-7.yml index c5ae557a..44d7c4fa 100644 --- a/.gitlab/ci/centos-7.yml +++ b/.gitlab/ci/centos-7.yml @@ -11,7 +11,8 @@ include: '/.gitlab/ci/platform-test.yml' swift trunk: extends: .centos7 image: swiftlang/swift:nightly-master-centos7 - allow_failure: true + variables: + RUN_NIGHTLY: 1 swift 5.5: extends: .centos7 diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml index 238011ff..ed3a9d8e 100644 --- a/.gitlab/ci/platform-test.yml +++ b/.gitlab/ci/platform-test.yml @@ -15,4 +15,10 @@ stages: script: - swift build --build-tests --enable-test-discovery --sanitize=thread -v - swift test --skip-build + rules: + - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUN_NIGHTLY' + allow_failure: true + when: always + - if: '$CI_PIPELINE_SOURCE != "schedule"' + when: always \ No newline at end of file diff --git a/.gitlab/ci/standard-platforms.yml b/.gitlab/ci/standard-platforms.yml index 9974ffb7..13ac9a59 100644 --- a/.gitlab/ci/standard-platforms.yml +++ b/.gitlab/ci/standard-platforms.yml @@ -3,7 +3,8 @@ include: '/.gitlab/ci/platform-test.yml' swift trunk: extends: .platform-test image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} - allow_failure: true + variables: + RUN_NIGHTLY: 1 swift 5.5: extends: .platform-test From 1ca0e9df410fa1d7ec6ca45e345a290f1e311ab1 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Wed, 3 Nov 2021 00:19:00 -0700 Subject: [PATCH 26/63] [Docs] Update test matrix in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0271d414..9bdf8ece 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ This policy is to balance the desire for as much backwards compatibility as poss The following table shows the combination of Swift language versions and operating systems that receive regular unit testing (either in development, or with CI) against the **current version** of **RediStack**. -| Platform | Swift 5.2 | 5.3 | 5.4 | Trunk | +| Platform | Swift 5.3 | 5.4 | 5.5 | Trunk | |:----------------------|:---------:|:---:|:---:|:-----:| | macOS Latest (Intel) | | | ✅ | | | Ubuntu 20.04 (Focal) | ✅ | ✅ | ✅ | ✅ | From 8ff8b03907ca0ff9d9f1778c932dbb0ae6884bd9 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 7 Nov 2021 22:38:29 -0800 Subject: [PATCH 27/63] [CI] Replace rules usage with only/except to correctly run nightlies --- .gitlab/ci/centos-7.yml | 3 +-- .gitlab/ci/platform-test.yml | 11 +++++------ .gitlab/ci/standard-platforms.yml | 3 +-- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.gitlab/ci/centos-7.yml b/.gitlab/ci/centos-7.yml index 44d7c4fa..94877912 100644 --- a/.gitlab/ci/centos-7.yml +++ b/.gitlab/ci/centos-7.yml @@ -11,8 +11,7 @@ include: '/.gitlab/ci/platform-test.yml' swift trunk: extends: .centos7 image: swiftlang/swift:nightly-master-centos7 - variables: - RUN_NIGHTLY: 1 + except: swift 5.5: extends: .centos7 diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml index ed3a9d8e..ae68d282 100644 --- a/.gitlab/ci/platform-test.yml +++ b/.gitlab/ci/platform-test.yml @@ -15,10 +15,9 @@ stages: script: - swift build --build-tests --enable-test-discovery --sanitize=thread -v - swift test --skip-build - rules: - - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUN_NIGHTLY' - allow_failure: true - when: always - - if: '$CI_PIPELINE_SOURCE != "schedule"' - when: always + only: + - branches + - tags + except: + - schedules \ No newline at end of file diff --git a/.gitlab/ci/standard-platforms.yml b/.gitlab/ci/standard-platforms.yml index 13ac9a59..f9fe4740 100644 --- a/.gitlab/ci/standard-platforms.yml +++ b/.gitlab/ci/standard-platforms.yml @@ -3,8 +3,7 @@ include: '/.gitlab/ci/platform-test.yml' swift trunk: extends: .platform-test image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} - variables: - RUN_NIGHTLY: 1 + except: swift 5.5: extends: .platform-test From b449334c8a774cb194de3b0365406610b93f15e0 Mon Sep 17 00:00:00 2001 From: Daniel Ramteke Date: Mon, 21 Feb 2022 19:10:34 +0000 Subject: [PATCH 28/63] [Commands] Add STRLEN --- Sources/RediStack/Commands/StringCommands.swift | 6 ++++++ .../Commands/StringCommandsTests.swift | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Sources/RediStack/Commands/StringCommands.swift b/Sources/RediStack/Commands/StringCommands.swift index e68f59f8..486592a0 100644 --- a/Sources/RediStack/Commands/StringCommands.swift +++ b/Sources/RediStack/Commands/StringCommands.swift @@ -222,6 +222,12 @@ extension RedisCommand { ] return .init(keyword: "SETNX", arguments: args) } + + ///[STRLEN](https://redis.io/commands/strln) + /// - Parameter key: The key to fetch the length of. + public static func strln(_ key: RedisKey) -> RedisCommand { + .init(keyword: "STRLEN", arguments: [.init(from: key)]) + } } // MARK: - diff --git a/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift index e86032e6..a0b19ce8 100644 --- a/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift @@ -229,4 +229,10 @@ final class StringCommandsTests: RediStackIntegrationTestCase { result = try connection.send(.decrby(#function, by: 0)).wait() XCTAssertEqual(result, 7) } + + func test_strlen() throws { + XCTAssertNoThrow(try connection.set(#function, to: "value").wait()) + let val = try connection.send(.strln(#function)).wait() + XCTAssertEqual(val, 5) + } } From 6c4ca52f74644606300794c47c3259ce8285bf09 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 15 Mar 2022 04:33:35 +0000 Subject: [PATCH 29/63] 104 -- Add real world test case to ensure revrange bug doesn't persist --- .../Commands/SortedSetCommandsTests.swift | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift index cd84ae01..91e0f4ff 100644 --- a/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift @@ -512,3 +512,55 @@ final class SortedSetCommandsTests: RediStackIntegrationTestCase { XCTAssertEqual(count, 1) } } + +// MARK: - #104 zrevrange & zrange bug + +extension SortedSetCommandsTests { + func test_zrange_realworld() throws { + struct Keys { + static let first = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0185" + static let second = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0186" + static let third = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0187" + static let fourth = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0188" + static let fifth = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0189" + } + _ = try self.connection.send(.zadd([ + (Keys.first, 1), + (Keys.second, 1), + (Keys.third, 1), + (Keys.fourth, 1), + (Keys.fifth, 1), + ], to: #function)).wait() + + let elements = try self.connection.send(.zrange(from: #function, fromIndex: 0, returning: .valuesOnly)) + .wait() + .compactMap(\.string) + + XCTAssertEqual(elements.count, 5) + XCTAssertEqual(elements, elements.sorted(by: <)) + } + + func test_zrevrange_realworld() throws { + struct Keys { + static let first = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0185" + static let second = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0186" + static let third = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0187" + static let fourth = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0188" + static let fifth = "1E4FD2C5-C32E-4E3F-91B3-45478BCF0189" + } + _ = try self.connection.send(.zadd([ + (Keys.first, 1), + (Keys.second, 1), + (Keys.third, 1), + (Keys.fourth, 1), + (Keys.fifth, 1), + ], to: #function)).wait() + + let elements = try self.connection.send(.zrevrange(from: #function, fromIndex: 0, returning: .valuesOnly)) + .wait() + .compactMap(\.string) + + XCTAssertEqual(elements.count, 5) + XCTAssertEqual(elements, elements.sorted(by: >)) + } +} From 370ef8c4ac89703f45fce8d7720ce2912d74c960 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 15 Mar 2022 21:12:22 -0500 Subject: [PATCH 30/63] 101 -- Add KEYS command --- Sources/RediStack/Commands/KeyCommands.swift | 17 ++++++++++++++++- .../Commands/KeyCommandsTests.swift | 12 +++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/RediStack/Commands/KeyCommands.swift b/Sources/RediStack/Commands/KeyCommands.swift index 55ed5fa5..30f63708 100644 --- a/Sources/RediStack/Commands/KeyCommands.swift +++ b/Sources/RediStack/Commands/KeyCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -67,6 +67,12 @@ extension RedisCommand { return .init(milliseconds: try $0.map()) } } + + /// [KEYS](https://redis.io/commands/keys) + /// - Parameter pattern: The key pattern to search for matching keys that exist in Redis. + public static func keys(matching pattern: String) -> RedisCommand<[String]> { + return .init(keyword: "KEYS", arguments: [pattern.convertedToRESPValue()]) + } /// [SCAN](https://redis.io/commands/scan) /// - Parameters: @@ -115,6 +121,15 @@ extension RedisClient { return self.send(.expire(key, after: timeout)) } + /// Searches the keys in the database that match the given pattern. + /// + /// See ``RedisCommand/keys(matching:)`` + /// - Parameter pattern: The key pattern to search for matching keys that exist in Redis. + /// - Returns: A list of keys that matched the provided pattern. + public func listKeys(matching pattern: String) -> EventLoopFuture<[String]> { + return self.send(.keys(matching: pattern)) + } + /// Incrementally iterates over all keys in the currently selected database. /// /// See `RedisCommand.scan(startingFrom:matching:count:)` diff --git a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift index 1c47b230..ca6e49c2 100644 --- a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -148,4 +148,14 @@ final class KeyCommandsTests: RediStackIntegrationTestCase { XCTAssertGreaterThanOrEqual(keys.count, 1) XCTAssertLessThanOrEqual(keys.count, 7) } + + func test_keys() throws { + let range = Range(0...3) + try range.forEach { + try self.connection.set("\(#function)_\($0)", to: $0).wait() + } + let keys = try self.connection.listKeys(matching: "\(#function)*").wait() + XCTAssertEqual(keys.count, range.count) + XCTAssertTrue(keys.allSatisfy({ $0.contains(#function) })) + } } From 252b0b80619e31d9f916226e6b17cb3211963887 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 15 Mar 2022 22:30:13 -0500 Subject: [PATCH 31/63] Improve KEYS command to be more type-safe --- Sources/RediStack/Commands/KeyCommands.swift | 4 ++-- .../RediStackIntegrationTests/Commands/KeyCommandsTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/RediStack/Commands/KeyCommands.swift b/Sources/RediStack/Commands/KeyCommands.swift index 30f63708..780dc6e0 100644 --- a/Sources/RediStack/Commands/KeyCommands.swift +++ b/Sources/RediStack/Commands/KeyCommands.swift @@ -70,7 +70,7 @@ extension RedisCommand { /// [KEYS](https://redis.io/commands/keys) /// - Parameter pattern: The key pattern to search for matching keys that exist in Redis. - public static func keys(matching pattern: String) -> RedisCommand<[String]> { + public static func keys(matching pattern: String) -> RedisCommand<[RedisKey]> { return .init(keyword: "KEYS", arguments: [pattern.convertedToRESPValue()]) } @@ -126,7 +126,7 @@ extension RedisClient { /// See ``RedisCommand/keys(matching:)`` /// - Parameter pattern: The key pattern to search for matching keys that exist in Redis. /// - Returns: A list of keys that matched the provided pattern. - public func listKeys(matching pattern: String) -> EventLoopFuture<[String]> { + public func listKeys(matching pattern: String) -> EventLoopFuture<[RedisKey]> { return self.send(.keys(matching: pattern)) } diff --git a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift index ca6e49c2..052e4c1a 100644 --- a/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/KeyCommandsTests.swift @@ -156,6 +156,6 @@ final class KeyCommandsTests: RediStackIntegrationTestCase { } let keys = try self.connection.listKeys(matching: "\(#function)*").wait() XCTAssertEqual(keys.count, range.count) - XCTAssertTrue(keys.allSatisfy({ $0.contains(#function) })) + XCTAssertTrue(keys.allSatisfy({ $0.rawValue.contains(#function) })) } } From 498b6a5eb59a628f639a014c2c360166ca9107af Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 15 Mar 2022 22:12:50 -0500 Subject: [PATCH 32/63] Conform RedisCommand to Equatable --- Sources/RediStack/Commands/RedisCommand.swift | 12 ++++++- Tests/RediStackTests/RedisCommandTests.swift | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Tests/RediStackTests/RedisCommandTests.swift diff --git a/Sources/RediStack/Commands/RedisCommand.swift b/Sources/RediStack/Commands/RedisCommand.swift index c9c180cc..94785726 100644 --- a/Sources/RediStack/Commands/RedisCommand.swift +++ b/Sources/RediStack/Commands/RedisCommand.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -83,6 +83,16 @@ extension RedisCommand where ResultType == Void { } } + +// MARK: Equatable +extension RedisCommand: Equatable { + public static func ==(lhs: RedisCommand, rhs: RedisCommand) -> Bool { + return lhs.keyword == rhs.keyword && lhs.arguments == rhs.arguments + } +} + +// MARK: - Common helpers + extension RedisCommand { @usableFromInline internal static func _scan( diff --git a/Tests/RediStackTests/RedisCommandTests.swift b/Tests/RediStackTests/RedisCommandTests.swift new file mode 100644 index 00000000..d622d584 --- /dev/null +++ b/Tests/RediStackTests/RedisCommandTests.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2022 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import RediStack +import XCTest + +final class RedisCommandTests: XCTestCase { } + +// MARK: Equatable Tests + +extension RedisCommandTests { + func test_equatableConformance() { + let first = RedisCommand(keyword: #function, arguments: []) + let second = RedisCommand(keyword: #function, arguments: []) + XCTAssertEqual(first, second) + + let third = RedisCommand(keyword: #function, arguments: [#line.convertedToRESPValue()]) + XCTAssertNotEqual(first, third) + XCTAssertNotEqual(second, third) + + let fourth = RedisCommand(keyword: "buzz", arguments: []) + XCTAssertNotEqual(first, fourth) + XCTAssertNotEqual(third, fourth) + } +} From 2d43c71fddb1b7761cc2c2554fce7b30d8f858a2 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 2 Nov 2021 23:26:52 -0700 Subject: [PATCH 33/63] Add Package manifest for Swift 5.5 users for DocC --- Package.swift | 10 +++--- Package@swift-5.5.swift | 76 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 Package@swift-5.5.swift diff --git a/Package.swift b/Package.swift index f3481285..79c7236d 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2021 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -36,7 +36,8 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "Metrics", package: "swift-metrics"), .product(name: "ServiceDiscovery", package: "swift-service-discovery") - ] + ], + exclude: ["Documentation.docc"] ), .testTarget( name: "RediStackTests", @@ -47,7 +48,7 @@ let package = Package( ] ), - .target(name: "RedisTypes", dependencies: ["RediStack"]), + .target(name: "RedisTypes", dependencies: ["RediStack"], exclude: ["Documentation.docc"]), .testTarget( name: "RedisTypesTests", dependencies: [ @@ -61,7 +62,8 @@ let package = Package( dependencies: [ .product(name: "NIO", package: "swift-nio"), "RediStack" - ] + ], + exclude: ["Documentation.docc"] ), .testTarget( diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift new file mode 100644 index 00000000..4efc3044 --- /dev/null +++ b/Package@swift-5.5.swift @@ -0,0 +1,76 @@ +// swift-tools-version:5.5 +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2022 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import PackageDescription + +let package = Package( + name: "RediStack", + products: [ + .library(name: "RediStack", targets: ["RediStack"]), + .library(name: "RediStackTestUtils", targets: ["RediStackTestUtils"]), + .library(name: "RedisTypes", targets: ["RedisTypes"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.18.0"), + .package(url: "https://github.com/apple/swift-service-discovery", from: "1.0.0"), + ], + targets: [ + .target( + name: "RediStack", + dependencies: [ + .product(name: "NIO", package: "swift-nio"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Metrics", package: "swift-metrics"), + .product(name: "ServiceDiscovery", package: "swift-service-discovery") + ] + ), + .testTarget( + name: "RediStackTests", + dependencies: [ + "RediStack", "RediStackTestUtils", + .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOTestUtils", package: "swift-nio") + ] + ), + + .target(name: "RedisTypes", dependencies: ["RediStack"]), + .testTarget( + name: "RedisTypesTests", + dependencies: [ + "RediStack", "RedisTypes", "RediStackTestUtils", + .product(name: "NIO", package: "swift-nio") + ] + ), + + .target( + name: "RediStackTestUtils", + dependencies: [ + .product(name: "NIO", package: "swift-nio"), + "RediStack" + ] + ), + + .testTarget( + name: "RediStackIntegrationTests", + dependencies: [ + "RediStack", "RediStackTestUtils", + .product(name: "NIO", package: "swift-nio"), + .product(name: "ServiceDiscovery", package: "swift-service-discovery") + ] + ) + ] +) From d5f38b7b92c8a27d70ee664868d3e8654a23302a Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 15 Mar 2022 23:29:54 -0500 Subject: [PATCH 34/63] Add basic DocC files for modules --- .../RediStack/Documentation.docc/RediStack.md | 67 +++++++++++++++++++ .../Documentation.docc/RediStacktestUtils.md | 10 +++ 2 files changed, 77 insertions(+) create mode 100644 Sources/RediStack/Documentation.docc/RediStack.md create mode 100644 Sources/RediStackTestUtils/Documentation.docc/RediStacktestUtils.md diff --git a/Sources/RediStack/Documentation.docc/RediStack.md b/Sources/RediStack/Documentation.docc/RediStack.md new file mode 100644 index 00000000..cde0f7b6 --- /dev/null +++ b/Sources/RediStack/Documentation.docc/RediStack.md @@ -0,0 +1,67 @@ +# ``RediStack`` + +A non-blocking Swift client for Redis built on top of SwiftNIO. + +## Overview + +**RediStack** is quick to use - all you need is an [`EventLoop`](https://apple.github.io/swift-nio/docs/current/NIO/Protocols/EventLoop.html) from **SwiftNIO**. + +```swift +import NIO +import RediStack + +let eventLoop: EventLoop = ... +let connection = RedisConnection.make( + configuration: try .init(hostname: "127.0.0.1"), + boundEventLoop: eventLoop +).wait() + +let result = try connection.set("my_key", to: "some value") + .flatMap { return connection.get("my_key") } + .wait() + +print(result) // Optional("some value") +``` + +> Important: Use of `wait()` was used here for simplicity. Never call this method on an `eventLoop`! + +## Topics + +### Creating Connections + +- ``RedisConnection`` +- ``RedisConnectionPool`` + +### Sending Commands + +- ``RedisClient`` +- ``RedisCommand`` +- ``RedisKey`` + +### Pub/Sub + +- ``RedisChannelName`` + +### Error Handling + +- ``RedisError`` +- ``RedisClientError`` +- ``RedisConnectionPoolError`` + +### Monitoring + +- ``RedisMetrics`` +- ``RedisLogging`` + +### Creating Redis NIO Pipelines + +- ``RedisByteDecoder`` +- ``RedisCommandHandler`` +- ``RedisMessageEncoder`` +- ``RedisPubSubHandler`` + +### Redis Serialization Protocol + +- ``RESPTranslator`` +- ``RESPValue`` +- ``RESPValueConvertible`` diff --git a/Sources/RediStackTestUtils/Documentation.docc/RediStacktestUtils.md b/Sources/RediStackTestUtils/Documentation.docc/RediStacktestUtils.md new file mode 100644 index 00000000..cc943ac1 --- /dev/null +++ b/Sources/RediStackTestUtils/Documentation.docc/RediStacktestUtils.md @@ -0,0 +1,10 @@ +# ``RediStackTestUtils`` + +A collection of useful utilities for testing code that interacts with Redis. + +## Topics + +### Integration Tests + +- ``RedisIntegrationTestCase`` +- ``RedisConnectionPoolIntegrationTestCase`` From 20f6c45d76b3a94ab15efd795d689879d7e2c8ea Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 15 Mar 2022 23:30:08 -0500 Subject: [PATCH 35/63] Add DocC symbol reference file for RedisCommand --- .../Documentation.docc/RedisCommand.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 Sources/RediStack/Documentation.docc/RedisCommand.md diff --git a/Sources/RediStack/Documentation.docc/RedisCommand.md b/Sources/RediStack/Documentation.docc/RedisCommand.md new file mode 100644 index 00000000..75f83ef3 --- /dev/null +++ b/Sources/RediStack/Documentation.docc/RedisCommand.md @@ -0,0 +1,240 @@ +# ``RediStack/RedisCommand`` + +@Metadata { + @DocumentationExtension(mergeBehavior: append) +} + +## Command Definitions + +Commands that are directly supported by **RediStack** will use the same spelling and naming as found in Redis, +and are defined as static functions. + +For the full list of Redis commands, reference the [Redis Command documentation](https://redis.io/commands). + +Some commands are available directly as extensions on ``RedisClient``. + +## Custom Commands + +If a command is not directly supported by **RediStack**, you can write your own extension on ``RedisCommand`` and use +the various initializers. + +While the `ResultType` of the command is a concrete Swift type, such as an enum, it needs to first be parsed from +the raw ``RESPValue`` Redis response, which is done with the `mapValueToResult` closure of ``init(keyword:arguments:mapValueToResult:)``. + +For types conforming to ``RESPValueConvertible``, this done automatically with the `init(keyword:arguments)` initializer. + +## Topics + +### Command components + +- ``keyword`` +- ``arguments`` + +### Creating a custom command + +- ``init(keyword:arguments:mapValueToResult:)`` + +### Connection Commands + +- ``echo(_:)`` +- ``ping(with:)`` +- ``auth(with:)`` +- ``select(database:)`` + +### Hash Commands + +- ``hdel(_:from:)-4s5f6`` +- ``hdel(_:from:)-8zyjq`` +- ``hexists(_:in:)`` +- ``hget(_:from:)`` +- ``hgetall(from:)`` +- ``hincrby(_:field:in:)`` +- ``hincrbyfloat(_:field:in:)`` +- ``hkeys(in:)`` +- ``hlen(of:)`` +- ``hmget(_:from:)-az2q`` +- ``hmget(_:from:)-52q0x`` +- ``hset(_:to:in:)`` +- ``hmset(_:in:)`` +- ``hsetnx(_:to:in:)`` +- ``hstrlen(of:in:)`` +- ``hvals(in:)`` +- ``hscan(_:startingFrom:matching:count:)`` + +### Key Commands + +- ``del(_:)`` +- ``exists(_:)-5g9rq`` +- ``exists(_:)-9qli9`` +- ``expire(_:after:)`` +- ``ttl(_:)`` +- ``pttl(_:)`` +- ``keys(matching:)`` +- ``scan(startingFrom:matching:count:)`` + +### List Commands + +- ``blpop(from:timeout:)-64l4w`` +- ``blpop(from:timeout:)-5y458`` +- ``blpop(from:timeout:)-881zh`` +- ``brpop(from:timeout:)-3uers`` +- ``brpop(from:timeout:)-235os`` +- ``brpop(from:timeout:)-906qk`` +- ``brpoplpush(from:to:timeout:)`` +- ``lindex(_:from:)`` +- ``linsert(_:into:before:)`` +- ``linsert(_:into:after:)`` +- ``llen(of:)`` +- ``lpop(from:)`` +- ``lpush(_:into:)-9wnpn`` +- ``lpush(_:into:)-6vdyu`` +- ``lpushx(_:into:)`` +- ``lrange(from:firstIndex:lastIndex:)`` +- ``lrange(from:indices:)-947k1`` +- ``lrange(from:indices:)-555up`` +- ``lrange(from:fromIndex:)`` +- ``lrange(from:upToIndex:)`` +- ``lrange(from:throughIndex:)`` +- ``lrem(_:from:count:)`` +- ``lset(index:to:in:)`` +- ``ltrim(_:before:after:)`` +- ``ltrim(_:keepingIndices:)-594fr`` +- ``ltrim(_:keepingIndices:)-9k7s6`` +- ``ltrim(_:keepingIndices:)-9gvv8`` +- ``ltrim(_:keepingIndices:)-243g`` +- ``ltrim(_:keepingIndices:)-3q1zz`` +- ``rpop(from:)`` +- ``rpoplpush(from:to:)`` +- ``rpush(_:into:)-9k27q`` +- ``rpush(_:into:)-97xay`` +- ``rpushx(_:into:)`` + +### Pub/Sub Commands + +- ``publish(_:to:)`` +- ``pubsubChannels(matching:)`` +- ``pubsubNumpat()`` +- ``pubsubNumsub(forChannels:)`` + +### Server Commands + +- ``swapdb(_:with:)`` + +### Set Commands + +- ``sadd(_:to:)-2hh4m`` +- ``sadd(_:to:)-4sxtr`` +- ``scard(of:)`` +- ``sdiff(of:)-67ekq`` +- ``sdiff(of:)-3f66d`` +- ``sdiffstore(as:sources:)`` +- ``sinter(of:)-7pqph`` +- ``sinter(of:)-90a5u`` +- ``sinterstore(as:sources:)`` +- ``sismember(_:of:)`` +- ``smembers(of:)`` +- ``smove(_:from:to:)`` +- ``spop(from:max:)`` +- ``srandmember(from:max:)`` +- ``srem(_:from:)-2n6ud`` +- ``srem(_:from:)-21xah`` +- ``sunion(of:)-2gx4`` +- ``sunion(of:)-3baqs`` +- ``sunionstore(as:sources:)`` +- ``sscan(_:startingFrom:matching:count:)`` + +### SortedSet Commands + +- ``bzpopmin(from:timeout:)-7ht1z`` +- ``bzpopmin(from:timeout:)-5lf0y`` +- ``bzpopmin(from:timeout:)-97ikd`` +- ``bzpopmax(from:timeout:)-9f01n`` +- ``bzpopmax(from:timeout:)-6c5lj`` +- ``bzpopmax(from:timeout:)-79p3g`` +- ``zadd(_:to:inserting:returning:)-4yuz4`` +- ``zadd(_:to:inserting:returning:)-1evtg`` +- ``zadd(_:to:inserting:returning:)-1drwk`` +- ``zcount(of:withScoresBetween:)`` +- ``zcount(of:withMaximumScoreOf:)`` +- ``zcount(of:withMinimumScoreOf:)`` +- ``zcount(of:withScores:)-6b7ne`` +- ``zcount(of:withScores:)-6bujq`` +- ``zcard(of:)`` +- ``zincrby(_:in:by:)`` +- ``zinterstore(as:sources:weights:aggregateMethod:)`` +- ``zlexcount(of:withValuesBetween:)`` +- ``zlexcount(of:withMinimumValueOf:)`` +- ``zlexcount(of:withMaximumValueOf:)`` +- ``zpopmax(from:)`` +- ``zpopmax(from:max:)`` +- ``zpopmin(from:)`` +- ``zpopmin(from:max:)`` +- ``zrange(from:firstIndex:lastIndex:returning:)`` +- ``zrange(from:indices:returning:)-95y9o`` +- ``zrange(from:indices:returning:)-4pd8n`` +- ``zrange(from:fromIndex:returning:)`` +- ``zrange(from:throughIndex:returning:)`` +- ``zrange(from:upToIndex:returning:)`` +- ``zrangebylex(from:withValuesBetween:limitBy:)`` +- ``zrangebylex(from:withMaximumValueOf:limitBy:)`` +- ``zrangebylex(from:withMinimumValueOf:limitBy:)`` +- ``zrevrangebylex(from:withValuesBetween:limitBy:)`` +- ``zrevrangebylex(from:withMaximumValueOf:limitBy:)`` +- ``zrevrangebylex(from:withMinimumValueOf:limitBy:)`` +- ``zrangebyscore(from:withScoresBetween:limitBy:returning:)`` +- ``zrangebyscore(from:withScores:limitBy:returning:)-4ukbv`` +- ``zrangebyscore(from:withScores:limitBy:returning:)-phw`` +- ``zrangebyscore(from:withMaximumScoreOf:limitBy:returning:)`` +- ``zrangebyscore(from:withMinimumScoreOf:limitBy:returning:)`` +- ``zrank(of:in:)`` +- ``zrem(_:from:)-86osv`` +- ``zrem(_:from:)-1ey05`` +- ``zremrangebylex(from:withValuesBetween:)`` +- ``zremrangebylex(from:withMaximumValueOf:)`` +- ``zremrangebylex(from:withMinimumValueOf:)`` +- ``zremrangebyrank(from:firstIndex:lastIndex:)`` +- ``zremrangebyrank(from:throughIndex:)`` +- ``zremrangebyrank(from:upToIndex:)`` +- ``zremrangebyrank(from:fromIndex:)`` +- ``zremrangebyrank(from:indices:)-2mwa6`` +- ``zremrangebyrank(from:indices:)-9svqd`` +- ``zremrangebyscore(from:withScoresBetween:)`` +- ``zremrangebyscore(from:withScores:)-4lm7a`` +- ``zremrangebyscore(from:withScores:)-1t3ww`` +- ``zremrangebyscore(from:withMaximumScoreOf:)`` +- ``zremrangebyscore(from:withMinimumScoreOf:)`` +- ``zrevrange(from:firstIndex:lastIndex:returning:)`` +- ``zrevrange(from:fromIndex:returning:)`` +- ``zrevrange(from:upToIndex:returning:)`` +- ``zrevrange(from:throughIndex:returning:)`` +- ``zrevrange(from:indices:returning:)-3ikhd`` +- ``zrevrange(from:indices:returning:)-3t0hk`` +- ``zrevrangebyscore(from:withScoresBetween:limitBy:returning:)`` +- ``zrevrangebyscore(from:withScores:limitBy:returning:)-2vp67`` +- ``zrevrangebyscore(from:withScores:limitBy:returning:)-3jdpl`` +- ``zrevrangebyscore(from:withMaximumScoreOf:limitBy:returning:)`` +- ``zrevrangebyscore(from:withMinimumScoreOf:limitBy:returning:)`` +- ``zrevrank(of:in:)`` +- ``zscore(of:in:)`` +- ``zunionstore(as:sources:weights:aggregateMethod:)`` +- ``zscan(_:startingFrom:matching:count:)`` + +### String Commands + +- ``append(_:to:)`` +- ``decr(_:)`` +- ``decrby(_:by:)`` +- ``get(_:)`` +- ``incr(_:)`` +- ``incrby(_:by:)`` +- ``incrbyfloat(_:by:)`` +- ``mget(_:)-9m30p`` +- ``mget(_:)-6kz3i`` +- ``mset(_:)`` +- ``msetnx(_:)`` +- ``psetex(_:to:expirationInMilliseconds:)`` +- ``set(_:to:)`` +- ``set(_:to:onCondition:expiration:)`` +- ``setex(_:to:expirationInSeconds:)`` +- ``setnx(_:to:)`` +- ``strln(_:)`` From a4aec72592e8bbc605d5492b69b959924dc02e35 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 15 Mar 2022 23:29:28 -0500 Subject: [PATCH 36/63] Update command symbol docs to use DocC symbol references --- .../Commands/ConnectionCommands.swift | 8 +-- Sources/RediStack/Commands/HashCommands.swift | 8 +-- Sources/RediStack/Commands/KeyCommands.swift | 10 +-- Sources/RediStack/Commands/ListCommands.swift | 12 ++-- .../RediStack/Commands/PubSubCommands.swift | 4 +- Sources/RediStack/Commands/RedisCommand.swift | 6 +- .../RediStack/Commands/ServerCommands.swift | 4 +- Sources/RediStack/Commands/SetCommands.swift | 4 +- .../Commands/SortedSetCommands.swift | 72 +++++++++---------- .../RediStack/Commands/StringCommands.swift | 20 +++--- 10 files changed, 74 insertions(+), 74 deletions(-) diff --git a/Sources/RediStack/Commands/ConnectionCommands.swift b/Sources/RediStack/Commands/ConnectionCommands.swift index 37f816f3..4d348b04 100644 --- a/Sources/RediStack/Commands/ConnectionCommands.swift +++ b/Sources/RediStack/Commands/ConnectionCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -60,7 +60,7 @@ extension RedisCommand { extension RedisClient { /// Pings the server, which will respond with a message. /// - /// See `RedisCommand.ping(with:)` + /// See ``RedisCommand/ping(with:)`` /// - Parameter message: The optional message that the server should respond with instead of the default. /// - Returns: A `NIO.EventLoopFuture` that resolves the given `message` or Redis' default response of `PONG`. public func ping(with message: String? = nil) -> EventLoopFuture { @@ -69,7 +69,7 @@ extension RedisClient { /// Requests the client to authenticate with Redis to allow other commands to be executed. /// - /// See `RedisCommand.auth(with:)` + /// See ``RedisCommand/auth(with:)`` /// - Parameter password: The password to authenticate with. /// - Returns: A `NIO.EventLoopFuture` that resolves if the password as accepted, otherwise it fails. public func authorize(with password: String) -> EventLoopFuture { @@ -78,7 +78,7 @@ extension RedisClient { /// Selects the Redis logical database having the given zero-based numeric index. /// - /// See `RedisCommand.select(database:)` + /// See ``RedisCommand/select(database:)`` /// - Note: New connections always use the database `0`. /// - Parameter index: The 0-based index of the database that the connection sending this command will execute later commands against. /// - Returns: A `NIO.EventLoopFuture` resolving once the operation has succeeded. diff --git a/Sources/RediStack/Commands/HashCommands.swift b/Sources/RediStack/Commands/HashCommands.swift index 3db2c851..18e9a481 100644 --- a/Sources/RediStack/Commands/HashCommands.swift +++ b/Sources/RediStack/Commands/HashCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -148,7 +148,7 @@ extension RedisCommand { } /// [HSET](https://redis.io/commands/hset) - /// - Note: If you do not want to overwrite existing values, use `hsetnx(_:field:to:)`. + /// - Note: If you do not want to overwrite existing values, use ``hsetnx(_:field:to:)``. /// - Parameters: /// - field: The key of the field in the hash being set. /// - value: The value the hash field should be set to. @@ -168,7 +168,7 @@ extension RedisCommand { } /// [HSETNX](https://redis.io/commands/hsetnx) - /// - Note: If you do not care about overwriting existing values, use `hset(_:field:to:)`. + /// - Note: If you do not care about overwriting existing values, use ``hset(_:field:to:)``. /// - Parameters: /// - field: The key of the field in the hash being set. /// - value: The value the hash field should be set to. @@ -231,7 +231,7 @@ extension RedisCommand { extension RedisClient { /// Incrementally iterates over all fields in a hash. /// - /// See `RedisCommand.hscan(_:startingFrom:matching:count:)` + /// See ``RedisCommand/hscan(_:startingFrom:matching:count:)`` /// - Parameters: /// - key: The key of the hash. /// - position: The position to start the scan from. diff --git a/Sources/RediStack/Commands/KeyCommands.swift b/Sources/RediStack/Commands/KeyCommands.swift index 780dc6e0..3d92c286 100644 --- a/Sources/RediStack/Commands/KeyCommands.swift +++ b/Sources/RediStack/Commands/KeyCommands.swift @@ -93,7 +93,7 @@ extension RedisCommand { extension RedisClient { /// Deletes the given keys. Any key that does not exist is ignored. /// - /// See `RedisCommand.del(keys:)` + /// See ``RedisCommand/.del(keys:)`` /// - Parameter keys: The list of keys to delete from the database. /// - Returns: A `NIO.EventLoopFuture` that resolves the number of keys that were deleted from the database. public func delete(_ keys: RedisKey...) -> EventLoopFuture { @@ -102,7 +102,7 @@ extension RedisClient { /// Deletes the given keys. Any key that does not exist is ignored. /// - /// See `RedisCommand.del(keys:)` + /// See ``RedisCommand/del(keys:)`` /// - Parameter keys: The list of keys to delete from the database. /// - Returns: A `NIO.EventLoopFuture` that resolves the number of keys that were deleted from the database. public func delete(_ keys: [RedisKey]) -> EventLoopFuture { @@ -112,7 +112,7 @@ extension RedisClient { /// Sets a timeout on key. After the timeout has expired, the key will automatically be deleted. /// - /// See `RedisCommand.expire(_:after:)` + /// See ``RedisCommand/expire(_:after:)`` /// - Parameters: /// - key: The key to set the expiration on. /// - timeout: The time from now the key will expire at. @@ -125,14 +125,14 @@ extension RedisClient { /// /// See ``RedisCommand/keys(matching:)`` /// - Parameter pattern: The key pattern to search for matching keys that exist in Redis. - /// - Returns: A list of keys that matched the provided pattern. + /// - Returns: A result set of ``RedisKey`` values that exist and match the provided pattern. public func listKeys(matching pattern: String) -> EventLoopFuture<[RedisKey]> { return self.send(.keys(matching: pattern)) } /// Incrementally iterates over all keys in the currently selected database. /// - /// See `RedisCommand.scan(startingFrom:matching:count:)` + /// See ``RedisCommand/scan(startingFrom:matching:count:)`` /// - Parameters: /// - position: The cursor position to start from. /// - match: A glob-style pattern to filter values to be selected from the result set. diff --git a/Sources/RediStack/Commands/ListCommands.swift b/Sources/RediStack/Commands/ListCommands.swift index 7eb37446..4fbf0f5a 100644 --- a/Sources/RediStack/Commands/ListCommands.swift +++ b/Sources/RediStack/Commands/ListCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -193,7 +193,7 @@ extension RedisCommand { ) -> RedisCommand { .lpush(elements, into: key) } /// [LPUSHX](https://redis.io/commands/lpushx) - /// - Note: This inserts the element at the head of the list, for the tail see `rpushx(_:into:)`. + /// - Note: This inserts the element at the head of the list, for the tail see ``rpushx(_:into:)``. /// - Parameters: /// - element: The value to try and push into the list. /// - key: The key of the list. @@ -239,7 +239,7 @@ extension RedisCommand { /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, /// `ClosedRange` will trigger a precondition failure. /// - /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. + /// If you need such a range, use ``lrange(from:firstIndex:lastIndex:)`` instead. /// - Parameters: /// - key: The key of the List to return elements from. /// - range: The range of inclusive indices of elements to get. @@ -266,7 +266,7 @@ extension RedisCommand { /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, /// `Range` will trigger a precondition failure. /// - /// If you need such a range, use `lrange(from:firstIndex:lastIndex:)` instead. + /// If you need such a range, use ``lrange(from:firstIndex:lastIndex:)`` instead. /// - Parameters: /// - key: The key of the List to return elements from. /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. @@ -394,7 +394,7 @@ extension RedisCommand { /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, /// `ClosedRange` will trigger a precondition failure. /// - /// If you need such a range, use `ltrim(_:before:after:)` instead. + /// If you need such a range, use ``ltrim(_:before:after:)`` instead. /// - Parameters: /// - key: The key of the List to trim. /// - range: The range of indices that should be kept in the List. @@ -515,7 +515,7 @@ extension RedisCommand { } /// [RPUSHX](https://redis.io/commands/rpushx) - /// - Note: This inserts the element at the tail of the list; for the head see `lpushx(_:into:)`. + /// - Note: This inserts the element at the tail of the list; for the head see ``lpushx(_:into:)``. /// - Parameters: /// - element: The value to try and push into the list. /// - key: The key of the list. diff --git a/Sources/RediStack/Commands/PubSubCommands.swift b/Sources/RediStack/Commands/PubSubCommands.swift index cf057e2b..2553aa1f 100644 --- a/Sources/RediStack/Commands/PubSubCommands.swift +++ b/Sources/RediStack/Commands/PubSubCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -82,7 +82,7 @@ extension RedisCommand { extension RedisClient { /// Publishes the provided message to a specific Redis channel. /// - /// See `RedisCommand.publish(_:to:)` + /// See ``RedisCommand/publish(_:to:)`` /// - Parameters: /// - message: The "message" value to publish on the channel. /// - channel: The name of the channel to publish the message to. diff --git a/Sources/RediStack/Commands/RedisCommand.swift b/Sources/RediStack/Commands/RedisCommand.swift index 94785726..60a70752 100644 --- a/Sources/RediStack/Commands/RedisCommand.swift +++ b/Sources/RediStack/Commands/RedisCommand.swift @@ -17,10 +17,10 @@ /// An instance will retain the keyword of the command in plaintext as a `String` for identity purposes, /// while all the arguments will be stored as `RESPValue` representations. /// -/// ## ResultType -/// Each `RedisCommand` has a generic type referred to as `ResultType` that is the native Swift representation of the response Redis will send for the command. +/// Each `RedisCommand` has a generic type referred to as `ResultType` that is the native Swift representation +/// of the final result type parsed from the Redis command response. /// -/// When creating a `RedisCommand`, a closure will be provided for transforming an arbitrary `RESPValue` instance into the `ResultType`. +/// When creating a `RedisCommand`, a closure is provided for transforming an arbitrary `RESPValue` instance into the `ResultType`. public struct RedisCommand { public let keyword: String public let arguments: [RESPValue] diff --git a/Sources/RediStack/Commands/ServerCommands.swift b/Sources/RediStack/Commands/ServerCommands.swift index 42f9de12..158da081 100644 --- a/Sources/RediStack/Commands/ServerCommands.swift +++ b/Sources/RediStack/Commands/ServerCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -37,7 +37,7 @@ extension RedisCommand { extension RedisClient { /// Swaps the data of two Redis databases by their index IDs. /// - /// See `RedisCommand.swapdb(_:with:)` + /// See ``RedisCommand/swapdb(_:with:)`` /// - Parameters: /// - first: The index of the first database. /// - second: The index of the second database. diff --git a/Sources/RediStack/Commands/SetCommands.swift b/Sources/RediStack/Commands/SetCommands.swift index 29195e24..d372911b 100644 --- a/Sources/RediStack/Commands/SetCommands.swift +++ b/Sources/RediStack/Commands/SetCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -248,7 +248,7 @@ extension RedisCommand { extension RedisClient { /// Incrementally iterates over all values in a set. /// - /// See `RedisCommand.sscan(_:startingFrom:matching:count:)` + /// See ``RedisCommand/sscan(_:startingFrom:matching:count:)`` /// - Parameters: /// - key: The key of the set. /// - position: The position to start the scan from. diff --git a/Sources/RediStack/Commands/SortedSetCommands.swift b/Sources/RediStack/Commands/SortedSetCommands.swift index 99b9cb54..52b3639b 100644 --- a/Sources/RediStack/Commands/SortedSetCommands.swift +++ b/Sources/RediStack/Commands/SortedSetCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -367,7 +367,7 @@ extension RedisCommand { /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, /// `ClosedRange` will trigger a precondition failure. /// - /// If you need such a range, use `zrange(from:firstIndex:lastIndex:resultOption:)` instead. + /// If you need such a range, use ``zrange(from:firstIndex:lastIndex:resultOption:)`` instead. /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// /// For the inverse, see `zrevrange(from:indices:returning:)`. @@ -388,7 +388,7 @@ extension RedisCommand { /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, /// `Range` will trigger a precondition failure. /// - /// If you need such a range, use `zrange(from:firstIndex:lastIndex:)` instead. + /// If you need such a range, use ``zrange(from:firstIndex:lastIndex:)`` instead. /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// /// For the inverse, see `zrevrange(from:indices:returning:)`. @@ -408,7 +408,7 @@ extension RedisCommand { /// [ZRANGE](https://redis.io/commands/zrange) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrange(from:fromIndex:returning:)`. + /// For the inverse, see ``zrevrange(from:fromIndex:returning:)``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - index: The index of the first element that will be in the returned values. @@ -425,7 +425,7 @@ extension RedisCommand { /// [ZRANGE](https://redis.io/commands/zrange) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrange(from:throughIndex:returning:)`. + /// For the inverse, see ``zrevrange(from:throughIndex:returning:)`. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - index: The index of the last element that will be in the returned values. @@ -442,7 +442,7 @@ extension RedisCommand { /// [ZRANGE](https://redis.io/commands/zrange) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrange(from:upToIndex:returning:)`. + /// For the inverse, see ``zrevrange(from:upToIndex:returning:)``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - index: The index of the last element to not include in the returned values. @@ -460,7 +460,7 @@ extension RedisCommand { /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebylex(from:withValuesBetween:limitBy:)`. + /// For the inverse, see ``zrevrangebylex(from:withValuesBetween:limitBy:)``. /// - Parameters: /// - key: The key of the SortedSet that will be counted. /// - range: The min and max value bounds for filtering elements by. @@ -478,7 +478,7 @@ extension RedisCommand { /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebylex(from:withMinimumValueOf:limitBy:)`. + /// For the inverse, see ``zrevrangebylex(from:withMinimumValueOf:limitBy:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be included in the result set. @@ -496,7 +496,7 @@ extension RedisCommand { /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebylex(from:withMaximumValueOf:limitBy:)`. + /// For the inverse, see ``zrevrangebylex(from:withMaximumValueOf:limitBy:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - minValue: The maximum lexiographical value an element in the SortedSet should have to be included in the result set. @@ -514,7 +514,7 @@ extension RedisCommand { /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebylex(from:withValuesBetween:limitBy:)`. + /// For the inverse, see ``zrangebylex(from:withValuesBetween:limitBy:)`. /// - Parameters: /// - key: The key of the SortedSet that will be counted. /// - range: The min and max value bounds for filtering elements by. @@ -532,7 +532,7 @@ extension RedisCommand { /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebylex(from:withMinimumValueOf:limitBy:)`. + /// For the inverse, see ``zrangebylex(from:withMinimumValueOf:limitBy:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - minValue: The minimum lexiographical value an element in the SortedSet should have to be included in the result set. @@ -550,7 +550,7 @@ extension RedisCommand { /// - Warning: This assumes all elements in the SortedSet have the same score. If not, the returned elements are unspecified. /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebylex(from:withMaximumValueOf:limitBy:)`. + /// For the inverse, see ``zrangebylex(from:withMaximumValueOf:limitBy:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - minValue: The maximum lexiographical value an element in the SortedSet should have to be included in the result set. @@ -567,7 +567,7 @@ extension RedisCommand { /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebyscore(from:withScoresBetween:limitBy:returning:)`. + /// For the inverse, see ``zrevrangebyscore(from:withScoresBetween:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The min and max score bounds to filter elements by. @@ -586,7 +586,7 @@ extension RedisCommand { /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebyscore(from:withScores:limitBy:returning:)`. + /// For the inverse, see ``zrevrangebyscore(from:withScores:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The inclusive range of scores to filter elements by. @@ -610,7 +610,7 @@ extension RedisCommand { /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebyscore(from:withScores:limitBy:returning:)`. + /// For the inverse, see ``zrevrangebyscore(from:withScores:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. @@ -634,7 +634,7 @@ extension RedisCommand { /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebyscore(from:withMinimumScoreOf:limitBy:returning:)`. + /// For the inverse, see ``zrevrangebyscore(from:withMinimumScoreOf:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The minimum score bound an element in the SortedSet should have to be included in the response. @@ -658,7 +658,7 @@ extension RedisCommand { /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see `zrevrangebyscore(from:withMaximumScoreOf:limitBy:returning:)`. + /// For the inverse, see ``zrevrangebyscore(from:withMaximumScoreOf:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The maximum score bound an element in the SortedSet should have to be included in the response. @@ -682,7 +682,7 @@ extension RedisCommand { /// [ZRANK](https://redis.io/commands/zrank) /// - Important: This treats the ordered set as ordered from low to high. /// - /// For the inverse, see `zrevrank(of:in:)`. + /// For the inverse, see ``zrevrank(of:in:)``. /// - Parameters: /// - element: The element in the sorted set to search for. /// - key: The key of the sorted set to search. @@ -773,7 +773,7 @@ extension RedisCommand { /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, /// `ClosedRange` will trigger a precondition failure. /// - /// If you need such a range, use `zremrangebyrank(from:firstIndex:lastIndex:)` instead. + /// If you need such a range, use ``zremrangebyrank(from:firstIndex:lastIndex:)`` instead. /// - Parameters: /// - key: The key of the SortedSet to remove elements from. /// - range: The range of inclusive indices of elements to remove. @@ -785,7 +785,7 @@ extension RedisCommand { /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, /// `Range` will trigger a precondition failure. /// - /// If you need such a range, use `zremrangebyrank(from:firstIndex:lastIndex:)` instead. + /// If you need such a range, use ``zremrangebyrank(from:firstIndex:lastIndex:)`` instead. /// - Parameters: /// - key: The key of the SortedSet to remove elements from. /// - range: The range of indices (inclusive lower, exclusive upper) elements to remove. @@ -869,7 +869,7 @@ extension RedisCommand { /// [ZREVRANGE](https://redis.io/commands/zrevrange) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrange(from:firstIndex:lastIndex:returning:)`. + /// For the inverse, see ``zrange(from:firstIndex:lastIndex:returning:)``. /// - Parameters: /// - key: The key of the SortedSet /// - firstIndex: The index of the first element to include in the range of elements returned. @@ -887,10 +887,10 @@ extension RedisCommand { /// - Precondition: A `ClosedRange` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0...-1`, /// `ClosedRange` will trigger a precondition failure. /// - /// If you need such a range, use `zrevrange(from:firstIndex:lastIndex:)` instead. + /// If you need such a range, use ``zrevrange(from:firstIndex:lastIndex:)`` instead. /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrange(from:indices:returning:)`. + /// For the inverse, see ``zrange(from:indices:returning:)``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - range: The range of inclusive indices of elements to get. @@ -906,10 +906,10 @@ extension RedisCommand { /// - Precondition: A `Range` cannot be created where `upperBound` is less than `lowerBound`; so while Redis may support `0..<(-1)`, /// `Range` will trigger a precondition failure. /// - /// If you need such a range, use `zrevrange(from:firstIndex:lastIndex:)` instead. + /// If you need such a range, use ``zrevrange(from:firstIndex:lastIndex:)`` instead. /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrange(from:indices:returning:)`. + /// For the inverse, see ``zrange(from:indices:returning:)``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. @@ -924,7 +924,7 @@ extension RedisCommand { /// [ZREVRANGE](https://redis.io/commands/zrevrange) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrange(from:fromIndex:returning:)`. + /// For the inverse, see ``zrange(from:fromIndex:returning:)``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - index: The index of the first element that will be in the returned values. @@ -939,7 +939,7 @@ extension RedisCommand { /// [ZREVRANGE](https://redis.io/commands/zrevrange) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrange(from:throughIndex:returning:)`. + /// For the inverse, see ``zrange(from:throughIndex:returning:)``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - index: The index of the last element that will be in the returned values. @@ -954,7 +954,7 @@ extension RedisCommand { /// [ZREVRANGE](https://redis.io/commands/zrevrange) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrange(from:upToIndex:returning:)`. + /// For the inverse, see ``zrange(from:upToIndex:returning:)``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - index: The index of the last element to not include in the returned values. @@ -969,7 +969,7 @@ extension RedisCommand { /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withScoresBetween:limitBy:returning:)`. + /// For the inverse, see ``zrangebyscore(from:withScoresBetween:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The min and max score bounds to filter elements by. @@ -988,7 +988,7 @@ extension RedisCommand { /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withScores:limitBy:returning:)`. + /// For the inverse, see ``zrangebyscore(from:withScores:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The inclusive range of scores to filter elements by. @@ -1012,7 +1012,7 @@ extension RedisCommand { /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withScores:limitBy:returning:)`. + /// For the inverse, see ``zrangebyscore(from:withScores:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. @@ -1036,7 +1036,7 @@ extension RedisCommand { /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withMinimumScoreOf:limitBy:returning:)`. + /// For the inverse, see ``zrangebyscore(from:withMinimumScoreOf:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The minimum score bound an element in the SortedSet should have to be included in the response. @@ -1060,7 +1060,7 @@ extension RedisCommand { /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see `zrangebyscore(from:withMaximumScoreOf:limitBy:returning:)`. + /// For the inverse, see ``zrangebyscore(from:withMaximumScoreOf:limitBy:returning:)``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The maximum score bound an element in the SortedSet should have to be included in the response. @@ -1084,7 +1084,7 @@ extension RedisCommand { /// [ZREVRANK](https://redis.io/commands/zrevrank) /// - Important: This treats the ordered set as ordered from high to low. /// - /// For the inverse, see `zrank(of:in:)`. + /// For the inverse, see ``zrank(of:in:)``. /// - Parameters: /// - element: The element in the sorted set to search for. /// - key: The key of the sorted set to search. @@ -1148,7 +1148,7 @@ extension RedisCommand { extension RedisClient { /// Incrementally iterates over all elements in a sorted set. /// - /// See `RedisCommand.zscan(_:startingFrom:matching:count:)` + /// See ``RedisCommand/zscan(_:startingFrom:matching:count:)`` /// - Parameters: /// - key: The key identifying the sorted set. /// - position: The position to start the scan from. @@ -1172,7 +1172,7 @@ extension RedisClient { /// /// `zadd` normally inserts all given elements into the SortedSet, updating the score of any element that already exist in the set. /// -/// However, it other behaviors are available: [ZADD Options](https://redis.io/commands/zadd#zadd-options). +/// See [ZADD Options](https://redis.io/commands/zadd#zadd-options). public struct RedisZaddInsertBehavior { /// Insert new elements and update the score of existing elements. public static let allElements = RedisZaddInsertBehavior(nil) diff --git a/Sources/RediStack/Commands/StringCommands.swift b/Sources/RediStack/Commands/StringCommands.swift index 486592a0..dbfa9a6e 100644 --- a/Sources/RediStack/Commands/StringCommands.swift +++ b/Sources/RediStack/Commands/StringCommands.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -117,14 +117,14 @@ extension RedisCommand { } /// [MSET](https://redis.io/commands/mset) - /// - Note: Use `msetnx(_:)` if you don't want to overwrite values. + /// - Note: Use ``msetnx(_:)`` if you don't want to overwrite values. /// - Parameter operations: The key-value list of SET operations to execute. public static func mset(_ operations: [RedisKey: RESPValueConvertible]) -> RedisCommand { return ._mset(keyword: "MSET", operations) { _ in } } /// [MSETNX](https://redis.io/commands/msetnx) - /// - Note: Use `mset(_:)` if you don't care about overwriting values. + /// - Note: Use ``mset(_:)`` if you don't care about overwriting values. /// - Parameter operations: The key-value list of SET operations to execute. public static func msetnx(_ operations: [RedisKey: RESPValueConvertible]) -> RedisCommand { return ._mset(keyword: "MSETNX", operations) { @@ -235,7 +235,7 @@ extension RedisCommand { extension RedisClient { /// Gets the value of the given key. /// - /// See `RedisCommand.get(_:)` + /// See ``RedisCommand/get(_:)`` /// - Parameter key: The key to fetch the value from. /// - Returns: A `NIO.EventLoopFuture` that resolves the value stored at the given key, otherwise `nil`. public func get(_ key: RedisKey) -> EventLoopFuture { @@ -244,7 +244,7 @@ extension RedisClient { /// Gets the value of the given key, converting it to the desired type. /// - /// See `RedisCommand.get(_:)` + /// See ``RedisCommand/get(_:)` /// - Parameters: /// - key: The key to fetch the value from. /// - type: The desired type to convert the stored data to. @@ -260,7 +260,7 @@ extension RedisClient { /// Gets the value of the given key, decoding it as a JSON data structure. /// - /// See `RedisCommand.get(_:)` + /// See ``RedisCommand/get(_:)`` /// - Parameters: /// - key: The key to fetch the value from. /// - type: The JSON type to decode to. @@ -282,7 +282,7 @@ extension RedisClient { /// /// Any previous expiration set on the key is discarded if the `SET` operation was successful. /// - /// See `RedisCommand.set(_:to:)` + /// See ``RedisCommand/set(_:to:)`` /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. /// - Parameters: /// - key: The key to use to uniquely identify this value in Redis. @@ -295,7 +295,7 @@ extension RedisClient { /// Sets the value stored at the given key with options to control how to set it. /// - /// See `RedisCommand.set(_:to:onCondition:expiration:)` + /// See ``RedisCommand/set(_:to:onCondition:expiration:)`` /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. /// - Parameters: /// - key: The key to use to uniquely identify this value. @@ -317,7 +317,7 @@ extension RedisClient { /// Sets the value stored at the given key to the given value as JSON data. /// - /// See `RedisCommand.set(_:to:)` + /// See ``RedisCommand/set(_:to:)`` /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. /// - Parameters: /// - key: The key to use to uniquely identify this value in Redis. @@ -339,7 +339,7 @@ extension RedisClient { /// Sets the value stored at the given key as JSON data with options to control how to set it. /// - /// See `RedisCommand.set(_:to:onCondition:expiration:)` + /// See ``RedisCommand/set(_:to:onCondition:expiration:)`` /// - Important: Regardless of the type of value stored at the `key`, it will be overwritten to a "string" value. /// - Parameters: /// - key: The key to use to uniquely identify this value in Redis. From 9da5773e7a45f3fc38293ae1d66849a3f9093f56 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 3 Apr 2022 23:43:17 -0500 Subject: [PATCH 37/63] 92 -- Accept event loop and logger in RedisClient commands There are many times that developers want exact control over which EventLoop will be executing their chained EventLoopFuture callbacks and which Logger will do the logging in calls deep within RediStack. All commands will now accept an optional EventLoop and Logger to hop to, and using the logger for desired logs. --- .../Commands/ConnectionCommands.swift | 40 +++- Sources/RediStack/Commands/HashCommands.swift | 10 +- Sources/RediStack/Commands/KeyCommands.swift | 59 +++-- .../RediStack/Commands/PubSubCommands.swift | 9 +- .../RediStack/Commands/ServerCommands.swift | 12 +- Sources/RediStack/Commands/SetCommands.swift | 9 +- .../Commands/SortedSetCommands.swift | 9 +- .../RediStack/Commands/StringCommands.swift | 65 ++++-- .../ConnectionPool/ConnectionPool.swift | 6 +- Sources/RediStack/RedisClient.swift | 110 ++++++--- Sources/RediStack/RedisConnection.swift | 147 ++++++------ Sources/RediStack/RedisConnectionPool.swift | 213 +++++++++--------- Sources/RediStack/RedisContext.swift | 24 -- Sources/RediStack/RedisLogging.swift | 123 ++++------ Sources/RedisTypes/RedisSet.swift | 45 ++-- .../RedisConnectionPoolTests.swift | 49 +++- .../RedisConnectionTests.swift | 84 ++++++- .../RedisLoggingTests.swift | 26 ++- .../RediStackTests/ConnectionPoolTests.swift | 8 +- .../RediStackTests/RedisConnectionTests.swift | 12 +- 20 files changed, 666 insertions(+), 394 deletions(-) delete mode 100644 Sources/RediStack/RedisContext.swift diff --git a/Sources/RediStack/Commands/ConnectionCommands.swift b/Sources/RediStack/Commands/ConnectionCommands.swift index 4d348b04..63ff325a 100644 --- a/Sources/RediStack/Commands/ConnectionCommands.swift +++ b/Sources/RediStack/Commands/ConnectionCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO // MARK: Connection @@ -61,28 +62,49 @@ extension RedisClient { /// Pings the server, which will respond with a message. /// /// See ``RedisCommand/ping(with:)`` - /// - Parameter message: The optional message that the server should respond with instead of the default. + /// - Parameters: + /// - message: The optional message that the server should respond with instead of the default. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves the given `message` or Redis' default response of `PONG`. - public func ping(with message: String? = nil) -> EventLoopFuture { - return self.send(.ping(with: message)) + public func ping( + with message: String? = nil, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.send(.ping(with: message), eventLoop: eventLoop, logger: logger) } /// Requests the client to authenticate with Redis to allow other commands to be executed. /// /// See ``RedisCommand/auth(with:)`` - /// - Parameter password: The password to authenticate with. + /// - Parameters: + /// - password: The password to authenticate with. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves if the password as accepted, otherwise it fails. - public func authorize(with password: String) -> EventLoopFuture { - return self.send(.auth(with: password)) + public func authorize( + with password: String, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.send(.auth(with: password), eventLoop: eventLoop, logger: logger) } /// Selects the Redis logical database having the given zero-based numeric index. /// /// See ``RedisCommand/select(database:)`` /// - Note: New connections always use the database `0`. - /// - Parameter index: The 0-based index of the database that the connection sending this command will execute later commands against. + /// - Parameters: + /// - index: The 0-based index of the database that the connection sending this command will execute later commands against. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` resolving once the operation has succeeded. - public func select(database index: Int) -> EventLoopFuture { - return self.send(.select(database: index)) + public func select( + database index: Int, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.send(.select(database: index), eventLoop: eventLoop, logger: logger) } } diff --git a/Sources/RediStack/Commands/HashCommands.swift b/Sources/RediStack/Commands/HashCommands.swift index 18e9a481..cc9cf064 100644 --- a/Sources/RediStack/Commands/HashCommands.swift +++ b/Sources/RediStack/Commands/HashCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO // MARK: Hashes @@ -237,16 +238,19 @@ extension RedisClient { /// - position: The position to start the scan from. /// - match: A glob-style pattern to filter values to be selected from the result set. /// - count: The number of elements to advance by. Redis default is 10. - /// - valueType: The type to cast all values to. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves a cursor position for additional scans, /// with a limited collection of fields and their associated values that were iterated over. public func scanHashFields( in key: RedisKey, startingFrom position: Int = 0, matching match: String? = nil, - count: Int? = nil + count: Int? = nil, + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture<(Int, [RedisHashFieldKey: RESPValue])> { - return self.send(.hscan(key, startingFrom: position, matching: match, count: count)) + return self.send(.hscan(key, startingFrom: position, matching: match, count: count), eventLoop: eventLoop, logger: logger) } } diff --git a/Sources/RediStack/Commands/KeyCommands.swift b/Sources/RediStack/Commands/KeyCommands.swift index 3d92c286..b2839e08 100644 --- a/Sources/RediStack/Commands/KeyCommands.swift +++ b/Sources/RediStack/Commands/KeyCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO // MARK: Key @@ -94,20 +95,34 @@ extension RedisClient { /// Deletes the given keys. Any key that does not exist is ignored. /// /// See ``RedisCommand/.del(keys:)`` - /// - Parameter keys: The list of keys to delete from the database. + /// - Parameters: + /// - keys: The list of keys to delete from the database. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves the number of keys that were deleted from the database. - public func delete(_ keys: RedisKey...) -> EventLoopFuture { - return self.delete(keys) + public func delete( + _ keys: RedisKey..., + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.delete(keys, eventLoop: eventLoop, logger: logger) } /// Deletes the given keys. Any key that does not exist is ignored. /// /// See ``RedisCommand/del(keys:)`` - /// - Parameter keys: The list of keys to delete from the database. + /// - Parameters: + /// - keys: The list of keys to delete from the database. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves the number of keys that were deleted from the database. - public func delete(_ keys: [RedisKey]) -> EventLoopFuture { + public func delete( + _ keys: [RedisKey], + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { guard keys.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } - return self.send(.del(keys)) + return self.send(.del(keys), eventLoop: eventLoop, logger: logger) } /// Sets a timeout on key. After the timeout has expired, the key will automatically be deleted. @@ -116,18 +131,32 @@ extension RedisClient { /// - Parameters: /// - key: The key to set the expiration on. /// - timeout: The time from now the key will expire at. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves `true` if the expiration was set and `false` if it wasn't. - public func expire(_ key: RedisKey, after timeout: TimeAmount) -> EventLoopFuture { - return self.send(.expire(key, after: timeout)) + public func expire( + _ key: RedisKey, + after timeout: TimeAmount, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.send(.expire(key, after: timeout), eventLoop: eventLoop, logger: logger) } /// Searches the keys in the database that match the given pattern. /// /// See ``RedisCommand/keys(matching:)`` - /// - Parameter pattern: The key pattern to search for matching keys that exist in Redis. + /// - Parameters: + /// - pattern: The key pattern to search for matching keys that exist in Redis. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A result set of ``RedisKey`` values that exist and match the provided pattern. - public func listKeys(matching pattern: String) -> EventLoopFuture<[RedisKey]> { - return self.send(.keys(matching: pattern)) + public func listKeys( + matching pattern: String, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture<[RedisKey]> { + return self.send(.keys(matching: pattern), eventLoop: eventLoop, logger: logger) } /// Incrementally iterates over all keys in the currently selected database. @@ -137,12 +166,16 @@ extension RedisClient { /// - position: The cursor position to start from. /// - match: A glob-style pattern to filter values to be selected from the result set. /// - count: The number of elements to advance by. Redis default is 10. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A cursor position for additional invocations with a limited collection of keys found in the database. public func scanKeys( startingFrom position: Int = 0, matching match: String? = nil, - count: Int? = nil + count: Int? = nil, + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture<(Int, [RedisKey])> { - return self.send(.scan(startingFrom: position, matching: match, count: count)) + return self.send(.scan(startingFrom: position, matching: match, count: count), eventLoop: eventLoop, logger: logger) } } diff --git a/Sources/RediStack/Commands/PubSubCommands.swift b/Sources/RediStack/Commands/PubSubCommands.swift index 2553aa1f..8d36edc7 100644 --- a/Sources/RediStack/Commands/PubSubCommands.swift +++ b/Sources/RediStack/Commands/PubSubCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO // MARK: PubSub @@ -86,13 +87,17 @@ extension RedisClient { /// - Parameters: /// - message: The "message" value to publish on the channel. /// - channel: The name of the channel to publish the message to. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: The number of subscribed clients that received the message. @inlinable @discardableResult public func publish( _ message: Message, - to channel: RedisChannelName + to channel: RedisChannelName, + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture { - return self.send(.publish(message, to: channel)) + return self.send(.publish(message, to: channel), eventLoop: eventLoop, logger: logger) } } diff --git a/Sources/RediStack/Commands/ServerCommands.swift b/Sources/RediStack/Commands/ServerCommands.swift index 158da081..bb777b33 100644 --- a/Sources/RediStack/Commands/ServerCommands.swift +++ b/Sources/RediStack/Commands/ServerCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO // MARK: Server @@ -41,8 +42,15 @@ extension RedisClient { /// - Parameters: /// - first: The index of the first database. /// - second: The index of the second database. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves `true` if the command succeed or `false` if it didn't. - public func swapDatabase(_ first: Int, with second: Int) -> EventLoopFuture { - return self.send(.swapdb(first, with: second)) + public func swapDatabase( + _ first: Int, + with second: Int, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.send(.swapdb(first, with: second), eventLoop: eventLoop, logger: logger) } } diff --git a/Sources/RediStack/Commands/SetCommands.swift b/Sources/RediStack/Commands/SetCommands.swift index d372911b..de13f704 100644 --- a/Sources/RediStack/Commands/SetCommands.swift +++ b/Sources/RediStack/Commands/SetCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO // MARK: Sets @@ -254,13 +255,17 @@ extension RedisClient { /// - position: The position to start the scan from. /// - count: The number of elements to advance by. Redis default is 10. /// - match: A glob-style pattern to filter values to be selected from the result set. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves a cursor position for additional scans, with a limited collection of values that were iterated over. public func scanSetValues( in key: RedisKey, startingFrom position: Int = 0, matching match: String? = nil, - count: Int? = nil + count: Int? = nil, + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture<(Int, [RESPValue])> { - return self.send(.sscan(key, startingFrom: position, matching: match, count: count)) + return self.send(.sscan(key, startingFrom: position, matching: match, count: count), eventLoop: eventLoop, logger: logger) } } diff --git a/Sources/RediStack/Commands/SortedSetCommands.swift b/Sources/RediStack/Commands/SortedSetCommands.swift index 52b3639b..3376a1c1 100644 --- a/Sources/RediStack/Commands/SortedSetCommands.swift +++ b/Sources/RediStack/Commands/SortedSetCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO // MARK: Sorted Sets @@ -1154,15 +1155,19 @@ extension RedisClient { /// - position: The position to start the scan from. /// - match: A glob-style pattern to filter values to be selected from the result set. /// - count: The number of elements to advance by. Redis default is 10. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves a cursor position for additional scans, /// with a limited collection of elements with their scores found in the Sorted Set. public func scanSortedSetValues( in key: RedisKey, startingFrom position: Int = 0, matching match: String? = nil, - count: Int? = nil + count: Int? = nil, + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture<(Int, [(RESPValue, Double)])> { - return self.send(.zscan(key, startingFrom: position, matching: match, count: count)) + return self.send(.zscan(key, startingFrom: position, matching: match, count: count), eventLoop: eventLoop, logger: logger) } } diff --git a/Sources/RediStack/Commands/StringCommands.swift b/Sources/RediStack/Commands/StringCommands.swift index dbfa9a6e..eb3176d4 100644 --- a/Sources/RediStack/Commands/StringCommands.swift +++ b/Sources/RediStack/Commands/StringCommands.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import Foundation import NIO @@ -236,10 +237,17 @@ extension RedisClient { /// Gets the value of the given key. /// /// See ``RedisCommand/get(_:)`` - /// - Parameter key: The key to fetch the value from. + /// - Parameters: + /// - key: The key to fetch the value from. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves the value stored at the given key, otherwise `nil`. - public func get(_ key: RedisKey) -> EventLoopFuture { - return self.send(.get(key)) + public func get( + _ key: RedisKey, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.send(.get(key), eventLoop: eventLoop, logger: logger) } /// Gets the value of the given key, converting it to the desired type. @@ -248,13 +256,17 @@ extension RedisClient { /// - Parameters: /// - key: The key to fetch the value from. /// - type: The desired type to convert the stored data to. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves the converted value stored at the given key, otherwise `nil` if the key does not exist or fails the type conversion. @inlinable public func get( _ key: RedisKey, - as type: Value.Type = Value.self + as type: Value.Type = Value.self, + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture { - return self.get(key) + return self.get(key, eventLoop: eventLoop, logger: logger) .flatMapThrowing { $0.flatMap(Value.init(fromRESP:)) } } @@ -265,14 +277,18 @@ extension RedisClient { /// - key: The key to fetch the value from. /// - type: The JSON type to decode to. /// - decoder: The optional JSON decoder instance to use. Defaults to `.init()`. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves the decoded JSON value at the given key, otherwise `nil` if the key does not exist or JSON decoding fails. @inlinable public func get( _ key: RedisKey, asJSON type: D.Type = D.self, - decoder: JSONDecoder = .init() + decoder: JSONDecoder = .init(), + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture { - return self.get(key, as: Data.self) + return self.get(key, as: Data.self, eventLoop: eventLoop, logger: logger) .flatMapThrowing { data in return try data.map { try decoder.decode(D.self, from: $0) } } @@ -287,10 +303,17 @@ extension RedisClient { /// - Parameters: /// - key: The key to use to uniquely identify this value in Redis. /// - value: The value to set the `key` to. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves if the operation was successful. @inlinable - public func set(_ key: RedisKey, to value: Value) -> EventLoopFuture { - return self.send(.set(key, to: value)) + public func set( + _ key: RedisKey, + to value: Value, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.send(.set(key, to: value), eventLoop: eventLoop, logger: logger) } /// Sets the value stored at the given key with options to control how to set it. @@ -302,6 +325,8 @@ extension RedisClient { /// - value: The value to set the `key` to. /// - condition: The condition under which the `key` should be set. /// - expiration: The expiration to set on the `key` when setting the value. If `nil`, no expiration will be set. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation; `.ok` if successful and `.conditionNotMet` if the given `condition` was not meth. /// /// If the condition `.none` was used, then the result value will always be `.ok`. @@ -310,9 +335,11 @@ extension RedisClient { _ key: RedisKey, to value: Value, onCondition condition: RedisSetCommandCondition, - expiration: RedisSetCommandExpiration? = nil + expiration: RedisSetCommandExpiration? = nil, + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture { - return self.send(.set(key, to: value, onCondition: condition, expiration: expiration)) + return self.send(.set(key, to: value, onCondition: condition, expiration: expiration), eventLoop: eventLoop, logger: logger) } /// Sets the value stored at the given key to the given value as JSON data. @@ -323,15 +350,19 @@ extension RedisClient { /// - key: The key to use to uniquely identify this value in Redis. /// - value: The value to convert to JSON data and set the `key` to. /// - encoder: The optional JSON encoder instance to use. Defaults to `.init()`. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves if the operation was successful. @inlinable public func set( _ key: RedisKey, toJSON value: E, - encoder: JSONEncoder = .init() + encoder: JSONEncoder = .init(), + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture { do { - return try self.set(key, to: encoder.encode(value)) + return try self.set(key, to: encoder.encode(value), eventLoop: eventLoop, logger: logger) } catch { return self.eventLoop.makeFailedFuture(error) } @@ -346,6 +377,8 @@ extension RedisClient { /// - value: The value to convert to JSON data set the `key` to. /// - condition: The condition under which the `key` should be set. /// - expiration: The expiration to set on the `key` when setting the value. If `nil`, no expiration will be set. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation; `.ok` if successful and `.conditionNotMet` if the given `condition` was not meth. /// /// If the condition `.none` was used, then the result value will always be `.ok`. @@ -355,10 +388,12 @@ extension RedisClient { toJSON value: E, onCondition condition: RedisSetCommandCondition, expiration: RedisSetCommandExpiration? = nil, - encoder: JSONEncoder = .init() + encoder: JSONEncoder = .init(), + eventLoop: EventLoop? = nil, + logger: Logger? = nil ) -> EventLoopFuture { do { - return try self.send(.set(key, to: encoder.encode(value), onCondition: condition, expiration: expiration)) + return try self.send(.set(key, to: encoder.encode(value), onCondition: condition, expiration: expiration), eventLoop: eventLoop, logger: logger) } catch { return self.eventLoop.makeFailedFuture(error) } diff --git a/Sources/RediStack/ConnectionPool/ConnectionPool.swift b/Sources/RediStack/ConnectionPool/ConnectionPool.swift index 738c334b..23dc02dc 100644 --- a/Sources/RediStack/ConnectionPool/ConnectionPool.swift +++ b/Sources/RediStack/ConnectionPool/ConnectionPool.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -103,13 +103,13 @@ internal final class ConnectionPool { minimumConnectionCount: Int, leaky: Bool, loop: EventLoop, - systemContext: Context, + poolLogger: Logger, connectionBackoffFactor: Float32 = 2, initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), connectionFactory: @escaping (EventLoop) -> EventLoopFuture ) { guard minimumConnectionCount <= maximumConnectionCount else { - systemContext.critical("pool's minimum connection count is higher than the maximum") + poolLogger.critical("pool's minimum connection count is higher than the maximum") preconditionFailure("Minimum connection count must not exceed maximum") } diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index fdc7053b..a4963a9e 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -16,12 +16,10 @@ import protocol Foundation.LocalizedError import struct Logging.Logger import NIO -// - Important: Any RedisClient defined by RediStack should conform to the RedisClientWithUserContext protocol as well - /// An object capable of sending commands and receiving responses. /// /// let client = ... -/// let result = client.send(command: "GET", arguments: ["my_key"]) +/// let result = client.send(.get("my_key")) /// // result == EventLoopFuture /// /// For the full list of available commands, see [https://redis.io/commands](https://redis.io/commands) @@ -29,17 +27,27 @@ public protocol RedisClient { /// The `NIO.EventLoop` that this client operates on. var eventLoop: EventLoop { get } + /// The client's configured default logger instance, if set. + var defaultLogger: Logger? { get } + + /// Overrides the default logger on the client with the provided instance for the duration of the returned object. + /// - Parameter logger: The logger instance to use in commands on the returned client instance. + /// - Returns: A client using the temporary default logger override for command logging. + func logging(to logger: Logger) -> RedisClient + /// Sends the given command to Redis. - /// - Parameter command: The command to send to Redis for execution. + /// - Parameters: + /// - command: The command to send to Redis for execution. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that will resolve when the Redis command receives a response. /// /// If a `RedisError` is returned, the future will be failed instead. - func send(_ command: RedisCommand) -> EventLoopFuture - - /// Temporarily overrides the default logger for command logs to the provided instance. - /// - Parameter logger: The `Logging.Logger` instance to use for command logs. - /// - Returns: A RedisClient with the temporary override for command logging. - func logging(to logger: Logger) -> RedisClient + func send( + _ command: RedisCommand, + eventLoop: EventLoop?, + logger: Logger? + ) -> EventLoopFuture /// Subscribes the client to the specified Redis channels, invoking the provided message receiver each time a message is published. /// @@ -49,14 +57,19 @@ public protocol RedisClient { /// Commands issued with this client outside of that list will resolve with failures. /// /// See the [PubSub specification](https://redis.io/topics/pubsub) + /// - Important: The callbacks will be invoked on the event loop of the client, not the one passed as `eventLoop`. /// - Parameters: /// - channels: The names of channels to subscribe to. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - receiver: A closure which will be invoked each time a channel with a name in `channels` publishes a message. /// - subscribeHandler: An optional closure to be invoked when the subscription becomes active. /// - unsubscribeHandler: An optional closure to be invoked when the subscription becomes inactive. /// - Returns: A notification `NIO.EventLoopFuture` that resolves once the subscription has been registered with Redis. func subscribe( to channels: [RedisChannelName], + eventLoop: EventLoop?, + logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? @@ -73,14 +86,19 @@ public protocol RedisClient { /// Commands issues with this client outside of that list will resolve with failures. /// /// See the [PubSub specification](https://redis.io/topics/pubsub) + /// - Important: The callbacks will be invoked on the event loop of the client, not the one passed as `eventLoop`. /// - Parameters: /// - patterns: A list of glob patterns used for matching against PubSub channel names to subscribe to. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - receiver: A closure which will be invoked each time a channel with a name matching the specified pattern(s) publishes a message. /// - subscribeHandler: An optional closure to be invoked when the subscription becomes active. /// - unsubscribeHandler: An optional closure to be invoked when the subscription becomes inactive. /// - Returns: A notification `NIO.EventLoopFuture` that resolves once the subscription has been registered with Redis. func psubscribe( to patterns: [String], + eventLoop: EventLoop?, + logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? @@ -96,9 +114,12 @@ public protocol RedisClient { /// It will then be allowed to use any command like normal. /// /// See the [PubSub specification](https://redis.io/topics/pubsub) - /// - Parameter channels: A list of channel names to be unsubscribed from. + /// - Parameters: + /// - channels: A list of channel names to be unsubscribed from. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A notification `NIO.EventLoopFuture` that resolves once the subscription(s) have been removed from Redis. - func unsubscribe(from channels: [RedisChannelName]) -> EventLoopFuture + func unsubscribe(from channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture /// Unsubscribes the client from a pattern of Redis channel names from receiving any future published messages. /// @@ -109,72 +130,107 @@ public protocol RedisClient { /// It will then be allowed to use any command like normal. /// /// See the [PubSub specification](https://redis.io/topics/pubsub) - /// - Parameter patterns: A list of glob patterns to be unsubscribed from. + /// - Parameters: + /// - patterns: A list of glob patterns to be unsubscribed from. + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A notification `NIO.EventLoopFuture` that resolves once the subscription(s) have been removed from Redis. - func punsubscribe(from patterns: [String]) -> EventLoopFuture + func punsubscribe(from patterns: [String], eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture +} + +// MARK: Default implementations + +extension RedisClient { + public var defaultLogger: Logger? { nil } } // MARK: Extension Methods extension RedisClient { /// Unsubscribes the client from all active Redis channel name subscriptions. + /// - Parameters: + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves when the subscriptions have been removed. - public func unsubscribe() -> EventLoopFuture { - return self.unsubscribe(from: []) + public func unsubscribe(eventLoop: EventLoop? = nil, logger: Logger? = nil) -> EventLoopFuture { + return self.unsubscribe(from: [], eventLoop: eventLoop, logger: logger) } /// Unsubscribes the client from all active Redis channel name patterns subscriptions. + /// - Parameters: + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this command. /// - Returns: A `NIO.EventLoopFuture` that resolves when the subscriptions have been removed. - public func punsubscribe() -> EventLoopFuture { - return self.punsubscribe(from: []) + public func punsubscribe(eventLoop: EventLoop? = nil, logger: Logger? = nil) -> EventLoopFuture { + return self.punsubscribe(from: [], eventLoop: eventLoop, logger: logger) } } // MARK: Overloads extension RedisClient { - public func unsubscribe(from channels: RedisChannelName...) -> EventLoopFuture { - return self.unsubscribe(from: channels) + // variadic parameter overloads + + public func unsubscribe( + from channels: RedisChannelName..., + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.unsubscribe(from: channels, eventLoop: eventLoop, logger: logger) } - public func punsubscribe(from patterns: String...) -> EventLoopFuture { - return self.punsubscribe(from: patterns) + public func punsubscribe( + from patterns: String..., + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self.punsubscribe(from: patterns, eventLoop: eventLoop, logger: logger) } + // trailing closure swift syntax overloads + public func subscribe( to channels: [RedisChannelName], + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil ) -> EventLoopFuture { - return self.subscribe(to: channels, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } public func subscribe( to channels: RedisChannelName..., + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil ) -> EventLoopFuture { - return self.subscribe(to: channels, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } public func psubscribe( to patterns: [String], + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil ) -> EventLoopFuture { - return self.psubscribe(to: patterns, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } public func psubscribe( to patterns: String..., + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil ) -> EventLoopFuture { - return self.psubscribe(to: patterns, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } } diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index 2f1227d1..a07f9f4d 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2021 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -57,7 +57,7 @@ extension RedisConnection { var future = client .connect(to: config.address) - .map { return RedisConnection(configuredRESPChannel: $0, context: config.defaultLogger) } + .map { return RedisConnection(configuredRESPChannel: $0, defaultLogger: config.defaultLogger) } // if a password is specified, use it to authenticate before further operations happen if let password = config.password { @@ -94,9 +94,11 @@ extension RedisConnection { /// print(result) // Optional("some value") /// /// Note: `wait()` is used in the example for simplicity. Never call `wait()` on an event loop. -public final class RedisConnection: RedisClient, RedisClientWithUserContext { +public final class RedisConnection: RedisClient { /// A unique identifer to represent this connection. public let id = UUID() + public let defaultLogger: Logger + public var eventLoop: EventLoop { return self.channel.eventLoop } /// Is the connection to Redis still open? public var isConnected: Bool { @@ -142,11 +144,10 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { /// A closure to invoke when the connection closes unexpectedly. /// /// An unexpected closure is when the connection is closed by any other method than by calling `close(logger:)`. + /// - Important: This closure will be executed on the connection's ``eventLoop``. public var onUnexpectedClosure: (() -> Void)? internal let channel: Channel - private let systemContext: Context - private var logger: Logger { self.systemContext } private let autoflush: NIOAtomic = .makeAtomic(value: true) private let allowPubSub: NIOAtomic = .makeAtomic(value: true) @@ -160,18 +161,18 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { deinit { if isConnected { assertionFailure("close() was not called before deinit!") - self.logger.warning("connection was not properly shutdown before deinit") + self.defaultLogger.warning("connection was not properly shutdown before deinit") } } - internal init(configuredRESPChannel: Channel, context: Context) { + internal init(configuredRESPChannel: Channel, defaultLogger: Logger) { self.channel = configuredRESPChannel // there is a mix of verbiage here as the API is forward thinking towards "baggage context" // while right now it's just an alias of a 'Logging.logger' // in the future this will probably be a property _on_ the context - var logger = context + var logger = defaultLogger logger[metadataKey: RedisLogging.MetadataKeys.connectionID] = "\(self.id.description)" - self.systemContext = logger + self.defaultLogger = logger RedisMetrics.activeConnectionCount.increment() RedisMetrics.totalConnectionCount.increment() @@ -184,12 +185,12 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { guard self.state.isConnected else { return } self.state = .closed - self.logger.warning("connection was closed unexpectedly") + self.defaultLogger.warning("connection was closed unexpectedly") RedisMetrics.activeConnectionCount.decrement() self.onUnexpectedClosure?() } - self.logger.trace("connection created") + self.defaultLogger.trace("connection created") } internal enum ConnectionState { @@ -214,19 +215,18 @@ public final class RedisConnection: RedisClient, RedisClientWithUserContext { // MARK: Sending Commands extension RedisConnection { - public func send(_ command: RedisCommand) -> EventLoopFuture { - self.eventLoop.flatSubmit { - return self.send(command, context: nil) - } - } + public func send( + _ command: RedisCommand, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + let logger = self.prepareLoggerForUse(logger) + let finalEventLoop = eventLoop ?? self.eventLoop - internal func send(_ command: RedisCommand, context: Context?) -> EventLoopFuture { - let logger = self.prepareLoggerForUse(context) - guard self.isConnected else { let error = RedisClientError.connectionClosed logger.warning("\(error.localizedDescription)") - return self.eventLoop.makeFailedFuture(error) + return finalEventLoop.makeFailedFuture(error) } logger.trace("received command request") @@ -265,14 +265,7 @@ extension RedisConnection { return writeFuture .flatMap { promise.futureResult } .flatMapThrowing { try command.transform($0) } - } - - internal func send( - command: String, - with arguments: [RESPValue], - context: Context? - ) -> EventLoopFuture { - self.eventLoop.makeFailedFuture(RedisClientError.connectionClosed) + .hop(to: finalEventLoop) } } @@ -284,17 +277,22 @@ extension RedisConnection { /// See [https://redis.io/commands/quit](https://redis.io/commands/quit) /// - Important: Regardless if the returned `NIO.EventLoopFuture` fails or succeeds - after calling this method the connection should no longer be /// used for sending commands to Redis. - /// - Parameter logger: An optional logger instance to use while trying to close the connection. - /// If one is not provided, the pool will use its default logger. + /// - Parameters: + /// - logger: An optional logger instance to use while trying to close the connection. + /// + /// If one is not provided, the connection will use its default logger. /// - Returns: A `NIO.EventLoopFuture` that resolves when the connection has been closed. @discardableResult - public func close(logger: Logger? = nil) -> EventLoopFuture { + public func close(eventLoop: EventLoop? = nil, logger: Logger? = nil) -> EventLoopFuture { let logger = self.prepareLoggerForUse(logger) + let finalEventLoop = eventLoop ?? self.eventLoop guard self.isConnected else { // return the channel's close future, which is resolved as the last step in channel shutdown logger.info("received duplicate request to close connection") - return self.channel.closeFuture + return self.channel + .closeFuture + .hop(to: finalEventLoop) } logger.trace("received request to close the connection") @@ -303,6 +301,7 @@ extension RedisConnection { let notification = self.sendQuitCommand(logger: logger) // send "QUIT" so that all the responses are written out .flatMap { self.closeChannel() } // close the channel from our end + .hop(to: finalEventLoop) notification.whenFailure { logger.warning("failed to close connection", metadata: [ @@ -328,7 +327,8 @@ extension RedisConnection { logger.trace("sending QUIT command") - return channel.writeAndFlush(payload) // write the command + return self.channel + .writeAndFlush(payload) // write the command .flatMap { payload.responsePromise.futureResult } // chain the callback to the response's .map { _ in logger.trace("sent QUIT command") } // ignore the result's value .recover { _ in logger.debug("recovered from error sending QUIT") } // if there's an error, just return to void @@ -363,11 +363,11 @@ extension RedisConnection { extension RedisConnection { public func logging(to logger: Logger) -> RedisClient { - return UserContextRedisClient(client: self, context: self.prepareLoggerForUse(logger)) + return CustomLoggerRedisClient(defaultLogger: self.prepareLoggerForUse(logger), client: self) } private func prepareLoggerForUse(_ logger: Logger?) -> Logger { - guard var logger = logger else { return self.logger } + guard var logger = logger else { return self.defaultLogger } logger[metadataKey: RedisLogging.MetadataKeys.connectionID] = "\(self.id)" return logger } @@ -378,59 +378,45 @@ extension RedisConnection { extension RedisConnection { public func subscribe( to channels: [RedisChannelName], + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? ) -> EventLoopFuture { - return self._subscribe(.channels(channels), receiver, subscribeHandler, unsubscribeHandler, nil) + return self._subscribe(.channels(channels), receiver, subscribeHandler, unsubscribeHandler, eventLoop, logger) } - + public func psubscribe( to patterns: [String], + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil ) -> EventLoopFuture { - return self._subscribe(.patterns(patterns), receiver, subscribeHandler, unsubscribeHandler, nil) - } - - internal func subscribe( - to channels: [RedisChannelName], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, - context: Context? - ) -> EventLoopFuture { - return self._subscribe(.channels(channels), receiver, subscribeHandler, unsubscribeHandler, context) + return self._subscribe(.patterns(patterns), receiver, subscribeHandler, unsubscribeHandler, eventLoop, logger) } - - internal func psubscribe( - to patterns: [String], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, - context: Context? - ) -> EventLoopFuture { - return self._subscribe(.patterns(patterns), receiver, subscribeHandler, unsubscribeHandler, context) - } - + private func _subscribe( _ target: RedisSubscriptionTarget, _ receiver: @escaping RedisSubscriptionMessageReceiver, _ onSubscribe: RedisSubscriptionChangeHandler?, _ onUnsubscribe: RedisSubscriptionChangeHandler?, + _ eventLoop: EventLoop?, _ logger: Logger? ) -> EventLoopFuture { let logger = self.prepareLoggerForUse(logger) + let finalEventLoop = eventLoop ?? self.eventLoop logger.trace("received subscribe request") // if we're closed, just error out - guard self.state.isConnected else { return self.eventLoop.makeFailedFuture(RedisClientError.connectionClosed) } + guard self.state.isConnected else { return finalEventLoop.makeFailedFuture(RedisClientError.connectionClosed) } // if we're not allowed to to subscribe, then fail guard self.allowSubscriptions else { - return self.eventLoop.makeFailedFuture(RedisClientError.pubsubNotAllowed) + return finalEventLoop.makeFailedFuture(RedisClientError.pubsubNotAllowed) } logger.trace("adding subscription", metadata: [ @@ -470,41 +456,48 @@ extension RedisConnection { self.state = .pubsub(handler) logger.debug("the connection is now in pubsub mode") } + .hop(to: finalEventLoop) } // add the subscription and just ignore the subscription count return handler .addSubscription(for: target, messageReceiver: receiver, onSubscribe: onSubscribe, onUnsubscribe: onUnsubscribe) .map { _ in logger.trace("subscription added") } + .hop(to: finalEventLoop) } } // MARK: Leaving PubSub extension RedisConnection { - public func unsubscribe(from channels: [RedisChannelName]) -> EventLoopFuture { - return self._unsubscribe(.channels(channels), nil) - } - - public func punsubscribe(from patterns: [String]) -> EventLoopFuture { - return self._unsubscribe(.patterns(patterns), nil) - } - - internal func unsubscribe(from channels: [RedisChannelName], context: Context?) -> EventLoopFuture { - return self._unsubscribe(.channels(channels), context) + public func unsubscribe( + from channels: [RedisChannelName], + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self._unsubscribe(.channels(channels), eventLoop, logger) } - internal func punsubscribe(from patterns: [String], context: Context?) -> EventLoopFuture { - return self._unsubscribe(.patterns(patterns), context) + public func punsubscribe( + from patterns: [String], + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self._unsubscribe(.patterns(patterns), eventLoop, logger) } - private func _unsubscribe(_ target: RedisSubscriptionTarget, _ logger: Logger?) -> EventLoopFuture { + private func _unsubscribe( + _ target: RedisSubscriptionTarget, + _ eventLoop: EventLoop?, + _ logger: Logger? + ) -> EventLoopFuture { let logger = self.prepareLoggerForUse(logger) + let finalEventLoop = eventLoop ?? self.eventLoop logger.trace("received unsubscribe request") // if we're closed, just error out - guard self.state.isConnected else { return self.eventLoop.makeFailedFuture(RedisClientError.connectionClosed) } + guard self.state.isConnected else { return finalEventLoop.makeFailedFuture(RedisClientError.connectionClosed) } // if we're not in pubsub mode, then we just succeed as a no-op guard case let .pubsub(handler) = self.state else { @@ -520,7 +513,8 @@ extension RedisConnection { ]) // remove the subscription - return handler.removeSubscription(for: target) + return handler + .removeSubscription(for: target) .flatMap { // if we still have subscriptions, just succeed this request guard $0 == 0 else { @@ -538,5 +532,6 @@ extension RedisConnection { logger.debug("connection is now open to all commands") } } + .hop(to: finalEventLoop) } } diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 6932b2ce..5a9f9c34 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -30,10 +30,12 @@ import ServiceDiscovery public class RedisConnectionPool { /// A unique identifer to represent this connection. public let id = UUID() + /// The count of connections that are active and available for use. public var availableConnectionCount: Int { self.pool?.availableConnections.count ?? 0 } /// The number of connections that have been handed out and are in active use. public var leasedConnectionCount: Int { self.pool?.leasedConnectionCount ?? 0 } + public var defaultLogger: Logger { self.configuration.poolDefaultLogger } // this is not defined in the RedisClient conformance extension below because of https://bugs.swift.org/browse/SR-14985 private let loop: EventLoop // This needs to be var because we hand it a closure that references us strongly. This also @@ -90,7 +92,7 @@ public class RedisConnectionPool { minimumConnectionCount: config.minimumConnectionCount, leaky: config.maximumConnectionCount.leaky, loop: boundEventLoop, - systemContext: config.poolDefaultLogger, + poolLogger: config.poolDefaultLogger, connectionBackoffFactor: config.connectionRetryConfiguration.backoff.factor, initialConnectionBackoffDelay: config.connectionRetryConfiguration.backoff.initialDelay, connectionFactory: self.connectionFactory(_:) @@ -190,19 +192,27 @@ extension RedisConnectionPool { /// For example, if `select(database:)` is used, all future commands made with this connection will be against the selected database. /// /// To protect against future issues, make sure the final commands executed are to reset the connection to it's previous known state. - /// - Parameter operation: A closure that receives exclusive access to the provided `RedisConnection` for the lifetime of the closure for specialized Redis command chains. + /// - Parameters: + /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. + /// - logger: An optional logger instance to use for logs generated from this operation. + /// - operation: A closure that receives exclusive access to the provided connection for the lifetime of the closure for specialized Redis command chains. /// - Returns: A `NIO.EventLoopFuture` that resolves the value of the `NIO.EventLoopFuture` in the provided closure operation. @inlinable - public func leaseConnection(_ operation: @escaping (RedisConnection) -> EventLoopFuture) -> EventLoopFuture { + public func leaseConnection( + eventLoop: EventLoop? = nil, + logger: Logger? = nil, + _ operation: @escaping (RedisConnection) -> EventLoopFuture + ) -> EventLoopFuture { return self.forwardOperationToConnection( { - (connection, returnConnection, context) in + (connection, returnConnection, logger) in return operation(connection) - .always { _ in returnConnection(connection, context) } + .always { _ in returnConnection(connection, logger) } }, preferredConnection: nil, - context: nil + eventLoop: eventLoop, + taskLogger: logger ) } @@ -319,133 +329,117 @@ extension RedisConnectionPool { } } -// MARK: RedisClient conformance +// MARK: RedisClient extension RedisConnectionPool: RedisClient { public var eventLoop: EventLoop { self.loop } public func logging(to logger: Logger) -> RedisClient { - return UserContextRedisClient(client: self, context: self.prepareLoggerForUse(logger)) - } - - public func send(_ command: RedisCommand) -> EventLoopFuture { - return self.send(command, context: nil) - } - - public func subscribe( - to channels: [RedisChannelName], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - return self.subscribe( - to: channels, - messageReceiver: receiver, - onSubscribe: subscribeHandler, - onUnsubscribe: unsubscribeHandler, - context: nil - ) - } - - public func psubscribe( - to patterns: [String], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - return self.psubscribe( - to: patterns, - messageReceiver: receiver, - onSubscribe: subscribeHandler, - onUnsubscribe: unsubscribeHandler, - context: nil - ) + return CustomLoggerRedisClient(defaultLogger: logger, client: self) } - public func unsubscribe(from channels: [RedisChannelName]) -> EventLoopFuture { - return self.unsubscribe(from: channels, context: nil) - } - - public func punsubscribe(from patterns: [String]) -> EventLoopFuture { - return self.punsubscribe(from: patterns, context: nil) - } -} - -// MARK: RedisClientWithUserContext conformance -extension RedisConnectionPool: RedisClientWithUserContext { - internal func send(_ command: RedisCommand, context: Logger?) -> EventLoopFuture { + public func send( + _ command: RedisCommand, + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { return self.forwardOperationToConnection( - { (connection, returnConnection, context) in + { (connection, returnConnection, logger) in connection.sendCommandsImmediately = true return connection - .send(command, context: context) - .always { _ in returnConnection(connection, context) } + .send(command, eventLoop: eventLoop, logger: logger) + .always { _ in returnConnection(connection, logger) } }, preferredConnection: nil, - context: context + eventLoop: eventLoop, + taskLogger: logger ) + .hop(to: eventLoop ?? self.eventLoop) } - - internal func subscribe( + + public func subscribe( to channels: [RedisChannelName], + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, - context: Context? + onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? ) -> EventLoopFuture { - return self.subscribe( + return self._subscribe( using: { $0.subscribe( to: channels, + eventLoop: eventLoop, + logger: $2, messageReceiver: receiver, onSubscribe: subscribeHandler, - onUnsubscribe: $1, - context: $2 + onUnsubscribe: $1 ) }, onUnsubscribe: unsubscribeHandler, - context: context + eventLoop: eventLoop, + taskLogger: logger ) } - - internal func unsubscribe(from channels: [RedisChannelName], context: Context?) -> EventLoopFuture { - return self.unsubscribe(using: { $0.unsubscribe(from: channels, context: $1) }, context: context) - } - - internal func psubscribe( + + public func psubscribe( to patterns: [String], + eventLoop: EventLoop? = nil, + logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, - context: Context? + onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? ) -> EventLoopFuture { - return self.subscribe( + return self._subscribe( using: { $0.psubscribe( to: patterns, + eventLoop: eventLoop, + logger: $2, messageReceiver: receiver, onSubscribe: subscribeHandler, - onUnsubscribe: $1, - context: $2 + onUnsubscribe: $1 ) }, onUnsubscribe: unsubscribeHandler, - context: context + eventLoop: eventLoop, + taskLogger: logger ) } - - internal func punsubscribe(from patterns: [String], context: Context?) -> EventLoopFuture { - return self.unsubscribe(using: { $0.punsubscribe(from: patterns, context: $1) }, context: context) + + public func unsubscribe( + from channels: [RedisChannelName], + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self._unsubscribe( + using: { $0.unsubscribe(from: channels, eventLoop: eventLoop, logger: $1) }, + eventLoop: eventLoop, + taskLogger: logger + ) + } + + public func punsubscribe( + from patterns: [String], + eventLoop: EventLoop? = nil, + logger: Logger? = nil + ) -> EventLoopFuture { + return self._unsubscribe( + using: { $0.punsubscribe(from: patterns, eventLoop: eventLoop, logger: $1) }, + eventLoop: eventLoop, + taskLogger: logger + ) } - private func subscribe( - using operation: @escaping (RedisConnection, @escaping RedisSubscriptionChangeHandler, Context) -> EventLoopFuture, + private func _subscribe( + using operation: @escaping (RedisConnection, @escaping RedisSubscriptionChangeHandler, Logger) -> EventLoopFuture, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, - context: Context? + eventLoop: EventLoop?, + taskLogger: Logger? ) -> EventLoopFuture { return self.forwardOperationToConnection( - { (connection, returnConnection, context) in + { (connection, returnConnection, logger) in if self.pubsubConnection == nil { connection.allowSubscriptions = true // allow pubsub commands which are to come @@ -461,24 +455,27 @@ extension RedisConnectionPool: RedisClientWithUserContext { else { return } connection.allowSubscriptions = false // reset PubSub permissions - returnConnection(connection, context) + returnConnection(connection, logger) self.pubsubConnection = nil // break ref cycle } - return operation(connection, onUnsubscribe, context) + return operation(connection, onUnsubscribe, logger) }, preferredConnection: self.pubsubConnection, - context: context + eventLoop: eventLoop, + taskLogger: taskLogger ) + .hop(to: eventLoop ?? self.loop) } - private func unsubscribe( - using operation: @escaping (RedisConnection, Context) -> EventLoopFuture, - context: Context? + private func _unsubscribe( + using operation: @escaping (RedisConnection, Logger) -> EventLoopFuture, + eventLoop: EventLoop?, + taskLogger: Logger? ) -> EventLoopFuture { return self.forwardOperationToConnection( - { (connection, returnConnection, context) in - return operation(connection, context) + { (connection, returnConnection, logger) in + return operation(connection, logger) .always { _ in // we aren't responsible for releasing the connection, subscribing is // so we check if we have pubsub connection has been released, which indicates this might be @@ -487,19 +484,31 @@ extension RedisConnectionPool: RedisClientWithUserContext { self.pubsubConnection == nil, self.leasedConnectionCount > 0 else { return } - returnConnection(connection, context) + returnConnection(connection, logger) } }, preferredConnection: self.pubsubConnection, - context: context + eventLoop: eventLoop, + taskLogger: taskLogger ) + .hop(to: eventLoop ?? self.loop) } + /* + pool.returnConnection is safe to call from any thread, as it does an event loop check before scheduling to run + on the proper event loop for releasing the connection back into the pool + + as long as the operation just reads data from the pool or invokes the releaseConnection callback, + then it is safe to invoke any of the commands on the provided connection with the user-provided event loop + + inside the operation closure, the closure has exclusive access to the connection to do what it needs + */ @usableFromInline internal func forwardOperationToConnection( - _ operation: @escaping (RedisConnection, @escaping (RedisConnection, Context) -> Void, Context) -> EventLoopFuture, + _ operation: @escaping (RedisConnection, @escaping (RedisConnection, Logger) -> Void, Logger) -> EventLoopFuture, preferredConnection: RedisConnection?, - context: Context? + eventLoop: EventLoop?, + taskLogger: Logger? ) -> EventLoopFuture { // Establish event loop context then jump to the in-loop version. guard self.loop.inEventLoop else { @@ -507,18 +516,20 @@ extension RedisConnectionPool: RedisClientWithUserContext { return self.forwardOperationToConnection( operation, preferredConnection: preferredConnection, - context: context + eventLoop: eventLoop, + taskLogger: taskLogger ) } } self.loop.preconditionInEventLoop() + let finalEventLoop = eventLoop ?? self.loop guard let pool = self.pool else { - return self.loop.makeFailedFuture(RedisConnectionPoolError.poolClosed) + return finalEventLoop.makeFailedFuture(RedisConnectionPoolError.poolClosed) } - let logger = self.prepareLoggerForUse(context) + let logger = self.prepareLoggerForUse(taskLogger) guard let connection = preferredConnection else { return pool diff --git a/Sources/RediStack/RedisContext.swift b/Sources/RediStack/RedisContext.swift deleted file mode 100644 index 3e3229b1..00000000 --- a/Sources/RediStack/RedisContext.swift +++ /dev/null @@ -1,24 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the RediStack open source project -// -// Copyright (c) 2020 RediStack project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of RediStack project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import struct Logging.Logger - -// Right now this is just a typealias of Logger -// however, after https://forums.swift.org/t/the-context-passing-problem/39162 -// the future direction is to have a more complex 'baggage context' type that will be passed around -// so in order to be "future thinking" we create this typealias and interally refer to this passing of configuration -// as context - -@usableFromInline -internal typealias Context = Logging.Logger diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index 659635df..ef7fa03c 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -66,99 +66,56 @@ extension Logger { public static var redisBaseConnectionPoolLogger: Logger { RedisLogging.baseConnectionPoolLogger } } -// MARK: Protocol-based Context Passing - -/// An internal protocol for any `RedisClient` to conform to in order to use a user's execution context for the lifetime of a command. -/// -/// An execution context includes things like a `Logging.Logger` instance for command activity logs. -internal protocol RedisClientWithUserContext: RedisClient { - func send(_ command: RedisCommand, context: Context?) -> EventLoopFuture - - func subscribe( - to channels: [RedisChannelName], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, - context: Context? - ) -> EventLoopFuture - func unsubscribe(from channels: [RedisChannelName], context: Context?) -> EventLoopFuture - - func psubscribe( - to patterns: [String], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, - context: Context? - ) -> EventLoopFuture - func punsubscribe(from patterns: [String], context: Context?) -> EventLoopFuture -} +// MARK: RedisClient Logger Overrides -/// An internal implementation wrapper of a given `RedisClientWithUserContext` that enables users to pass a given `Logging.Logger` -/// instance to capture command logs within their preferred contexts. -internal struct UserContextRedisClient: RedisClient { +/// This is an implementation detail of baseline RediStack RedisClients that stores a reference to an underlying +/// RedisClient and a given logger instance, which is used as a new default logger on all commands. +internal struct CustomLoggerRedisClient: RedisClient { internal var eventLoop: EventLoop { self.client.eventLoop } - + + internal let defaultLogger: Logger + private let client: Client - internal let context: Context - - internal init(client: Client, context: Context) { + + internal init(defaultLogger: Logger, client: Client) { + self.defaultLogger = defaultLogger self.client = client - self.context = context } - - // Create a new instance of the custom logging implementation reusing the same client. - + + // create a new instance by just reusing the same client and passing the new logger instance + internal func logging(to logger: Logger) -> RedisClient { - return UserContextRedisClient(client: self.client, context: logger) + return Self(defaultLogger: logger, client: client) } - - // Forward the commands to the underlying client - - internal func send(_ command: RedisCommand) -> EventLoopFuture { - return self.eventLoop.flatSubmit { - return self.client.send(command, context: self.context) - } + + // forward methods to the underlying client + + // in each case we need to explicitly create a logger variable using the provided logger argument, defaulting to + // the default logger if the argument is nil, because if we do it inline, the compiler will deduce the type + // as optional, allowing the (possibly) nil argument to pass through without providing the default logger in nil cases + + internal func send(_ command: RedisCommand, eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture { + let logger = logger ?? self.defaultLogger + return self.client.send(command, eventLoop: eventLoop, logger: logger) } - - internal func unsubscribe(from channels: [RedisChannelName]) -> EventLoopFuture { - return self.eventLoop.flatSubmit { self.client.unsubscribe(from: channels, context: self.context) } + + internal func unsubscribe(from channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture { + let logger = logger ?? self.defaultLogger + return self.client.unsubscribe(from: channels, eventLoop: eventLoop, logger: logger) } - - internal func punsubscribe(from patterns: [String]) -> EventLoopFuture { - return self.eventLoop.flatSubmit { self.client.punsubscribe(from: patterns, context: self.context) } + + internal func punsubscribe(from patterns: [String], eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture { + let logger = logger ?? self.defaultLogger + return self.client.punsubscribe(from: patterns, eventLoop: eventLoop, logger: logger) } - internal func subscribe( - to channels: [RedisChannelName], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - return self.eventLoop.flatSubmit { - self.client.subscribe( - to: channels, - messageReceiver: receiver, - onSubscribe: subscribeHandler, - onUnsubscribe: unsubscribeHandler, - context: self.context - ) - } + internal func subscribe(to channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?) -> EventLoopFuture { + let logger = logger ?? self.defaultLogger + return self.client.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } - - internal func psubscribe( - to patterns: [String], - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? - ) -> EventLoopFuture { - return self.eventLoop.flatSubmit { - self.client.psubscribe( - to: patterns, - messageReceiver: receiver, - onSubscribe: subscribeHandler, - onUnsubscribe: unsubscribeHandler, - context: self.context - ) - } + + internal func psubscribe(to patterns: [String], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?) -> EventLoopFuture { + let logger = logger ?? self.defaultLogger + return self.client.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } } diff --git a/Sources/RedisTypes/RedisSet.swift b/Sources/RedisTypes/RedisSet.swift index 7796d4cb..24a6a1ef 100644 --- a/Sources/RedisTypes/RedisSet.swift +++ b/Sources/RedisTypes/RedisSet.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import struct Logging.Logger import NIO import RediStack @@ -62,20 +63,30 @@ public struct RedisSet where Element: RESPValueConvertible { private let id: RedisKey private let client: RedisClient + private let eventLoop: EventLoop + private let logger: Logger? /// Initializes a new reference to a specific Redis key that holds a Set value type. /// - Parameters: /// - identifier: The key identifier to reference this set. /// - client: The `RediStack.RedisClient` to use for making calls to Redis. - public init(identifier: RedisKey, client: RedisClient) { + /// - eventLoop: An optional event loop to hop to for any further chaining on returned event loop futures. + /// - logger: An optional logger instance to use for logs generated from commands. + public init(identifier: RedisKey, client: RedisClient, eventLoop: EventLoop? = nil, logger: Logger? = nil) { self.id = identifier self.client = client + self.eventLoop = eventLoop ?? client.eventLoop + self.logger = logger + } + + private func send(_ command: RedisCommand) -> EventLoopFuture { + return self.client.send(command, eventLoop: self.eventLoop, logger: self.logger) } /// Resolves the number of elements in the set. /// /// See `RediStack.RedisClient.scard(of:)` - public var count: EventLoopFuture { return self.client.send(.scard(of: self.id)) } + public var count: EventLoopFuture { return self.send(.scard(of: self.id)) } /// Resolves a Boolean value that indicates whether the set is empty. public var isEmpty: EventLoopFuture { return self.count.map { $0 == 0 } } /// Resolves all of elements in the set. @@ -85,7 +96,7 @@ public struct RedisSet where Element: RESPValueConvertible { /// /// See `RediStack.RedisClient.smembers(of:)` public var allElements: EventLoopFuture<[Element]> { - return self.client.send(.smembers(of: self.id)) + return self.send(.smembers(of: self.id)) .map { $0.compactMap(Element.init) } } @@ -95,7 +106,7 @@ public struct RedisSet where Element: RESPValueConvertible { /// - Parameter member: An element to look for in the set. /// - Returns: A `NIO.EventLoopFuture` resolving `true` if `member` exists in the set; otherwise, `false`. public func contains(_ member: Element) -> EventLoopFuture { - return self.client.send(.sismember(member, of: self.id)) + return self.send(.sismember(member, of: self.id)) } } @@ -118,8 +129,8 @@ extension RedisSet { /// - Parameter newMembers: The elements to insert into the set. /// - Returns: A `NIO.EventLoopFuture` resolving the number of elements inserted into the set. public func insert(contentsOf newMembers: [Element]) -> EventLoopFuture { - guard newMembers.count > 0 else { return self.client.eventLoop.makeSucceededFuture(0) } - return self.client.send(.sadd(newMembers, to: self.id)) + guard newMembers.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } + return self.send(.sadd(newMembers, to: self.id)) } } @@ -134,7 +145,7 @@ extension RedisSet { /// - other:A set of the same type as the current set. /// - Returns: A `NIO.EventLoopFuture` resolving `true` if the element was moved; otherwise, `false`. public func move(_ member: Element, to other: RedisSet) -> EventLoopFuture { - return self.client.send(.smove(member, from: self.id, to: other.id)) + return self.send(.smove(member, from: self.id, to: other.id)) } /// Removes the given element from the set. @@ -153,8 +164,8 @@ extension RedisSet { /// - Parameter members: The elements to remove from the set. /// - Returns: A `NIO.EventLoopFuture` resolving the number of elements removed from the set. public func remove(_ members: [Element]) -> EventLoopFuture { - guard members.count > 0 else { return self.client.eventLoop.makeSucceededFuture(0) } - return self.client.send(.srem(members, from: self.id)) + guard members.count > 0 else { return self.eventLoop.makeSucceededFuture(0) } + return self.send(.srem(members, from: self.id)) } /// Removes all elements from the array. @@ -162,7 +173,7 @@ extension RedisSet { /// See `RediStack.RedisClient.delete(_:)` /// - Returns: A `NIO.EventLoopFuture` resolving `true` if all elements were removed; otherwise, `false`. public func removeAll() -> EventLoopFuture { - return self.client.delete([self.id]) + return self.client.delete([self.id], eventLoop: self.eventLoop, logger: self.logger) .map { $0 == 1 } } } @@ -179,7 +190,7 @@ extension RedisSet { /// /// - Returns: A `NIO.EventLoopFuture` resolving a randomly popped element from the set, or `nil` if the set was empty. public func popRandomElement() -> EventLoopFuture { - return self.client.send(.spop(from: self.id)) + return self.send(.spop(from: self.id)) .map { response in guard response.count > 0 else { return nil } return Element(fromRESP: response[0]) @@ -195,9 +206,9 @@ extension RedisSet { /// - Parameter count: The max number of elements that should be popped from the set. /// - Returns: A `NIO.EventLoopFuture<[Element]>` resolving between `0` and `max` count of random elements in the set. public func popRandomElements(max count: Int) -> EventLoopFuture<[Element]> { - guard count >= 0 else { return self.client.eventLoop.makeFailedFuture(RedisError.indexOutOfRange) } - guard count >= 1 else { return self.client.eventLoop.makeSucceededFuture([]) } - return self.client.send(.spop(from: self.id, max: count)) + guard count >= 0 else { return self.eventLoop.makeFailedFuture(RedisError.indexOutOfRange) } + guard count >= 1 else { return self.eventLoop.makeSucceededFuture([]) } + return self.send(.spop(from: self.id, max: count)) .map { return $0.compactMap(Element.init) } } @@ -210,7 +221,7 @@ extension RedisSet { /// /// - Returns: A `NIO.EventLoopFuture` resolving a randoml element from the set, or `nil` if the set was empty. public func randomElement() -> EventLoopFuture { - return self.client.send(.srandmember(from: self.id)) + return self.send(.srandmember(from: self.id)) .map { response in guard response.count > 0 else { return nil } return Element(fromRESP: response[0]) @@ -239,7 +250,7 @@ extension RedisSet { assert(max > 0, "Max should be a positive value. Use 'allowDuplicates' to handle proper value signing.") let count = allowDuplicates ? -max : max - return self.client.send(.srandmember(from: self.id, max: count)) + return self.send(.srandmember(from: self.id, max: count)) .map { $0.compactMap(Element.init) } } } diff --git a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift index 1e5e3107..179b139e 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -18,7 +18,10 @@ import Logging import RediStackTestUtils import XCTest -final class RedisConnectionPoolTests: RediStackConnectionPoolIntegrationTestCase { +final class RedisConnectionPoolTests: RediStackConnectionPoolIntegrationTestCase { } + +// MARK: Basic Operations +extension RedisConnectionPoolTests { func test_basicPooledOperation() throws { // We're going to insert a bunch of elements into a set, and then when all is done confirm that every // element exists. @@ -155,3 +158,45 @@ extension RedisConnectionPoolTests { .wait() } } + +// MARK: EventLoop Hopping +extension RedisConnectionPoolTests { + func testCommandHopsEventLoop() throws { + let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() + + try self.pool.ping(eventLoop: eventLoop) + .map { _ in eventLoop.assertInEventLoop() } + .wait() + } + + func testSubscribeHopsEventLoop() throws { + let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() + defer { + try! self.pool + .unsubscribe(from: #function, eventLoop: eventLoop) + .map { _ in eventLoop.assertInEventLoop() } + .wait() + } + + try self.pool + .subscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .map { _ in eventLoop.assertInEventLoop() } + .wait() + } + + func testPSubscribeHopsEventLoop() throws { + let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() + defer { + try! self.pool + .punsubscribe(from: #function, eventLoop: eventLoop) + .map { _ in eventLoop.assertInEventLoop() } + .wait() + } + + try self.pool + .psubscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .map { _ in eventLoop.assertInEventLoop() } + .wait() + } +} + diff --git a/Tests/RediStackIntegrationTests/RedisConnectionTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionTests.swift index 79e8e7bc..358a614b 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import NIO @testable import RediStack import RediStackTestUtils import XCTest @@ -81,3 +82,84 @@ extension RedisConnectionTests { self.waitForExpectations(timeout: 1) } } + +// MARK: EventLoop Hopping +extension RedisConnectionTests { + func testCommandHopsEventLoop() throws { + let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() + + try self.connection.ping(eventLoop: eventLoop) + .map { _ in eventLoop.assertInEventLoop() } + .wait() + + try self.connection.ping() + .map { _ in + eventLoop.assertNotInEventLoop() + self.connection.eventLoop.assertInEventLoop() + } + .wait() + } + + func testSubscribeHopsEventLoop() throws { + let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() + defer { + try! self.connection + .unsubscribe(from: #function, eventLoop: eventLoop) + .map { _ in eventLoop.assertInEventLoop() } + .wait() + } + + try self.connection + .subscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .map { _ in eventLoop.assertInEventLoop() } + .wait() + + try self.connection + .subscribe(to: #function) { _, _ in } + .map { _ in + eventLoop.assertNotInEventLoop() + self.connection.eventLoop.assertInEventLoop() + } + .wait() + } + + func testPSubscribeHopsEventLoop() throws { + let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() + defer { + try! self.connection + .punsubscribe(from: #function, eventLoop: eventLoop) + .map { _ in eventLoop.assertInEventLoop() } + .wait() + } + + try self.connection + .psubscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .map { _ in eventLoop.assertInEventLoop() } + .wait() + + try self.connection + .psubscribe(to: #function) { _, _ in } + .map { _ in + eventLoop.assertNotInEventLoop() + self.connection.eventLoop.assertInEventLoop() + } + .wait() + } + + func testCloseHopsEventLoop() throws { + let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() + + try self.connection + .close(eventLoop: eventLoop) + .map { eventLoop.assertInEventLoop() } + .wait() + + let other = try self.makeNewConnection() + try other.close() + .map { + eventLoop.assertNotInEventLoop() + other.eventLoop.assertInEventLoop() + } + .wait() + } +} diff --git a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift index 5cfc3f30..5a49c963 100644 --- a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift +++ b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -27,7 +27,23 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { .logging(to: logger) .ping() .wait() - XCTAssertFalse(handler.messages.isEmpty) + XCTAssertFalse(handler.events.isEmpty) + } + + func test_connectionLoggerOverride_usesProvidedLoggerInstead() throws { + let defaultHandler = TestLogHandler() + let defaultLogger = Logger(label: #function, factory: { _ in return defaultHandler }) + + let expectedHandler = TestLogHandler() + let expectedLogger = Logger(label: "something_else", factory: { _ in return expectedHandler }) + + _ = try self.connection + .logging(to: defaultLogger) + .ping(logger: expectedLogger) + .wait() + + XCTAssertTrue(defaultHandler.events.isEmpty) + XCTAssertFalse(expectedHandler.events.isEmpty) } func test_connectionLoggerMetadata() throws { @@ -104,18 +120,18 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { } final class TestLogHandler: LogHandler { - var messages: [Logger.Message] var metadata: Logger.Metadata var logLevel: Logger.Level + var events: [(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, file: String, function: String, line: UInt)] init() { - self.messages = [] self.metadata = [:] + self.events = [] self.logLevel = .trace } func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, file: String, function: String, line: UInt) { - self.messages.append(message) + self.events.append((level, message, metadata, file, function, line)) } subscript(metadataKey key: String) -> Logger.Metadata.Value? { diff --git a/Tests/RediStackTests/ConnectionPoolTests.swift b/Tests/RediStackTests/ConnectionPoolTests.swift index 991c5ac8..9d021d1d 100644 --- a/Tests/RediStackTests/ConnectionPoolTests.swift +++ b/Tests/RediStackTests/ConnectionPoolTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -36,7 +36,7 @@ final class ConnectionPoolTests: XCTestCase { let channel = self.server.createConnectedChannel() // Wrap it - return RedisConnection(configuredRESPChannel: channel, context: .redisBaseConnectionLogger) + return RedisConnection(configuredRESPChannel: channel, defaultLogger: .redisBaseConnectionLogger) } func createPool(maximumConnectionCount: Int, minimumConnectionCount: Int, leaky: Bool) -> ConnectionPool { @@ -45,7 +45,7 @@ final class ConnectionPoolTests: XCTestCase { minimumConnectionCount: minimumConnectionCount, leaky: leaky, loop: self.server.loop, - systemContext: .redisBaseConnectionPoolLogger + poolLogger: .redisBaseConnectionPoolLogger ) { loop in return loop.makeSucceededFuture(self.createAConnection()) } @@ -57,7 +57,7 @@ final class ConnectionPoolTests: XCTestCase { minimumConnectionCount: minimumConnectionCount, leaky: leaky, loop: self.server.loop, - systemContext: .redisBaseConnectionPoolLogger, + poolLogger: .redisBaseConnectionPoolLogger, connectionFactory: connectionFactory ) } diff --git a/Tests/RediStackTests/RedisConnectionTests.swift b/Tests/RediStackTests/RedisConnectionTests.swift index 65727748..69e259e9 100644 --- a/Tests/RediStackTests/RedisConnectionTests.swift +++ b/Tests/RediStackTests/RedisConnectionTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2021 RediStack project authors +// Copyright (c) 2021-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -26,7 +26,10 @@ extension RedisConnectionTests { func test_connectionUnexpectedlyCloses_invokesCallback() throws { let loop = EmbeddedEventLoop() - let expectedClosureConnection = RedisConnection(configuredRESPChannel: EmbeddedChannel(loop: loop), context: Logger(label: "")) + let expectedClosureConnection = RedisConnection( + configuredRESPChannel: EmbeddedChannel(loop: loop), + defaultLogger: Logger(label: "") + ) let expectedClosureExpectation = self.expectation(description: "this should not be fulfilled") expectedClosureExpectation.isInverted = true @@ -34,7 +37,10 @@ extension RedisConnectionTests { _ = expectedClosureConnection.close(logger: nil) let channel = EmbeddedChannel(loop: loop) - let notExpectedClosureConnection = RedisConnection(configuredRESPChannel: channel, context: Logger(label: "")) + let notExpectedClosureConnection = RedisConnection( + configuredRESPChannel: channel, + defaultLogger: Logger(label: "") + ) let notExpectedClosureExpectation = self.expectation(description: "this should be fulfilled") notExpectedClosureConnection.onUnexpectedClosure = { notExpectedClosureExpectation.fulfill() } From 284b7f09bc518e87730a36686b7f25341325509b Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Tue, 19 Apr 2022 23:18:26 -0500 Subject: [PATCH 38/63] Add overload of ping command for nil message style --- Sources/RediStack/Commands/ConnectionCommands.swift | 3 +++ .../Commands/ConnectionCommandsTests.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Sources/RediStack/Commands/ConnectionCommands.swift b/Sources/RediStack/Commands/ConnectionCommands.swift index 63ff325a..31860c04 100644 --- a/Sources/RediStack/Commands/ConnectionCommands.swift +++ b/Sources/RediStack/Commands/ConnectionCommands.swift @@ -41,6 +41,9 @@ extension RedisCommand { } } + /// [PING](https://redis.io/commands/ping) + public static var ping: RedisCommand { Self.ping(with: nil) } + /// [AUTH](https://redis.io/commands/auth) /// - Parameter password: The password to authenticate with. public static func auth(with password: String) -> RedisCommand { diff --git a/Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift index 64dee20b..be9ed5b0 100644 --- a/Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/ConnectionCommandsTests.swift @@ -23,6 +23,9 @@ final class ConnectionCommandsTests: RediStackIntegrationTestCase { let second = try connection.ping(with: "My message").wait() XCTAssertEqual(second, "My message") + + let third = try connection.send(.ping).wait() + XCTAssertEqual(third, first) } func test_echo() throws { From cfb99ba0f792704d97edc8bedf65b159784ad137 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 24 Apr 2022 03:34:10 +0000 Subject: [PATCH 39/63] #100 -- Fix addPubSubHandler not checking if already added --- Sources/RediStack/Extensions/SwiftNIO.swift | 60 +++++++++++++------ Sources/RediStack/RedisConnection.swift | 6 +- .../Commands/PubSubCommandsTests.swift | 30 +++++++++- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/Sources/RediStack/Extensions/SwiftNIO.swift b/Sources/RediStack/Extensions/SwiftNIO.swift index bbac43d2..8dac3736 100644 --- a/Sources/RediStack/Extensions/SwiftNIO.swift +++ b/Sources/RediStack/Extensions/SwiftNIO.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -26,10 +26,10 @@ extension TimeAmount { // MARK: Pipeline manipulation -extension Channel { +extension ChannelPipeline { /// Adds the baseline channel handlers needed to support sending and receiving messages in Redis Serialization Protocol (RESP) format to the pipeline. /// - /// For implementation details, see `RedisMessageEncoder`, `RedisByteDecoder`, and `RedisCommandHandler`. + /// For implementation details, see ``RedisMessageEncoder``, ``RedisByteDecoder``, and ``RedisCommandHandler``. /// /// # Pipeline chart /// RedisClient.send @@ -62,20 +62,20 @@ extension Channel { (RedisCommandHandler(), "RediStack.CommandHandler") ] return .andAllSucceed( - handlers.map { self.pipeline.addHandler($0, name: $1) }, + handlers.map { self.addHandler($0, name: $1) }, on: self.eventLoop ) } /// Adds the channel handler that is responsible for handling everything related to Redis PubSub. - /// - Important: The connection that manages this channel is responsible for removing the `RedisPubSubHandler`. + /// - Important: The connection that manages this channel is responsible for removing the ``RedisPubSubHandler``. /// /// # Discussion /// PubSub responsibilities include managing subscription callbacks as well as parsing and dispatching messages received from Redis. /// - /// For implementation details, see `RedisPubSubHandler`. + /// For implementation details, see ``RedisPubSubHandler``. /// - /// The handler will be inserted in the `NIO.ChannelPipeline` just before the `RedisCommandHandler` instance. + /// The handler will be inserted in the `NIO.ChannelPipeline` just before the ``RedisCommandHandler`` instance. /// /// # Pipeline chart /// RedisClient.send @@ -106,14 +106,38 @@ extension Channel { /// | [ Socket.read ] | | [ Socket.write ] | /// +-----------------+ +------------------+ /// - Returns: A `NIO.EventLoopFuture` that resolves the instance of the PubSubHandler that was added to the pipeline. - public func addPubSubHandler() -> EventLoopFuture { - return self.pipeline - .handler(type: RedisCommandHandler.self) - .flatMap { - let pubsubHandler = RedisPubSubHandler(eventLoop: self.eventLoop) - return self.pipeline - .addHandler(pubsubHandler, name: "RediStack.PubSubHandler", position: .before($0)) - .map { pubsubHandler } + public func addRedisPubSubHandler() -> EventLoopFuture { + // first try to return the handler that already exists in the pipeline + + return self.handler(type: RedisPubSubHandler.self) + .flatMapError { + // if it doesn't exist, add it to the pipeline + guard + let error = $0 as? ChannelPipelineError, + error == .notFound + else { return self.eventLoop.makeFailedFuture($0) } + + return self.handler(type: RedisCommandHandler.self) + .flatMap { + let pubsubHandler = RedisPubSubHandler(eventLoop: self.eventLoop) + return self.addHandler(pubsubHandler, name: "RediStack.PubSubHandler", position: .before($0)) + .map { pubsubHandler } + } + } + } + + /// Removes the provided Redis PubSub handler. + /// - Returns: A `NIO.EventLoopFuture` that resolves when the handler was removed from the pipeline. + public func removeRedisPubSubHandler(_ handler: RedisPubSubHandler) -> EventLoopFuture { + self.removeHandler(handler) + .flatMapError { + // if it was already removed, then we can just succeed + guard + let error = $0 as? ChannelPipelineError, + error == .alreadyRemoved + else { return self.eventLoop.makeFailedFuture($0) } + + return self.eventLoop.makeSucceededVoidFuture() } } } @@ -124,9 +148,9 @@ extension ClientBootstrap { /// Makes a new `ClientBootstrap` instance with a baseline Redis `Channel` pipeline /// for sending and receiving messages in Redis Serialization Protocol (RESP) format. /// - /// For implementation details, see `RedisMessageEncoder`, `RedisByteDecoder`, and `RedisCommandHandler`. + /// For implementation details, see ``RedisMessageEncoder``, ``RedisByteDecoder``, and ``RedisCommandHandler``. /// - /// See also `Channel.addBaseRedisHandlers()`. + /// See also `ChannelPipeline.addBaseRedisHandlers()`. /// - Parameter group: The `EventLoopGroup` to create the `ClientBootstrap` with. /// - Returns: A TCP connection with the base configuration of a `Channel` pipeline for RESP messages. public static func makeRedisTCPClient(group: EventLoopGroup) -> ClientBootstrap { @@ -135,6 +159,6 @@ extension ClientBootstrap { ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1 ) - .channelInitializer { $0.addBaseRedisHandlers() } + .channelInitializer { $0.pipeline.addBaseRedisHandlers() } } } diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index a07f9f4d..639a39d7 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -427,8 +427,8 @@ extension RedisConnection { guard case let .pubsub(handler) = self.state else { logger.debug("not in pubsub mode, moving to pubsub mode") // otherwise, add it to the pipeline, add the subscriptions, and update our state after it was successful - return self.channel - .addPubSubHandler() + return self.channel.pipeline + .addRedisPubSubHandler() .flatMap { handler in logger.trace("handler added, adding subscription") return handler @@ -442,7 +442,7 @@ extension RedisConnection { ) // if there was an error, no subscriptions were made // so remove the handler and propogate the error to the caller by rethrowing it - return self.channel.pipeline.removeHandler(handler) + return self.channel.pipeline.removeRedisPubSubHandler(handler) .flatMapThrowing { throw error } } // success, return the handler diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index d7f9f27c..bf1ea2f7 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import NIO import RediStack import RediStackTestUtils import XCTest @@ -341,3 +342,30 @@ final class RedisPubSubCommandsPoolTests: RediStackConnectionPoolIntegrationTest XCTAssertEqual(self.pool.leasedConnectionCount, 0) } } + +// MARK: - #100 subscribe race condition + +extension RedisPubSubCommandsTests { + func test_pubsub_pipelineChanges_hasNoRaceCondition() throws { + func runOperation(_ factory: (RedisChannelName) -> EventLoopFuture) -> EventLoopFuture { + return .andAllSucceed( + (0...100_000).reduce(into: []) { + result, index in + + result.append(factory("\(#function)-\(index)")) + }, + on: self.connection.eventLoop + ) + } + + // subscribing (adding handler) + try runOperation { self.connection.subscribe(to: $0) { _, _ in } } + .wait() + + // unsubscribing (removing handler) + try runOperation { self.connection.unsubscribe(from: $0) } + .wait() + + try self.connection.close().wait() + } +} From eedf1581cf7a3cefb3ac728a278d136b5efed8df Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 24 Apr 2022 04:29:33 +0000 Subject: [PATCH 40/63] Replace remaining usage of pipeline.removeHandler(_:) --- Sources/RediStack/RedisConnection.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index 639a39d7..4996b804 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -526,7 +526,8 @@ extension RedisConnection { } logger.debug("subscription removed, with no current active subscriptions. leaving pubsub mode") // otherwise, remove the handler and update our state - return self.channel.pipeline.removeHandler(handler) + return self.channel.pipeline + .removeRedisPubSubHandler(handler) .map { self.state = .open logger.debug("connection is now open to all commands") From 820820d8770debaa4eadf15bd4fb2138bb760ec9 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 24 Apr 2022 23:37:22 -0500 Subject: [PATCH 41/63] Significantly Improve the Configuration API for Pools and Connections ## Motivation The API for establishing the configuration of a connection pool had a lot of jargon and properties that developers had issues keeping straight and understanding what each does. This commit provides first-class API support for concepts such as retry strategies, and how the pool handles connection counts. ## Changes - Add: New ConnectionCountBehavior for determining leaky / non-leaky behavior - Add: New ConnectionRetryStrategy for allowing customization of retry behavior - Change: RedisConnection.defaultPort to be a computed property - Change: The logging keys of pool connection retry metadata - Rename: Several configuration properties to drop prefixes or to be combined into new structures ## Result Developers should have a much better experience exploring the available configuration options for pools and connections, being able to understand how each piece works with the underlying system. --- .../ConnectionPool/ConnectionPool.swift | 123 ++++----- ...ft => RedisConnection+Configuration.swift} | 114 +------- .../RedisConnectionPool+Configuration.swift | 248 ++++++++++++++++++ Sources/RediStack/RedisConnectionPool.swift | 63 ++--- Sources/RediStack/RedisLogging.swift | 4 +- ...disConnectionPoolIntegrationTestCase.swift | 12 +- .../RedisConnectionPoolTests.swift | 2 +- .../RedisLoggingTests.swift | 8 +- .../RedisServiceDiscoveryTests.swift | 7 +- .../RediStackTests/ConnectionPoolTests.swift | 88 ++++--- 10 files changed, 401 insertions(+), 268 deletions(-) rename Sources/RediStack/{Configuration.swift => RedisConnection+Configuration.swift} (56%) create mode 100644 Sources/RediStack/RedisConnectionPool+Configuration.swift diff --git a/Sources/RediStack/ConnectionPool/ConnectionPool.swift b/Sources/RediStack/ConnectionPool/ConnectionPool.swift index 23dc02dc..5a1c21c0 100644 --- a/Sources/RediStack/ConnectionPool/ConnectionPool.swift +++ b/Sources/RediStack/ConnectionPool/ConnectionPool.swift @@ -48,34 +48,22 @@ internal final class ConnectionPool { /// The event loop we're on. private let loop: EventLoop - /// The exponential backoff factor for connection attempts. - internal let backoffFactor: Float32 - - /// The initial delay for backing off a reconnection attempt. - internal let initialBackoffDelay: TimeAmount - - /// The maximum number of connections the pool will preserve. Additional connections will be made available - /// past this limit if `leaky` is set to `true`, but they will not be persisted in the pool once used. - internal let maximumConnectionCount: Int + /// The strategy to use for finding and returning connections when requested. + internal let connectionRetryStrategy: RedisConnectionPool.PoolConnectionRetryStrategy /// The minimum number of connections the pool will keep alive. If a connection is disconnected while in the /// pool such that the number of connections drops below this number, the connection will be re-established. internal let minimumConnectionCount: Int + /// The maximum number of connections the pool will preserve. + internal let maximumConnectionCount: Int + /// The behavior to use for allowing or denying additional connections past the max connection count. + internal let maxConnectionCountBehavior: RedisConnectionPool.ConnectionCountBehavior.MaxConnectionBehavior /// The number of connection attempts currently outstanding. private var pendingConnectionCount: Int - /// The number of connections that have been handed out to users and are in active use. private(set) var leasedConnectionCount: Int - /// Whether this connection pool is "leaky". - /// - /// The difference between a leaky and non-leaky connection pool is their behaviour when the pool is currently - /// entirely in-use. For a leaky pool, if a connection is requested and none are available, a new connection attempt - /// will be made and the connection will be passed to the user. For a non-leaky pool, the user will wait for a connection - /// to be returned to the pool. - internal let leaky: Bool - /// The current state of this connection pool. private var state: State @@ -85,49 +73,48 @@ internal final class ConnectionPool { return self.availableConnections.count + self.pendingConnectionCount + self.leasedConnectionCount } - /// Whether a connection can be added into the availableConnections pool when it's returned. This is true - /// for non-leaky pools if the sum of availableConnections and leased connections is less than max connections, - /// and for leaky pools if the number of availableConnections is less than max connections (as we went to all - /// the effort to create the connection, we may as well keep it). - /// Note that this means connection attempts in flight may not be used for anything. This is ok! + /// Whether a connection can be added into the availableConnections pool when it's returned. private var canAddConnectionToPool: Bool { - if self.leaky { + switch self.maxConnectionCountBehavior { + // only if the current available count is less than the max + case .elastic: return self.availableConnections.count < self.maximumConnectionCount - } else { + + // only if the total connections count is less than the max + case .strict: return (self.availableConnections.count + self.leasedConnectionCount) < self.maximumConnectionCount } } internal init( - maximumConnectionCount: Int, minimumConnectionCount: Int, - leaky: Bool, + maximumConnectionCount: Int, + maxConnectionCountBehavior: RedisConnectionPool.ConnectionCountBehavior.MaxConnectionBehavior, + connectionRetryStrategy: RedisConnectionPool.PoolConnectionRetryStrategy, loop: EventLoop, poolLogger: Logger, - connectionBackoffFactor: Float32 = 2, - initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), connectionFactory: @escaping (EventLoop) -> EventLoopFuture ) { - guard minimumConnectionCount <= maximumConnectionCount else { + self.minimumConnectionCount = minimumConnectionCount + self.maximumConnectionCount = maximumConnectionCount + self.maxConnectionCountBehavior = maxConnectionCountBehavior + + guard self.minimumConnectionCount <= self.maximumConnectionCount else { poolLogger.critical("pool's minimum connection count is higher than the maximum") - preconditionFailure("Minimum connection count must not exceed maximum") + preconditionFailure("minimum connection count must not exceed maximum") } - self.connectionFactory = connectionFactory + self.pendingConnectionCount = 0 + self.leasedConnectionCount = 0 self.availableConnections = [] - self.availableConnections.reserveCapacity(maximumConnectionCount) + self.availableConnections.reserveCapacity(self.maximumConnectionCount) // 8 is a good number to skip the first few buffer resizings self.connectionWaiters = CircularBuffer(initialCapacity: 8) self.loop = loop - self.backoffFactor = connectionBackoffFactor - self.initialBackoffDelay = initialConnectionBackoffDelay + self.connectionFactory = connectionFactory + self.connectionRetryStrategy = connectionRetryStrategy - self.maximumConnectionCount = maximumConnectionCount - self.minimumConnectionCount = minimumConnectionCount - self.pendingConnectionCount = 0 - self.leasedConnectionCount = 0 - self.leaky = leaky self.state = .active } @@ -154,12 +141,13 @@ internal final class ConnectionPool { } } - func leaseConnection(deadline: NIODeadline, logger: Logger) -> EventLoopFuture { + func leaseConnection(logger: Logger, deadline: NIODeadline? = nil) -> EventLoopFuture { + let deadline = deadline ?? .now() + self.connectionRetryStrategy.timeout if self.loop.inEventLoop { - return self._leaseConnection(deadline, logger: logger) + return self._leaseConnection(logger: logger, deadline: deadline) } else { return self.loop.flatSubmit { - return self._leaseConnection(deadline, logger: logger) + return self._leaseConnection(logger: logger, deadline: deadline) } } } @@ -191,12 +179,16 @@ extension ConnectionPool { RedisLogging.MetadataKeys.connectionCount: "\(neededConnections)" ]) while neededConnections > 0 { - self._createConnection(backoff: self.initialBackoffDelay, startIn: .nanoseconds(0), logger: logger) + self._createConnection( + retryDelay: self.connectionRetryStrategy.initialDelay, + startIn: .nanoseconds(0), + logger: logger + ) neededConnections -= 1 } } - private func _createConnection(backoff: TimeAmount, startIn delay: TimeAmount, logger: Logger) { + private func _createConnection(retryDelay: TimeAmount, startIn delay: TimeAmount, logger: Logger) { self.loop.assertInEventLoop() self.pendingConnectionCount += 1 @@ -212,7 +204,7 @@ extension ConnectionPool { self.connectionCreationSucceeded(connection, logger: logger) case .failure(let error): - self.connectionCreationFailed(error, backoff: backoff, logger: logger) + self.connectionCreationFailed(error, retryDelay: retryDelay, logger: logger) } } } @@ -243,7 +235,7 @@ extension ConnectionPool { } } - private func connectionCreationFailed(_ error: Error, backoff: TimeAmount, logger: Logger) { + private func connectionCreationFailed(_ error: Error, retryDelay: TimeAmount, logger: Logger) { self.loop.assertInEventLoop() logger.warning("failed to create connection for pool", metadata: [ @@ -260,15 +252,17 @@ extension ConnectionPool { // for this connection. Waiters can time out: if they do, we can just give up this connection. // We know folks need this in the following conditions: // - // 1. For non-leaky buckets, we need this reconnection if there are any waiters AND the number of active connections (which includes + // 1. For non-elastic buckets, we need this reconnection if there are any waiters AND the number of active connections (which includes // pending connection attempts) is less than max connections - // 2. For leaky buckets, we need this reconnection if connectionWaiters.count is greater than the number of pending connection attempts. + // 2. For elastic buckets, we need this reconnection if connectionWaiters.count is greater than the number of pending connection attempts. // 3. For either kind, if the number of active connections is less than the minimum. let shouldReconnect: Bool - if self.leaky { + switch self.maxConnectionCountBehavior { + case .elastic: shouldReconnect = (self.connectionWaiters.count > self.pendingConnectionCount) || (self.minimumConnectionCount > self.activeConnectionCount) - } else { + + case .strict: shouldReconnect = (!self.connectionWaiters.isEmpty && self.maximumConnectionCount > self.activeConnectionCount) || (self.minimumConnectionCount > self.activeConnectionCount) } @@ -279,12 +273,12 @@ extension ConnectionPool { } // Ok, we need the new connection. - let newBackoff = TimeAmount.nanoseconds(Int64(Float32(backoff.nanoseconds) * self.backoffFactor)) + let nextRetryDelay = self.connectionRetryStrategy.determineNewDelay(currentDelay: retryDelay) logger.debug("reconnecting after failed connection attempt", metadata: [ - RedisLogging.MetadataKeys.poolConnectionRetryBackoff: "\(backoff)ns", - RedisLogging.MetadataKeys.poolConnectionRetryNewBackoff: "\(newBackoff)ns" + RedisLogging.MetadataKeys.poolConnectionRetryAmount: "\(retryDelay)ns", + RedisLogging.MetadataKeys.poolConnectionRetryNewAmount: "\(nextRetryDelay)ns" ]) - self._createConnection(backoff: newBackoff, startIn: backoff, logger: logger) + self._createConnection(retryDelay: nextRetryDelay, startIn: retryDelay, logger: logger) } /// A connection that was monitored by this pool has been closed. @@ -352,7 +346,7 @@ extension ConnectionPool { /// This is the on-thread implementation for leasing connections out to users. Here we work out how to get a new /// connection, and attempt to do so. - private func _leaseConnection(_ deadline: NIODeadline, logger: Logger) -> EventLoopFuture { + private func _leaseConnection(logger: Logger, deadline: NIODeadline) -> EventLoopFuture { self.loop.assertInEventLoop() guard case .active = self.state else { @@ -386,11 +380,22 @@ extension ConnectionPool { self.connectionWaiters.append(waiter) // Ok, we have connection targets. If the number of active connections is - // below the max, or the pool is leaky, we can create a new connection. Otherwise, we just have + // below the max, or the pool is elastic, we can create a new connection. Otherwise, we just have // to wait for a connection to come back. - if self.activeConnectionCount < self.maximumConnectionCount || self.leaky { + + let shouldCreateConnection: Bool + switch self.maxConnectionCountBehavior { + case .elastic: shouldCreateConnection = true + case .strict: shouldCreateConnection = false + } + + if self.activeConnectionCount < self.maximumConnectionCount || shouldCreateConnection { logger.trace("creating new connection") - self._createConnection(backoff: self.initialBackoffDelay, startIn: .nanoseconds(0), logger: logger) + self._createConnection( + retryDelay: self.connectionRetryStrategy.initialDelay, + startIn: .nanoseconds(0), + logger: logger + ) } return waiter.futureResult diff --git a/Sources/RediStack/Configuration.swift b/Sources/RediStack/RedisConnection+Configuration.swift similarity index 56% rename from Sources/RediStack/Configuration.swift rename to Sources/RediStack/RedisConnection+Configuration.swift index 573fff0d..325e4ff4 100644 --- a/Sources/RediStack/Configuration.swift +++ b/Sources/RediStack/RedisConnection+Configuration.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -65,7 +65,7 @@ extension RedisConnection { /// The default port that Redis uses. /// /// See [https://redis.io/topics/quickstart](https://redis.io/topics/quickstart) - public static var defaultPort = 6379 + public static var defaultPort: Int { 6379 } internal static let defaultLogger = Logger.redisBaseConnectionLogger @@ -203,113 +203,3 @@ extension RedisConnection { } } } - -// MARK: - RedisConnectionPool Config - -extension RedisConnectionPool { - /// A configuration object for creating Redis connections with a connection pool. - /// - Warning: This type has **reference** semantics due to the `NIO.ClientBootstrap` reference. - public struct ConnectionFactoryConfiguration { - // this needs to be var so it can be updated by the pool with the pool id - /// The logger prototype that will be used by connections by default when generating logs. - public internal(set) var connectionDefaultLogger: Logger - /// The password used to authenticate connections. - public let connectionPassword: String? - /// The initial database index that connections should use. - public let connectionInitialDatabase: Int? - /// The pre-configured TCP client for connections to use. - public let tcpClient: ClientBootstrap? - - /// Creates a new connection factory configuration with the provided options. - /// - Parameters: - /// - connectionInitialDatabase: The optional database index to initially connect to. The default is `nil`. - /// Redis by default opens connections against index `0`, so only set this value if the desired default is not `0`. - /// - connectionPassword: The optional password to authenticate connections with. The default is `nil`. - /// - connectionDefaultLogger: The optional prototype logger to use as the default logger instance when generating logs from connections. - /// If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`. - /// - tcpClient: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `.makeRedisTCPClient` factory instance. - public init( - connectionInitialDatabase: Int? = nil, - connectionPassword: String? = nil, - connectionDefaultLogger: Logger? = nil, - tcpClient: ClientBootstrap? = nil - ) { - self.connectionInitialDatabase = connectionInitialDatabase - self.connectionPassword = connectionPassword - self.connectionDefaultLogger = connectionDefaultLogger ?? RedisConnection.Configuration.defaultLogger - self.tcpClient = tcpClient - } - } - - /// A configuration object for connection pools. - /// - Warning: This type has **reference** semantics due to `ConnectionFactoryConfiguration`. - public struct Configuration { - /// The set of Redis servers to which this pool is initially willing to connect. - public let initialConnectionAddresses: [SocketAddress] - /// The minimum number of connections to preserve in the pool. - /// - /// If the pool is mostly idle and the Redis servers close these idle connections, - /// the `RedisConnectionPool` will initiate new outbound connections proactively to avoid the number of available connections dropping below this number. - public let minimumConnectionCount: Int - /// The maximum number of connections to for this pool, either to be preserved or as a hard limit. - public let maximumConnectionCount: RedisConnectionPoolSize - /// The configuration object that controls the connection retry behavior. - public let connectionRetryConfiguration: (backoff: (initialDelay: TimeAmount, factor: Float32), timeout: TimeAmount) - // these need to be var so they can be updated by the pool in some cases - public internal(set) var factoryConfiguration: ConnectionFactoryConfiguration - /// The logger prototype that will be used by the connection pool by default when generating logs. - public internal(set) var poolDefaultLogger: Logger - - /// Creates a new connection configuration with the provided options. - /// - Parameters: - /// - initialServerConnectionAddresses: The set of Redis servers to which this pool is initially willing to connect. - /// This set can be updated over time directly on the connection pool. - /// - maximumConnectionCount: The maximum number of connections to for this pool, either to be preserved or as a hard limit. - /// - connectionFactoryConfiguration: The configuration to use while creating connections to fill the pool. - /// - minimumConnectionCount: The minimum number of connections to preserve in the pool. If the pool is mostly idle - /// and the Redis servers close these idle connections, the `RedisConnectionPool` will initiate new outbound - /// connections proactively to avoid the number of available connections dropping below this number. Defaults to `1`. - /// - connectionBackoffFactor: Used when connection attempts fail to control the exponential backoff. This is a multiplicative - /// factor, each connection attempt will be delayed by this amount times the previous delay. - /// - initialConnectionBackoffDelay: If a TCP connection attempt fails, this is the first backoff value on the reconnection attempt. - /// Subsequent backoffs are computed by compounding this value by `connectionBackoffFactor`. - /// - connectionRetryTimeout: The max time to wait for a connection to be available before failing a particular command or connection operation. - /// The default is 60 seconds. - /// - poolDefaultLogger: The `Logger` used by the connection pool itself. - public init( - initialServerConnectionAddresses: [SocketAddress], - maximumConnectionCount: RedisConnectionPoolSize, - connectionFactoryConfiguration: ConnectionFactoryConfiguration, - minimumConnectionCount: Int = 1, - connectionBackoffFactor: Float32 = 2, - initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), - connectionRetryTimeout: TimeAmount? = .seconds(60), - poolDefaultLogger: Logger? = nil - ) { - self.initialConnectionAddresses = initialServerConnectionAddresses - self.maximumConnectionCount = maximumConnectionCount - self.factoryConfiguration = connectionFactoryConfiguration - self.minimumConnectionCount = minimumConnectionCount - self.connectionRetryConfiguration = ( - (initialConnectionBackoffDelay, connectionBackoffFactor), - connectionRetryTimeout ?? .milliseconds(10) // always default to a baseline 10ms - ) - self.poolDefaultLogger = poolDefaultLogger ?? .redisBaseConnectionPoolLogger - } - } -} - -/// `RedisConnectionPoolSize` controls how the maximum number of connections in a pool are interpreted. -public enum RedisConnectionPoolSize { - /// The pool will allow no more than this number of connections to be "active" (that is, connecting, in-use, - /// or pooled) at any one time. This will force possible future users of new connections to wait until a currently - /// active connection becomes available by being returned to the pool, but provides a hard upper limit on concurrency. - case maximumActiveConnections(Int) - - /// The pool will only store up to this number of connections that are not currently in-use. However, if the pool is - /// asked for more connections at one time than this number, it will create new connections to serve those waiting for - /// connections. These "extra" connections will not be preserved: while they will be used to satisfy those waiting for new - /// connections if needed, they will not be preserved in the pool if load drops low enough. This does not provide a hard - /// upper bound on concurrency, but does provide an upper bound on low-level load. - case maximumPreservedConnections(Int) -} diff --git a/Sources/RediStack/RedisConnectionPool+Configuration.swift b/Sources/RediStack/RedisConnectionPool+Configuration.swift new file mode 100644 index 00000000..0b499816 --- /dev/null +++ b/Sources/RediStack/RedisConnectionPool+Configuration.swift @@ -0,0 +1,248 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the RediStack open source project +// +// Copyright (c) 2022 RediStack project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of RediStack project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import NIO + +// MARK: - Pool Connection Congfiguration + +extension RedisConnectionPool { + /// A configuration object for a connection pool to use when creating Redis connections. + /// - Warning: This type has **reference** semantics due to the `NIO.ClientBootstrap` reference. + public struct PoolConnectionConfiguration { + // this needs to be var so it can be updated by the pool with the pool id + /// The logger that will be used by connections by default when generating logs. + public internal(set) var defaultLogger: Logger + /// The password used to authenticate connections. + public let password: String? + /// The initial database index that connections should use. + public let initialDatabase: Int? + /// The pre-configured TCP client for connections to use. + public let tcpClient: ClientBootstrap? + + /// Creates a new connection factory configuration with the provided options. + /// - Parameters: + /// - initialDatabase: The optional database index to initially connect to. The default is `nil`. + /// Redis by default opens connections against index `0`, so only set this value if the desired default is not `0`. + /// - password: The optional password to authenticate connections with. The default is `nil`. + /// - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from connections. + /// If one is not provided, one will be generated. See ``RedisLogging/baseConnectionLogger``. + /// - tcpClient: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `.makeRedisTCPClient` factory instance. + public init( + initialDatabase: Int? = nil, + password: String? = nil, + defaultLogger: Logger? = nil, + tcpClient: ClientBootstrap? = nil + ) { + self.initialDatabase = initialDatabase + self.password = password + self.defaultLogger = defaultLogger ?? RedisConnection.Configuration.defaultLogger + self.tcpClient = tcpClient + } + } +} + +// MARK: - Connection Count Behavior + +extension RedisConnectionPool { + /// The desired behavior for a connection pool to maintain its pool of "active" (connecting, in-use, or pooled) connections. + public struct ConnectionCountBehavior { + /// The pool will allow no more than the specified maximum number of connections to be "active" at any given time. + /// + /// This will force possible future users of connections to wait until an "active" connection becomes available + /// by being returned to the pool. + /// + /// In other words, this provides a hard upper limit on concurrency. + /// - Parameters: + /// - maximumConnectionCount: The maximum number of connections to preserve in the pool. + /// - minimumConnectionCount: The minimum number of connections to preserve in the pool. The default is `1`. + public static func strict(maximumConnectionCount: Int, minimumConnectionCount: Int = 1) -> Self { + return .init(min: minimumConnectionCount, max: maximumConnectionCount, behavior: .strict) + } + + /// The pool will maintain the specified number of maxiumum connections, + /// but will create more as needed based on demand. + /// + /// Connections created to meet demaind are treated as "extra" connections, + /// and will not be preserved after demand has reached below the specified ``maximumConnectionCount``. + /// + /// In other words, this does not provide a hard upper bound on concurrency, but does provide an upper bound on low-level load. + /// - Parameters: + /// - maximumConnectionCount: The maximum number of connections to preserve in the pool. + /// - minimumConnectionCount: The minimum number of connections to preserve in the pool. The default is `1`. + public static func elastic(maximumConnectionCount: Int, minimumConnectionCount: Int = 1) -> Self { + return .init(min: minimumConnectionCount, max: maximumConnectionCount, behavior: .elastic) + } + + /// Is the pool's maxiumum connection count elastic, allowing for additional on-demand connections? + public var isElastic: Bool { self.maxConnectionBehavior == .elastic } + + /// The minimum number of connections to preserve in the pool. + /// + /// If the pool is mostly idle and the Redis servers close these idle connections, + /// the ``RedisConnectionPool`` will initiate new outbound connections proactively + /// to avoid the number of available connections dropping below this number. + public let minimumConnectionCount: Int + /// The maximum number of connections to preserve in the pool. + /// + /// The actual maximum number of connections created by a pool could exceed this value, + /// based on if the behavior ``isElastic``. + public let maximumConnectionCount: Int + + internal let maxConnectionBehavior: MaxConnectionBehavior + internal enum MaxConnectionBehavior { + case strict + case elastic + } + + private init(min: Int, max: Int, behavior: MaxConnectionBehavior) { + + self.minimumConnectionCount = min + self.maximumConnectionCount = max + self.maxConnectionBehavior = behavior + } + } +} + +// MARK: - Connection Retry Strategy + +extension TimeAmount { + fileprivate static var minimumTimeoutTolerance: Self { .milliseconds(10) } +} + +extension RedisConnectionPool { + /// A definition of how a given connection pool will attempt to retry fulfilling requests for connections. + /// + /// Each strategy defines an ``initialDelay`` that will be waited before asking again for a connection. + /// + /// After that `initialDelay`, then the strategy's ``DeadlineProvider`` will be called + /// to provide a new delay value to wait. + /// + /// The strategy will continue to execute to fulfill a connection request until either + /// the ``timeout`` is reached or a connection is made available. + /// - Important: All `timeout` values are clamped to a minimum tolerance level to avoid false negative timeouts, + /// as there is a slight overhead to the connection pool's logic for finding available connections. + public struct PoolConnectionRetryStrategy { + /// A closure that receives the current retry delay and returns a new delay value to wait. + public typealias DeadlineProvider = (TimeAmount) -> TimeAmount + + /// The default timeout strategies will use. The value is `.seconds(60)`. + public static var defaultTimeout: TimeAmount { .seconds(60) } + + /// Requests for a connection from a pool will exponentially backoff polling the pool, or timeout. + /// - Parameters: + /// - initialDelay: The initial delay of further retries. The default is `.milliseconds(100)`. + /// - backoffFactor: The factor to multiply the current backoff amount by when additional retries are made. The default is `2`. + /// - timeout: The maximum amount of time to wait before retrying ends. The default is ``defaultTimeout``. + public static func exponentialBackoff( + initialDelay: TimeAmount = .milliseconds(100), + backoffFactor: Float32 = 2, + timeout: TimeAmount = Self.defaultTimeout + ) -> Self { + return .init( + initialDelay: initialDelay, + timeout: timeout, + { return .nanoseconds(Int64(Float32($0.nanoseconds) * backoffFactor)) } + ) + } + + /// No retrying will occur. Requests for a connection will fail immediately if a connection is not available. + public static var none: Self { return .none(timeout: .minimumTimeoutTolerance) } + + /// No retrying will occur. Requests for a connection will fail if a connection + /// is not available after the specified timeout. + /// - Parameter timeout: The maximum amount of time to wait before failing requests for a connection. + public static func none(timeout: TimeAmount) -> Self { + return .init(initialDelay: .zero, timeout: timeout, { _ in .zero }) + } + + public let initialDelay: TimeAmount + public let timeout: TimeAmount + private let deadlineProvider: DeadlineProvider + + /// Creates a strategy with a given initial delay value, a closure to calculate future delay values, + /// and a timeout to provide an upper limit on waiting. + /// - Parameters: + /// - initialDelay: The initial time to wait before retrying. + /// - timeout: The total time to wait before failing the request for a connection and cancelling retrying. + /// + /// The value provided is clamped to a minimum tolerance level to avoid false negative timeouts, + /// as there is a slight overhead to the connection pool's logic for finding available connections. + /// - deadlineProvider: A method of calulating new delay values, given the current delay. + public init( + initialDelay: TimeAmount, + timeout: TimeAmount = Self.defaultTimeout, + _ deadlineProvider: @escaping DeadlineProvider + ) { + self.initialDelay = initialDelay + // because there's some overhead in the connection pooling logic, + // we want a baseline minimum tolerance so we don't always have false immediate timeouts + self.timeout = timeout <= .minimumTimeoutTolerance ? .minimumTimeoutTolerance : timeout + self.deadlineProvider = deadlineProvider + } + + /// Determines the new delay amount to wait before the next retry attempt. + /// - Parameter currentDelay: The current delay value that was waited before the current retry attempt. + /// - Returns: A new delay value to wait before the next retry attempt. + public func determineNewDelay(currentDelay: TimeAmount) -> TimeAmount { + return self.deadlineProvider(currentDelay) + } + } +} + +// MARK: - Pool Configuration + +extension RedisConnectionPool { + /// A configuration object for connection pools. + /// - Warning: This type has **reference** semantics due to ``PoolConnectionConfiguration``. + public struct Configuration { + /// The set of Redis servers to which this pool is initially willing to connect. + public let initialConnectionAddresses: [SocketAddress] + /// The behavior the pool should use for maintaining its pool of "active" connections and providing connections upon request. + public let connectionCountBehavior: ConnectionCountBehavior + /// The strategy used by the connection pool to handle retrying to find an available "active" connection to use. + public let retryStrategy: PoolConnectionRetryStrategy + + // these need to be var so they can be updated by the pool in some cases + + /// The configuration used when creating connections. + public internal(set) var connectionConfiguration: PoolConnectionConfiguration + /// The logger prototype that will be used by the connection pool by default when generating logs. + public internal(set) var poolDefaultLogger: Logger + + /// Creates a new connection configuration with the provided options. + /// - Parameters: + /// - initialServerConnectionAddresses: The set of Redis servers to which this pool is initially willing to connect. + /// This set can be updated over time directly on the connection pool. + /// - connectionCountBehavior: The behavior used by the pool for maintaining it's count of connections. + /// - connectionConfiguration: The configuration to use when creating connections to fill the pool. + /// - retryStrategy: The retry strategy to apply while waiting for connections to become available for a particular command or connection operation. + /// + /// The default is ``RedisConnectionPool/PoolConnectionRetryStrategy/exponentialBackoff(initialDelay:backoffFactor:timeout:)`` with default values. + /// - poolDefaultLogger: The `Logger` used by the connection pool itself. + public init( + initialServerConnectionAddresses: [SocketAddress], + connectionCountBehavior: ConnectionCountBehavior, + connectionConfiguration: PoolConnectionConfiguration, + retryStrategy: PoolConnectionRetryStrategy = .exponentialBackoff(), + poolDefaultLogger: Logger? = nil + ) { + self.initialConnectionAddresses = initialServerConnectionAddresses + self.connectionCountBehavior = connectionCountBehavior + self.connectionConfiguration = connectionConfiguration + self.retryStrategy = retryStrategy + self.poolDefaultLogger = poolDefaultLogger ?? .redisBaseConnectionPoolLogger + } + } +} diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 5a9f9c34..791e7624 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -73,13 +73,10 @@ public class RedisConnectionPool { self.loop = boundEventLoop self.serverConnectionAddresses = ConnectionAddresses(initialAddresses: config.initialConnectionAddresses) - - // mix of terminology here with the loggers - // as we're being "forward thinking" in terms of the 'baggage context' future type - - var taggedConnectionLogger = config.factoryConfiguration.connectionDefaultLogger + + var taggedConnectionLogger = config.connectionConfiguration.defaultLogger taggedConnectionLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" - config.factoryConfiguration.connectionDefaultLogger = taggedConnectionLogger + config.connectionConfiguration.defaultLogger = taggedConnectionLogger var taggedPoolLogger = config.poolDefaultLogger taggedPoolLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" @@ -88,13 +85,12 @@ public class RedisConnectionPool { self.configuration = config self.pool = ConnectionPool( - maximumConnectionCount: config.maximumConnectionCount.size, - minimumConnectionCount: config.minimumConnectionCount, - leaky: config.maximumConnectionCount.leaky, + minimumConnectionCount: self.configuration.connectionCountBehavior.minimumConnectionCount, + maximumConnectionCount: self.configuration.connectionCountBehavior.maximumConnectionCount, + maxConnectionCountBehavior: self.configuration.connectionCountBehavior.maxConnectionBehavior, + connectionRetryStrategy: self.configuration.retryStrategy, loop: boundEventLoop, poolLogger: config.poolDefaultLogger, - connectionBackoffFactor: config.connectionRetryConfiguration.backoff.factor, - initialConnectionBackoffDelay: config.connectionRetryConfiguration.backoff.initialDelay, connectionFactory: self.connectionFactory(_:) ) } @@ -249,8 +245,6 @@ extension RedisConnectionPool { // Validate the loop invariants. self.loop.preconditionInEventLoop() targetLoop.preconditionInEventLoop() - - let factoryConfig = self.configuration.factoryConfiguration guard let nextTarget = self.serverConnectionAddresses.nextTarget() else { // No valid connection target, we'll keep track of the request and attempt to satisfy it later. @@ -270,9 +264,7 @@ extension RedisConnectionPool { do { connectionConfig = try .init( address: nextTarget, - password: factoryConfig.connectionPassword, - initialDatabase: factoryConfig.connectionInitialDatabase, - defaultLogger: factoryConfig.connectionDefaultLogger + prototypeConfiguration: self.configuration.connectionConfiguration ) } catch { // config validation failed, return the error @@ -283,7 +275,7 @@ extension RedisConnectionPool { .make( configuration: connectionConfig, boundEventLoop: targetLoop, - configuredTCPClient: factoryConfig.tcpClient + configuredTCPClient: self.configuration.connectionConfiguration.tcpClient ) .map { connection in // disallow subscriptions on all connections by default to enforce our management of PubSub state @@ -533,10 +525,7 @@ extension RedisConnectionPool: RedisClient { guard let connection = preferredConnection else { return pool - .leaseConnection( - deadline: .now() + self.configuration.connectionRetryConfiguration.timeout, - logger: logger - ) + .leaseConnection(logger: logger) .flatMap { operation($0, pool.returnConnection(_:logger:), logger) } } @@ -544,6 +533,19 @@ extension RedisConnectionPool: RedisClient { } } +// MARK: Helper for creating connection configs + +extension RedisConnection.Configuration { + fileprivate init(address: SocketAddress, prototypeConfiguration: RedisConnectionPool.PoolConnectionConfiguration) throws { + try self.init( + address: address, + password: prototypeConfiguration.password, + initialDatabase: prototypeConfiguration.initialDatabase, + defaultLogger: prototypeConfiguration.defaultLogger + ) + } +} + // MARK: Helper for round-robin connection establishment extension RedisConnectionPool { /// A helper structure for valid connection addresses. This structure implements round-robin connection establishment. @@ -581,22 +583,3 @@ extension RedisConnectionPool { } } } - -// MARK: RedisConnectionPoolSize helpers -extension RedisConnectionPoolSize { - fileprivate var size: Int { - switch self { - case .maximumActiveConnections(let size), .maximumPreservedConnections(let size): - return size - } - } - - fileprivate var leaky: Bool { - switch self { - case .maximumActiveConnections: - return false - case .maximumPreservedConnections: - return true - } - } -} diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index ef7fa03c..dd60b2d9 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -45,8 +45,8 @@ public enum RedisLogging { internal static var command: String { "rdstk_command" } internal static var commandResult: String { "rdstk_result" } internal static var connectionCount: String { "rdstk_conn_count" } - internal static var poolConnectionRetryBackoff: String { "rdstk_conn_retry_prev_backoff" } - internal static var poolConnectionRetryNewBackoff: String { "rdstk_conn_retry_new_backoff" } + internal static var poolConnectionRetryAmount: String { "rdstk_conn_retry_prev_amount" } + internal static var poolConnectionRetryNewAmount: String { "rdstk_conn_retry_new_amount" } internal static var poolConnectionCount: String { "rdstk_pool_active_connection_count" } internal static let pubsubTarget = "rdstk_ps_target" internal static let subscriptionCount = "rdstk_sub_count" diff --git a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift index 4aab3a64..f680f172 100644 --- a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -78,18 +78,16 @@ open class RedisConnectionPoolIntegrationTestCase: XCTestCase { public func makeNewPool( initialAddresses: [SocketAddress]? = nil, initialConnectionBackoffDelay: TimeAmount = .milliseconds(100), - connectionRetryTimeout: TimeAmount? = .seconds(5), + connectionRetryTimeout: TimeAmount = .seconds(5), minimumConnectionCount: Int = 0 ) throws -> RedisConnectionPool { let addresses = try initialAddresses ?? [SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort)] let pool = RedisConnectionPool( configuration: .init( initialServerConnectionAddresses: addresses, - maximumConnectionCount: .maximumActiveConnections(4), - connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword), - minimumConnectionCount: minimumConnectionCount, - initialConnectionBackoffDelay: initialConnectionBackoffDelay, - connectionRetryTimeout: connectionRetryTimeout + connectionCountBehavior: .strict(maximumConnectionCount: 4, minimumConnectionCount: minimumConnectionCount), + connectionConfiguration: .init(password: self.redisPassword), + retryStrategy: .exponentialBackoff(initialDelay: initialConnectionBackoffDelay, timeout: connectionRetryTimeout) ), boundEventLoop: self.eventLoopGroup.next() ) diff --git a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift index 179b139e..9524ed51 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift @@ -45,7 +45,7 @@ extension RedisConnectionPoolTests { } func test_nilConnectionRetryTimeoutStillWorks() throws { - let pool = try self.makeNewPool(connectionRetryTimeout: nil) + let pool = try self.makeNewPool(connectionRetryTimeout: .zero) defer { pool.close() } XCTAssertNoThrow(try pool.get(#function).wait()) } diff --git a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift index 5a49c963..d3726778 100644 --- a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift +++ b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift @@ -67,8 +67,8 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { let pool = RedisConnectionPool( configuration: .init( initialServerConnectionAddresses: [try .makeAddressResolvingHost(self.redisHostname, port: self.redisPort)], - maximumConnectionCount: .maximumActiveConnections(1), - connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword) + connectionCountBehavior: .strict(maximumConnectionCount: 1), + connectionConfiguration: .init(password: self.redisPassword) ), boundEventLoop: self.connection.eventLoop ) @@ -92,8 +92,8 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { let hosts = InMemoryServiceDiscovery(configuration: .init()) let config = RedisConnectionPool.Configuration( initialServerConnectionAddresses: [], - maximumConnectionCount: .maximumActiveConnections(1), - connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword) + connectionCountBehavior: .strict(maximumConnectionCount: 1), + connectionConfiguration: .init(password: self.redisPassword) ) let client = RedisConnectionPool.activatedServiceDiscoveryPool( service: "default.local", diff --git a/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift b/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift index 3b769f33..31249d95 100644 --- a/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift +++ b/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -24,9 +24,8 @@ final class RedisServiceDiscoveryTests: RediStackConnectionPoolIntegrationTestCa let hosts = InMemoryServiceDiscovery(configuration: .init()) let config = RedisConnectionPool.Configuration( initialServerConnectionAddresses: [], - maximumConnectionCount: .maximumActiveConnections(5), - connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword), - minimumConnectionCount: 1 + connectionCountBehavior: .strict(maximumConnectionCount: 5), + connectionConfiguration: .init(password: self.redisPassword) ) let client = RedisConnectionPool.activatedServiceDiscoveryPool( service: "default.local", diff --git a/Tests/RediStackTests/ConnectionPoolTests.swift b/Tests/RediStackTests/ConnectionPoolTests.swift index 9d021d1d..62229918 100644 --- a/Tests/RediStackTests/ConnectionPoolTests.swift +++ b/Tests/RediStackTests/ConnectionPoolTests.swift @@ -39,23 +39,33 @@ final class ConnectionPoolTests: XCTestCase { return RedisConnection(configuredRESPChannel: channel, defaultLogger: .redisBaseConnectionLogger) } - func createPool(maximumConnectionCount: Int, minimumConnectionCount: Int, leaky: Bool) -> ConnectionPool { + func createPool( + maximumConnectionCount: Int, + minimumConnectionCount: Int, + behavior: RedisConnectionPool.ConnectionCountBehavior.MaxConnectionBehavior + ) -> ConnectionPool { return ConnectionPool( - maximumConnectionCount: maximumConnectionCount, minimumConnectionCount: minimumConnectionCount, - leaky: leaky, + maximumConnectionCount: maximumConnectionCount, + maxConnectionCountBehavior: behavior, + connectionRetryStrategy: .exponentialBackoff(), loop: self.server.loop, - poolLogger: .redisBaseConnectionPoolLogger - ) { loop in - return loop.makeSucceededFuture(self.createAConnection()) - } + poolLogger: .redisBaseConnectionPoolLogger, + connectionFactory: { return $0.makeSucceededFuture(self.createAConnection()) } + ) } - func createPool(maximumConnectionCount: Int, minimumConnectionCount: Int, leaky: Bool, connectionFactory: @escaping (EventLoop) -> EventLoopFuture) -> ConnectionPool { + func createPool( + maximumConnectionCount: Int, + minimumConnectionCount: Int, + behavior: RedisConnectionPool.ConnectionCountBehavior.MaxConnectionBehavior, + connectionFactory: @escaping (EventLoop) -> EventLoopFuture + ) -> ConnectionPool { return ConnectionPool( - maximumConnectionCount: maximumConnectionCount, minimumConnectionCount: minimumConnectionCount, - leaky: leaky, + maximumConnectionCount: maximumConnectionCount, + maxConnectionCountBehavior: behavior, + connectionRetryStrategy: .exponentialBackoff(), loop: self.server.loop, poolLogger: .redisBaseConnectionPoolLogger, connectionFactory: connectionFactory @@ -63,7 +73,7 @@ final class ConnectionPoolTests: XCTestCase { } func testPoolMaintainsMinimumConnections() throws { - let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 4, leaky: true) + let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 4, behavior: .elastic) XCTAssertNoThrow(try self.server.runWhileActive()) XCTAssertEqual(self.server.channels.count, 0) @@ -93,7 +103,7 @@ final class ConnectionPoolTests: XCTestCase { } func testConnectionPoolCanLeaseConnections() throws { - let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 4, leaky: true) + let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 4, behavior: .elastic) defer { pool.close() } @@ -120,7 +130,7 @@ final class ConnectionPoolTests: XCTestCase { } func testNonLeakyParallelLease() throws { - let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 1, leaky: false) + let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 1, behavior: .strict) defer { pool.close() } @@ -179,7 +189,7 @@ final class ConnectionPoolTests: XCTestCase { } func testLeakyParallelLease() throws { - let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 1, leaky: true) + let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 1, behavior: .elastic) defer { pool.close() } @@ -231,7 +241,7 @@ final class ConnectionPoolTests: XCTestCase { } func testReturningClosedConnectionsGetReopened() throws { - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, behavior: .strict) defer { pool.close() } @@ -266,7 +276,7 @@ final class ConnectionPoolTests: XCTestCase { } func testLeasingFromClosedPoolsFails() throws { - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, behavior: .strict) pool.activate() pool.close() @@ -276,7 +286,7 @@ final class ConnectionPoolTests: XCTestCase { } func testNothingBadHappensWhenYouRepeatedlyCloseAPool() throws { - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, behavior: .strict) pool.activate() // Just spam close @@ -286,7 +296,7 @@ final class ConnectionPoolTests: XCTestCase { } func testPendingWaitersAreFailedOnPoolClose() throws { - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, behavior: .strict) defer { pool.close() } @@ -322,7 +332,7 @@ final class ConnectionPoolTests: XCTestCase { func testConnectionsThatCompleteAfterCloseAreClosed() throws { var connectionPromise: EventLoopPromise? = nil - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) { loop in + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, behavior: .strict) { loop in XCTAssertTrue(loop === self.server.loop) connectionPromise = self.server.loop.makePromise() return connectionPromise!.futureResult @@ -345,7 +355,7 @@ final class ConnectionPoolTests: XCTestCase { func testConnectionsCanFailAfterCloseWithoutIncident() throws { var connectionPromise: EventLoopPromise? = nil - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) { loop in + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, behavior: .strict) { loop in XCTAssertTrue(loop === self.server.loop) connectionPromise = self.server.loop.makePromise() return connectionPromise!.futureResult @@ -372,7 +382,7 @@ final class ConnectionPoolTests: XCTestCase { func testExponentialConnectionBackoff() throws { var connectionPromise: EventLoopPromise? = nil - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, leaky: false) { loop in + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 1, behavior: .strict) { loop in XCTAssertTrue(loop === self.server.loop) connectionPromise = self.server.loop.makePromise() return connectionPromise!.futureResult @@ -382,7 +392,7 @@ final class ConnectionPoolTests: XCTestCase { XCTAssertEqual(self.server.channels.count, 0) XCTAssertNotNil(connectionPromise) - var delay = pool.initialBackoffDelay + var delay = pool.connectionRetryStrategy.initialDelay let oneNanosecond = TimeAmount.nanoseconds(1) for _ in 0..<10 { let promise = connectionPromise @@ -394,7 +404,7 @@ final class ConnectionPoolTests: XCTestCase { self.server.loop.advanceTime(by: oneNanosecond) XCTAssertNotNil(connectionPromise) - delay = .nanoseconds(Int64(Float32(delay.nanoseconds) * pool.backoffFactor)) + delay = pool.connectionRetryStrategy.determineNewDelay(currentDelay: delay) } pool.close() @@ -403,7 +413,7 @@ final class ConnectionPoolTests: XCTestCase { func testNonLeakyBucketWillKeepConnectingIfThereIsSpaceAndWaiters() throws { var connectionPromise: EventLoopPromise? = nil - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, leaky: false) { loop in + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, behavior: .strict) { loop in XCTAssertTrue(loop === self.server.loop) connectionPromise = self.server.loop.makePromise() return connectionPromise!.futureResult @@ -418,7 +428,7 @@ final class ConnectionPoolTests: XCTestCase { XCTAssertNoThrow(try self.server.runWhileActive()) XCTAssertNotNil(connectionPromise) - var delay = pool.initialBackoffDelay + var delay = pool.connectionRetryStrategy.initialDelay let oneNanosecond = TimeAmount.nanoseconds(1) for _ in 0..<10 { let promise = connectionPromise @@ -430,7 +440,7 @@ final class ConnectionPoolTests: XCTestCase { self.server.loop.advanceTime(by: oneNanosecond) XCTAssertNotNil(connectionPromise) - delay = .nanoseconds(Int64(Float32(delay.nanoseconds) * pool.backoffFactor)) + delay = pool.connectionRetryStrategy.determineNewDelay(currentDelay: delay) } pool.close() @@ -442,7 +452,7 @@ final class ConnectionPoolTests: XCTestCase { func testLeakyBucketWillKeepConnectingIfThereAreWaitersEvenIfTheresNoSpace() throws { var connectionPromise: EventLoopPromise? = nil - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, leaky: true) { loop in + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, behavior: .elastic) { loop in XCTAssertTrue(loop === self.server.loop) connectionPromise = self.server.loop.makePromise() return connectionPromise!.futureResult @@ -469,7 +479,7 @@ final class ConnectionPoolTests: XCTestCase { XCTAssertNoThrow(try self.server.runWhileActive()) XCTAssertNotNil(connectionPromise) - var delay = pool.initialBackoffDelay + var delay = pool.connectionRetryStrategy.initialDelay let oneNanosecond = TimeAmount.nanoseconds(1) for _ in 0..<10 { let promise = connectionPromise @@ -481,7 +491,7 @@ final class ConnectionPoolTests: XCTestCase { self.server.loop.advanceTime(by: oneNanosecond) XCTAssertNotNil(connectionPromise) - delay = .nanoseconds(Int64(Float32(delay.nanoseconds) * pool.backoffFactor)) + delay = pool.connectionRetryStrategy.determineNewDelay(currentDelay: delay) } pool.close() @@ -493,7 +503,7 @@ final class ConnectionPoolTests: XCTestCase { func testDeadlinesWork() throws { var promises: [EventLoopPromise] = [] - let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 0, leaky: true) { loop in + let pool = self.createPool(maximumConnectionCount: 8, minimumConnectionCount: 0, behavior: .elastic) { loop in let connectionPromise = self.server.loop.makePromise(of: RedisConnection.self) promises.append(connectionPromise) return connectionPromise.futureResult @@ -550,7 +560,7 @@ final class ConnectionPoolTests: XCTestCase { func testPoolWillStoreConnectionIfWaiterGoesAway() throws { var connectionPromise: EventLoopPromise? = nil - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, leaky: true) { loop in + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, behavior: .elastic) { loop in XCTAssertTrue(loop === self.server.loop) connectionPromise = self.server.loop.makePromise() return connectionPromise!.futureResult @@ -586,7 +596,7 @@ final class ConnectionPoolTests: XCTestCase { } func testPoolCorrectlyClosesItselfWhenLeasedConnectionsAreReturned() throws { - let pool = self.createPool(maximumConnectionCount: 2, minimumConnectionCount: 1, leaky: false) + let pool = self.createPool(maximumConnectionCount: 2, minimumConnectionCount: 1, behavior: .strict) defer { pool.close() } @@ -615,7 +625,7 @@ final class ConnectionPoolTests: XCTestCase { func testLeasedConnectionsInExcessOfMaxReplacePooledOnes() throws { // This test validates that if a leaky pool has allowed extra connections, and all those connections are // returned back, the active connections are the ones that were returned to the pool last. - let pool = self.createPool(maximumConnectionCount: 4, minimumConnectionCount: 0, leaky: true) + let pool = self.createPool(maximumConnectionCount: 4, minimumConnectionCount: 0, behavior: .elastic) defer { pool.close() } @@ -651,9 +661,9 @@ final class ConnectionPoolTests: XCTestCase { } extension ConnectionPoolTests { - private func stopReconnectingIfThereAreNoWaiters(leaky: Bool) throws { + private func stopReconnectingIfThereAreNoWaiters(behavior: RedisConnectionPool.ConnectionCountBehavior.MaxConnectionBehavior) throws { var connectionPromise: EventLoopPromise? = nil - let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, leaky: leaky) { loop in + let pool = self.createPool(maximumConnectionCount: 1, minimumConnectionCount: 0, behavior: behavior) { loop in XCTAssertTrue(loop === self.server.loop) connectionPromise = self.server.loop.makePromise() return connectionPromise!.futureResult @@ -683,7 +693,7 @@ extension ConnectionPoolTests { XCTAssertNil(connectionPromise) // Now advance time the remaining amount. - XCTAssertNoThrow(self.server.loop.advanceTime(by: pool.initialBackoffDelay)) + XCTAssertNoThrow(self.server.loop.advanceTime(by: pool.connectionRetryStrategy.initialDelay)) XCTAssertNoThrow(try self.server.runWhileActive()) XCTAssertNotNil(connectionPromise) @@ -699,12 +709,12 @@ extension ConnectionPoolTests { } func testLeakyPoolStopsReconnecting() throws { - try self.stopReconnectingIfThereAreNoWaiters(leaky: true) + try self.stopReconnectingIfThereAreNoWaiters(behavior: .elastic) } func testNonLeakyPoolStopsReconnectingIfThereAreNoWaiters() throws { // This is the same as the test above, but the pool isn't leaky. - try self.stopReconnectingIfThereAreNoWaiters(leaky: false) + try self.stopReconnectingIfThereAreNoWaiters(behavior: .strict) } } @@ -714,7 +724,7 @@ extension ConnectionPool { func activate() { self.activate(logger: .redisBaseConnectionPoolLogger) } func leaseConnection(deadline: NIODeadline) -> EventLoopFuture { - return self.leaseConnection(deadline: deadline, logger: .redisBaseConnectionPoolLogger) + return self.leaseConnection(logger: .redisBaseConnectionPoolLogger, deadline: deadline) } func returnConnection(_ connection: RedisConnection) { From 555062c62e1568ed3125a51103ac42a9e4f7a626 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 24 Apr 2022 23:32:06 -0500 Subject: [PATCH 42/63] [Docs] Fix symbol links resolution for various redis commands --- Sources/RediStack/Commands/HashCommands.swift | 4 ++-- Sources/RediStack/Commands/KeyCommands.swift | 4 ++-- Sources/RediStack/Commands/SortedSetCommands.swift | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/RediStack/Commands/HashCommands.swift b/Sources/RediStack/Commands/HashCommands.swift index cc9cf064..06aef550 100644 --- a/Sources/RediStack/Commands/HashCommands.swift +++ b/Sources/RediStack/Commands/HashCommands.swift @@ -149,7 +149,7 @@ extension RedisCommand { } /// [HSET](https://redis.io/commands/hset) - /// - Note: If you do not want to overwrite existing values, use ``hsetnx(_:field:to:)``. + /// - Note: If you do not want to overwrite existing values, use ``hsetnx(_:to:in:)``. /// - Parameters: /// - field: The key of the field in the hash being set. /// - value: The value the hash field should be set to. @@ -169,7 +169,7 @@ extension RedisCommand { } /// [HSETNX](https://redis.io/commands/hsetnx) - /// - Note: If you do not care about overwriting existing values, use ``hset(_:field:to:)``. + /// - Note: If you do not care about overwriting existing values, use ``hset(_:to:in:)``. /// - Parameters: /// - field: The key of the field in the hash being set. /// - value: The value the hash field should be set to. diff --git a/Sources/RediStack/Commands/KeyCommands.swift b/Sources/RediStack/Commands/KeyCommands.swift index b2839e08..a0395a0f 100644 --- a/Sources/RediStack/Commands/KeyCommands.swift +++ b/Sources/RediStack/Commands/KeyCommands.swift @@ -94,7 +94,7 @@ extension RedisCommand { extension RedisClient { /// Deletes the given keys. Any key that does not exist is ignored. /// - /// See ``RedisCommand/.del(keys:)`` + /// See ``RedisCommand/del(_:)``. /// - Parameters: /// - keys: The list of keys to delete from the database. /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. @@ -110,7 +110,7 @@ extension RedisClient { /// Deletes the given keys. Any key that does not exist is ignored. /// - /// See ``RedisCommand/del(keys:)`` + /// See ``RedisCommand/del(_:)`` /// - Parameters: /// - keys: The list of keys to delete from the database. /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. diff --git a/Sources/RediStack/Commands/SortedSetCommands.swift b/Sources/RediStack/Commands/SortedSetCommands.swift index 3376a1c1..1f7f0fb6 100644 --- a/Sources/RediStack/Commands/SortedSetCommands.swift +++ b/Sources/RediStack/Commands/SortedSetCommands.swift @@ -587,7 +587,7 @@ extension RedisCommand { /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see ``zrevrangebyscore(from:withScores:limitBy:returning:)``. + /// For the inverse, see ``zrevrangebyscore(from:withScores:limitBy:returning:)-2vp67``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The inclusive range of scores to filter elements by. @@ -611,7 +611,7 @@ extension RedisCommand { /// [ZRANGEBYSCORE](https://redis.io/commands/zrangebyscore) /// - Important: This treats the SortedSet as ordered from **low** to **high**. /// - /// For the inverse, see ``zrevrangebyscore(from:withScores:limitBy:returning:)``. + /// For the inverse, see ``zrevrangebyscore(from:withScores:limitBy:returning:)-3jdpl``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. @@ -891,7 +891,7 @@ extension RedisCommand { /// If you need such a range, use ``zrevrange(from:firstIndex:lastIndex:)`` instead. /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see ``zrange(from:indices:returning:)``. + /// For the inverse, see ``zrange(from:indices:returning:)-95y9o``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - range: The range of inclusive indices of elements to get. @@ -910,7 +910,7 @@ extension RedisCommand { /// If you need such a range, use ``zrevrange(from:firstIndex:lastIndex:)`` instead. /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see ``zrange(from:indices:returning:)``. + /// For the inverse, see ``zrange(from:indices:returning:)-4pd8n``. /// - Parameters: /// - key: The key of the SortedSet to return elements from. /// - range: The range of indices (inclusive lower, exclusive upper) elements to get. @@ -989,7 +989,7 @@ extension RedisCommand { /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see ``zrangebyscore(from:withScores:limitBy:returning:)``. + /// For the inverse, see ``zrangebyscore(from:withScores:limitBy:returning:)-phw``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: The inclusive range of scores to filter elements by. @@ -1013,7 +1013,7 @@ extension RedisCommand { /// [ZREVRANGEBYSCORE](https://redis.io/commands/zrevrangebyscore) /// - Important: This treats the SortedSet as ordered from **high** to **low**. /// - /// For the inverse, see ``zrangebyscore(from:withScores:limitBy:returning:)``. + /// For the inverse, see ``zrangebyscore(from:withScores:limitBy:returning:)-4ukbv``. /// - Parameters: /// - key: The key of the SortedSet. /// - range: A range with an inclusive lower and exclusive upper bound of scores to filter elements by. From c76203c61a7083203c4a8c70ee5119832afed6dc Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 17 Apr 2022 23:30:44 -0500 Subject: [PATCH 43/63] #103 -- Provide greater context to Pub/Sub Unsubscribe events --- .../ChannelHandlers/RedisPubSubHandler.swift | 59 +++++++++---- Sources/RediStack/RedisClient.swift | 24 +++--- Sources/RediStack/RedisConnection.swift | 12 +-- Sources/RediStack/RedisConnectionPool.swift | 18 ++-- Sources/RediStack/RedisLogging.swift | 4 +- .../Commands/PubSubCommandsTests.swift | 85 +++++++++++++------ 6 files changed, 132 insertions(+), 70 deletions(-) diff --git a/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift b/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift index 1eb3770d..0984a268 100644 --- a/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift +++ b/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -24,18 +24,44 @@ import NIO /// - message: The message data that was received from the `publisher`. public typealias RedisSubscriptionMessageReceiver = (_ publisher: RedisChannelName, _ message: RESPValue) -> Void -/// A closure handler invoked for Pub/Sub subscription changes. +/// The details of the subscription change. +/// - Parameters: +/// - subscriptionKey: The subscribed channel or pattern that had its subscription status changed. +/// - currentSubscriptionCount: The current total number of subscriptions the connection has. +public typealias RedisSubscriptionChangeDetails = (subscriptionKey: String, currentSubscriptionCount: Int) + +/// A closure handler invoked for Pub/Sub subscribe commands. +/// +/// This closure will be invoked only *once* for each individual channel or pattern that is having its subscription changed, +/// even if it was done as a single PSUBSCRIBE or SUBSCRIBE command. +/// - Warning: The receiver is called on the same `NIO.EventLoop` that processed the message. +/// +/// If you are doing non-trivial work in response to PubSub messages, it is **highly recommended** that the work be dispatched to another thread +/// so as to not block further messages from being processed. +/// - Parameter details: The details of the subscription. +public typealias RedisSubscribeHandler = (_ details: RedisSubscriptionChangeDetails) -> Void + +/// An enumeration of possible sources of Pub/Sub unsubscribe events. +public enum RedisUnsubscribeEventSource { + /// The client sent an unsubscribe command either as UNSUBSCRIBE or PUNSUBSCRIBE. + case userInitiated + /// The client encountered an error and had to unsubscribe. + /// - Parameter _: The error the client encountered. + case clientError(_ error: Error) +} + +/// A closure handler invoked for Pub/Sub unsubscribe commands. /// /// This closure will be invoked only *once* for each individual channel or pattern that is having its subscription changed, -/// even if it was done as a single PSUBSCRIBE, SUBSCRIBE, PUNSUBSCRIBE, or UNSUBSCRIBE command. +/// even if it was done as a single PUNSUBSCRIBE or UNSUBSCRIBE command. /// - Warning: The receiver is called on the same `NIO.EventLoop` that processed the message. /// /// If you are doing non-trivial work in response to PubSub messages, it is **highly recommended** that the work be dispatched to another thread /// so as to not block further messages from being processed. /// - Parameters: -/// - subscriptionKey: The subscribed channel or pattern that had its subscription status changed. -/// - currentSubscriptionCount: The current total number of subscriptions the connection has. -public typealias RedisSubscriptionChangeHandler = (_ subscriptionKey: String, _ currentSubscriptionCount: Int) -> Void +/// - details: The details of the subscription. +/// - source: The source of the unsubscribe event. +public typealias RedisUnsubscribeHandler = (_ details: RedisSubscriptionChangeDetails, _ source: RedisUnsubscribeEventSource) -> Void /// A list of patterns or channels that a Pub/Sub subscription change is targetting. /// @@ -146,7 +172,7 @@ extension RedisPubSubHandler { guard let subscription = self.subscriptions[prefixedKey] else { return } - subscription.onSubscribe?(subscriptionKey, subscriptionCount) + subscription.onSubscribe?((subscriptionKey, subscriptionCount)) subscription.onSubscribe = nil // nil to free memory self.subscriptions[prefixedKey] = subscription @@ -161,8 +187,8 @@ extension RedisPubSubHandler { ) { let prefixedKey = self.prefixKey(subscriptionKey, with: keyPrefix) guard let subscription = self.subscriptions.removeValue(forKey: prefixedKey) else { return } - - subscription.onUnsubscribe?(subscriptionKey, subscriptionCount) + + subscription.onUnsubscribe?((subscriptionKey, subscriptionCount), .userInitiated) subscription.type.gauge.decrement() switch self.pendingUnsubscribes.removeValue(forKey: prefixedKey) { @@ -208,8 +234,8 @@ extension RedisPubSubHandler { public func addSubscription( for target: RedisSubscriptionTarget, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? + onSubscribe subscribeHandler: RedisSubscribeHandler?, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? ) -> EventLoopFuture { guard self.eventLoop.inEventLoop else { return self.eventLoop.flatSubmit { @@ -481,7 +507,8 @@ extension RedisPubSubHandler: ChannelInboundHandler { let receivers = self.subscriptions self.subscriptions.removeAll() receivers.forEach { - $0.value.onUnsubscribe?($0.key, 0) + let source: RedisUnsubscribeEventSource = error.map { .clientError($0) } ?? .userInitiated + $0.value.onUnsubscribe?(($0.key, 0), source) $0.value.type.gauge.decrement() } } @@ -521,14 +548,14 @@ extension RedisPubSubHandler { fileprivate final class Subscription { let type: SubscriptionType let onMessage: RedisSubscriptionMessageReceiver - var onSubscribe: RedisSubscriptionChangeHandler? // will be set to nil after first call - let onUnsubscribe: RedisSubscriptionChangeHandler? + var onSubscribe: RedisSubscribeHandler? // will be set to nil after first call + let onUnsubscribe: RedisUnsubscribeHandler? init( type: SubscriptionType, messageReceiver: @escaping RedisSubscriptionMessageReceiver, - subscribeHandler: RedisSubscriptionChangeHandler?, - unsubscribeHandler: RedisSubscriptionChangeHandler? + subscribeHandler: RedisSubscribeHandler?, + unsubscribeHandler: RedisUnsubscribeHandler? ) { self.type = type self.onMessage = messageReceiver diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index a4963a9e..9e8344bf 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -71,8 +71,8 @@ public protocol RedisClient { eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? + onSubscribe subscribeHandler: RedisSubscribeHandler?, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? ) -> EventLoopFuture /// Subscribes the client to the specified Redis channel name patterns, invoking the provided message receiver each time a message is published to @@ -100,8 +100,8 @@ public protocol RedisClient { eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? + onSubscribe subscribeHandler: RedisSubscribeHandler?, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? ) -> EventLoopFuture /// Unsubscribes the client from a specific Redis channel from receiving any future published messages. @@ -194,8 +194,8 @@ extension RedisClient { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil + onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil ) -> EventLoopFuture { return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } @@ -205,8 +205,8 @@ extension RedisClient { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil + onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil ) -> EventLoopFuture { return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } @@ -216,8 +216,8 @@ extension RedisClient { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil + onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil ) -> EventLoopFuture { return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } @@ -227,8 +227,8 @@ extension RedisClient { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil + onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil ) -> EventLoopFuture { return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index 4996b804..f1688152 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -381,8 +381,8 @@ extension RedisConnection { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? + onSubscribe subscribeHandler: RedisSubscribeHandler?, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? ) -> EventLoopFuture { return self._subscribe(.channels(channels), receiver, subscribeHandler, unsubscribeHandler, eventLoop, logger) } @@ -392,8 +392,8 @@ extension RedisConnection { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? = nil + onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil ) -> EventLoopFuture { return self._subscribe(.patterns(patterns), receiver, subscribeHandler, unsubscribeHandler, eventLoop, logger) } @@ -401,8 +401,8 @@ extension RedisConnection { private func _subscribe( _ target: RedisSubscriptionTarget, _ receiver: @escaping RedisSubscriptionMessageReceiver, - _ onSubscribe: RedisSubscriptionChangeHandler?, - _ onUnsubscribe: RedisSubscriptionChangeHandler?, + _ onSubscribe: RedisSubscribeHandler?, + _ onUnsubscribe: RedisUnsubscribeHandler?, _ eventLoop: EventLoop?, _ logger: Logger? ) -> EventLoopFuture { diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 791e7624..5175b4bf 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -355,8 +355,8 @@ extension RedisConnectionPool: RedisClient { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? + onSubscribe subscribeHandler: RedisSubscribeHandler?, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? ) -> EventLoopFuture { return self._subscribe( using: { @@ -380,8 +380,8 @@ extension RedisConnectionPool: RedisClient { eventLoop: EventLoop? = nil, logger: Logger? = nil, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler? + onSubscribe subscribeHandler: RedisSubscribeHandler?, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? ) -> EventLoopFuture { return self._subscribe( using: { @@ -425,8 +425,8 @@ extension RedisConnectionPool: RedisClient { } private func _subscribe( - using operation: @escaping (RedisConnection, @escaping RedisSubscriptionChangeHandler, Logger) -> EventLoopFuture, - onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?, + using operation: @escaping (RedisConnection, @escaping RedisUnsubscribeHandler, Logger) -> EventLoopFuture, + onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler?, eventLoop: EventLoop?, taskLogger: Logger? ) -> EventLoopFuture { @@ -438,11 +438,11 @@ extension RedisConnectionPool: RedisClient { self.pubsubConnection = connection } - let onUnsubscribe: RedisSubscriptionChangeHandler = { channelName, subCount in - defer { unsubscribeHandler?(channelName, subCount) } + let onUnsubscribe: RedisUnsubscribeHandler = { subscriptionDetails, eventSource in + defer { unsubscribeHandler?(subscriptionDetails, eventSource) } guard - subCount == 0, + subscriptionDetails.currentSubscriptionCount == 0, let connection = self.pubsubConnection else { return } diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index dd60b2d9..34c26d3d 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -109,12 +109,12 @@ internal struct CustomLoggerRedisClient: RedisClient { return self.client.punsubscribe(from: patterns, eventLoop: eventLoop, logger: logger) } - internal func subscribe(to channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?) -> EventLoopFuture { + internal func subscribe(to channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscribeHandler?, onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler?) -> EventLoopFuture { let logger = logger ?? self.defaultLogger return self.client.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } - internal func psubscribe(to patterns: [String], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscriptionChangeHandler?, onUnsubscribe unsubscribeHandler: RedisSubscriptionChangeHandler?) -> EventLoopFuture { + internal func psubscribe(to patterns: [String], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscribeHandler?, onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler?) -> EventLoopFuture { let logger = logger ?? self.defaultLogger return self.client.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) } diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index bf1ea2f7..13e1f7b3 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -13,7 +13,8 @@ //===----------------------------------------------------------------------===// import NIO -import RediStack +import NIOEmbedded +@testable import RediStack import RediStackTestUtils import XCTest @@ -41,9 +42,16 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { guard $0 == #function, $1 == 1 else { return } subscribeExpectation.fulfill() }, - onUnsubscribe: { - guard $0 == #function, $1 == 0 else { return } - unsubscribeExpectation.fulfill() + onUnsubscribe: { details, eventSource in + switch eventSource { + case .clientError: return + case .userInitiated: + guard + details.subscriptionKey == #function, + details.currentSubscriptionCount == 0 + else { return } + unsubscribeExpectation.fulfill() + } } ).wait() @@ -295,9 +303,16 @@ final class RedisPubSubCommandsPoolTests: RediStackConnectionPoolIntegrationTest guard $0 == #function, $1 == 1 else { return } subscribeExpectation.fulfill() }, - onUnsubscribe: { - guard $0 == #function, $1 == 0 else { return } - unsubscribeExpectation.fulfill() + onUnsubscribe: { details, eventSource in + switch eventSource { + case .clientError: return + case .userInitiated: + guard + details.subscriptionKey == #function, + details.currentSubscriptionCount == 0 + else { return } + unsubscribeExpectation.fulfill() + } } ).wait() XCTAssertEqual(subscriber.leasedConnectionCount, 1) @@ -343,29 +358,49 @@ final class RedisPubSubCommandsPoolTests: RediStackConnectionPoolIntegrationTest } } -// MARK: - #100 subscribe race condition +// MARK: - #103 tests extension RedisPubSubCommandsTests { - func test_pubsub_pipelineChanges_hasNoRaceCondition() throws { - func runOperation(_ factory: (RedisChannelName) -> EventLoopFuture) -> EventLoopFuture { - return .andAllSucceed( - (0...100_000).reduce(into: []) { - result, index in - - result.append(factory("\(#function)-\(index)")) - }, - on: self.connection.eventLoop + func test_pubsub_calls_unsubscribe_whenUnexpectedClose() throws { + let channel = EmbeddedChannel() + try channel + .pipeline + .addBaseRedisHandlers() + .wait() + + let subscribeExpectation = self.expectation(description: "should see subscribe") + let unsubscribeExpectation = self.expectation(description: "should see unsubscribe") + + let connection = RedisConnection(configuredRESPChannel: channel, defaultLogger: .init(label: "")) + let subscribeFuture = connection + .subscribe( + to: [.init(#function)], + messageReceiver: { _, _ in }, + onSubscribe: { _, _ in subscribeExpectation.fulfill() }, + onUnsubscribe: { _, eventSource in + switch eventSource { + case .userInitiated: return + case .clientError: unsubscribeExpectation.fulfill() + } + } ) - } - // subscribing (adding handler) - try runOperation { self.connection.subscribe(to: $0) { _, _ in } } - .wait() + // mimics a successful subscription response from the server + let allocator = ByteBufferAllocator() + var buffer = allocator.buffer(capacity: 300) + buffer.writeRESPValue(.array([ + .init(bulk: "subscribe"), + .init(bulk: "\(#function)"), + .integer(1) + ])) + try channel.writeInbound(buffer) - // unsubscribing (removing handler) - try runOperation { self.connection.unsubscribe(from: $0) } - .wait() + // lets the initial subscription work finish + try subscribeFuture.wait() + + // 'unexpected' close, should trigger expectations + try channel.close().wait() - try self.connection.close().wait() + self.waitForExpectations(timeout: 0.5) } } From 4e2217cffd411d43b48b624584603e67bf917d17 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sat, 13 Aug 2022 21:36:02 -0500 Subject: [PATCH 44/63] Add support for Swift Package Index documentation hosting --- .spi.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .spi.yml diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 00000000..52f39836 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: ['RediStack', 'RedisTypes', 'RediStackTestUtils'] \ No newline at end of file From 44319ea3ac47e04a0f55ef573021566b0c257c80 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 14 Aug 2022 21:52:16 -0500 Subject: [PATCH 45/63] Set Swift 5.5 as the minimum version --- .gitlab/ci/centos-7.yml | 15 +++--- .gitlab/ci/eol-platforms-5.5.yml | 5 ++ .gitlab/ci/main.yml | 12 +++-- .gitlab/ci/standard-platforms.yml | 15 +++--- Package.swift | 10 ++-- Package@swift-5.5.swift | 76 ------------------------------- README.md | 20 ++++---- 7 files changed, 42 insertions(+), 111 deletions(-) create mode 100644 .gitlab/ci/eol-platforms-5.5.yml delete mode 100644 Package@swift-5.5.swift diff --git a/.gitlab/ci/centos-7.yml b/.gitlab/ci/centos-7.yml index 94877912..784b1ffb 100644 --- a/.gitlab/ci/centos-7.yml +++ b/.gitlab/ci/centos-7.yml @@ -10,17 +10,16 @@ include: '/.gitlab/ci/platform-test.yml' swift trunk: extends: .centos7 - image: swiftlang/swift:nightly-master-centos7 - except: + image: swiftlang/swift:nightly-main-centos7 -swift 5.5: +swift 5.7: extends: .centos7 - image: swift:5.5-centos7 + image: swiftlang/swift:nightly-5.7-centos7 -swift 5.4: +swift 5.6: extends: .centos7 - image: swift:5.4-centos7 + image: swift:5.6-centos7 -swift 5.3: +swift 5.5: extends: .centos7 - image: swift:5.3-centos7 + image: swift:5.5-centos7 diff --git a/.gitlab/ci/eol-platforms-5.5.yml b/.gitlab/ci/eol-platforms-5.5.yml new file mode 100644 index 00000000..f85d8241 --- /dev/null +++ b/.gitlab/ci/eol-platforms-5.5.yml @@ -0,0 +1,5 @@ +include: '/.gitlab/ci/platform-test.yml' + +swift 5.5: + extends: .platform-test + image: swift:5.5-${SWIFT_PLATFORM_NAME} diff --git a/.gitlab/ci/main.yml b/.gitlab/ci/main.yml index 6e56d50b..f48d6a92 100644 --- a/.gitlab/ci/main.yml +++ b/.gitlab/ci/main.yml @@ -64,15 +64,17 @@ Code Climate: strategy: depend include: '/.gitlab/ci/standard-platforms.yml' -Ubuntu Bionic: +Ubuntu Xenial: extends: .standard-platform-test variables: - SWIFT_PLATFORM_NAME: bionic + SWIFT_PLATFORM_NAME: xenial + trigger: + include: '/.gitlab/ci/eol-platforms-5.5.yml' -Ubuntu Xenial: +Ubuntu Bionic: extends: .standard-platform-test variables: - SWIFT_PLATFORM_NAME: xenial + SWIFT_PLATFORM_NAME: bionic Ubuntu Focal: extends: .standard-platform-test @@ -88,6 +90,8 @@ CentOS 8: extends: .standard-platform-test variables: SWIFT_PLATFORM_NAME: centos8 + trigger: + include: '/.gitlab/ci/eol-platforms-5.5.yml' Amazon Linux 2: extends: .standard-platform-test diff --git a/.gitlab/ci/standard-platforms.yml b/.gitlab/ci/standard-platforms.yml index f9fe4740..7ca840df 100644 --- a/.gitlab/ci/standard-platforms.yml +++ b/.gitlab/ci/standard-platforms.yml @@ -2,17 +2,16 @@ include: '/.gitlab/ci/platform-test.yml' swift trunk: extends: .platform-test - image: swiftlang/swift:nightly-master-${SWIFT_PLATFORM_NAME} - except: + image: swiftlang/swift:nightly-main-${SWIFT_PLATFORM_NAME} -swift 5.5: +swift 5.7: extends: .platform-test - image: swift:5.5-${SWIFT_PLATFORM_NAME} + image: swiftlang/swift:nightly-5.7-${SWIFT_PLATFORM_NAME} -swift 5.4: +swift 5.6: extends: .platform-test - image: swift:5.4-${SWIFT_PLATFORM_NAME} + image: swift:5.6-${SWIFT_PLATFORM_NAME} -swift 5.3: +swift 5.5: extends: .platform-test - image: swift:5.3-${SWIFT_PLATFORM_NAME} + image: swift:5.5-${SWIFT_PLATFORM_NAME} diff --git a/Package.swift b/Package.swift index 79c7236d..ebe8b8de 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.5 //===----------------------------------------------------------------------===// // // This source file is part of the RediStack open source project @@ -36,8 +36,7 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "Metrics", package: "swift-metrics"), .product(name: "ServiceDiscovery", package: "swift-service-discovery") - ], - exclude: ["Documentation.docc"] + ] ), .testTarget( name: "RediStackTests", @@ -48,7 +47,7 @@ let package = Package( ] ), - .target(name: "RedisTypes", dependencies: ["RediStack"], exclude: ["Documentation.docc"]), + .target(name: "RedisTypes", dependencies: ["RediStack"]), .testTarget( name: "RedisTypesTests", dependencies: [ @@ -62,8 +61,7 @@ let package = Package( dependencies: [ .product(name: "NIO", package: "swift-nio"), "RediStack" - ], - exclude: ["Documentation.docc"] + ] ), .testTarget( diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift deleted file mode 100644 index 4efc3044..00000000 --- a/Package@swift-5.5.swift +++ /dev/null @@ -1,76 +0,0 @@ -// swift-tools-version:5.5 -//===----------------------------------------------------------------------===// -// -// This source file is part of the RediStack open source project -// -// Copyright (c) 2022 RediStack project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of RediStack project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import PackageDescription - -let package = Package( - name: "RediStack", - products: [ - .library(name: "RediStack", targets: ["RediStack"]), - .library(name: "RediStackTestUtils", targets: ["RediStackTestUtils"]), - .library(name: "RedisTypes", targets: ["RedisTypes"]) - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.18.0"), - .package(url: "https://github.com/apple/swift-service-discovery", from: "1.0.0"), - ], - targets: [ - .target( - name: "RediStack", - dependencies: [ - .product(name: "NIO", package: "swift-nio"), - .product(name: "Logging", package: "swift-log"), - .product(name: "Metrics", package: "swift-metrics"), - .product(name: "ServiceDiscovery", package: "swift-service-discovery") - ] - ), - .testTarget( - name: "RediStackTests", - dependencies: [ - "RediStack", "RediStackTestUtils", - .product(name: "NIO", package: "swift-nio"), - .product(name: "NIOTestUtils", package: "swift-nio") - ] - ), - - .target(name: "RedisTypes", dependencies: ["RediStack"]), - .testTarget( - name: "RedisTypesTests", - dependencies: [ - "RediStack", "RedisTypes", "RediStackTestUtils", - .product(name: "NIO", package: "swift-nio") - ] - ), - - .target( - name: "RediStackTestUtils", - dependencies: [ - .product(name: "NIO", package: "swift-nio"), - "RediStack" - ] - ), - - .testTarget( - name: "RediStackIntegrationTests", - dependencies: [ - "RediStack", "RediStackTestUtils", - .product(name: "NIO", package: "swift-nio"), - .product(name: "ServiceDiscovery", package: "swift-service-discovery") - ] - ) - ] -) diff --git a/README.md b/README.md index 9bdf8ece..67aa12c3 100644 --- a/README.md +++ b/README.md @@ -124,15 +124,17 @@ This policy is to balance the desire for as much backwards compatibility as poss The following table shows the combination of Swift language versions and operating systems that receive regular unit testing (either in development, or with CI) against the **current version** of **RediStack**. -| Platform | Swift 5.3 | 5.4 | 5.5 | Trunk | -|:----------------------|:---------:|:---:|:---:|:-----:| -| macOS Latest (Intel) | | | ✅ | | -| Ubuntu 20.04 (Focal) | ✅ | ✅ | ✅ | ✅ | -| Ubuntu 18.04 (Bionic) | ✅ | ✅ | ✅ | ✅ | -| Ubuntu 16.04 (Xenial) | ✅ | ✅ | ✅ | ✅ | -| Amazon Linux 2 | ✅ | ✅ | ✅ | ✅ | -| CentOS 8 | ✅ | ✅ | ✅ | ✅ | -| CentOS 7 | ✅ | ✅ | ✅ | ✅ | +| Platform | Swift 5.5 | 5.6 | 5.7 | Trunk | +|:----------------------------|:---------:|:---:|:---:|:-----:| +| macOS Latest (M1) | | | ✅ | | +| Ubuntu 20.04 (Focal) | ✅ | ✅ | ✅ | ✅ | +| Ubuntu 18.04 (Bionic) | ✅ | ✅ | ✅ | ✅ | +| Ubuntu 16.04 (Xenial)**³** | ✅ | ❌ | ❌ | ❌ | +| Amazon Linux 2 | ✅ | ✅ | ✅ | ✅ | +| CentOS 8**³** | ✅ | ❌ | ❌ | ❌ | +| CentOS 7 | ✅ | ✅ | ✅ | ✅ | + +> **³** _CentOS 8 and Ubuntu 16.04 are no longer officially supported by Swift after [Swift 5.5](https://github.com/apple/swift-docker/pull/273)._ For older versions of **RediStack**, view each summary below. From 3ad1e26280b72a8e4fe3809831366578028cfbde Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sat, 13 Aug 2022 22:20:35 -0500 Subject: [PATCH 46/63] Update CI to only run platform tests on git push events --- .gitlab/ci/main.yml | 2 ++ .gitlab/ci/platform-test.yml | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.gitlab/ci/main.yml b/.gitlab/ci/main.yml index f48d6a92..cff9debb 100644 --- a/.gitlab/ci/main.yml +++ b/.gitlab/ci/main.yml @@ -59,6 +59,8 @@ Code Climate: codequality: gl-code-quality-report.json .standard-platform-test: + rules: + - if: $CI_PIPELINE_SOURCE == "push" stage: Platform Tests trigger: strategy: depend diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml index ae68d282..bf7fb51d 100644 --- a/.gitlab/ci/platform-test.yml +++ b/.gitlab/ci/platform-test.yml @@ -15,9 +15,3 @@ stages: script: - swift build --build-tests --enable-test-discovery --sanitize=thread -v - swift test --skip-build - only: - - branches - - tags - except: - - schedules - \ No newline at end of file From 0465b34ef3f45c45d751100ef572c1afb4b1b50c Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 14 Aug 2022 21:51:19 -0500 Subject: [PATCH 47/63] Allow nightly trunks CI jobs to fail --- .gitlab/ci/platform-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab/ci/platform-test.yml b/.gitlab/ci/platform-test.yml index bf7fb51d..fb591474 100644 --- a/.gitlab/ci/platform-test.yml +++ b/.gitlab/ci/platform-test.yml @@ -3,6 +3,7 @@ stages: .platform-test: stage: Test + allow_failure: true tags: - docker variables: From d5ed8fcebd80aff270cdb639b9b472480bbd069d Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 14 Aug 2022 23:25:41 -0500 Subject: [PATCH 48/63] Remove Jazzy documentation job and rely on Swift Package Index --- .gitlab/ci/main.yml | 31 ------------------------------- README.md | 6 +++--- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/.gitlab/ci/main.yml b/.gitlab/ci/main.yml index cff9debb..f013d7e3 100644 --- a/.gitlab/ci/main.yml +++ b/.gitlab/ci/main.yml @@ -1,37 +1,6 @@ stages: - Platform Tests - Quality Checks - - Docs - -pages: - stage: Docs - only: - - tags - image: norionomura/jazzy - tags: - - docker - variables: - MODULE_NAME: "RediStack" - REPO_URL: "https://gitlab.com/mordil/swift-redi-stack" - script: | - export VERSION=$(git describe --abbrev=0 --tags || echo "0.0.0") - swift build - sourcekitten doc --spm-module "$MODULE_NAME" > "./$MODULE_NAME.json" - jazzy --clean \ - --author "Nathan Harris (Mordil)" \ - --readme "./README.md" \ - --author_url "https://www.mordil.info" \ - --github_url "$REPO_URL" \ - --github-file-prefix "$REPO_URL/blob/$VERSION" \ - --root-url "https://mordil.gitlab.io/swift-redi-stack/" \ - --module "$MODULE_NAME" \ - --module-version "$VERSION" \ - --theme docs/theme \ - --sourcekitten-sourcefile "./$MODULE_NAME.json" \ - --output "./public" - artifacts: - paths: - - public Code Climate: only: diff --git a/README.md b/README.md index 67aa12c3..6bb0a274 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@

SSWG Maturity Apache 2 License - Swift 5.2+ + Swift 5.5+ Redis 5.0

Build Status - Documentation Coverage + Documentation Coverage

@@ -77,7 +77,7 @@ print(result) // Optional("some value") ## Documentation -The docs for the latest tagged release are always available at [docs.redistack.info](http://docs.redistack.info). +The docs for the latest tagged release are always available from the [Swift Package Index](https://swiftpackageindex.com/Mordil/RediStack/master/documentation/redistack). ## Questions From adbc2e3e16558dbb96230f1ca0ffd555c0048794 Mon Sep 17 00:00:00 2001 From: Michael Stegeman Date: Sun, 21 Aug 2022 02:47:00 +0000 Subject: [PATCH 49/63] Switch from NIOAtomic to ManagedAtomic. --- Sources/RediStack/RedisConnection.swift | 15 ++++++++------- Sources/RediStack/RedisMetrics.swift | 17 +++++++++-------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index f1688152..16b8375d 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -14,6 +14,7 @@ import struct Foundation.UUID import struct Dispatch.DispatchTime +import Atomics import Logging import Metrics import NIO @@ -119,10 +120,10 @@ public final class RedisConnection: RedisClient { /// - Important: Even when set to `true`, the host machine may still choose to delay sending commands. /// - Note: Setting this to `true` will immediately drain the buffer. public var sendCommandsImmediately: Bool { - get { return autoflush.load() } + get { return autoflush.load(ordering: .sequentiallyConsistent) } set(newValue) { if newValue { self.channel.flush() } - autoflush.store(newValue) + autoflush.store(newValue, ordering: .sequentiallyConsistent) } } /// Controls the permission of the connection to be able to have PubSub subscriptions or not. @@ -130,11 +131,11 @@ public final class RedisConnection: RedisClient { /// When set to `true`, this connection is allowed to create subscriptions. /// When set to `false`, this connection is not allowed to create subscriptions. Any potentially existing subscriptions will be removed. public var allowSubscriptions: Bool { - get { self.allowPubSub.load() } + get { self.allowPubSub.load(ordering: .sequentiallyConsistent) } set(newValue) { - self.allowPubSub.store(newValue) + self.allowPubSub.store(newValue, ordering: .sequentiallyConsistent) // if we're subscribed, and we're not allowed to be in pubsub, end our subscriptions - guard self.isSubscribed && !self.allowPubSub.load() else { return } + guard self.isSubscribed && !self.allowPubSub.load(ordering: .sequentiallyConsistent) else { return } _ = EventLoopFuture.whenAllComplete([ self.unsubscribe(), self.punsubscribe() @@ -149,8 +150,8 @@ public final class RedisConnection: RedisClient { internal let channel: Channel - private let autoflush: NIOAtomic = .makeAtomic(value: true) - private let allowPubSub: NIOAtomic = .makeAtomic(value: true) + private let autoflush = ManagedAtomic(true) + private let allowPubSub = ManagedAtomic(true) private let _stateLock = Lock() private var _state = ConnectionState.open private var state: ConnectionState { diff --git a/Sources/RediStack/RedisMetrics.swift b/Sources/RediStack/RedisMetrics.swift index 2452e782..cb007d0c 100644 --- a/Sources/RediStack/RedisMetrics.swift +++ b/Sources/RediStack/RedisMetrics.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import Atomics import Metrics import NIOConcurrencyHelpers @@ -69,10 +70,10 @@ extension RedisMetrics { /// A specialized wrapper class for working with `Metrics.Gauge` objects for the purpose of an incrementing or decrementing count of active objects. public class IncrementalGauge { private let gauge: Gauge - private let count = NIOAtomic.makeAtomic(value: 0) + private let count = ManagedAtomic(0) /// The number of the objects that are currently reported as active. - public var currentCount: Int { return count.load() } + public var currentCount: Int { return count.load(ordering: .sequentiallyConsistent) } internal init(_ label: Label) { self.gauge = .init(label: label) @@ -81,21 +82,21 @@ extension RedisMetrics { /// Increments the current count by the amount specified. /// - Parameter amount: The number to increase the current count by. Default is `1`. public func increment(by amount: Int = 1) { - _ = self.count.add(amount) - self.gauge.record(self.count.load()) + self.count.wrappingIncrement(by: amount, ordering: .sequentiallyConsistent) + self.gauge.record(self.count.load(ordering: .sequentiallyConsistent)) } /// Decrements the current count by the amount specified. /// - Parameter amount: The number to decrease the current count by. Default is `1`. public func decrement(by amount: Int = 1) { - _ = self.count.sub(amount) - self.gauge.record(self.count.load()) + self.count.wrappingDecrement(by: amount, ordering: .sequentiallyConsistent) + self.gauge.record(self.count.load(ordering: .sequentiallyConsistent)) } /// Resets the current count to `0`. public func reset() { - _ = self.count.exchange(with: 0) - self.gauge.record(self.count.load()) + _ = self.count.exchange(0, ordering: .sequentiallyConsistent) + self.gauge.record(self.count.load(ordering: .sequentiallyConsistent)) } } } From 922c4f4b6e22e7d33c9e3d4f23a0914a32d399ad Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sun, 25 Sep 2022 21:06:15 +0000 Subject: [PATCH 50/63] Add Code Coverage reports in Merge Requests Motivation: To maintain quality, automated code coverage reports should be generated and archived as build artifacts in CI pipelines. Modifications: Update CI config to run a job for running unit tests with code coverage, and exporting the report to GitLab. Result: Code coverage will be tracked and history recorded to compare individual code changes. --- .gitlab/ci/main.yml | 14 ++++++++++++++ README.md | 3 ++- scripts/generate_code_coverage.sh | 31 +++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100755 scripts/generate_code_coverage.sh diff --git a/.gitlab/ci/main.yml b/.gitlab/ci/main.yml index f013d7e3..ea824de0 100644 --- a/.gitlab/ci/main.yml +++ b/.gitlab/ci/main.yml @@ -1,3 +1,5 @@ +include: '/.gitlab/ci/platform-test.yml' + stages: - Platform Tests - Quality Checks @@ -27,6 +29,18 @@ Code Climate: reports: codequality: gl-code-quality-report.json +Code Coverage: + extends: .platform-test + stage: Quality Checks + allow_failure: false + image: swift:latest + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: + - '*.swift' + script: scripts/generate_code_coverage.sh + coverage: '/TOTAL.*(\s\d+\.\d+%)/' + .standard-platform-test: rules: - if: $CI_PIPELINE_SOURCE == "push" diff --git a/README.md b/README.md index 6bb0a274..9575404a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@

- Build Status + pipeline status + coverage report Documentation Coverage

diff --git a/scripts/generate_code_coverage.sh b/scripts/generate_code_coverage.sh new file mode 100755 index 00000000..27ba6d1b --- /dev/null +++ b/scripts/generate_code_coverage.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +##===----------------------------------------------------------------------===## +## +## This source file is part of the RediStack open source project +## +## Copyright (c) 2022 RediStack project authors +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.txt for the list of RediStack project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## + +swift test --enable-code-coverage --enable-test-discovery + +BUILD_BIN_PATH=$(swift build --show-bin-path) +CODE_COV_PATH=$(swift test --show-codecov-path) + +PROF_DATA_PATH="${CODE_COV_PATH%/*}/default.profdata" +TEST_BINARY_PATH="${BUILD_BIN_PATH}/RediStackPackageTests.xctest" + +IGNORE_FILENAME_REGEX="(\.build|TestUtils|Tests)" + +llvm-cov report \ + $TEST_BINARY_PATH \ + --format=text \ + --instr-profile="$PROF_DATA_PATH" \ + --ignore-filename-regex="$IGNORE_FILENAME_REGEX" From 459f2cc4cb66c2be6eab9c789ee0451437e85e3b Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Thu, 17 Nov 2022 16:05:10 +0100 Subject: [PATCH 51/63] Fix `NIOLock` warning --- Package.swift | 2 +- Sources/RediStack/RedisConnection.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index ebe8b8de..03cb640d 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.18.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.43.0"), .package(url: "https://github.com/apple/swift-service-discovery", from: "1.0.0"), ], targets: [ diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index 16b8375d..a0187ad9 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -152,7 +152,7 @@ public final class RedisConnection: RedisClient { private let autoflush = ManagedAtomic(true) private let allowPubSub = ManagedAtomic(true) - private let _stateLock = Lock() + private let _stateLock = NIOLock() private var _state = ConnectionState.open private var state: ConnectionState { get { return _stateLock.withLock { self._state } } From 00eb9b5e33a9afbf019a099767deb5baf5174275 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Sat, 19 Nov 2022 21:43:26 -0600 Subject: [PATCH 52/63] Unify PubSub Handler Signature Motivation Right now the PubSub handlers are split into three separate closures, with the subscribe/unsubscribe handlers being optional. This won't play well with AsyncStream for being able to respond to all events that a PubSub subscription can cause. Additionally, the current structure is very verbose in code to maintain - but also adds complexity to developers who are first getting started to understand the lifecycle of PubSub events. Changes - Add: New `RedisPubSubEvent` enum that captures the subscribe, unsubscribe, and message lifecycle events - Add: New `RedisPubSubEventReceiver` that combines the previous 3 closure types - Add: Dedicated DocC Symbol Extension file for `RedisPubSubHandler` - Change: `RedisClient.subscribe` and `RedisClient.psubscribe` method signatures to only require a single unlabeled closure - Rename: `RedisUnsubscribeEventSource` to `RedisPubSubEvent.UnsubscribeEventSource` - Remove: `RedisSubscriptionMessageReceiver`, `RedisSubscriptionChangeDetails`, `RedisSubscribeHandler`, and `RedisUnsubscribeHandler` types Result Developers should have a much easier time getting started and understanding PubSub with assistance from the compiler with types to understand what they're being given and what's available to them as information to make more informed decisions in their app logic. --- .../ChannelHandlers/RedisPubSubHandler.swift | 167 ++++++----------- .../RediStack/Documentation.docc/RediStack.md | 1 + .../{ => Symbol Extensions}/RedisCommand.md | 0 .../Symbol Extensions/RedisPubSubHandler.md | 41 +++++ Sources/RediStack/RedisClient.swift | 63 +++---- Sources/RediStack/RedisConnection.swift | 20 +-- Sources/RediStack/RedisConnectionPool.swift | 74 ++++---- Sources/RediStack/RedisLogging.swift | 8 +- .../Commands/PubSubCommandsTests.swift | 169 ++++++++++-------- .../RedisConnectionPoolTests.swift | 4 +- .../RedisConnectionTests.swift | 24 +-- 11 files changed, 296 insertions(+), 275 deletions(-) rename Sources/RediStack/Documentation.docc/{ => Symbol Extensions}/RedisCommand.md (100%) create mode 100644 Sources/RediStack/Documentation.docc/Symbol Extensions/RedisPubSubHandler.md diff --git a/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift b/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift index 0984a268..872cc30d 100644 --- a/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift +++ b/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift @@ -14,58 +14,52 @@ import NIO -/// A closure receiver of individual Pub/Sub messages from Redis subscriptions to channels and patterns. -/// - Warning: The receiver is called on the same `NIO.EventLoop` that processed the message. -/// -/// If you are doing non-trivial work in response to PubSub messages, it is **highly recommended** that the work be dispatched to another thread -/// so as to not block further messages from being processed. -/// - Parameters: -/// - publisher: The name of the channel that published the message. -/// - message: The message data that was received from the `publisher`. -public typealias RedisSubscriptionMessageReceiver = (_ publisher: RedisChannelName, _ message: RESPValue) -> Void - -/// The details of the subscription change. -/// - Parameters: -/// - subscriptionKey: The subscribed channel or pattern that had its subscription status changed. -/// - currentSubscriptionCount: The current total number of subscriptions the connection has. -public typealias RedisSubscriptionChangeDetails = (subscriptionKey: String, currentSubscriptionCount: Int) - -/// A closure handler invoked for Pub/Sub subscribe commands. -/// -/// This closure will be invoked only *once* for each individual channel or pattern that is having its subscription changed, -/// even if it was done as a single PSUBSCRIBE or SUBSCRIBE command. -/// - Warning: The receiver is called on the same `NIO.EventLoop` that processed the message. -/// -/// If you are doing non-trivial work in response to PubSub messages, it is **highly recommended** that the work be dispatched to another thread -/// so as to not block further messages from being processed. -/// - Parameter details: The details of the subscription. -public typealias RedisSubscribeHandler = (_ details: RedisSubscriptionChangeDetails) -> Void - -/// An enumeration of possible sources of Pub/Sub unsubscribe events. -public enum RedisUnsubscribeEventSource { - /// The client sent an unsubscribe command either as UNSUBSCRIBE or PUNSUBSCRIBE. - case userInitiated - /// The client encountered an error and had to unsubscribe. - /// - Parameter _: The error the client encountered. - case clientError(_ error: Error) +/// The possible events that are received from Redis Pub/Sub channels. +public enum RedisPubSubEvent { + /// The available sources of Pub/Sub unsubscribe events. + public enum UnsubscribeEventSource { + /// The client sent an unsubscribe command either as UNSUBSCRIBE or PUNSUBSCRIBE. + case userInitiated + /// The client encountered an error and had to unsubscribe. + /// - Parameter _: The error the client encountered. + case clientError(_ error: Error) + } + + /// The connection has been subscribed to a channel. + /// + /// This event should only be received once, before receiving messages. + /// - Parameters: + /// - key: The subscribed channel or pattern that was subscribed to. + /// - currentSubscriptionCount: The current total number of subscriptions the connection has after subscribing. + case subscribed(key: String, currentSubscriptionCount: Int) + /// The connection has been unsubscribed from a channel. + /// + /// This event should only be received once, after all messages received have been processed, with no further messages being received. + /// - Parameters: + /// - key: The subscribed channel or pattern that was unsubscribed from. + /// - currentSubscriptionCount: The current total number of subscriptions the connection has after unsubscribing. + /// - source: The source of the unsubscribe event. + case unsubscribed(key: String, currentSubscriptionCount: Int, source: UnsubscribeEventSource) + /// The connection has received a message on the given channel. + /// + /// This event can be received an infinite number of times, until the connection has unsubscribed from the channel. + /// - Parameters: + /// - publisher: The name of the channel that published the message. + /// - message: The message data that was received from the `publisher`. + case message(publisher: RedisChannelName, message: RESPValue) } -/// A closure handler invoked for Pub/Sub unsubscribe commands. +/// A closure receiver of individual Pub/Sub events from Redis subscriptions to channels and patterns. +/// - Warning: The receiver is called on the same `NIO.EventLoop` that processed the message (the `EventLoop` of the `NIO.ChannelPipeline`). /// -/// This closure will be invoked only *once* for each individual channel or pattern that is having its subscription changed, -/// even if it was done as a single PUNSUBSCRIBE or UNSUBSCRIBE command. -/// - Warning: The receiver is called on the same `NIO.EventLoop` that processed the message. -/// -/// If you are doing non-trivial work in response to PubSub messages, it is **highly recommended** that the work be dispatched to another thread -/// so as to not block further messages from being processed. -/// - Parameters: -/// - details: The details of the subscription. -/// - source: The source of the unsubscribe event. -public typealias RedisUnsubscribeHandler = (_ details: RedisSubscriptionChangeDetails, _ source: RedisUnsubscribeEventSource) -> Void +/// If you are doing non-trivial work in response to PubSub messages, it is **highly recommended** that the work +/// be dispatched to another thread, so as to not block further messages from being processed. +/// - Parameter event: The event that the connection is responding to. +public typealias RedisPubSubEventReceiver = (_ event: RedisPubSubEvent) -> Void /// A list of patterns or channels that a Pub/Sub subscription change is targetting. /// -/// See `RedisChannelName`, [PSUBSCRIBE](https://redis.io/commands/psubscribe) and [SUBSCRIBE](https://redis.io/commands/subscribe) +/// See ``RedisChannelName`` or the Redis documentation on [PSUBSCRIBE](https://redis.io/commands/psubscribe) and [SUBSCRIBE](https://redis.io/commands/subscribe). /// /// Use the `values` property to quickly access the underlying list of the target for any purpose that requires a the `String` values. public enum RedisSubscriptionTarget: Equatable, CustomDebugStringConvertible { @@ -97,33 +91,6 @@ public enum RedisSubscriptionTarget: Equatable, CustomDebugStringConvertible { } /// A channel handler that stores a map of closures and channel or pattern names subscribed to in Redis using Pub/Sub. -/// -/// These `RedisPubSubMessageReceiver` closures are added and removed using methods directly on an instance of this handler. -/// -/// When a receiver is added or removed, the handler will send the appropriate subscribe or unsubscribe message to Redis so that the connection -/// reflects the local Channel state. -/// -/// # ChannelInboundHandler -/// This handler is designed to be placed _before_ a `RedisCommandHandler` so that it can intercept Pub/Sub messages and dispatch them to the appropriate -/// receiver. -/// -/// If a response is not in the Pub/Sub message format as specified by Redis, then it is treated as a normal Redis command response and sent further into -/// the pipeline so that eventually a `RedisCommandHandler` can process it. -/// -/// # ChannelOutboundHandler -/// This handler is what is defined as a "transparent" `NIO.ChannelOutboundHandler` in that it does absolutely nothing except forward outgoing commands -/// in the pipeline. -/// -/// The reason why this handler needs to conform to this protocol at all, is that subscribe and unsubscribe commands are executed outside of a normal -/// `NIO.Channel.write(_:)` cycle, as message receivers aren't command arguments and need to be stored. -/// -/// All of this is outside the responsibility of the `RedisCommandHandler`, -/// so the `RedisPubSubHandler` uses its own `NIO.ChannelHandlerContext` being before the command handler to short circuit the pipeline. -/// -/// # RemovableChannelHandler -/// As a connection can move in and out of "PubSub mode", this handler is can be added and removed from a `NIO.ChannelPipeline` as needed. -/// -/// When the handler has received a `removeHandler(context:removalToken:)` request, it will remove itself immediately. public final class RedisPubSubHandler { private var state: State = .default @@ -172,8 +139,7 @@ extension RedisPubSubHandler { guard let subscription = self.subscriptions[prefixedKey] else { return } - subscription.onSubscribe?((subscriptionKey, subscriptionCount)) - subscription.onSubscribe = nil // nil to free memory + subscription.onEvent(.subscribed(key: subscriptionKey, currentSubscriptionCount: subscriptionCount)) self.subscriptions[prefixedKey] = subscription subscription.type.gauge.increment() @@ -188,7 +154,11 @@ extension RedisPubSubHandler { let prefixedKey = self.prefixKey(subscriptionKey, with: keyPrefix) guard let subscription = self.subscriptions.removeValue(forKey: prefixedKey) else { return } - subscription.onUnsubscribe?((subscriptionKey, subscriptionCount), .userInitiated) + subscription.onEvent(.unsubscribed( + key: subscriptionKey, + currentSubscriptionCount: subscriptionCount, + source: .userInitiated + )) subscription.type.gauge.decrement() switch self.pendingUnsubscribes.removeValue(forKey: prefixedKey) { @@ -215,7 +185,7 @@ extension RedisPubSubHandler { keyPrefix: String ) { guard let subscription = self.subscriptions[self.prefixKey(subscriptionKey, with: keyPrefix)] else { return } - subscription.onMessage(channel, message) + subscription.onEvent(.message(publisher: channel, message: message)) RedisMetrics.subscriptionMessagesReceivedCount.increment() } } @@ -223,28 +193,19 @@ extension RedisPubSubHandler { // MARK: Subscription Management extension RedisPubSubHandler { - /// Registers the provided subscription message receiver to receive messages from the specified subscription target. + /// Registers the provided subscription event handler to receive events from the specified subscription target. /// - Important: Any previously registered receiver will be replaced and not notified. /// - Parameters: /// - target: The channels or patterns that the receiver should receive messages for. - /// - receiver: The closure that receives any future pub/sub messages. - /// - subscribeHandler: An optional closure to invoke when the subscription first becomes active. - /// - unsubscribeHandler: An optional closure to invoke when the subscription becomes inactive. + /// - receiver: The closure that receives any future pub/sub events. /// - Returns: A `NIO.EventLoopFuture` that resolves the number of subscriptions the client has after the subscription has been added. public func addSubscription( for target: RedisSubscriptionTarget, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler?, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? + receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { guard self.eventLoop.inEventLoop else { return self.eventLoop.flatSubmit { - return self.addSubscription( - for: target, - messageReceiver: receiver, - onSubscribe: subscribeHandler, - onUnsubscribe: unsubscribeHandler - ) + return self.addSubscription(for: target, receiver: receiver) } } @@ -260,12 +221,7 @@ extension RedisPubSubHandler { let newSubscriptionTargets = target.values .compactMap { (targetKey) -> String? in - let subscription = Subscription( - type: target.subscriptionType, - messageReceiver: receiver, - subscribeHandler: subscribeHandler, - unsubscribeHandler: unsubscribeHandler - ) + let subscription = Subscription(type: target.subscriptionType, eventReceiver: receiver) let prefixedKey = self.prefixKey(targetKey, with: target.keyPrefix) guard self.subscriptions.updateValue(subscription, forKey: prefixedKey) == nil else { return nil } return targetKey @@ -507,8 +463,8 @@ extension RedisPubSubHandler: ChannelInboundHandler { let receivers = self.subscriptions self.subscriptions.removeAll() receivers.forEach { - let source: RedisUnsubscribeEventSource = error.map { .clientError($0) } ?? .userInitiated - $0.value.onUnsubscribe?(($0.key, 0), source) + let source: RedisPubSubEvent.UnsubscribeEventSource = error.map { .clientError($0) } ?? .userInitiated + $0.value.onEvent(.unsubscribed(key: $0.key, currentSubscriptionCount: 0, source: source)) $0.value.type.gauge.decrement() } } @@ -547,20 +503,11 @@ extension RedisPubSubHandler { fileprivate final class Subscription { let type: SubscriptionType - let onMessage: RedisSubscriptionMessageReceiver - var onSubscribe: RedisSubscribeHandler? // will be set to nil after first call - let onUnsubscribe: RedisUnsubscribeHandler? + let onEvent: RedisPubSubEventReceiver - init( - type: SubscriptionType, - messageReceiver: @escaping RedisSubscriptionMessageReceiver, - subscribeHandler: RedisSubscribeHandler?, - unsubscribeHandler: RedisUnsubscribeHandler? - ) { + init(type: SubscriptionType, eventReceiver: @escaping RedisPubSubEventReceiver) { self.type = type - self.onMessage = messageReceiver - self.onSubscribe = subscribeHandler - self.onUnsubscribe = unsubscribeHandler + self.onEvent = eventReceiver } } diff --git a/Sources/RediStack/Documentation.docc/RediStack.md b/Sources/RediStack/Documentation.docc/RediStack.md index cde0f7b6..573b82d0 100644 --- a/Sources/RediStack/Documentation.docc/RediStack.md +++ b/Sources/RediStack/Documentation.docc/RediStack.md @@ -41,6 +41,7 @@ print(result) // Optional("some value") ### Pub/Sub - ``RedisChannelName`` +- ``RedisPubSubEventReceiver`` ### Error Handling diff --git a/Sources/RediStack/Documentation.docc/RedisCommand.md b/Sources/RediStack/Documentation.docc/Symbol Extensions/RedisCommand.md similarity index 100% rename from Sources/RediStack/Documentation.docc/RedisCommand.md rename to Sources/RediStack/Documentation.docc/Symbol Extensions/RedisCommand.md diff --git a/Sources/RediStack/Documentation.docc/Symbol Extensions/RedisPubSubHandler.md b/Sources/RediStack/Documentation.docc/Symbol Extensions/RedisPubSubHandler.md new file mode 100644 index 00000000..6d31e33b --- /dev/null +++ b/Sources/RediStack/Documentation.docc/Symbol Extensions/RedisPubSubHandler.md @@ -0,0 +1,41 @@ +# ``RediStack/RedisPubSubHandler`` + +``RedisPubSubEventReceiver`` closures are added and removed using methods directly on an instance of this handler. + +When a receiver is added or removed, the handler will send the appropriate subscribe or unsubscribe message to Redis so that the connection +reflects the local Channel state. + +## ChannelInboundHandler +This handler is designed to be placed _before_ a ``RedisCommandHandler`` so that it can intercept Pub/Sub messages and dispatch them to the appropriate +receiver. + +If a response is not in the Pub/Sub message format as specified by Redis, then it is treated as a normal Redis command response and sent further into +the pipeline so that eventually a ``RedisCommandHandler`` can process it. + +## ChannelOutboundHandler +This handler is what is defined as a "transparent" `NIO.ChannelOutboundHandler` in that it does absolutely nothing except forward outgoing commands +in the pipeline. + +The reason why this handler needs to conform to this protocol at all, is that subscribe and unsubscribe commands are executed outside of a normal +`NIO.Channel.write(_:)` cycle, as message receivers aren't command arguments and need to be stored. + +All of this is outside the responsibility of the ``RedisCommandHandler``, +so the ``RedisPubSubHandler`` uses its own `NIO.ChannelHandlerContext` being before the command handler to short circuit the pipeline. + +## RemovableChannelHandler +As a connection can move in and out of "PubSub mode", this handler can be added and removed from a `NIO.ChannelPipeline` as needed. + +When the handler has received a `removeHandler(context:removalToken:)` request, it will remove itself immediately. + +## Topics + +### Managing Subscriptions + +- ``RedisSubscriptionTarget`` +- ``addSubscription(for:receiver:)`` +- ``removeSubscription(for:)`` + +### Pub/Sub Events + +- ``RedisPubSubEvent`` +- ``RedisPubSubEventReceiver`` diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index 9e8344bf..9c05ee12 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -62,17 +62,13 @@ public protocol RedisClient { /// - channels: The names of channels to subscribe to. /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. /// - logger: An optional logger instance to use for logs generated from this command. - /// - receiver: A closure which will be invoked each time a channel with a name in `channels` publishes a message. - /// - subscribeHandler: An optional closure to be invoked when the subscription becomes active. - /// - unsubscribeHandler: An optional closure to be invoked when the subscription becomes inactive. + /// - receiver: A closure which will be invoked each time a Pub/Sub event on a channel matching a name in the given `channels`. /// - Returns: A notification `NIO.EventLoopFuture` that resolves once the subscription has been registered with Redis. func subscribe( to channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler?, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture /// Subscribes the client to the specified Redis channel name patterns, invoking the provided message receiver each time a message is published to @@ -91,17 +87,13 @@ public protocol RedisClient { /// - patterns: A list of glob patterns used for matching against PubSub channel names to subscribe to. /// - eventLoop: An optional event loop to hop to for any further chaining on the returned event loop future. /// - logger: An optional logger instance to use for logs generated from this command. - /// - receiver: A closure which will be invoked each time a channel with a name matching the specified pattern(s) publishes a message. - /// - subscribeHandler: An optional closure to be invoked when the subscription becomes active. - /// - unsubscribeHandler: An optional closure to be invoked when the subscription becomes inactive. + /// - receiver: A closure which will be invoked each time a Pub/Sub event on a channel with a name matching the given `patterns`. /// - Returns: A notification `NIO.EventLoopFuture` that resolves once the subscription has been registered with Redis. func psubscribe( to patterns: [String], eventLoop: EventLoop?, logger: Logger?, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler?, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture /// Unsubscribes the client from a specific Redis channel from receiving any future published messages. @@ -187,50 +179,61 @@ extension RedisClient { return self.punsubscribe(from: patterns, eventLoop: eventLoop, logger: logger) } - // trailing closure swift syntax overloads - public func subscribe( to channels: [RedisChannelName], eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { - return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, receiver) } public func subscribe( to channels: RedisChannelName..., eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { - return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.subscribe(to: channels, eventLoop: eventLoop, logger: logger, receiver) } public func psubscribe( to patterns: [String], eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { - return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, receiver) } public func psubscribe( to patterns: String..., eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil + _ receiver: @escaping RedisPubSubEventReceiver + ) -> EventLoopFuture { + return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, receiver) + } + + // these overloads shouldn't be necessary... but after refactoring from multiple closure parameters to a unified one... + // calling the above overloads with a single value fails to compile... + + public func subscribe( + to channel: RedisChannelName, + eventLoop: EventLoop? = nil, + logger: Logger? = nil, + _ receiver: @escaping RedisPubSubEventReceiver + ) -> EventLoopFuture { + return self.subscribe(to: [channel], eventLoop: eventLoop, logger: logger, receiver) + } + + public func psubscribe( + to pattern: String, + eventLoop: EventLoop? = nil, + logger: Logger? = nil, + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { - return self.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.psubscribe(to: [pattern], eventLoop: eventLoop, logger: logger, receiver) } } diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index a0187ad9..e8eec4af 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -381,29 +381,23 @@ extension RedisConnection { to channels: [RedisChannelName], eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler?, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { - return self._subscribe(.channels(channels), receiver, subscribeHandler, unsubscribeHandler, eventLoop, logger) + return self._subscribe(.channels(channels), receiver, eventLoop, logger) } public func psubscribe( to patterns: [String], eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler? = nil, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? = nil + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { - return self._subscribe(.patterns(patterns), receiver, subscribeHandler, unsubscribeHandler, eventLoop, logger) + return self._subscribe(.patterns(patterns), receiver, eventLoop, logger) } private func _subscribe( _ target: RedisSubscriptionTarget, - _ receiver: @escaping RedisSubscriptionMessageReceiver, - _ onSubscribe: RedisSubscribeHandler?, - _ onUnsubscribe: RedisUnsubscribeHandler?, + _ receiver: @escaping RedisPubSubEventReceiver, _ eventLoop: EventLoop?, _ logger: Logger? ) -> EventLoopFuture { @@ -433,7 +427,7 @@ extension RedisConnection { .flatMap { handler in logger.trace("handler added, adding subscription") return handler - .addSubscription(for: target, messageReceiver: receiver, onSubscribe: onSubscribe, onUnsubscribe: onUnsubscribe) + .addSubscription(for: target, receiver: receiver) .flatMapError { error in logger.debug( "failed to add subscriptions that triggered pubsub mode. removing handler", @@ -462,7 +456,7 @@ extension RedisConnection { // add the subscription and just ignore the subscription count return handler - .addSubscription(for: target, messageReceiver: receiver, onSubscribe: onSubscribe, onUnsubscribe: onUnsubscribe) + .addSubscription(for: target, receiver: receiver) .map { _ in logger.trace("subscription added") } .hop(to: finalEventLoop) } diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 5175b4bf..7677596f 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -354,22 +354,20 @@ extension RedisConnectionPool: RedisClient { to channels: [RedisChannelName], eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler?, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { return self._subscribe( using: { - $0.subscribe( + (connection, wrappedReceiver, logger) in + + connection.subscribe( to: channels, eventLoop: eventLoop, - logger: $2, - messageReceiver: receiver, - onSubscribe: subscribeHandler, - onUnsubscribe: $1 + logger: logger, + wrappedReceiver ) }, - onUnsubscribe: unsubscribeHandler, + receiver: receiver, eventLoop: eventLoop, taskLogger: logger ) @@ -379,22 +377,20 @@ extension RedisConnectionPool: RedisClient { to patterns: [String], eventLoop: EventLoop? = nil, logger: Logger? = nil, - messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, - onSubscribe subscribeHandler: RedisSubscribeHandler?, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler? + _ receiver: @escaping RedisPubSubEventReceiver ) -> EventLoopFuture { return self._subscribe( using: { - $0.psubscribe( + (connection, wrappedReceiver, logger) in + + connection.psubscribe( to: patterns, eventLoop: eventLoop, - logger: $2, - messageReceiver: receiver, - onSubscribe: subscribeHandler, - onUnsubscribe: $1 + logger: logger, + wrappedReceiver ) }, - onUnsubscribe: unsubscribeHandler, + receiver: receiver, eventLoop: eventLoop, taskLogger: logger ) @@ -424,9 +420,12 @@ extension RedisConnectionPool: RedisClient { ) } + /// Handles the connection management for PubSub state, providing a dedicated connection to the operation to use. + /// + /// The provided receiver is wrapped to handle proper cleanup of PubSub state, which is provided to the operation to use. private func _subscribe( - using operation: @escaping (RedisConnection, @escaping RedisUnsubscribeHandler, Logger) -> EventLoopFuture, - onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler?, + using operation: @escaping (RedisConnection, @escaping RedisPubSubEventReceiver, Logger) -> EventLoopFuture, + receiver: @escaping RedisPubSubEventReceiver, eventLoop: EventLoop?, taskLogger: Logger? ) -> EventLoopFuture { @@ -437,21 +436,30 @@ extension RedisConnectionPool: RedisClient { connection.allowSubscriptions = true // allow pubsub commands which are to come self.pubsubConnection = connection } - - let onUnsubscribe: RedisUnsubscribeHandler = { subscriptionDetails, eventSource in - defer { unsubscribeHandler?(subscriptionDetails, eventSource) } - - guard - subscriptionDetails.currentSubscriptionCount == 0, - let connection = self.pubsubConnection - else { return } - - connection.allowSubscriptions = false // reset PubSub permissions - returnConnection(connection, logger) - self.pubsubConnection = nil // break ref cycle + + let wrappedReceiver: RedisPubSubEventReceiver = { event in + // we only care about unsubscribe events, everything else is just passed to the user's receiver + switch event { + case .subscribed, .message: + receiver(event) + + case let .unsubscribed(_, connectionCount, _): + // always make sure we're still passing to the user's receiver + defer { receiver(event) } + + // we only care if this was the last subscription, and we haven't updated our internal state + guard + connectionCount == 0, + let connection = self.pubsubConnection + else { return } + + connection.allowSubscriptions = false // reset PubSub permissions + returnConnection(connection, logger) + self.pubsubConnection = nil // break ref cycle + } } - return operation(connection, onUnsubscribe, logger) + return operation(connection, wrappedReceiver, logger) }, preferredConnection: self.pubsubConnection, eventLoop: eventLoop, diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index 34c26d3d..ef94708b 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -109,13 +109,13 @@ internal struct CustomLoggerRedisClient: RedisClient { return self.client.punsubscribe(from: patterns, eventLoop: eventLoop, logger: logger) } - internal func subscribe(to channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscribeHandler?, onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler?) -> EventLoopFuture { + internal func subscribe(to channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?, _ receiver: @escaping RedisPubSubEventReceiver) -> EventLoopFuture { let logger = logger ?? self.defaultLogger - return self.client.subscribe(to: channels, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.client.subscribe(to: channels, eventLoop: eventLoop, logger: logger, receiver) } - internal func psubscribe(to patterns: [String], eventLoop: EventLoop?, logger: Logger?, messageReceiver receiver: @escaping RedisSubscriptionMessageReceiver, onSubscribe subscribeHandler: RedisSubscribeHandler?, onUnsubscribe unsubscribeHandler: RedisUnsubscribeHandler?) -> EventLoopFuture { + internal func psubscribe(to patterns: [String], eventLoop: EventLoop?, logger: Logger?, _ receiver: @escaping RedisPubSubEventReceiver) -> EventLoopFuture { let logger = logger ?? self.defaultLogger - return self.client.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, messageReceiver: receiver, onSubscribe: subscribeHandler, onUnsubscribe: unsubscribeHandler) + return self.client.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, receiver) } } diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index 13e1f7b3..3e502891 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -28,29 +28,32 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { defer { try? subscriber.close().wait() } let message = "Hello from Redis!" - + try subscriber.subscribe( to: #function, - messageReceiver: { - guard - $0 == #function, - $1.string == message - else { return } - messageExpectation.fulfill() - }, - onSubscribe: { - guard $0 == #function, $1 == 1 else { return } - subscribeExpectation.fulfill() - }, - onUnsubscribe: { details, eventSource in - switch eventSource { - case .clientError: return - case .userInitiated: + { event in + switch event { + case let .subscribed(key, currentSubscriptionCount): guard - details.subscriptionKey == #function, - details.currentSubscriptionCount == 0 + key == #function, + currentSubscriptionCount == 1 + else { return } + subscribeExpectation.fulfill() + + case let .unsubscribed(key, currentSubscriptionCount, source): + guard + case .userInitiated = source, + key == #function, + currentSubscriptionCount == 0 else { return } unsubscribeExpectation.fulfill() + + case let .message(key, body): + guard + key == #function, + body.string == message + else { return } + messageExpectation.fulfill() } } ).wait() @@ -74,10 +77,22 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { let pattern = "\(channel.rawValue.dropLast(channel.rawValue.count / 2))*" try subscriber - .subscribe(to: channel) { (_, _) in channelMessageExpectation.fulfill() } + .subscribe( + to: channel, + { + guard case .message = $0 else { return } + channelMessageExpectation.fulfill() + } + ) .wait() try subscriber - .psubscribe(to: pattern) { (_, _) in patternMessageExpectation.fulfill() } + .psubscribe( + to: pattern, + { + guard case .message = $0 else { return } + patternMessageExpectation.fulfill() + } + ) .wait() let subscriberCount = try self.connection.publish("hello!", to: channel).wait() @@ -91,7 +106,7 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { } func test_blockedCommandsThrowInPubSubMode() throws { - try self.connection.subscribe(to: #function) { (_, _) in }.wait() + try self.connection.subscribe(to: #function, { _ in }).wait() defer { try? self.connection.unsubscribe(from: #function).wait() } XCTAssertThrowsError(try self.connection.send(.lpush("value", into: "List")).wait()) { @@ -100,7 +115,7 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { } func test_pingInPubSub() throws { - try self.connection.subscribe(to: #function) { (_, _) in }.wait() + try self.connection.subscribe(to: #function, { _ in }).wait() defer { try? self.connection.unsubscribe(from: #function).wait() } let pong = try self.connection.ping().wait() @@ -111,7 +126,7 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { } func test_quitInPubSub() throws { - try self.connection.subscribe(to: #function) { (_, _) in }.wait() + try self.connection.subscribe(to: #function, { _ in }).wait() defer { try? self.connection.unsubscribe(from: #function).wait() } let quit = RedisCommand(keyword: "QUIT", arguments: []) @@ -130,9 +145,10 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { try subscriber.subscribe( to: channels, - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: { _, _ in expectation.fulfill() } + { + guard case .unsubscribed = $0 else { return } + expectation.fulfill() + } ).wait() XCTAssertTrue(subscriber.isSubscribed) @@ -153,9 +169,10 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { try subscriber.psubscribe( to: patterns, - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: { _, _ in expectation.fulfill() } + { + guard case .unsubscribed = $0 else { return } + expectation.fulfill() + } ).wait() XCTAssertTrue(subscriber.isSubscribed) @@ -176,17 +193,19 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { try subscriber.subscribe( to: #function, - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: { _, _ in expectation.fulfill() } + { + guard case .unsubscribed = $0 else { return } + expectation.fulfill() + } ).wait() XCTAssertTrue(subscriber.isSubscribed) try subscriber.psubscribe( to: "*\(#function)", - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: { _, _ in expectation.fulfill() } + { + guard case .unsubscribed = $0 else { return } + expectation.fulfill() + } ).wait() XCTAssertTrue(subscriber.isSubscribed) @@ -217,12 +236,9 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { } for channelName in channelNames { - try subscriber.subscribe( - to: channelName, - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: nil - ).wait() + try subscriber + .subscribe(to: channelName, { _ in }) + .wait() } XCTAssertTrue(subscriber.isSubscribed) defer { @@ -252,12 +268,9 @@ final class RedisPubSubCommandsTests: RediStackIntegrationTestCase { } for channelName in channelNames { - try subscriber.subscribe( - to: channelName, - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: nil - ).wait() + try subscriber + .subscribe(to: channelName, { _ in }) + .wait() } XCTAssertTrue(subscriber.isSubscribed) defer { @@ -292,26 +305,29 @@ final class RedisPubSubCommandsPoolTests: RediStackConnectionPoolIntegrationTest try subscriber.subscribe( to: #function, - messageReceiver: { - guard - $0 == #function, - $1.string == message - else { return } - messageExpectation.fulfill() - }, - onSubscribe: { - guard $0 == #function, $1 == 1 else { return } - subscribeExpectation.fulfill() - }, - onUnsubscribe: { details, eventSource in - switch eventSource { - case .clientError: return - case .userInitiated: + { event in + switch event { + case let .subscribed(key, count): + guard + key == #function, + count == 1 + else { return } + subscribeExpectation.fulfill() + + case let .unsubscribed(key, count, source): guard - details.subscriptionKey == #function, - details.currentSubscriptionCount == 0 + case .userInitiated = source, + key == #function, + count == 0 else { return } unsubscribeExpectation.fulfill() + + case let .message(key, body): + guard + key == #function, + body.string == message + else { return } + messageExpectation.fulfill() } } ).wait() @@ -337,11 +353,17 @@ final class RedisPubSubCommandsPoolTests: RediStackConnectionPoolIntegrationTest let pattern = "\(channel.rawValue.dropLast(channel.rawValue.count / 2))*" try subscriber - .subscribe(to: channel) { (_, _) in channelMessageExpectation.fulfill() } + .subscribe(to: channel, { + guard case .message = $0 else { return } + channelMessageExpectation.fulfill() + }) .wait() XCTAssertEqual(subscriber.leasedConnectionCount, 1) try subscriber - .psubscribe(to: pattern) { (_, _) in patternMessageExpectation.fulfill() } + .psubscribe(to: pattern, { + guard case .message = $0 else { return } + patternMessageExpectation.fulfill() + }) .wait() XCTAssertEqual(subscriber.leasedConnectionCount, 1) @@ -375,12 +397,15 @@ extension RedisPubSubCommandsTests { let subscribeFuture = connection .subscribe( to: [.init(#function)], - messageReceiver: { _, _ in }, - onSubscribe: { _, _ in subscribeExpectation.fulfill() }, - onUnsubscribe: { _, eventSource in - switch eventSource { - case .userInitiated: return - case .clientError: unsubscribeExpectation.fulfill() + { event in + switch event { + case .message: break + + case .subscribed: subscribeExpectation.fulfill() + + case let .unsubscribed(_, _, source): + guard case .clientError = source else { return } + unsubscribeExpectation.fulfill() } } ) diff --git a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift index 9524ed51..3354477b 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift @@ -179,7 +179,7 @@ extension RedisConnectionPoolTests { } try self.pool - .subscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .subscribe(to: #function, eventLoop: eventLoop, { _ in }) .map { _ in eventLoop.assertInEventLoop() } .wait() } @@ -194,7 +194,7 @@ extension RedisConnectionPoolTests { } try self.pool - .psubscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .psubscribe(to: #function, eventLoop: eventLoop, { _ in }) .map { _ in eventLoop.assertInEventLoop() } .wait() } diff --git a/Tests/RediStackIntegrationTests/RedisConnectionTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionTests.swift index 358a614b..f7abb92d 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionTests.swift @@ -47,7 +47,7 @@ final class RedisConnectionTests: RediStackIntegrationTestCase { extension RedisConnectionTests { func test_subscriptionNotAllowedFails() throws { self.connection.allowSubscriptions = false - let subscription = self.connection.subscribe(to: #function) { (_, _) in } + let subscription = self.connection.subscribe(to: #function, { _ in }) XCTAssertThrowsError(try subscription.wait()) { guard let error = $0 as? RedisClientError else { @@ -66,15 +66,17 @@ extension RedisConnectionTests { _ = try connection.subscribe( to: #function, - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: { _, _ in subscriptionClosedExpectation.fulfill() } + { + guard case .unsubscribed = $0 else { return } + subscriptionClosedExpectation.fulfill() + } ).wait() _ = try connection.psubscribe( to: #function, - messageReceiver: { _, _ in }, - onSubscribe: nil, - onUnsubscribe: { _, _ in subscriptionClosedExpectation.fulfill() } + { + guard case .unsubscribed = $0 else { return } + subscriptionClosedExpectation.fulfill() + } ).wait() connection.allowSubscriptions = false @@ -110,12 +112,12 @@ extension RedisConnectionTests { } try self.connection - .subscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .subscribe(to: #function, eventLoop: eventLoop, { _ in }) .map { _ in eventLoop.assertInEventLoop() } .wait() try self.connection - .subscribe(to: #function) { _, _ in } + .subscribe(to: #function, { _ in }) .map { _ in eventLoop.assertNotInEventLoop() self.connection.eventLoop.assertInEventLoop() @@ -133,12 +135,12 @@ extension RedisConnectionTests { } try self.connection - .psubscribe(to: #function, eventLoop: eventLoop) { _, _ in } + .psubscribe(to: #function, eventLoop: eventLoop, { _ in }) .map { _ in eventLoop.assertInEventLoop() } .wait() try self.connection - .psubscribe(to: #function) { _, _ in } + .psubscribe(to: #function, { _ in }) .map { _ in eventLoop.assertNotInEventLoop() self.connection.eventLoop.assertInEventLoop() From f6cb83ca2a70b928f1c89eab44545bbebcb58013 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Sat, 26 Nov 2022 14:05:56 +0100 Subject: [PATCH 53/63] Explicitly depend on Atomics --- Package.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Package.swift b/Package.swift index 03cb640d..857d341e 100644 --- a/Package.swift +++ b/Package.swift @@ -26,12 +26,14 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.43.0"), + .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"), .package(url: "https://github.com/apple/swift-service-discovery", from: "1.0.0"), ], targets: [ .target( name: "RediStack", dependencies: [ + .product(name: "Atomics", package: "swift-atomics"), .product(name: "NIO", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), .product(name: "Metrics", package: "swift-metrics"), From d0f15ad55b38e14c207ae876cca56bb89f453ef0 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Sat, 26 Nov 2022 14:17:58 +0100 Subject: [PATCH 54/63] Conform `RedisByteDecoder` to `NIOSingleStepByteToMessageDecoder` --- .../ChannelHandlers/RedisByteDecoder.swift | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift b/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift index 1c9a2970..dac77854 100644 --- a/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift +++ b/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -17,8 +17,8 @@ import NIO /// Handles incoming byte messages from Redis /// and decodes them according to the Redis Serialization Protocol (RESP). /// -/// See `NIO.ByteToMessageDecoder`, `RESPTranslator` and [https://redis.io/topics/protocol](https://redis.io/topics/protocol) -public final class RedisByteDecoder: ByteToMessageDecoder { +/// See `NIO.NIOSingleStepByteToMessageDecoder`, `RESPTranslator` and [https://redis.io/topics/protocol](https://redis.io/topics/protocol) +public struct RedisByteDecoder: NIOSingleStepByteToMessageDecoder { /// `ByteToMessageDecoder.InboundOut` public typealias InboundOut = RESPValue @@ -28,18 +28,13 @@ public final class RedisByteDecoder: ByteToMessageDecoder { self.parser = RESPTranslator() } - /// See `ByteToMessageDecoder.decode(context:buffer:)` - public func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState { - guard let value = try self.parser.parseBytes(from: &buffer) else { return .needMoreData } - - context.fireChannelRead(wrapInboundOut(value)) - return .continue + /// See `NIOSingleStepByteToMessageDecoder.decode(buffer:)` + public func decode(buffer: inout ByteBuffer) throws -> RESPValue? { + try self.parser.parseBytes(from: &buffer) } - /// See `ByteToMessageDecoder.decodeLast(context:buffer:seenEOF)` - public func decodeLast( - context: ChannelHandlerContext, - buffer: inout ByteBuffer, - seenEOF: Bool - ) throws -> DecodingState { return .needMoreData } + /// See `NIOSingleStepByteToMessageDecoder.decodeLast(buffer:seenEOF)` + public func decodeLast(buffer: inout ByteBuffer, seenEOF: Bool) throws -> RESPValue? { + try self.decode(buffer: &buffer) + } } From c366a16fe8536cf363f9ca997f05dd30ae6492ce Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Sat, 26 Nov 2022 14:42:32 +0100 Subject: [PATCH 55/63] Explicitly depend on NIO modules --- Package.swift | 13 +++++++++---- .../ChannelHandlers/RedisByteDecoder.swift | 2 +- .../ChannelHandlers/RedisCommandHandler.swift | 4 ++-- .../ChannelHandlers/RedisMessageEncoder.swift | 4 ++-- .../ChannelHandlers/RedisPubSubHandler.swift | 2 +- Sources/RediStack/Commands/ConnectionCommands.swift | 2 +- Sources/RediStack/Commands/HashCommands.swift | 2 +- Sources/RediStack/Commands/KeyCommands.swift | 2 +- Sources/RediStack/Commands/ListCommands.swift | 2 +- Sources/RediStack/Commands/PubSubCommands.swift | 2 +- Sources/RediStack/Commands/ServerCommands.swift | 2 +- Sources/RediStack/Commands/SetCommands.swift | 2 +- Sources/RediStack/Commands/SortedSetCommands.swift | 2 +- Sources/RediStack/Commands/StringCommands.swift | 2 +- .../RediStack/ConnectionPool/ConnectionPool.swift | 2 +- Sources/RediStack/Documentation.docc/RediStack.md | 2 +- Sources/RediStack/Extensions/SwiftNIO.swift | 3 ++- Sources/RediStack/RESP/RESPTranslator.swift | 4 ++-- Sources/RediStack/RESP/RESPValue.swift | 4 ++-- Sources/RediStack/RedisClient.swift | 2 +- .../RediStack/RedisConnection+Configuration.swift | 2 +- Sources/RediStack/RedisConnection.swift | 3 ++- .../RedisConnectionPool+Configuration.swift | 3 ++- Sources/RediStack/RedisConnectionPool.swift | 2 +- Sources/RediStack/RedisKey+TTL.swift | 4 ++-- Sources/RediStack/RedisLogging.swift | 2 +- .../EmbeddedMockRedisServer.swift | 5 +++-- Sources/RediStackTestUtils/Extensions/General.swift | 4 ++-- .../RediStackTestUtils/Extensions/RediStack.swift | 4 ++-- .../RedisConnectionPoolIntegrationTestCase.swift | 3 ++- .../RedisIntegrationTestCase.swift | 5 +++-- Sources/RedisTypes/RedisSet.swift | 2 +- .../Commands/PubSubCommandsTests.swift | 2 +- .../Commands/SortedSetCommandsTests.swift | 4 ++-- .../RedisConnectionPoolTests.swift | 3 ++- .../RedisConnectionTests.swift | 3 ++- .../RedisLoggingTests.swift | 2 +- .../RedisServiceDiscoveryTests.swift | 2 +- .../ChannelHandlers/RedisByteDecoderTests.swift | 5 +++-- .../ChannelHandlers/RedisCommandHandlerTests.swift | 5 +++-- .../ChannelHandlers/RedisMessageEncoderTests.swift | 5 +++-- Tests/RediStackTests/ConfigurationTests.swift | 4 ++-- Tests/RediStackTests/ConnectionPoolTests.swift | 3 ++- Tests/RediStackTests/RESPTranslatorTests.swift | 4 ++-- Tests/RediStackTests/RedisConnectionTests.swift | 3 ++- 45 files changed, 81 insertions(+), 63 deletions(-) diff --git a/Package.swift b/Package.swift index 857d341e..08e3d896 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,8 @@ let package = Package( name: "RediStack", dependencies: [ .product(name: "Atomics", package: "swift-atomics"), - .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), .product(name: "Metrics", package: "swift-metrics"), .product(name: "ServiceDiscovery", package: "swift-service-discovery") @@ -44,7 +45,9 @@ let package = Package( name: "RediStackTests", dependencies: [ "RediStack", "RediStackTestUtils", - .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOEmbedded", package: "swift-nio"), .product(name: "NIOTestUtils", package: "swift-nio") ] ), @@ -54,14 +57,16 @@ let package = Package( name: "RedisTypesTests", dependencies: [ "RediStack", "RedisTypes", "RediStackTestUtils", - .product(name: "NIO", package: "swift-nio") + .product(name: "NIOCore", package: "swift-nio") ] ), .target( name: "RediStackTestUtils", dependencies: [ - .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOEmbedded", package: "swift-nio"), "RediStack" ] ), diff --git a/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift b/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift index dac77854..bcf38f7c 100644 --- a/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift +++ b/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore /// Handles incoming byte messages from Redis /// and decodes them according to the Redis Serialization Protocol (RESP). diff --git a/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift b/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift index f1716dca..1393c6e3 100644 --- a/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift +++ b/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore /// An object that operates in a First In, First Out (FIFO) request-response cycle. /// diff --git a/Sources/RediStack/ChannelHandlers/RedisMessageEncoder.swift b/Sources/RediStack/ChannelHandlers/RedisMessageEncoder.swift index 17f27008..2293d533 100644 --- a/Sources/RediStack/ChannelHandlers/RedisMessageEncoder.swift +++ b/Sources/RediStack/ChannelHandlers/RedisMessageEncoder.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore #if DEBUG // used only for debugging purposes where we build a formatted string for the encoded bytes diff --git a/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift b/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift index 872cc30d..b652471e 100644 --- a/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift +++ b/Sources/RediStack/ChannelHandlers/RedisPubSubHandler.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore /// The possible events that are received from Redis Pub/Sub channels. public enum RedisPubSubEvent { diff --git a/Sources/RediStack/Commands/ConnectionCommands.swift b/Sources/RediStack/Commands/ConnectionCommands.swift index 31860c04..89cc1b08 100644 --- a/Sources/RediStack/Commands/ConnectionCommands.swift +++ b/Sources/RediStack/Commands/ConnectionCommands.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore // MARK: Connection diff --git a/Sources/RediStack/Commands/HashCommands.swift b/Sources/RediStack/Commands/HashCommands.swift index 06aef550..b6a39c57 100644 --- a/Sources/RediStack/Commands/HashCommands.swift +++ b/Sources/RediStack/Commands/HashCommands.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore // MARK: Hashes diff --git a/Sources/RediStack/Commands/KeyCommands.swift b/Sources/RediStack/Commands/KeyCommands.swift index a0395a0f..83757251 100644 --- a/Sources/RediStack/Commands/KeyCommands.swift +++ b/Sources/RediStack/Commands/KeyCommands.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore // MARK: Key diff --git a/Sources/RediStack/Commands/ListCommands.swift b/Sources/RediStack/Commands/ListCommands.swift index 4fbf0f5a..a2b80d99 100644 --- a/Sources/RediStack/Commands/ListCommands.swift +++ b/Sources/RediStack/Commands/ListCommands.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore // MARK: Lists diff --git a/Sources/RediStack/Commands/PubSubCommands.swift b/Sources/RediStack/Commands/PubSubCommands.swift index 8d36edc7..af57353f 100644 --- a/Sources/RediStack/Commands/PubSubCommands.swift +++ b/Sources/RediStack/Commands/PubSubCommands.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore // MARK: PubSub diff --git a/Sources/RediStack/Commands/ServerCommands.swift b/Sources/RediStack/Commands/ServerCommands.swift index bb777b33..ef2551a3 100644 --- a/Sources/RediStack/Commands/ServerCommands.swift +++ b/Sources/RediStack/Commands/ServerCommands.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore // MARK: Server diff --git a/Sources/RediStack/Commands/SetCommands.swift b/Sources/RediStack/Commands/SetCommands.swift index de13f704..b71e6c62 100644 --- a/Sources/RediStack/Commands/SetCommands.swift +++ b/Sources/RediStack/Commands/SetCommands.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore // MARK: Sets diff --git a/Sources/RediStack/Commands/SortedSetCommands.swift b/Sources/RediStack/Commands/SortedSetCommands.swift index 1f7f0fb6..294b6fa6 100644 --- a/Sources/RediStack/Commands/SortedSetCommands.swift +++ b/Sources/RediStack/Commands/SortedSetCommands.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore // MARK: Sorted Sets diff --git a/Sources/RediStack/Commands/StringCommands.swift b/Sources/RediStack/Commands/StringCommands.swift index eb3176d4..5fee6f59 100644 --- a/Sources/RediStack/Commands/StringCommands.swift +++ b/Sources/RediStack/Commands/StringCommands.swift @@ -14,7 +14,7 @@ import struct Logging.Logger import Foundation -import NIO +import NIOCore // MARK: Strings diff --git a/Sources/RediStack/ConnectionPool/ConnectionPool.swift b/Sources/RediStack/ConnectionPool/ConnectionPool.swift index 5a1c21c0..f7d25094 100644 --- a/Sources/RediStack/ConnectionPool/ConnectionPool.swift +++ b/Sources/RediStack/ConnectionPool/ConnectionPool.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Logging -import NIO +import NIOCore /// `ConnectionPool` is RediStack's internal representation of a pool of Redis connections. /// diff --git a/Sources/RediStack/Documentation.docc/RediStack.md b/Sources/RediStack/Documentation.docc/RediStack.md index 573b82d0..4021f0d1 100644 --- a/Sources/RediStack/Documentation.docc/RediStack.md +++ b/Sources/RediStack/Documentation.docc/RediStack.md @@ -7,7 +7,7 @@ A non-blocking Swift client for Redis built on top of SwiftNIO. **RediStack** is quick to use - all you need is an [`EventLoop`](https://apple.github.io/swift-nio/docs/current/NIO/Protocols/EventLoop.html) from **SwiftNIO**. ```swift -import NIO +import NIOCore import RediStack let eventLoop: EventLoop = ... diff --git a/Sources/RediStack/Extensions/SwiftNIO.swift b/Sources/RediStack/Extensions/SwiftNIO.swift index 8dac3736..ddc0b1f0 100644 --- a/Sources/RediStack/Extensions/SwiftNIO.swift +++ b/Sources/RediStack/Extensions/SwiftNIO.swift @@ -12,7 +12,8 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore +import NIOPosix // MARK: Convenience extensions diff --git a/Sources/RediStack/RESP/RESPTranslator.swift b/Sources/RediStack/RESP/RESPTranslator.swift index 2a0d8f93..774239ec 100644 --- a/Sources/RediStack/RESP/RESPTranslator.swift +++ b/Sources/RediStack/RESP/RESPTranslator.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import protocol Foundation.LocalizedError -import NIO +import NIOCore /// A helper object for translating between raw bytes and Swift types according to the Redis Serialization Protocol (RESP). /// diff --git a/Sources/RediStack/RESP/RESPValue.swift b/Sources/RediStack/RESP/RESPValue.swift index e3196a59..4a81a60b 100644 --- a/Sources/RediStack/RESP/RESPValue.swift +++ b/Sources/RediStack/RESP/RESPValue.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Foundation.Data -import NIO +import NIOCore /// A representation of a Redis Serialization Protocol (RESP) primitive value. /// diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index 9c05ee12..957099e0 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -14,7 +14,7 @@ import protocol Foundation.LocalizedError import struct Logging.Logger -import NIO +import NIOCore /// An object capable of sending commands and receiving responses. /// diff --git a/Sources/RediStack/RedisConnection+Configuration.swift b/Sources/RediStack/RedisConnection+Configuration.swift index 325e4ff4..134cd559 100644 --- a/Sources/RediStack/RedisConnection+Configuration.swift +++ b/Sources/RediStack/RedisConnection+Configuration.swift @@ -14,7 +14,7 @@ import Foundation import Logging -import NIO +import NIOCore extension RedisConnection.Configuration { public struct ValidationError: LocalizedError, Equatable { diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index e8eec4af..93bdaeb1 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -17,8 +17,9 @@ import struct Dispatch.DispatchTime import Atomics import Logging import Metrics -import NIO +import NIOCore import NIOConcurrencyHelpers +import NIOPosix extension RedisConnection { diff --git a/Sources/RediStack/RedisConnectionPool+Configuration.swift b/Sources/RediStack/RedisConnectionPool+Configuration.swift index 0b499816..326a8af1 100644 --- a/Sources/RediStack/RedisConnectionPool+Configuration.swift +++ b/Sources/RediStack/RedisConnectionPool+Configuration.swift @@ -13,7 +13,8 @@ //===----------------------------------------------------------------------===// import Logging -import NIO +import NIOCore +import NIOPosix // MARK: - Pool Connection Congfiguration diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 7677596f..1de6a71b 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// import struct Foundation.UUID -import NIO +import NIOCore import NIOConcurrencyHelpers import Logging import ServiceDiscovery diff --git a/Sources/RediStack/RedisKey+TTL.swift b/Sources/RediStack/RedisKey+TTL.swift index 07535723..150bcd0e 100644 --- a/Sources/RediStack/RedisKey+TTL.swift +++ b/Sources/RediStack/RedisKey+TTL.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore extension RedisKey.Lifetime { /// The lifetime duration for a `RedisKey` which has an expiry set. diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index ef94708b..d113133e 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Logging -import NIO +import NIOCore /// The system funnel for all `Logging` static details such as labels, `Logging.Logger` prototypes, and metadata keys used by RediStack. public enum RedisLogging { diff --git a/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift b/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift index d694e5a2..a9be12ef 100644 --- a/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift +++ b/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -14,7 +14,8 @@ import RediStack import XCTest -import NIO +import NIOCore +import NIOEmbedded internal enum MockConnectionPoolError: Error { case unexpectedMessage diff --git a/Sources/RediStackTestUtils/Extensions/General.swift b/Sources/RediStackTestUtils/Extensions/General.swift index 97fe9a31..5a66217d 100644 --- a/Sources/RediStackTestUtils/Extensions/General.swift +++ b/Sources/RediStackTestUtils/Extensions/General.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore private let allocator = ByteBufferAllocator() diff --git a/Sources/RediStackTestUtils/Extensions/RediStack.swift b/Sources/RediStackTestUtils/Extensions/RediStack.swift index baac9172..e8168ad4 100644 --- a/Sources/RediStackTestUtils/Extensions/RediStack.swift +++ b/Sources/RediStackTestUtils/Extensions/RediStack.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Foundation -import NIO +import NIOCore import RediStack extension RedisConnection.Configuration { diff --git a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift index f680f172..c9d48857 100644 --- a/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisConnectionPoolIntegrationTestCase.swift @@ -13,7 +13,8 @@ //===----------------------------------------------------------------------===// import Logging -import NIO +import NIOCore +import NIOPosix import RediStack import XCTest diff --git a/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift b/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift index 7f7b3b74..91cbfa47 100644 --- a/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift +++ b/Sources/RediStackTestUtils/RedisIntegrationTestCase.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,8 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore +import NIOPosix import RediStack import XCTest diff --git a/Sources/RedisTypes/RedisSet.swift b/Sources/RedisTypes/RedisSet.swift index 24a6a1ef..587f3ba5 100644 --- a/Sources/RedisTypes/RedisSet.swift +++ b/Sources/RedisTypes/RedisSet.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Logging.Logger -import NIO +import NIOCore import RediStack extension RedisClient { diff --git a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift index 3e502891..a5b44bb8 100644 --- a/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/PubSubCommandsTests.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore import NIOEmbedded @testable import RediStack import RediStackTestUtils diff --git a/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift index 91e0f4ff..64679d64 100644 --- a/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/SortedSetCommandsTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore @testable import RediStack import RediStackTestUtils import XCTest diff --git a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift index 3354477b..4bd2c8c9 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionPoolTests.swift @@ -12,7 +12,8 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore +import NIOPosix import Logging @testable import RediStack import RediStackTestUtils diff --git a/Tests/RediStackIntegrationTests/RedisConnectionTests.swift b/Tests/RediStackIntegrationTests/RedisConnectionTests.swift index f7abb92d..164c0750 100644 --- a/Tests/RediStackIntegrationTests/RedisConnectionTests.swift +++ b/Tests/RediStackIntegrationTests/RedisConnectionTests.swift @@ -12,7 +12,8 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore +import NIOPosix @testable import RediStack import RediStackTestUtils import XCTest diff --git a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift index d3726778..03d65433 100644 --- a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift +++ b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift @@ -14,7 +14,7 @@ import Logging import ServiceDiscovery -import NIO +import NIOCore import RediStack import RediStackTestUtils import XCTest diff --git a/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift b/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift index 31249d95..3af86a62 100644 --- a/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift +++ b/Tests/RediStackIntegrationTests/RedisServiceDiscoveryTests.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import ServiceDiscovery -import NIO +import NIOCore import Logging @testable import RediStack import RediStackTestUtils diff --git a/Tests/RediStackTests/ChannelHandlers/RedisByteDecoderTests.swift b/Tests/RediStackTests/ChannelHandlers/RedisByteDecoderTests.swift index 575f3b0f..2cd4c953 100644 --- a/Tests/RediStackTests/ChannelHandlers/RedisByteDecoderTests.swift +++ b/Tests/RediStackTests/ChannelHandlers/RedisByteDecoderTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,8 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore +import NIOEmbedded import NIOTestUtils @testable import RediStack import XCTest diff --git a/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift b/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift index b92e4b47..d1959673 100644 --- a/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift +++ b/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019-2020 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,8 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore +import NIOPosix @testable import RediStack import XCTest diff --git a/Tests/RediStackTests/ChannelHandlers/RedisMessageEncoderTests.swift b/Tests/RediStackTests/ChannelHandlers/RedisMessageEncoderTests.swift index 1dfb182c..944be540 100644 --- a/Tests/RediStackTests/ChannelHandlers/RedisMessageEncoderTests.swift +++ b/Tests/RediStackTests/ChannelHandlers/RedisMessageEncoderTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,8 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore +import NIOEmbedded @testable import RediStack import RediStackTestUtils import XCTest diff --git a/Tests/RediStackTests/ConfigurationTests.swift b/Tests/RediStackTests/ConfigurationTests.swift index 3deab956..25d853b9 100644 --- a/Tests/RediStackTests/ConfigurationTests.swift +++ b/Tests/RediStackTests/ConfigurationTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2020 RediStack project authors +// Copyright (c) 2020-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import NIO +import NIOCore import RediStack import XCTest diff --git a/Tests/RediStackTests/ConnectionPoolTests.swift b/Tests/RediStackTests/ConnectionPoolTests.swift index 62229918..799a5858 100644 --- a/Tests/RediStackTests/ConnectionPoolTests.swift +++ b/Tests/RediStackTests/ConnectionPoolTests.swift @@ -15,7 +15,8 @@ @testable import RediStack @testable import RediStackTestUtils import XCTest -import NIO +import NIOCore +import NIOEmbedded enum ConnectionPoolTestError: Error { case connectionFailedForSomeReason diff --git a/Tests/RediStackTests/RESPTranslatorTests.swift b/Tests/RediStackTests/RESPTranslatorTests.swift index f613d09c..2673cd94 100644 --- a/Tests/RediStackTests/RESPTranslatorTests.swift +++ b/Tests/RediStackTests/RESPTranslatorTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the RediStack open source project // -// Copyright (c) 2019 RediStack project authors +// Copyright (c) 2019-2022 RediStack project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import struct Foundation.Data -import NIO +import NIOCore @testable import RediStack import XCTest diff --git a/Tests/RediStackTests/RedisConnectionTests.swift b/Tests/RediStackTests/RedisConnectionTests.swift index 69e259e9..7b36a48d 100644 --- a/Tests/RediStackTests/RedisConnectionTests.swift +++ b/Tests/RediStackTests/RedisConnectionTests.swift @@ -13,7 +13,8 @@ //===----------------------------------------------------------------------===// import Logging -import NIO +import NIOCore +import NIOEmbedded @testable import RediStack import XCTest From b88fac059d984549f4f00618a8e3614694eb09c7 Mon Sep 17 00:00:00 2001 From: Nathan Harris <2nd.lt.harris@gmail.com> Date: Wed, 30 Nov 2022 22:04:15 -0600 Subject: [PATCH 56/63] #115 -- Remove logging(to:) method --- Sources/RediStack/RedisClient.swift | 5 -- Sources/RediStack/RedisConnection.swift | 4 -- Sources/RediStack/RedisConnectionPool.swift | 7 +-- Sources/RediStack/RedisLogging.swift | 54 ------------------- .../Extensions/RediStack.swift | 6 ++- .../RedisLoggingTests.swift | 27 ++++++---- docs/api-design/Logging.md | 36 ++++--------- 7 files changed, 34 insertions(+), 105 deletions(-) diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index 957099e0..04821559 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -30,11 +30,6 @@ public protocol RedisClient { /// The client's configured default logger instance, if set. var defaultLogger: Logger? { get } - /// Overrides the default logger on the client with the provided instance for the duration of the returned object. - /// - Parameter logger: The logger instance to use in commands on the returned client instance. - /// - Returns: A client using the temporary default logger override for command logging. - func logging(to logger: Logger) -> RedisClient - /// Sends the given command to Redis. /// - Parameters: /// - command: The command to send to Redis for execution. diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index 93bdaeb1..cb9a12af 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -364,10 +364,6 @@ extension RedisConnection { // MARK: Logging extension RedisConnection { - public func logging(to logger: Logger) -> RedisClient { - return CustomLoggerRedisClient(defaultLogger: self.prepareLoggerForUse(logger), client: self) - } - private func prepareLoggerForUse(_ logger: Logger?) -> Logger { guard var logger = logger else { return self.defaultLogger } logger[metadataKey: RedisLogging.MetadataKeys.connectionID] = "\(self.id)" diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index 1de6a71b..f92dc097 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -174,7 +174,8 @@ extension RedisConnectionPool { /// For example: /// ```swift /// let countFuture = pool.leaseConnection { - /// let client = $0.logging(to: myLogger) + /// client in + /// /// return client.authorize(with: userPassword) /// .flatMap { connection.select(database: userDatabase) } /// .flatMap { connection.increment(counterKey) } @@ -324,10 +325,6 @@ extension RedisConnectionPool { // MARK: RedisClient extension RedisConnectionPool: RedisClient { public var eventLoop: EventLoop { self.loop } - - public func logging(to logger: Logger) -> RedisClient { - return CustomLoggerRedisClient(defaultLogger: logger, client: self) - } public func send( _ command: RedisCommand, diff --git a/Sources/RediStack/RedisLogging.swift b/Sources/RediStack/RedisLogging.swift index d113133e..ca1f1e63 100644 --- a/Sources/RediStack/RedisLogging.swift +++ b/Sources/RediStack/RedisLogging.swift @@ -65,57 +65,3 @@ extension Logger { /// The prototypical instance used for Redis connection pools. public static var redisBaseConnectionPoolLogger: Logger { RedisLogging.baseConnectionPoolLogger } } - -// MARK: RedisClient Logger Overrides - -/// This is an implementation detail of baseline RediStack RedisClients that stores a reference to an underlying -/// RedisClient and a given logger instance, which is used as a new default logger on all commands. -internal struct CustomLoggerRedisClient: RedisClient { - internal var eventLoop: EventLoop { self.client.eventLoop } - - internal let defaultLogger: Logger - - private let client: Client - - internal init(defaultLogger: Logger, client: Client) { - self.defaultLogger = defaultLogger - self.client = client - } - - // create a new instance by just reusing the same client and passing the new logger instance - - internal func logging(to logger: Logger) -> RedisClient { - return Self(defaultLogger: logger, client: client) - } - - // forward methods to the underlying client - - // in each case we need to explicitly create a logger variable using the provided logger argument, defaulting to - // the default logger if the argument is nil, because if we do it inline, the compiler will deduce the type - // as optional, allowing the (possibly) nil argument to pass through without providing the default logger in nil cases - - internal func send(_ command: RedisCommand, eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture { - let logger = logger ?? self.defaultLogger - return self.client.send(command, eventLoop: eventLoop, logger: logger) - } - - internal func unsubscribe(from channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture { - let logger = logger ?? self.defaultLogger - return self.client.unsubscribe(from: channels, eventLoop: eventLoop, logger: logger) - } - - internal func punsubscribe(from patterns: [String], eventLoop: EventLoop?, logger: Logger?) -> EventLoopFuture { - let logger = logger ?? self.defaultLogger - return self.client.punsubscribe(from: patterns, eventLoop: eventLoop, logger: logger) - } - - internal func subscribe(to channels: [RedisChannelName], eventLoop: EventLoop?, logger: Logger?, _ receiver: @escaping RedisPubSubEventReceiver) -> EventLoopFuture { - let logger = logger ?? self.defaultLogger - return self.client.subscribe(to: channels, eventLoop: eventLoop, logger: logger, receiver) - } - - internal func psubscribe(to patterns: [String], eventLoop: EventLoop?, logger: Logger?, _ receiver: @escaping RedisPubSubEventReceiver) -> EventLoopFuture { - let logger = logger ?? self.defaultLogger - return self.client.psubscribe(to: patterns, eventLoop: eventLoop, logger: logger, receiver) - } -} diff --git a/Sources/RediStackTestUtils/Extensions/RediStack.swift b/Sources/RediStackTestUtils/Extensions/RediStack.swift index e8168ad4..3d628008 100644 --- a/Sources/RediStackTestUtils/Extensions/RediStack.swift +++ b/Sources/RediStackTestUtils/Extensions/RediStack.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import Foundation +import Logging import NIOCore import RediStack @@ -23,9 +24,10 @@ extension RedisConnection.Configuration { public init( host: String = RedisConnection.Configuration.defaultHostname, port: Int = RedisConnection.Configuration.defaultPort, - password: String? = nil + password: String? = nil, + defaultLogger: Logger? = nil ) throws { - try self.init(hostname: host, port: port, password: password) + try self.init(hostname: host, port: port, password: password, defaultLogger: defaultLogger) } } diff --git a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift index 03d65433..cf9278d3 100644 --- a/Tests/RediStackIntegrationTests/RedisLoggingTests.swift +++ b/Tests/RediStackIntegrationTests/RedisLoggingTests.swift @@ -24,8 +24,7 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { let handler = TestLogHandler() let logger = Logger(label: #function, factory: { _ in return handler }) _ = try self.connection - .logging(to: logger) - .ping() + .ping(logger: logger) .wait() XCTAssertFalse(handler.events.isEmpty) } @@ -34,11 +33,21 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { let defaultHandler = TestLogHandler() let defaultLogger = Logger(label: #function, factory: { _ in return defaultHandler }) + let connection = try RedisConnection.make( + configuration: .init( + host: self.redisHostname, + port: self.redisPort, + password: self.redisPassword, + defaultLogger: defaultLogger + ), + boundEventLoop: self.connection.eventLoop + ).wait() + defaultHandler.events = [] // empty out events from initialization + let expectedHandler = TestLogHandler() let expectedLogger = Logger(label: "something_else", factory: { _ in return expectedHandler }) - _ = try self.connection - .logging(to: defaultLogger) + _ = try connection .ping(logger: expectedLogger) .wait() @@ -51,8 +60,7 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { let logger = Logger(label: #function, factory: { _ in return handler }) _ = try self.connection - .logging(to: logger) - .ping() + .ping(logger: logger) .wait() XCTAssertEqual( handler.metadata[RedisLogging.MetadataKeys.connectionID], @@ -76,8 +84,7 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { pool.activate() _ = try pool - .logging(to: logger) - .ping() + .ping(logger: logger) .wait() XCTAssertTrue(handler.metadata.keys.contains(RedisLogging.MetadataKeys.connectionID)) XCTAssertEqual( @@ -108,8 +115,7 @@ final class RedisLoggingTests: RediStackIntegrationTestCase { hosts.register("default.local", instances: [address]) _ = try client - .logging(to: logger) - .ping() + .ping(logger: logger) .wait() XCTAssertTrue(handler.metadata.keys.contains(RedisLogging.MetadataKeys.connectionID)) XCTAssertEqual( @@ -132,6 +138,7 @@ final class TestLogHandler: LogHandler { func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, file: String, function: String, line: UInt) { self.events.append((level, message, metadata, file, function, line)) + // print(message, dump(metadata), file, function, line) } subscript(metadataKey key: String) -> Logger.Metadata.Value? { diff --git a/docs/api-design/Logging.md b/docs/api-design/Logging.md index da13265a..485bcd33 100644 --- a/docs/api-design/Logging.md +++ b/docs/api-design/Logging.md @@ -28,24 +28,10 @@ to as [_Protocol-based Context Passing_](https://forums.swift.org/t/the-context- ```swift // example code, may not reflect current implementation -private struct CustomLoggingRedisClient: RedisClient { - // a client that this object will act as a context proxy for - private let client: RedisClient - private let logger: Logger - /* conformance to RedisClient protocol */ -} - -extension RedisClient { - public func logging(to logger: Logger) -> RedisClient { - return CustomLoggingRedisClient(client: self, logger: logger) - } -} - let myCustomLogger = ... let connection = ... -connection - .logging(to: myCustomLogger) // will use this logger for all 'user-space' logs for any requests made - .ping() +// will use this logger for all 'user-space' logs generated while serving this command +connection.ping(logger: myCustomLogger) ``` ## Log Guidelines @@ -53,18 +39,18 @@ connection 1. Prefer logging at `trace` levels 1. Prefer `debug` for any log that contains metadata, especially complex ones like structs or classes - exceptions to this guideline may include metadata such as object IDs that are triggering the logs -1. Dynamic values should be attached as metadata rather than string interpolated -1. All log metadata keys should be added to the `RedisLogging` namespace -1. Log messages should be in all lowercase, with no punctuation preferred - - if a Redis command keyword (such as `QUIT`) is in the log message, it should be in all caps -1. `warning` logs should be reserved for situations that could lead to `error` or `critical` conditions - - this may include leaks or bad state +1. Dynamic values SHOULD be attached as metadata rather than string interpolated +1. All log metadata keys SHOULD be added to the `RedisLogging` namespace +1. Log messages SHOULD be in all lowercase, with no punctuation preferred + - if a Redis command keyword (such as `QUIT`) is in the log message, it MUST be in all caps +1. `warning` logs SHOULD be reserved for situations that could lead to `error` or `critical` conditions + - this MAY include leaks or bad state 1. Only use `error` in situations where the error cannot be expressed by the language, such as by throwing an error or failing `EventLoopFuture`s. - this is to avoid high severity logs that developers cannot control and must create filtering mechanisms if they want to ignore emitted logs from **RediStack** 1. Log a `critical` message before any `preconditionFailure` or `fatalError` ### Metadata -1. All keys should have the `rdstk` prefix to avoid collisions -1. Public metadata keys should be 16 characters or less to avoid as many String allocations as possible -1. Keys should be computed properties to avoid memory costs +1. All keys SHOULD have the `rdstk` prefix to avoid collisions +1. Public metadata keys SHOULD be 16 characters or less to avoid as many String allocations as possible +1. Keys SHOULD be computed properties to avoid memory costs From 4ea66c47884c9e75ac03f23ff14dd1d27db390b7 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Thu, 1 Dec 2022 13:59:32 +0100 Subject: [PATCH 57/63] Add support for graceful shutdown to the RedisCommandHandler --- .../ChannelHandlers/RedisCommandHandler.swift | 56 ++++++++++++- .../RedisCommandHandlerTests.swift | 79 +++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift b/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift index 1393c6e3..0bdc53ee 100644 --- a/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift +++ b/Sources/RediStack/ChannelHandlers/RedisCommandHandler.swift @@ -44,7 +44,9 @@ public final class RedisCommandHandler { } private enum State { - case `default`, error(Error) + case `default` + case draining(EventLoopPromise?) + case error(Error) } } @@ -70,6 +72,7 @@ extension RedisCommandHandler: ChannelInboundHandler { /// See `NIO.ChannelInboundHandler.channelInactive(context:)` /// - Note: `RedisMetrics.commandFailureCount` is **not** incremented from this method. public func channelInactive(context: ChannelHandlerContext) { + self.state = .error(RedisClientError.connectionClosed) self._failCommandQueue(because: RedisClientError.connectionClosed) } @@ -100,6 +103,16 @@ extension RedisCommandHandler: ChannelInboundHandler { leadPromise.succeed(value) RedisMetrics.commandSuccessCount.increment() } + + switch self.state { + case .draining(let promise): + if self.commandResponseQueue.isEmpty { + context.close(mode: .all, promise: promise) + } + + case .error, .`default`: + break + } } } @@ -119,7 +132,11 @@ extension RedisCommandHandler: ChannelOutboundHandler { let commandPayload = self.unwrapOutboundIn(data) switch self.state { - case let .error(e): commandPayload.responsePromise.fail(e) + case let .error(e): + commandPayload.responsePromise.fail(e) + + case .draining: + commandPayload.responsePromise.fail(RedisClientError.connectionClosed) case .default: self.commandResponseQueue.append(commandPayload.responsePromise) @@ -129,4 +146,39 @@ extension RedisCommandHandler: ChannelOutboundHandler { ) } } + + /// Listens for ``RedisGracefulConnectionCloseEvent``. If such an event is received the handler will wait + /// until all currently running commands have returned. Once all requests are fulfilled the handler will close the channel. + /// + /// If a command is sent on the channel, after the ``RedisGracefulConnectionCloseEvent`` was scheduled, + /// the command will be failed with a ``RedisClientError/connectionClosed``. + /// + /// See `NIO.ChannelOutboundHandler.triggerUserOutboundEvent(context:event:promise:)` + public func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + switch event { + case is RedisGracefulConnectionCloseEvent: + switch self.state { + case .default: + if self.commandResponseQueue.isEmpty { + self.state = .error(RedisClientError.connectionClosed) + context.close(mode: .all, promise: promise) + } else { + self.state = .draining(promise) + } + + case .error, .draining: + promise?.succeed(()) + break + } + + default: + context.triggerUserOutboundEvent(event, promise: promise) + } + } +} + +/// A channel event that informs the ``RedisCommandHandler`` that it should close the channel gracefully +public struct RedisGracefulConnectionCloseEvent { + /// Creates a ``RedisGracefulConnectionCloseEvent`` + public init() {} } diff --git a/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift b/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift index d1959673..026a6253 100644 --- a/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift +++ b/Tests/RediStackTests/ChannelHandlers/RedisCommandHandlerTests.swift @@ -14,6 +14,8 @@ import NIOCore import NIOPosix +import NIOEmbedded +import Atomics @testable import RediStack import XCTest @@ -44,6 +46,83 @@ final class RedisCommandHandlerTests: XCTestCase { XCTAssertEqual(error, .connectionClosed) } } + + func testCloseIsTriggeredOnceCommandQueueIsEmpty() { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(handler: RedisCommandHandler(), loop: loop) + + XCTAssertNoThrow(try channel.connect(to: .init(unixDomainSocketPath: "/foo")).wait()) + XCTAssertTrue(channel.isActive) + + let getFoo = RESPValue.array([.bulkString(.init(string: "GET")), .bulkString(.init(string: "foo"))]) + let promiseFoo = loop.makePromise(of: RESPValue.self) + let commandFoo = (message: getFoo, responsePromise: promiseFoo) + XCTAssertNoThrow(try channel.writeOutbound(commandFoo)) + XCTAssertEqual(try channel.readOutbound(as: RESPValue.self), getFoo) + + let getBar = RESPValue.array([.bulkString(.init(string: "GET")), .bulkString(.init(string: "bar"))]) + let promiseBar = loop.makePromise(of: RESPValue.self) + let commandBar = (message: getBar, responsePromise: promiseBar) + XCTAssertNoThrow(try channel.writeOutbound(commandBar)) + XCTAssertEqual(try channel.readOutbound(as: RESPValue.self), getBar) + + let getBaz = RESPValue.array([.bulkString(.init(string: "GET")), .bulkString(.init(string: "baz"))]) + let promiseBaz = loop.makePromise(of: RESPValue.self) + let commandBaz = (message: getBaz, responsePromise: promiseBaz) + XCTAssertNoThrow(try channel.writeOutbound(commandBaz)) + XCTAssertEqual(try channel.readOutbound(as: RESPValue.self), getBaz) + + let gracefulClosePromise = loop.makePromise(of: Void.self) + let channelCloseHitCounter = ManagedAtomic(0) + gracefulClosePromise.futureResult.whenComplete { _ in + channelCloseHitCounter.wrappingIncrement(ordering: .relaxed) + } + channel.triggerUserOutboundEvent(RedisGracefulConnectionCloseEvent(), promise: gracefulClosePromise) + XCTAssertEqual(channelCloseHitCounter.load(ordering: .relaxed), 0) + + let fooResponse = RESPValue.simpleString(.init(string: "fooresult")) + XCTAssertNoThrow(try channel.writeInbound(fooResponse)) + XCTAssertTrue(channel.isActive) + XCTAssertEqual(channelCloseHitCounter.load(ordering: .relaxed), 0) + XCTAssertEqual(try promiseFoo.futureResult.wait(), fooResponse) + + let barResponse = RESPValue.simpleString(.init(string: "barresult")) + XCTAssertNoThrow(try channel.writeInbound(barResponse)) + XCTAssertTrue(channel.isActive) + XCTAssertEqual(channelCloseHitCounter.load(ordering: .relaxed), 0) + XCTAssertEqual(try promiseBar.futureResult.wait(), barResponse) + + let bazResponse = RESPValue.simpleString(.init(string: "bazresult")) + XCTAssertNoThrow(try channel.writeInbound(bazResponse)) + XCTAssertEqual(try promiseBaz.futureResult.wait(), bazResponse) + XCTAssertFalse(channel.isActive) + XCTAssertEqual(channelCloseHitCounter.load(ordering: .relaxed), 1) + XCTAssertNoThrow(try gracefulClosePromise.futureResult.wait()) + } + + func testCloseIsTriggeredRightAwayIfCommandQueueIsEmpty() { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(handler: RedisCommandHandler(), loop: loop) + XCTAssertNoThrow(try channel.connect(to: .init(unixDomainSocketPath: "/foo")).wait()) + XCTAssertTrue(channel.isActive) + + let gracefulClosePromise = loop.makePromise(of: Void.self) + let gracefulCloseHitCounter = ManagedAtomic(0) + gracefulClosePromise.futureResult.whenComplete { _ in + gracefulCloseHitCounter.wrappingIncrement(ordering: .relaxed) + } + channel.triggerUserOutboundEvent(RedisGracefulConnectionCloseEvent(), promise: gracefulClosePromise) + XCTAssertFalse(channel.isActive) + XCTAssertEqual(gracefulCloseHitCounter.load(ordering: .relaxed), 1) + + let getBar = RESPValue.array([.bulkString(.init(string: "GET")), .bulkString(.init(string: "bar"))]) + let promiseBar = loop.makePromise(of: RESPValue.self) + let commandBar = (message: getBar, responsePromise: promiseBar) + channel.write(commandBar, promise: nil) + XCTAssertThrowsError(try promiseBar.futureResult.wait()) { + XCTAssertEqual($0 as? RedisClientError, .connectionClosed) + } + } } private final class RemoteCloseHandler: ChannelInboundHandler { From e0cab21f952d3be370fe87be03b7ad7b7bc86077 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Sat, 26 Nov 2022 18:55:39 +0100 Subject: [PATCH 58/63] Graceful connection close without sending a QUIT command first --- Sources/RediStack/RedisConnection.swift | 65 ++++--------------- .../EmbeddedMockRedisServer.swift | 17 ++++- 2 files changed, 30 insertions(+), 52 deletions(-) diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index cb9a12af..7642ac84 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -184,12 +184,17 @@ public final class RedisConnection: RedisClient { self.channel.closeFuture.whenSuccess { // if our state is still open, that means we didn't cause the closeFuture to resolve. // update state, metrics, and logging - guard self.state.isConnected else { return } - + let oldState = self.state self.state = .closed - self.defaultLogger.warning("connection was closed unexpectedly") RedisMetrics.activeConnectionCount.decrement() - self.onUnexpectedClosure?() + + switch oldState { + case .shuttingDown, .closed: + break + case .open, .pubsub: + self.defaultLogger.warning("connection was closed unexpectedly") + self.onUnexpectedClosure?() + } } self.defaultLogger.trace("connection created") @@ -300,10 +305,11 @@ extension RedisConnection { // we're now in a shutdown state, starting with the command queue. self.state = .shuttingDown - - let notification = self.sendQuitCommand(logger: logger) // send "QUIT" so that all the responses are written out - .flatMap { self.closeChannel() } // close the channel from our end - .hop(to: finalEventLoop) + + // Inform ChannelHandler about close intent using "RedisGracefulConnectionCloseEvent" + let promise = finalEventLoop.makePromise(of: Void.self) + let notification = promise.futureResult + self.channel.triggerUserOutboundEvent(RedisGracefulConnectionCloseEvent(), promise: promise) notification.whenFailure { logger.warning("failed to close connection", metadata: [ @@ -311,54 +317,11 @@ extension RedisConnection { ]) } notification.whenSuccess { - self.state = .closed logger.trace("connection is now closed") - RedisMetrics.activeConnectionCount.decrement() } return notification } - - /// Bypasses everything for a normal command and explicitly just sends a "QUIT" command to Redis. - /// - Note: If the command fails, the `NIO.EventLoopFuture` will still succeed - as it's not critical for the command to succeed. - private func sendQuitCommand(logger: Logger) -> EventLoopFuture { - let payload: RedisCommandHandler.OutboundCommandPayload = ( - RedisCommand(keyword: "QUIT", arguments: []).serialized(), - self.eventLoop.makePromise() - ) - - logger.trace("sending QUIT command") - - return self.channel - .writeAndFlush(payload) // write the command - .flatMap { payload.responsePromise.futureResult } // chain the callback to the response's - .map { _ in logger.trace("sent QUIT command") } // ignore the result's value - .recover { _ in logger.debug("recovered from error sending QUIT") } // if there's an error, just return to void - } - - /// Attempts to close the `NIO.Channel`. - /// SwiftNIO throws a `NIO.EventLoopError.shutdown` if the channel is already closed, - /// so that case is captured to let this method's `NIO.EventLoopFuture` still succeed. - private func closeChannel() -> EventLoopFuture { - let promise = self.channel.eventLoop.makePromise(of: Void.self) - - self.channel.close(promise: promise) - - // if we succeed, great, if not - check the error that happened - return promise.futureResult - .flatMapError { error in - guard let e = error as? EventLoopError else { - return self.eventLoop.makeFailedFuture(error) - } - - // if the error is that the channel is already closed, great - just succeed. - // otherwise, fail the chain - switch e { - case .shutdown: return self.eventLoop.makeSucceededFuture(()) - default: return self.eventLoop.makeFailedFuture(e) - } - } - } } // MARK: Logging diff --git a/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift b/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift index a9be12ef..b7797720 100644 --- a/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift +++ b/Sources/RediStackTestUtils/EmbeddedMockRedisServer.swift @@ -68,7 +68,7 @@ internal final class EmbeddedMockRedisServer { } func createConnectedChannel() -> Channel { - let channel = EmbeddedChannel(loop: self.loop) + let channel = EmbeddedChannel(handler: GracefulShutdownToCloseHandler(), loop: self.loop) channel.closeFuture.whenComplete { _ in self.channels.removeAll(where: { $0 === channel }) } @@ -84,3 +84,18 @@ internal final class EmbeddedMockRedisServer { try self.loop.close() } } + +/// A `ChannelHandler` that triggers a channel close once `RedisGracefulConnectionCloseEvent` is received +private final class GracefulShutdownToCloseHandler: ChannelHandler, ChannelOutboundHandler { + typealias OutboundIn = NIOAny + + func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + switch event { + case is RedisGracefulConnectionCloseEvent: + context.close(mode: .all, promise: promise) + + default: + context.triggerUserOutboundEvent(event, promise: promise) + } + } +} From a431ae8c6ce003c71aeb8f868b988fbe3cd563f1 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Mon, 12 Dec 2022 20:34:56 -0600 Subject: [PATCH 59/63] [List] Properly map results when key has no values Fixes #116 --- Sources/RediStack/Commands/ListCommands.swift | 18 +++++- .../Commands/ListCommandsTests.swift | 60 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Sources/RediStack/Commands/ListCommands.swift b/Sources/RediStack/Commands/ListCommands.swift index a2b80d99..d99abdef 100644 --- a/Sources/RediStack/Commands/ListCommands.swift +++ b/Sources/RediStack/Commands/ListCommands.swift @@ -25,6 +25,7 @@ extension RedisCommand { /// - Parameters: /// - key: The key of the list to pop from. /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + /// - Returns: The value popped from the list, otherwise `nil`. public static func blpop(from key: RedisKey, timeout: TimeAmount = .seconds(0)) -> RedisCommand { return ._bpop(keyword: "BLPOP", [key], timeout, { $0?.1 }) } @@ -37,6 +38,7 @@ extension RedisCommand { /// - Parameters: /// - keys: The list of keys to pop from. /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + /// - Returns: The popped value and the key of its source list, otherwise `nil`. public static func blpop( from keys: [RedisKey], timeout: TimeAmount = .seconds(0) @@ -52,6 +54,7 @@ extension RedisCommand { /// - Parameters: /// - keys: The list of keys to pop from. /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + /// - Returns: The popped value and the key of its source list, otherwise `nil`. public static func blpop( from keys: RedisKey..., timeout: TimeAmount = .seconds(0) @@ -65,6 +68,7 @@ extension RedisCommand { /// - Parameters: /// - key: The key of the list to pop from. /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + /// - Returns: The value popped from the list, otherwise `nil`. public static func brpop(from key: RedisKey, timeout: TimeAmount = .seconds(0)) -> RedisCommand { return ._bpop(keyword: "BRPOP", [key], timeout, { $0?.1 }) } @@ -77,6 +81,7 @@ extension RedisCommand { /// - Parameters: /// - key: The key of the list to pop from. /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + /// - Returns: The popped value and the key of its source list, otherwise `nil`. public static func brpop(from keys: [RedisKey], timeout: TimeAmount = .seconds(0)) -> RedisCommand<(RedisKey, RESPValue)?> { return ._bpop(keyword: "BRPOP", keys, timeout, { $0 }) } @@ -89,6 +94,7 @@ extension RedisCommand { /// - Parameters: /// - key: The key of the list to pop from. /// - timeout: The max time to wait for a value to use. `0`seconds means to wait indefinitely. + /// - Returns: The popped value and the key of its source list, otherwise `nil`. public static func brpop( from keys: RedisKey..., timeout: TimeAmount = .seconds(0) @@ -103,23 +109,26 @@ extension RedisCommand { /// - source: The key of the list to pop from. /// - dest: The key of the list to push to. /// - timeout: The max time to wait for a value to use. `0` seconds means to wait indefinitely. + /// - Returns: The value removed from the `source`, otherwise `nil`. public static func brpoplpush( from source: RedisKey, to dest: RedisKey, timeout: TimeAmount = .seconds(0) ) -> RedisCommand { + assert(timeout >= .seconds(0), "anything smaller than a second will be treated as 0 seconds") let args: [RESPValue] = [ .init(from: source), .init(from: dest), .init(from: timeout.seconds) ] - return .init(keyword: "BRPOPLPUSH", arguments: args) + return .init(keyword: "BRPOPLPUSH", arguments: args, mapValueToResult: { try? $0.map() }) } /// [LINDEX](https://redis.io/commands/lindex) /// - Parameters: /// - index: The 0-based index of the element to get. /// - key: The key of the list. + /// - Returns: The value stored at the index, otherwise `nil`. public static func lindex(_ index: Int, from key: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: key), @@ -161,6 +170,7 @@ extension RedisCommand { /// [LPOP](https://redis.io/commands/lpop) /// - Parameter key: The key of the list to pop from. + /// - Returns: The value popped from the list, otherwise `nil`. public static func lpop(from key: RedisKey) -> RedisCommand { let args = [RESPValue(from: key)] return .init(keyword: "LPOP", arguments: args) { try? $0.map() } @@ -472,6 +482,7 @@ extension RedisCommand { /// [RPOP](https://redis.io/commands/rpop) /// - Parameter key: The key of the list to pop from. + /// - Returns: The value popped from the list, otherwise `nil`. public static func rpop(from key: RedisKey) -> RedisCommand { let args = [RESPValue(from: key)] return .init(keyword: "RPOP", arguments: args) { try? $0.map() } @@ -481,12 +492,13 @@ extension RedisCommand { /// - Parameters: /// - source: The key of the list to pop from. /// - dest: The key of the list to push to. + /// - Returns: The value removed from the `source`, otherwise `nil`. public static func rpoplpush(from source: RedisKey, to dest: RedisKey) -> RedisCommand { let args: [RESPValue] = [ .init(from: source), .init(from: dest) ] - return .init(keyword: "RPOPLPUSH", arguments: args) + return .init(keyword: "RPOPLPUSH", arguments: args, mapValueToResult: { try? $0.map() }) } /// [RPUSH](https://redis.io/commands/rpush) @@ -537,6 +549,8 @@ extension RedisCommand { _ timeout: TimeAmount, _ transform: @escaping ((RedisKey, RESPValue)?) throws -> ResultType? ) -> RedisCommand { + assert(timeout >= .seconds(0), "anything smaller than a second will be treated as 0 seconds") + var args = keys.map(RESPValue.init(from:)) args.append(.init(bulk: timeout.seconds)) diff --git a/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift index 30af2c4a..ac683578 100644 --- a/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/ListCommandsTests.swift @@ -274,3 +274,63 @@ final class ListCommandsTests: RediStackIntegrationTestCase { XCTAssertEqual(elements.count, 3) } } + +// MARK: #116 tests + +extension ListCommandsTests { + func test_rpoplpush_whenEmpty_succeeds_withNil() throws { + let result = try self + .connection + .send(.rpoplpush(from: "list1", to: "\(#function)")) + .wait() + XCTAssertNil(result) + } + + func test_rpop_whenEmpty_succeeds_withNil() throws { + let result = try self + .connection + .send(.rpop(from: "\(#function)")) + .wait() + XCTAssertNil(result) + } + + func test_lpop_whenEmpty_succeeds_withNil() throws { + let result = try self + .connection + .send(.lpop(from: "\(#function)")) + .wait() + XCTAssertNil(result) + } + + func test_lindex_whenEmpty_succeeds_withNil() throws { + let result = try self + .connection + .send(.lindex(3, from: "\(#function)")) + .wait() + XCTAssertNil(result) + } + + func test_lrange_whenEmpty_succeeds_withEmpty() throws { + let result = try self + .connection + .send(.lrange(from: "\(#function)", firstIndex: 0, lastIndex: 3)) + .wait() + XCTAssertTrue(result.isEmpty) + } + + func test_brpoplpush_whenEmpty_succeeds_withNil() throws { + let result = try self + .connection + .send(.brpoplpush(from: "\(#function)", to: "list1", timeout: .seconds(1))) + .wait() + XCTAssertNil(result) + } + + func test_brpop_whenEmpty_succeeds_withNil() throws { + let result = try self + .connection + .send(.brpop(from: "\(#function)", timeout: .seconds(1))) + .wait() + XCTAssertNil(result) + } +} From 3f7fedb6db5dfcb12bd2263dfaaec108b250ec79 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Mon, 12 Dec 2022 20:55:08 -0600 Subject: [PATCH 60/63] [Misc] Update contributors --- CONTRIBUTORS.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index ba53ca9c..6a37d0ca 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -13,9 +13,13 @@ needs to be listed here. ### Contributors - Cory Benfield +- Daniel Ramteke +- Fabian Fett - George Barnett +- Michael Stegeman - Nathan Harris - Ondrej Rafaj +- Peter Adams - Tanner Nelson **Updating this list** From aa185a013355b98e01dc58c04744c3311dac8435 Mon Sep 17 00:00:00 2001 From: Nathan Harris Date: Mon, 26 Dec 2022 14:43:21 -0600 Subject: [PATCH 61/63] Lay groundwork for RESP3 support and flat ChannelHandler hierarchy ## Motivation Since Redis 6.0, a new serialization protocol format (v3) is available that gives richer semantic reasoning behind the different types to enable commands to better understand the return types to provide in their programming language. In addition, the `RESPTranslator` type is going to see more direct usage, and the current API doesn't make read well. ## Changes - Add: Internal `RESPVersion` enum that the `RESPTranslator` will start to use - Rename: `RESPTranslator.parseBytes` to `RESPTranslator.read(from:)` --- .../ChannelHandlers/RedisByteDecoder.swift | 2 +- Sources/RediStack/RESP/RESPTranslator.swift | 157 ++++++++++-------- .../RediStackTests/RESPTranslatorTests.swift | 10 +- 3 files changed, 95 insertions(+), 74 deletions(-) diff --git a/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift b/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift index bcf38f7c..c4221523 100644 --- a/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift +++ b/Sources/RediStack/ChannelHandlers/RedisByteDecoder.swift @@ -30,7 +30,7 @@ public struct RedisByteDecoder: NIOSingleStepByteToMessageDecoder { /// See `NIOSingleStepByteToMessageDecoder.decode(buffer:)` public func decode(buffer: inout ByteBuffer) throws -> RESPValue? { - try self.parser.parseBytes(from: &buffer) + try self.parser.read(from: &buffer) } /// See `NIOSingleStepByteToMessageDecoder.decodeLast(buffer:seenEOF)` diff --git a/Sources/RediStack/RESP/RESPTranslator.swift b/Sources/RediStack/RESP/RESPTranslator.swift index 774239ec..37f7b494 100644 --- a/Sources/RediStack/RESP/RESPTranslator.swift +++ b/Sources/RediStack/RESP/RESPTranslator.swift @@ -15,18 +15,96 @@ import protocol Foundation.LocalizedError import NIOCore +extension UInt8 { + static let newline = UInt8(ascii: "\n") + static let carriageReturn = UInt8(ascii: "\r") + static let dollar = UInt8(ascii: "$") + static let asterisk = UInt8(ascii: "*") + static let plus = UInt8(ascii: "+") + static let hyphen = UInt8(ascii: "-") + static let colon = UInt8(ascii: ":") +} + +// This is not ready for prime-time + +/// An exhaustive list of the available versions of the Redis Serialization Protocol. +/// - Warning: These values are not generally intended to be used outside of this library, +/// so no guarantees to source stability are given. +fileprivate enum RESPVersion { + /// The RESP version first made available in Redis 1.2. + /// + /// It was made the default version in Redis 2.0. + case v2 + /// The RESP version first made available in Redis 6.0. + case v3 +} + +extension RESPTranslator { + /// Possible errors thrown while parsing RESP messages. + /// - Important: Any of these errors should be considered a **BUG**. + /// + /// Please file a bug at [https://www.gitlab.com/swift-server-community/RediStack/-/issues](https://www.gitlab.com/swift-server-community/RediStack/-/issues). + public struct ParsingError: LocalizedError, Equatable { + /// An invalid RESP data type identifier was found. + public static let invalidToken = ParsingError(.invalidToken) + /// A bulk string size did not match the RESP schema. + public static let invalidBulkStringSize = ParsingError(.invalidBulkStringSize) + /// A bulk string's declared size did not match its content size. + public static let bulkStringSizeMismatch = ParsingError(.bulkStringSizeMismatch) + /// A RESP integer did not follow the RESP schema. + public static let invalidIntegerFormat = ParsingError(.invalidIntegerFormat) + + public var errorDescription: String? { + return self.base.rawValue + } + + private let base: Base + private init(_ base: Base) { self.base = base } + + private enum Base: String, Equatable { + case invalidToken = "Cannot parse RESP: invalid token" + case invalidBulkStringSize = "Cannot parse RESP Bulk String: received invalid size" + case bulkStringSizeMismatch = "Cannot parse RESP Bulk String: declared size and content size do not match" + case invalidIntegerFormat = "Cannot parse RESP Integer: invalid integer format" + } + } +} + /// A helper object for translating between raw bytes and Swift types according to the Redis Serialization Protocol (RESP). /// /// See [https://redis.io/topics/protocol](https://redis.io/topics/protocol) public struct RESPTranslator { - public init() { } + private let version: RESPVersion + + public init() { + self.version = .v2 + } + + /// Attempts to read a complete `RESPValue` from the `ByteBuffer`. + /// - Important: The provided `buffer` will have its reader index moved on a successful read. + /// - Throws: + /// - `RESPTranslator.ParsingError.invalidToken` if the first byte is not an expected RESP Data Type token. + /// - Parameter buffer: The buffer that contains the bytes that need to be parsed. + /// - Returns: The parsed `RESPValue` or nil. + public func read(from buffer: inout ByteBuffer) throws -> RESPValue? { + return try self.parseBytesV2(from: &buffer) + } + + /// Writes the value into the desired `ByteBuffer` in RESP format. + /// - Parameters: + /// - value: The value to write into the buffer. + /// - out: The `ByteBuffer` that should be written to. + @inlinable + public func write(_ value: Value, into out: inout ByteBuffer) { + out.writeRESPValue(value.convertedToRESPValue()) + } } // MARK: Writing RESP /// The carriage return and newline escape symbols, used as the standard signal in RESP for a "message" end. /// A "message" in this case is a single data type. -fileprivate let respEnd: StaticString = "\r\n" +fileprivate let kSegmentEnd: StaticString = "\r\n" extension ByteBuffer { /// Writes the `RESPValue` into the current buffer, following the RESP specification. @@ -38,14 +116,14 @@ extension ByteBuffer { case .simpleString(var buffer): self.writeStaticString("+") self.writeBuffer(&buffer) - self.writeStaticString(respEnd) + self.writeStaticString(kSegmentEnd) case .bulkString(.some(var buffer)): self.writeStaticString("$") self.writeString("\(buffer.readableBytes)") - self.writeStaticString(respEnd) + self.writeStaticString(kSegmentEnd) self.writeBuffer(&buffer) - self.writeStaticString(respEnd) + self.writeStaticString(kSegmentEnd) case .bulkString(.none): self.writeStaticString("$0\r\n\r\n") @@ -53,7 +131,7 @@ extension ByteBuffer { case .integer(let number): self.writeStaticString(":") self.writeString(number.description) - self.writeStaticString(respEnd) + self.writeStaticString(kSegmentEnd) case .null: self.writeStaticString("$-1\r\n") @@ -61,78 +139,21 @@ extension ByteBuffer { case .error(let error): self.writeStaticString("-") self.writeString(error.message) - self.writeStaticString(respEnd) + self.writeStaticString(kSegmentEnd) case .array(let array): self.writeStaticString("*") self.writeString("\(array.count)") - self.writeStaticString(respEnd) + self.writeStaticString(kSegmentEnd) array.forEach { self.writeRESPValue($0) } } } } -extension RESPTranslator { - /// Writes the value into the desired `ByteBuffer` in RESP format. - /// - Parameters: - /// - value: The value to write into the buffer. - /// - out: The `ByteBuffer` that should be written to. - public func write(_ value: Value, into out: inout ByteBuffer) { - out.writeRESPValue(value.convertedToRESPValue()) - } -} - -// MARK: Reading RESP - -extension UInt8 { - static let newline = UInt8(ascii: "\n") - static let carriageReturn = UInt8(ascii: "\r") - static let dollar = UInt8(ascii: "$") - static let asterisk = UInt8(ascii: "*") - static let plus = UInt8(ascii: "+") - static let hyphen = UInt8(ascii: "-") - static let colon = UInt8(ascii: ":") -} +// MARK: V2 Parsing extension RESPTranslator { - /// Possible errors thrown while parsing RESP messages. - /// - Important: Any of these errors should be considered a **BUG**. - /// - /// Please file a bug at [https://www.gitlab.com/mordil/RediStack/-/issues](https://www.gitlab.com/mordil/RediStack/-/issues). - public struct ParsingError: LocalizedError, Equatable { - /// An invalid RESP data type identifier was found. - public static let invalidToken = ParsingError(.invalidToken) - /// A bulk string size did not match the RESP schema. - public static let invalidBulkStringSize = ParsingError(.invalidBulkStringSize) - /// A bulk string's declared size did not match its content size. - public static let bulkStringSizeMismatch = ParsingError(.bulkStringSizeMismatch) - /// A RESP integer did not follow the RESP schema. - public static let invalidIntegerFormat = ParsingError(.invalidIntegerFormat) - - public var errorDescription: String? { - return self.base.rawValue - } - - private let base: Base - private init(_ base: Base) { self.base = base } - - private enum Base: String, Equatable { - case invalidToken = "Cannot parse RESP: invalid token" - case invalidBulkStringSize = "Cannot parse RESP Bulk String: received invalid size" - case bulkStringSizeMismatch = "Cannot parse RESP Bulk String: declared size and content size do not match" - case invalidIntegerFormat = "Cannot parse RESP Integer: invalid integer format" - } - } -} - -extension RESPTranslator { - /// Attempts to parse a `RESPValue` from the `ByteBuffer`. - /// - Important: The provided `buffer` will have its reader index moved on a successful parse. - /// - Throws: - /// - `RESPTranslator.ParsingError.invalidToken` if the first byte is not an expected RESP Data Type token. - /// - Parameter buffer: The buffer that contains the bytes that need to be parsed. - /// - Returns: The parsed `RESPValue` or nil. - public func parseBytes(from buffer: inout ByteBuffer) throws -> RESPValue? { + private func parseBytesV2(from buffer: inout ByteBuffer) throws -> RESPValue? { var copy = buffer guard let token = copy.readInteger(as: UInt8.self) else { return nil } @@ -253,7 +274,7 @@ extension RESPTranslator { for _ in 0.. 0 else { return nil } - guard let element = try self.parseBytes(from: &buffer) else { return nil } + guard let element = try self.read(from: &buffer) else { return nil } results.append(element) } diff --git a/Tests/RediStackTests/RESPTranslatorTests.swift b/Tests/RediStackTests/RESPTranslatorTests.swift index 2673cd94..54b04b72 100644 --- a/Tests/RediStackTests/RESPTranslatorTests.swift +++ b/Tests/RediStackTests/RESPTranslatorTests.swift @@ -108,7 +108,7 @@ extension RESPTranslatorTests { func testParsing_invalidToken() { var buffer = self.allocator.buffer(capacity: 128) buffer.writeString("!!!!") - XCTAssertThrowsError(try self.parser.parseBytes(from: &buffer)) { error in + XCTAssertThrowsError(try self.parser.read(from: &buffer)) { error in XCTAssertEqual(error as? RESPTranslator.ParsingError, .invalidToken) } } @@ -118,7 +118,7 @@ extension RESPTranslatorTests { var buffer = allocator.buffer(capacity: testRESP.count) buffer.writeString(testRESP) - XCTAssertThrowsError(try parser.parseBytes(from: &buffer)) + XCTAssertThrowsError(try parser.read(from: &buffer)) XCTAssertEqual(buffer.readerIndex, 0) } @@ -211,7 +211,7 @@ extension RESPTranslatorTests { var buffer = allocator.buffer(capacity: expectedContent.count) buffer.writeString(testString) - guard let value = try parser.parseBytes(from: &buffer) else { return XCTFail("Failed to parse error") } + guard let value = try parser.read(from: &buffer) else { return XCTFail("Failed to parse error") } XCTAssertEqual(value.error?.message.contains(expectedContent), true) } @@ -224,7 +224,7 @@ extension RESPTranslatorTests { var buffer = allocator.buffer(capacity: input.count) buffer.writeBytes(input) - let result = try parser.parseBytes(from: &buffer) + let result = try parser.read(from: &buffer) assert(buffer.readerIndex == buffer.writerIndex) return result @@ -242,7 +242,7 @@ extension RESPTranslatorTests { for chunk in messageChunks { buffer.writeBytes(chunk) - guard let result = try parser.parseBytes(from: &buffer) else { continue } + guard let result = try parser.read(from: &buffer) else { continue } results.append(result) } From 8ac2d742cb17cddf933cb133a8d708c6949e0996 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Sun, 28 May 2023 02:30:15 +0000 Subject: [PATCH 62/63] Make the parameter-less RedisClientError factory statics computed properties. As stored properties, they trigger Thread Sanitizer errors when multiple connections trigger the same errors (usually connectionClosed) too close together due to lazy once-only initialization. --- Sources/RediStack/RedisClient.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/RediStack/RedisClient.swift b/Sources/RediStack/RedisClient.swift index 04821559..016b0695 100644 --- a/Sources/RediStack/RedisClient.swift +++ b/Sources/RediStack/RedisClient.swift @@ -237,11 +237,11 @@ extension RedisClient { /// When working with `RedisClient`, runtime errors can be thrown to indicate problems with connection state, decoding assertions, or otherwise. public struct RedisClientError: LocalizedError, Equatable, Hashable { /// The connection is closed, but was used to try and send a command to Redis. - public static let connectionClosed = RedisClientError(.connectionClosed) + public static var connectionClosed: RedisClientError { .init(.connectionClosed) } /// A race condition was triggered between unsubscribing from the last target while subscribing to a new target. - public static let subscriptionModeRaceCondition = RedisClientError(.subscriptionModeRaceCondition) + public static var subscriptionModeRaceCondition: RedisClientError { .init(.subscriptionModeRaceCondition) } /// A connection that is not authorized for PubSub subscriptions attempted to create a subscription. - public static let pubsubNotAllowed = RedisClientError(.pubsubNotAllowed) + public static var pubsubNotAllowed: RedisClientError { .init(.pubsubNotAllowed) } /// Conversion from `RESPValue` to the specified type failed. /// From 35f9e7aab8aa8abe60e5ed286f8b33c26b21d19a Mon Sep 17 00:00:00 2001 From: Marius Seufzer Date: Sun, 4 Jun 2023 18:10:05 +0000 Subject: [PATCH 63/63] Add onUnexpectedConnectionClose callback to pool Currently there is no way to set `RedisConnection`'s `onUnexpectedClosure` for connections within a `RedisConnectionPool`. This PR adds a new closure to `RedisConnectionPool.onUnexpectedConnectionClose` which will be triggered for every pool connection that closed unexpectedly. --- Sources/RediStack/RedisConnection.swift | 52 +++++++++---------- .../RedisConnectionPool+Configuration.swift | 4 ++ Sources/RediStack/RedisConnectionPool.swift | 45 ++++++++-------- 3 files changed, 54 insertions(+), 47 deletions(-) diff --git a/Sources/RediStack/RedisConnection.swift b/Sources/RediStack/RedisConnection.swift index 7642ac84..dd9e4169 100644 --- a/Sources/RediStack/RedisConnection.swift +++ b/Sources/RediStack/RedisConnection.swift @@ -22,7 +22,7 @@ import NIOConcurrencyHelpers import NIOPosix extension RedisConnection { - + /// Creates a new connection with provided configuration and sychronization objects. /// /// If you would like to specialize the `NIO.ClientBootstrap` that the connection communicates on, override the default by passing it in as `configuredTCPClient`. @@ -56,7 +56,7 @@ extension RedisConnection { configuredTCPClient client: ClientBootstrap? = nil ) -> EventLoopFuture { let client = client ?? .makeRedisTCPClient(group: eventLoop) - + var future = client .connect(to: config.address) .map { return RedisConnection(configuredRESPChannel: $0, defaultLogger: config.defaultLogger) } @@ -74,7 +74,7 @@ extension RedisConnection { return connection.select(database: database).map { connection } } } - + return future } } @@ -150,7 +150,7 @@ public final class RedisConnection: RedisClient { public var onUnexpectedClosure: (() -> Void)? internal let channel: Channel - + private let autoflush = ManagedAtomic(true) private let allowPubSub = ManagedAtomic(true) private let _stateLock = NIOLock() @@ -159,14 +159,14 @@ public final class RedisConnection: RedisClient { get { return _stateLock.withLock { self._state } } set(newValue) { _stateLock.withLockVoid { self._state = newValue } } } - + deinit { if isConnected { assertionFailure("close() was not called before deinit!") self.defaultLogger.warning("connection was not properly shutdown before deinit") } } - + internal init(configuredRESPChannel: Channel, defaultLogger: Logger) { self.channel = configuredRESPChannel // there is a mix of verbiage here as the API is forward thinking towards "baggage context" @@ -178,7 +178,7 @@ public final class RedisConnection: RedisClient { RedisMetrics.activeConnectionCount.increment() RedisMetrics.totalConnectionCount.increment() - + // attach a callback to the channel to capture situations where the channel might be closed out from under // the connection self.channel.closeFuture.whenSuccess { @@ -199,13 +199,13 @@ public final class RedisConnection: RedisClient { self.defaultLogger.trace("connection created") } - + internal enum ConnectionState { case open case pubsub(RedisPubSubHandler) case shuttingDown case closed - + var isConnected: Bool { switch self { case .open, .pubsub: return true @@ -236,25 +236,25 @@ extension RedisConnection { return finalEventLoop.makeFailedFuture(error) } logger.trace("received command request") - + logger.debug("sending command", metadata: [ RedisLogging.MetadataKeys.command: "\(command)" ]) - + let promise = self.eventLoop.makePromise(of: RESPValue.self) - + let startTime = DispatchTime.now().uptimeNanoseconds promise.futureResult.whenComplete { result in let duration = DispatchTime.now().uptimeNanoseconds - startTime RedisMetrics.commandRoundTripTime.recordNanoseconds(duration) - + // log data based on the result switch result { case let .failure(error): logger.debug("command failed", metadata: [ RedisLogging.MetadataKeys.error: "\(error.localizedDescription)" ]) - + case let .success(value): logger.debug("command succeeded", metadata: [ RedisLogging.MetadataKeys.commandResult: "\(value)" @@ -263,7 +263,7 @@ extension RedisConnection { } defer { logger.trace("command sent") } - + let outboundData: RedisCommandHandler.OutboundCommandPayload = (command.serialized(), promise) let writeFuture: EventLoopFuture = self.sendCommandsImmediately ? self.channel.writeAndFlush(outboundData) @@ -310,7 +310,7 @@ extension RedisConnection { let promise = finalEventLoop.makePromise(of: Void.self) let notification = promise.futureResult self.channel.triggerUserOutboundEvent(RedisGracefulConnectionCloseEvent(), promise: promise) - + notification.whenFailure { logger.warning("failed to close connection", metadata: [ RedisLogging.MetadataKeys.error: "\($0)" @@ -319,7 +319,7 @@ extension RedisConnection { notification.whenSuccess { logger.trace("connection is now closed") } - + return notification } } @@ -363,9 +363,9 @@ extension RedisConnection { ) -> EventLoopFuture { let logger = self.prepareLoggerForUse(logger) let finalEventLoop = eventLoop ?? self.eventLoop - + logger.trace("received subscribe request") - + // if we're closed, just error out guard self.state.isConnected else { return finalEventLoop.makeFailedFuture(RedisClientError.connectionClosed) } @@ -413,7 +413,7 @@ extension RedisConnection { } .hop(to: finalEventLoop) } - + // add the subscription and just ignore the subscription count return handler .addSubscription(for: target, receiver: receiver) @@ -432,7 +432,7 @@ extension RedisConnection { ) -> EventLoopFuture { return self._unsubscribe(.channels(channels), eventLoop, logger) } - + public func punsubscribe( from patterns: [String], eventLoop: EventLoop? = nil, @@ -440,7 +440,7 @@ extension RedisConnection { ) -> EventLoopFuture { return self._unsubscribe(.patterns(patterns), eventLoop, logger) } - + private func _unsubscribe( _ target: RedisSubscriptionTarget, _ eventLoop: EventLoop?, @@ -448,12 +448,12 @@ extension RedisConnection { ) -> EventLoopFuture { let logger = self.prepareLoggerForUse(logger) let finalEventLoop = eventLoop ?? self.eventLoop - + logger.trace("received unsubscribe request") // if we're closed, just error out guard self.state.isConnected else { return finalEventLoop.makeFailedFuture(RedisClientError.connectionClosed) } - + // if we're not in pubsub mode, then we just succeed as a no-op guard case let .pubsub(handler) = self.state else { // but we still assert just to give some notification to devs at debug @@ -462,11 +462,11 @@ extension RedisConnection { ]) return self.eventLoop.makeSucceededFuture(()) } - + logger.trace("removing subscription", metadata: [ RedisLogging.MetadataKeys.pubsubTarget: "\(target.debugDescription)" ]) - + // remove the subscription return handler .removeSubscription(for: target) diff --git a/Sources/RediStack/RedisConnectionPool+Configuration.swift b/Sources/RediStack/RedisConnectionPool+Configuration.swift index 326a8af1..22e4f990 100644 --- a/Sources/RediStack/RedisConnectionPool+Configuration.swift +++ b/Sources/RediStack/RedisConnectionPool+Configuration.swift @@ -214,6 +214,8 @@ extension RedisConnectionPool { public let connectionCountBehavior: ConnectionCountBehavior /// The strategy used by the connection pool to handle retrying to find an available "active" connection to use. public let retryStrategy: PoolConnectionRetryStrategy + /// Called when a connection in the pool is closed unexpectedly. + public let onUnexpectedConnectionClose: ((RedisConnection) -> Void)? // these need to be var so they can be updated by the pool in some cases @@ -237,12 +239,14 @@ extension RedisConnectionPool { connectionCountBehavior: ConnectionCountBehavior, connectionConfiguration: PoolConnectionConfiguration, retryStrategy: PoolConnectionRetryStrategy = .exponentialBackoff(), + onUnexpectedConnectionClose: ((RedisConnection) -> Void)? = nil, poolDefaultLogger: Logger? = nil ) { self.initialConnectionAddresses = initialServerConnectionAddresses self.connectionCountBehavior = connectionCountBehavior self.connectionConfiguration = connectionConfiguration self.retryStrategy = retryStrategy + self.onUnexpectedConnectionClose = onUnexpectedConnectionClose self.poolDefaultLogger = poolDefaultLogger ?? .redisBaseConnectionPoolLogger } } diff --git a/Sources/RediStack/RedisConnectionPool.swift b/Sources/RediStack/RedisConnectionPool.swift index f92dc097..a0e6e7fb 100644 --- a/Sources/RediStack/RedisConnectionPool.swift +++ b/Sources/RediStack/RedisConnectionPool.swift @@ -67,7 +67,7 @@ public class RedisConnectionPool { /// are buffered only when there are no available addresses to connect to, so in practice it's highly unlikely this will be /// hit, but either way, 100 concurrent connection requests ought to be plenty in this case. private static let maximumBufferedConnectionRequests = 100 - + public init(configuration: Configuration, boundEventLoop: EventLoop) { var config = configuration @@ -77,13 +77,13 @@ public class RedisConnectionPool { var taggedConnectionLogger = config.connectionConfiguration.defaultLogger taggedConnectionLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" config.connectionConfiguration.defaultLogger = taggedConnectionLogger - + var taggedPoolLogger = config.poolDefaultLogger taggedPoolLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)" config.poolDefaultLogger = taggedPoolLogger - + self.configuration = config - + self.pool = ConnectionPool( minimumConnectionCount: self.configuration.connectionCountBehavior.minimumConnectionCount, maximumConnectionCount: self.configuration.connectionCountBehavior.maximumConnectionCount, @@ -175,7 +175,7 @@ extension RedisConnectionPool { /// ```swift /// let countFuture = pool.leaseConnection { /// client in - /// + /// /// return client.authorize(with: userPassword) /// .flatMap { connection.select(database: userDatabase) } /// .flatMap { connection.increment(counterKey) } @@ -203,7 +203,7 @@ extension RedisConnectionPool { return self.forwardOperationToConnection( { (connection, returnConnection, logger) in - + return operation(connection) .always { _ in returnConnection(connection, logger) } }, @@ -228,7 +228,7 @@ extension RedisConnectionPool { .info("pool updated with new target addresses", metadata: [ RedisLogging.MetadataKeys.newConnectionPoolTargetAddresses: "\(newAddresses)" ]) - + self.loop.execute { self.serverConnectionAddresses.update(newAddresses) @@ -260,7 +260,7 @@ extension RedisConnectionPool { self.requestsForConnections.append(promise) return promise.futureResult } - + let connectionConfig: RedisConnection.Configuration do { connectionConfig = try .init( @@ -281,6 +281,9 @@ extension RedisConnectionPool { .map { connection in // disallow subscriptions on all connections by default to enforce our management of PubSub state connection.allowSubscriptions = false + connection.onUnexpectedClosure = { [weak self] in + self?.configuration.onUnexpectedConnectionClose?(connection) + } return connection } } @@ -325,7 +328,7 @@ extension RedisConnectionPool { // MARK: RedisClient extension RedisConnectionPool: RedisClient { public var eventLoop: EventLoop { self.loop } - + public func send( _ command: RedisCommand, eventLoop: EventLoop? = nil, @@ -346,7 +349,7 @@ extension RedisConnectionPool: RedisClient { ) .hop(to: eventLoop ?? self.eventLoop) } - + public func subscribe( to channels: [RedisChannelName], eventLoop: EventLoop? = nil, @@ -369,7 +372,7 @@ extension RedisConnectionPool: RedisClient { taskLogger: logger ) } - + public func psubscribe( to patterns: [String], eventLoop: EventLoop? = nil, @@ -392,7 +395,7 @@ extension RedisConnectionPool: RedisClient { taskLogger: logger ) } - + public func unsubscribe( from channels: [RedisChannelName], eventLoop: EventLoop? = nil, @@ -404,7 +407,7 @@ extension RedisConnectionPool: RedisClient { taskLogger: logger ) } - + public func punsubscribe( from patterns: [String], eventLoop: EventLoop? = nil, @@ -455,7 +458,7 @@ extension RedisConnectionPool: RedisClient { self.pubsubConnection = nil // break ref cycle } } - + return operation(connection, wrappedReceiver, logger) }, preferredConnection: self.pubsubConnection, @@ -527,7 +530,7 @@ extension RedisConnectionPool: RedisClient { } let logger = self.prepareLoggerForUse(taskLogger) - + guard let connection = preferredConnection else { return pool .leaseConnection(logger: logger) @@ -558,30 +561,30 @@ extension RedisConnectionPool { private var addresses: [SocketAddress] private var index: Array.Index - + internal init(initialAddresses: [SocketAddress]) { self.addresses = initialAddresses self.index = self.addresses.startIndex } - + internal mutating func nextTarget() -> SocketAddress? { // early exit on 0, makes life easier guard !self.addresses.isEmpty else { self.index = self.addresses.startIndex return nil } - + let nextTarget = self.addresses[self.index] - + // it's an invariant of this function that the index is always valid for subscripting the collection self.addresses.formIndex(after: &self.index) if self.index == self.addresses.endIndex { self.index = self.addresses.startIndex } - + return nextTarget } - + internal mutating func update(_ newAddresses: [SocketAddress]) { self.addresses = newAddresses self.index = self.addresses.startIndex