81 -- Add Configuration types for initialization

This commit is contained in:
Nathan Harris
2020-11-07 21:03:06 -08:00
parent ec1a38ba7f
commit b2367ac33e
14 changed files with 699 additions and 200 deletions
+3 -3
View File
@@ -60,9 +60,9 @@ import NIO
import RediStack
let eventLoop: EventLoop = ...
let connection = RedisConnection.connect(
to: try .init(ipAddress: "127.0.0.1", port: RedisConnection.defaultPort),
on: 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")
+315
View File
@@ -0,0 +1,315 @@
//===----------------------------------------------------------------------===//
//
// 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 Foundation
import Logging
import NIO
extension RedisConnection.Configuration {
public struct ValidationError: LocalizedError, Equatable {
public static let invalidURLString = ValidationError(.invalidURLString)
public static let missingURLScheme = ValidationError(.missingURLScheme)
public static let invalidURLScheme = ValidationError(.invalidURLScheme)
public static let missingURLHost = ValidationError(.missingURLHost)
public static let outOfBoundsDatabaseID = ValidationError(.outOfBoundsDatabaseID)
var localizedDescription: String { self.kind.localizedDescription }
private let kind: Kind
private init(_ kind: Kind) { self.kind = kind }
public static func ==(lhs: ValidationError, rhs: ValidationError) -> Bool {
return lhs.kind == rhs.kind
}
private enum Kind: LocalizedError {
case invalidURLString
case missingURLScheme
case invalidURLScheme
case missingURLHost
case outOfBoundsDatabaseID
var localizedDescription: String {
let message: String = {
switch self {
case .invalidURLString: return "invalid URL string"
case .missingURLScheme: return "URL scheme is missing"
case .invalidURLScheme: return "invalid URL scheme, expected 'redis'"
case .missingURLHost: return "missing remote hostname"
case .outOfBoundsDatabaseID: return "database index out of bounds"
}
}()
return "(RediStack) \(RedisConnection.Configuration.self) validation failed: \(message)"
}
}
}
}
// MARK: - RedisConnection Config
extension RedisConnection {
/// A configuration object for creating a single connection to Redis.
public struct Configuration {
/// The default port that Redis uses.
///
/// See [https://redis.io/topics/quickstart](https://redis.io/topics/quickstart)
public static var defaultPort = 6379
internal static let defaultLogger = Logger.redisBaseConnectionLogger
/// The hostname of the connection address. If the address is a Unix socket, then it will be `nil`.
public var hostname: String? {
switch self.address {
case let .v4(addr): return addr.host
case let .v6(addr): return addr.host
case .unixDomainSocket: return nil
}
}
/// The port of the connection address. If the address is a Unix socket, then it will be `nil`.
public var port: Int? { self.address.port }
/// The password used to authenticate the connection.
public let password: String?
/// The initial database index that the connection should use.
public let initialDatabase: Int?
/// The logger prototype that will be used by the connection by default when generating logs.
public let defaultLogger: Logger
internal let address: SocketAddress
/// Creates a new connection configuration with the provided details.
/// - Parameters:
/// - address: The socket address information to use for creating the Redis connection.
/// - password: The optional password to authenticate the connection with. The default is `nil`.
/// - 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`.
/// - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
/// If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
/// - Throws: `RedisConnection.Configuration.ValidationError` if invalid arguments are provided.
public init(
address: SocketAddress,
password: String? = nil,
initialDatabase: Int? = nil,
defaultLogger: Logger? = nil
) throws {
if initialDatabase != nil && initialDatabase! < 0 {
throw ValidationError.outOfBoundsDatabaseID
}
self.address = address
self.password = password
self.initialDatabase = initialDatabase
self.defaultLogger = defaultLogger ?? Configuration.defaultLogger
}
/// Creates a new connection configuration with exact details.
/// - Parameters:
/// - hostname: The remote hostname to connect to.
/// - port: The port that the Redis instance connects with. The default is `RedisConnection.Configuration.defaultPort`.
/// - password: The optional password to authenticate the connection with. The default is `nil`.
/// - 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`.
/// - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
/// If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
/// - Throws:
/// - `NIO.SocketAddressError` if hostname resolution fails.
/// - `RedisConnection.Configuration.ValidationError` if invalid arguments are provided.
public init(
hostname: String,
port: Int = Self.defaultPort,
password: String? = nil,
initialDatabase: Int? = nil,
defaultLogger: Logger? = nil
) throws {
try self.init(
address: try .makeAddressResolvingHost(hostname, port: port),
password: password,
initialDatabase: initialDatabase,
defaultLogger: defaultLogger
)
}
/// Creates a new connection configuration from the provided RFC 1808 URL formatted string.
///
/// This is a convenience initializer over creating a `Foundation.URL` directly and passing it to the overloaded initializer.
///
/// The string is expected to match the [RFC 1808](https://tools.ietf.org/html/rfc1808) format.
///
/// An example string:
///
/// redis://:password@localhost:6379/3
///
/// For more details, see the `Configuration.init(url:)` overload that accepts a `Foundation.URL`.
/// - Parameters:
/// - string: The URL formatted string.
/// - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
/// If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
/// - Throws:
/// - `RedisConnection.Configuration.ValidationError` if required URL components are invalid or missing.
/// - `NIO.SocketAddressError` if hostname resolution fails.
public init(url string: String, defaultLogger: Logger? = nil) throws {
guard let url = URL(string: string) else { throw ValidationError.invalidURLString }
try self.init(url: url, defaultLogger: defaultLogger)
}
/// Creates a new connection configuration from the provided URL object.
///
/// The `scheme` and `host` are the only properties that need to be established.
/// - Invariant: The `port` property is optional, with the `RedisConnection.Configuration.defaultPort` being used by default.
/// - Invariant: `password` is only required if the Redis instance specifies a password is required. This will not be detected until trying to establish a connection
/// with this configuration.
/// - Invariant: To set the default selected database index, provide the index as the `lastPathComponent` of the `Foundation.URL`.
/// - Requires: The URL **must** use the `redis://` scheme.
/// - Parameters:
/// - url: The URL to use to resolve and authenticate the remote connection.
/// - defaultLogger: The optional prototype logger to use as the default logger instance when generating logs from the connection.
/// If one is not provided, one will be generated. See `RedisLogging.baseConnectionLogger`.
/// - Throws:
/// - `RedisConnection.Configuration.ValidationError` if required URL components are invalid or missing.
/// - `NIO.SocketAddressError` if hostname resolution fails.
public init(url: URL, defaultLogger: Logger? = nil) throws {
try Self.validateRedisURL(url)
guard let host = url.host, !host.isEmpty else { throw ValidationError.missingURLHost }
let databaseID = Int(url.lastPathComponent)
try self.init(
address: try .makeAddressResolvingHost(host, port: url.port ?? Self.defaultPort),
password: url.password,
initialDatabase: databaseID,
defaultLogger: defaultLogger
)
}
private static func validateRedisURL(_ url: URL) throws {
guard
let scheme = url.scheme,
!scheme.isEmpty
else { throw ValidationError.missingURLScheme }
guard scheme == "redis" else { throw ValidationError.invalidURLScheme }
}
}
}
// 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)
}
+40 -42
View File
@@ -20,25 +20,20 @@ import NIO
import NIOConcurrencyHelpers
extension RedisConnection {
/// The documented default port that Redis connects through.
///
/// See [https://redis.io/topics/quickstart](https://redis.io/topics/quickstart)
public static let defaultPort = 6379
/// Creates a new connection to a Redis instance.
/// 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 `tcpClient`.
/// If you would like to specialize the `NIO.ClientBootstrap` that the connection communicates on, override the default by passing it in as `configuredTCPClient`.
///
/// 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
/// let connection = RedisConnection.make(
/// configuration: ...,
/// boundEventLoop: eventLoopGroup.next(),
/// configuredTCPClient: customTCPClient
/// ).wait()
///
/// It is recommended that you be familiar with `ClientBootstrap.makeRedisTCPClient(group:)` and `NIO.ClientBootstrap` in general before doing so.
@@ -46,45 +41,50 @@ extension RedisConnection {
/// 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.
///
/// - Invariant: If a `password` is provided in the configuration, the connection will send an "AUTH" command to Redis as soon as it has been opened.
/// - Invariant: If a `database` index is provided in the configuration, the connection will send a "SELECT" command to Redis after it has been authenticated.
/// - 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.
public static func connect(
to socket: SocketAddress,
on eventLoop: EventLoop,
password: String? = nil,
logger: Logger = .redisBaseConnectionLogger,
tcpClient: ClientBootstrap? = nil
/// - config: The configuration to use for creating the connection.
/// - eventLoop: The `NIO.EventLoop` that the connection will be bound to.
/// - client: If you have chosen to configure a `NIO.ClientBootstrap` yourself, this will be used instead of the `.makeRedisTCPClient` factory instance.
/// - Returns: A `NIO.EventLoopFuture` that resolves with the new connection after it has been opened, configured, and authenticated per the `configuration` object.
public static func make(
configuration config: Configuration,
boundEventLoop eventLoop: EventLoop,
configuredTCPClient client: ClientBootstrap? = nil
) -> EventLoopFuture<RedisConnection> {
let client = tcpClient ?? ClientBootstrap.makeRedisTCPClient(group: eventLoop)
let client = client ?? .makeRedisTCPClient(group: eventLoop)
return client.connect(to: socket)
.map { return RedisConnection(configuredRESPChannel: $0, context: logger) }
.flatMap { connection in
guard let pw = password else {
return connection.eventLoop.makeSucceededFuture(connection)
}
return connection.authorize(with: pw)
.map { return connection }
var future = client
.connect(to: config.address)
.map { return RedisConnection(configuredRESPChannel: $0, context: config.defaultLogger) }
// if a password is specified, use it to authenticate before further operations happen
if let password = config.password {
future = future.flatMap { connection in
return connection.authorize(with: password).map { connection }
}
}
// if a database index is specified, use it to switch the selected database before further operations happen
if let database = config.initialDatabase {
future = future.flatMap { connection in
return connection.select(database: database).map { connection }
}
}
return future
}
}
/// A concrete `RedisClient` implementation that represents an individual connection to a Redis database instance.
/// A concrete `RedisClient` implementation that represents an individual connection to a Redis instance.
///
/// For basic setups, you will just need a `NIO.SocketAddress` and a `NIO.EventLoop` and perhaps a `password`.
/// For basic setups, you will just need a `NIO.EventLoop` and perhaps a `password`.
///
/// let eventLoop: EventLoop = ...
/// let connection = RedisConnection.connect(
/// to: try .makeAddressResolvingHost("my.redis.url", port: RedisConnection.defaultPort),
/// on: eventLoop
/// let connection = RedisConnection.make(
/// configuration: .init(hostname: "my.redis.url", password: "some_password"),
/// boundEventLoop: eventLoop
/// ).wait()
///
/// let result = try connection.set("my_key", to: "some value")
@@ -94,8 +94,6 @@ extension RedisConnection {
/// print(result) // Optional("some value")
///
/// Note: `wait()` is used in the example for simplicity. Never call `wait()` on an event loop.
///
/// See `NIO.SocketAddress`, `NIO.EventLoop`, and `RedisClient`.
public final class RedisConnection: RedisClient, RedisClientWithUserContext {
/// A unique identifer to represent this connection.
public let id = UUID()
+79 -105
View File
@@ -34,83 +34,47 @@ public class RedisConnectionPool {
/// The number of connections that have been handed out and are in active use.
public var leasedConnectionCount: Int { self.pool?.leasedConnectionCount ?? 0 }
private let loop: EventLoop
// This needs to be var because we hand it a closure that references us strongly. This also
// establishes a reference cycle which we need to break.
// Aside from on init, all other operations on this var must occur on the event loop.
private var pool: ConnectionPool?
/// This needs to be var because it is updatable and mutable. As a result, aside from init,
/// all use of this var must occur on the event loop.
// This needs to be var because of some logger metadata tagging.
// This should only be mutated on init, and safe to read anywhere else
private var configuration: Configuration
// This needs to be var because it is updatable and mutable.
// As a result, aside from init, all use of this var must occur on the event loop.
private var serverConnectionAddresses: ConnectionAddresses
/// This needs to be a var because we reuse the same connection
// 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?
public init(configuration: Configuration, boundEventLoop: EventLoop) {
var config = configuration
private let connectionRetryTimeout: TimeAmount
private let connectionPassword: String?
private let connectionSystemContext: Logger
private let poolSystemContext: Context
private let loop: EventLoop
private let connectionTCPClient: ClientBootstrap?
/// 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.
public init(
serverConnectionAddresses: [SocketAddress],
loop: EventLoop,
maximumConnectionCount: RedisConnectionPoolSize,
minimumConnectionCount: Int = 1,
connectionPassword: String? = nil,
connectionLogger: Logger = .redisBaseConnectionLogger,
connectionTCPClient: ClientBootstrap? = nil,
poolLogger: Logger = .redisBaseConnectionPoolLogger,
connectionBackoffFactor: Float32 = 2,
initialConnectionBackoffDelay: TimeAmount = .milliseconds(100),
connectionRetryTimeout: TimeAmount? = .seconds(60)
) {
self.loop = loop
self.serverConnectionAddresses = ConnectionAddresses(initialAddresses: serverConnectionAddresses)
self.connectionPassword = connectionPassword
self.connectionRetryTimeout = connectionRetryTimeout ?? .milliseconds(10)
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 connectionLogger = connectionLogger
connectionLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)"
self.connectionSystemContext = connectionLogger
var poolLogger = poolLogger
poolLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)"
self.poolSystemContext = poolLogger
self.connectionTCPClient = connectionTCPClient
var taggedConnectionLogger = config.factoryConfiguration.connectionDefaultLogger
taggedConnectionLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)"
config.factoryConfiguration.connectionDefaultLogger = taggedConnectionLogger
var taggedPoolLogger = config.poolDefaultLogger
taggedPoolLogger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)"
config.poolDefaultLogger = taggedPoolLogger
self.configuration = config
self.pool = ConnectionPool(
maximumConnectionCount: maximumConnectionCount.size,
minimumConnectionCount: minimumConnectionCount,
leaky: maximumConnectionCount.leaky,
loop: loop,
systemContext: poolLogger,
connectionBackoffFactor: connectionBackoffFactor,
initialConnectionBackoffDelay: initialConnectionBackoffDelay,
maximumConnectionCount: config.maximumConnectionCount.size,
minimumConnectionCount: config.minimumConnectionCount,
leaky: config.maximumConnectionCount.leaky,
loop: boundEventLoop,
systemContext: config.poolDefaultLogger,
connectionBackoffFactor: config.connectionRetryConfiguration.backoff.factor,
initialConnectionBackoffDelay: config.connectionRetryConfiguration.backoff.initialDelay,
connectionFactory: self.connectionFactory(_:)
)
}
@@ -190,7 +154,7 @@ extension RedisConnectionPool {
}
/// Updates the list of valid connection addresses.
///
/// - Warning: This will replace any previously set list of addresses.
/// - Note: This does not invalidate existing connections: as long as those connections continue to stay up, they will be kept by
/// this client.
///
@@ -214,26 +178,42 @@ 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 fail.
return targetLoop.makeFailedFuture(RedisConnectionPoolError.noAvailableConnectionTargets)
}
let connectionConfig: RedisConnection.Configuration
do {
connectionConfig = try .init(
address: nextTarget,
password: factoryConfig.connectionPassword,
initialDatabase: factoryConfig.connectionInitialDatabase,
defaultLogger: factoryConfig.connectionDefaultLogger
)
} catch {
// config validation failed, return the error
return targetLoop.makeFailedFuture(error)
}
let connectFuture = RedisConnection.connect(
to: nextTarget,
on: targetLoop,
password: self.connectionPassword,
logger: self.connectionSystemContext,
tcpClient: self.connectionTCPClient
)
// disallow subscriptions on all connections by default so that we can enforce our management of PubSub state
connectFuture.whenSuccess { $0.allowSubscriptions = false }
return connectFuture
return RedisConnection
.make(
configuration: connectionConfig,
boundEventLoop: targetLoop,
configuredTCPClient: factoryConfig.tcpClient
)
.map { connection in
// disallow subscriptions on all connections by default to enforce our management of PubSub state
connection.allowSubscriptions = false
return connection
}
}
private func prepareLoggerForUse(_ logger: Logger?) -> Logger {
guard var logger = logger else { return self.poolSystemContext }
guard var logger = logger else { return self.configuration.poolDefaultLogger }
logger[metadataKey: RedisLogging.MetadataKeys.connectionPoolID] = "\(self.id)"
return logger
}
@@ -441,7 +421,11 @@ extension RedisConnectionPool: RedisClientWithUserContext {
let logger = self.prepareLoggerForUse(context)
guard let connection = preferredConnection else {
return pool.leaseConnection(deadline: .now() + self.connectionRetryTimeout, logger: logger)
return pool
.leaseConnection(
deadline: .now() + self.configuration.connectionRetryConfiguration.timeout,
logger: logger
)
.flatMap { operation($0, pool.returnConnection(_:logger:), logger) }
}
@@ -456,57 +440,47 @@ extension RedisConnectionPool {
private var addresses: [SocketAddress]
private var index: Array<SocketAddress>.Index
init(initialAddresses: [SocketAddress]) {
internal init(initialAddresses: [SocketAddress]) {
self.addresses = initialAddresses
self.index = self.addresses.startIndex
}
mutating func nextTarget() -> SocketAddress? {
// Early exit on 0, makes life easier.
guard self.addresses.count > 0 else {
internal mutating func nextTarget() -> SocketAddress? {
// early exit on 0, makes life easier
guard !self.addresses.isEmpty else {
self.index = self.addresses.startIndex
return nil
}
// It's an invariant of this function that the index is always valid for subscripting the collection.
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
}
mutating func update(_ newAddresses: [SocketAddress]) {
internal mutating func update(_ newAddresses: [SocketAddress]) {
self.addresses = newAddresses
self.index = self.addresses.startIndex
}
}
}
/// `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)
internal var size: Int {
// MARK: RedisConnectionPoolSize helpers
extension RedisConnectionPoolSize {
fileprivate var size: Int {
switch self {
case .maximumActiveConnections(let size), .maximumPreservedConnections(let size):
return size
}
}
internal var leaky: Bool {
fileprivate var leaky: Bool {
switch self {
case .maximumActiveConnections:
return false
+132
View File
@@ -0,0 +1,132 @@
//===----------------------------------------------------------------------===//
//
// 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 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<RedisConnection> {
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
)
}
}
@@ -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
@@ -16,32 +16,15 @@ import Foundation
import NIO
import RediStack
extension RedisConnection {
extension RedisConnection.Configuration {
/// A default hostname of `localhost` to try and connect to Redis at.
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.
public static func connect(
on eventLoop: EventLoop,
host: String = RedisConnection.defaultHostname,
port: Int = RedisConnection.defaultPort,
public init(
host: String = RedisConnection.Configuration.defaultHostname,
port: Int = RedisConnection.Configuration.defaultPort,
password: String? = nil
) -> EventLoopFuture<RedisConnection> {
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)
) throws {
try self.init(hostname: host, port: port, password: password)
}
}
@@ -25,10 +25,10 @@ open class RedisConnectionPoolIntegrationTestCase: XCTestCase {
/// The default value is `RedisConnection.defaultHostname`
///
/// This is especially useful to override if you build on Linux & macOS where Redis might be installed locally vs. through Docker.
open var redisHostname: String { return RedisConnection.defaultHostname }
open var redisHostname: String { RedisConnection.Configuration.defaultHostname }
/// The port to connect over to Redis, defaulting to `RedisConnection.defaultPort`.
open var redisPort: Int { return RedisConnection.defaultPort }
open var redisPort: Int { RedisConnection.Configuration.defaultPort }
/// The password to use to connect to Redis. Default is `nil` - no password authentication.
open var redisPassword: String? { return nil }
@@ -80,12 +80,14 @@ open class RedisConnectionPoolIntegrationTestCase: XCTestCase {
) throws -> RedisConnectionPool {
let address = try SocketAddress.makeAddressResolvingHost(self.redisHostname, port: self.redisPort)
let pool = RedisConnectionPool(
serverConnectionAddresses: [address],
loop: self.eventLoopGroup.next(),
maximumConnectionCount: .maximumActiveConnections(4),
minimumConnectionCount: minimumConnectionCount,
connectionPassword: self.redisPassword,
connectionRetryTimeout: connectionRetryTimeout
configuration: .init(
initialServerConnectionAddresses: [address],
maximumConnectionCount: .maximumActiveConnections(4),
connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword),
minimumConnectionCount: minimumConnectionCount,
connectionRetryTimeout: connectionRetryTimeout
),
boundEventLoop: self.eventLoopGroup.next()
)
pool.activate()
@@ -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,17 +18,17 @@ import XCTest
/// A helper `XCTestCase` subclass that does the standard work of creating a connection to use in test cases.
///
/// See `RedisConnection.connect(to:port:)` to understand how connections are made.
/// See `RedisConnection.make(configuration:boundEventLoop:)` to understand how connections are made.
open class RedisIntegrationTestCase: XCTestCase {
/// An overridable value of the Redis instance's hostname to connect to for the test suite(s).
///
/// The default value is `RedisConnection.defaultHostname`
///
/// This is especially useful to override if you build on Linux & macOS where Redis might be installed locally vs. through Docker.
open var redisHostname: String { return RedisConnection.defaultHostname }
open var redisHostname: String { RedisConnection.Configuration.defaultHostname }
/// The port to connect over to Redis, defaulting to `RedisConnection.defaultPort`.
open var redisPort: Int { return RedisConnection.defaultPort }
open var redisPort: Int { RedisConnection.Configuration.defaultPort }
/// The password to use to connect to Redis. Default is `nil` - no password authentication.
open var redisPassword: String? { return nil }
@@ -77,14 +77,16 @@ open class RedisIntegrationTestCase: XCTestCase {
/// Creates a new connection for use in tests.
///
/// See `RedisConnection.connect(to:port:)`
/// See `RedisConnection.make(configuration:boundEventLoop:)`
/// - Returns: The new `RediStack.RedisConnection`.
public func makeNewConnection() throws -> RedisConnection {
return try RedisConnection.connect(
on: eventLoopGroup.next(),
host: self.redisHostname,
port: self.redisPort,
password: self.redisPassword
return try RedisConnection.make(
configuration: .init(
host: self.redisHostname,
port: self.redisPort,
password: self.redisPassword
),
boundEventLoop: eventLoopGroup.next()
).wait()
}
}
@@ -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
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<RedisConnection> {
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)
}
}
@@ -47,10 +47,12 @@ final class RedisLoggingTests: RediStackIntegrationTestCase {
let logger = Logger(label: #function, factory: { _ in return handler })
let pool = RedisConnectionPool(
serverConnectionAddresses: [try .makeAddressResolvingHost(self.redisHostname, port: self.redisPort)],
loop: self.connection.eventLoop,
maximumConnectionCount: .maximumActiveConnections(1),
connectionPassword: self.redisPassword
configuration: .init(
initialServerConnectionAddresses: [try .makeAddressResolvingHost(self.redisHostname, port: self.redisPort)],
maximumConnectionCount: .maximumActiveConnections(1),
connectionFactoryConfiguration: .init(connectionPassword: self.redisPassword)
),
boundEventLoop: self.connection.eventLoop
)
defer { pool.close() }
pool.activate()
@@ -29,9 +29,9 @@ final class RedisCommandHandlerTests: XCTestCase {
.wait()
defer { try? server.close().wait() }
let connection = try RedisConnection.connect(
to: socketAddress,
on: group.next()
let connection = try RedisConnection.make(
configuration: .init(hostname: "localhost", port: 8080),
boundEventLoop: group.next()
).wait()
defer { try? connection.close().wait() }
@@ -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 NIO
import RediStack
import XCTest
final class ConfigurationTests: XCTestCase {
func test_connectionConfiguration_stringURLValidation() throws {
XCTAssertNoThrow(try RedisConnection.Configuration(url: "redis://localhost:6379"))
XCTAssertNoThrow(try RedisConnection.Configuration(url: "redis://localhost:6379/0"))
XCTAssertNoThrow(try RedisConnection.Configuration(url: "redis://:password@localhost"))
XCTAssertThrowsError(try RedisConnection.Configuration(url: "localhost:6379"))
XCTAssertThrowsError(try RedisConnection.Configuration(url: "redis://:6379"))
XCTAssertThrowsError(try RedisConnection.Configuration(url: "redis://localhost/-1"))
}
func test_connectionConfiguration_urlComponents() throws {
let configuration = try RedisConnection.Configuration(url: "redis://:password@localhost:6666/1")
XCTAssertEqual(configuration.hostname, "localhost")
XCTAssertEqual(configuration.password, "password")
XCTAssertEqual(configuration.port, 6666)
XCTAssertEqual(configuration.initialDatabase, 1)
}
func test_connectionConfiguration_databaseIndex() throws {
let address = try SocketAddress.makeAddressResolvingHost("localhost", port: RedisConnection.Configuration.defaultPort)
XCTAssertThrowsError(try RedisConnection.Configuration(address: address, initialDatabase: -1)) {
XCTAssertEqual($0 as? RedisConnection.Configuration.ValidationError, .outOfBoundsDatabaseID)
}
}
}