Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Sources/Redis/Application.Redis+configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,18 @@ extension Application.Redis {
self.application.redisStorage.use(newConfig, as: self.id)
}
}

/// Attempts to resolve any pending hostname resolutions.
/// This can be used if hostname resolution failed during initial configuration
/// and you want to retry after DNS becomes available
///
/// This only updates the configuration and any existing connection pools are not affected.
public func retryHostnameResolution() throws {
try self.application.redisStorage.retryHostnameResolution(for: self.id)
}

/// Indicates whether this Redis configuration has unresolved hostnames
public var hasUnresolvedHostname: Bool {
return self.configuration?.hasUnresolvedHostname ?? false
}
}
63 changes: 54 additions & 9 deletions Sources/Redis/RedisConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ public struct RedisConfiguration: Sendable {
public var tlsConfiguration: TLSConfiguration?
public var tlsHostname: String?

internal var deferredHostname: String?
internal var deferredPort: Int?

public var hasUnresolvedHostname: Bool {
return deferredHostname != nil
}

public struct PoolOptions: Sendable {
public var maximumConnectionCount: RedisConnectionPoolSize
public var minimumConnectionCount: Int
Expand Down Expand Up @@ -83,14 +90,22 @@ public struct RedisConfiguration: Sendable {
) throws {
if database != nil && database! < 0 { throw ValidationError.outOfBoundsDatabaseID }

try self.init(
serverAddresses: [.makeAddressResolvingHost(hostname, port: port)],
password: password,
tlsConfiguration: tlsConfiguration,
tlsHostname: hostname,
database: database,
pool: pool
)
do {
let resolvedAdress = try SocketAddress.makeAddressResolvingHost(hostname, port: port)
self.serverAddresses = [resolvedAdress]
self.deferredHostname = nil
self.deferredPort = nil
} catch {
self.serverAddresses = []
self.deferredHostname = hostname
self.deferredPort = port
}

self.password = password
self.tlsConfiguration = tlsConfiguration
self.tlsHostname = hostname
self.database = database
self.pool = pool
}

public init(
Expand All @@ -102,18 +117,48 @@ public struct RedisConfiguration: Sendable {
pool: PoolOptions = .init()
) throws {
self.serverAddresses = serverAddresses
self.deferredHostname = nil
self.deferredPort = nil
self.password = password
self.tlsConfiguration = tlsConfiguration
self.tlsHostname = tlsHostname
self.database = database
self.pool = pool
}

/// Attempts to resolve any pending hostname resolution
/// - Returns: new configuration with resolved addresses, or throws if resolution fails
public func resolveServerAddresses() throws -> RedisConfiguration {
guard let hostname = deferredHostname, let port = deferredPort else {
return self
}

var resolved = self
let resolvedAddress = try SocketAddress.makeAddressResolvingHost(hostname, port: port)
resolved.serverAddresses = [resolvedAddress]
resolved.deferredHostname = nil
resolved.deferredPort = nil
return resolved
}
}

extension RedisConnectionPool.Configuration {
internal init(_ config: RedisConfiguration, defaultLogger: Logger, customClient: ClientBootstrap?) {
// Handle deferred hostname resolution at pool creation time
var addresses = config.serverAddresses

if let hostname = config.deferredHostname, let port = config.deferredPort {
do {
let resolvedAddress = try SocketAddress.makeAddressResolvingHost(hostname, port: port)
addresses = [resolvedAddress]
} catch {
defaultLogger.notice("Hostname '\(hostname)' could not be resolved at pool creation time: \(error). Redis connections will fail until hostname becomes resolvable.")
// Placeholder address so Redis operations fail gracefully
addresses = [try! SocketAddress.makeAddressResolvingHost("0.0.0.0", port: 1)]
}
}
self.init(
initialServerConnectionAddresses: config.serverAddresses,
initialServerConnectionAddresses: addresses,
maximumConnectionCount: config.pool.maximumConnectionCount,
connectionFactoryConfiguration: .init(
connectionInitialDatabase: config.database,
Expand Down
33 changes: 30 additions & 3 deletions Sources/Redis/RedisStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ final class RedisStorage: Sendable {
}
return pool
}

func retryHostnameResolution(for id: RedisID = .default) throws {
guard let configuration = self.configuration(for: id) else {
throw RedisConfiguration.ValidationError.missingURLHost
}

guard configuration.hasUnresolvedHostname else {
return
}

let resolvedConfiguration = try configuration.resolveServerAddresses()
self.use(resolvedConfiguration, as: id)
}
}

extension RedisStorage {
Expand All @@ -73,12 +86,26 @@ extension RedisStorage {
for eventLoop in application.eventLoopGroup.makeIterator() {
redisStorage.box.withLockedValue { storageBox in
for (redisID, configuration) in storageBox.configurations {
// Attempt to resolve any deferred hostnames at boot time
let resolvedConfiguration: RedisConfiguration
if configuration.hasUnresolvedHostname {
do {
resolvedConfiguration = try configuration.resolveServerAddresses()
application.logger.info("Successfully resolved Redis hostname for \(redisID) during application boot")
storageBox.configurations[redisID] = resolvedConfiguration
} catch {
application.logger.warning("Redis hostname resolution failed for \(redisID) during boot: \(error)")
resolvedConfiguration = configuration
}
} else {
resolvedConfiguration = configuration
}

let newKey: PoolKey = PoolKey(eventLoopKey: eventLoop.key, redisID: redisID)

let redisTLSClient: ClientBootstrap? = {
guard let tlsConfig = configuration.tlsConfiguration,
let tlsHost = configuration.tlsHostname else { return nil }
guard let tlsConfig = resolvedConfiguration.tlsConfiguration,
let tlsHost = resolvedConfiguration.tlsHostname else { return nil }

return ClientBootstrap(group: eventLoop)
.channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
Expand All @@ -97,7 +124,7 @@ extension RedisStorage {
}()

let newPool = RedisConnectionPool(
configuration: .init(configuration, defaultLogger: application.logger, customClient: redisTLSClient),
configuration: .init(resolvedConfiguration, defaultLogger: application.logger, customClient: redisTLSClient),
boundEventLoop: eventLoop)

newPools[newKey] = newPool
Expand Down
33 changes: 33 additions & 0 deletions Tests/RedisTests/RedisTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,39 @@ extension RedisTests {
XCTAssertEqual(redisConfiguration.password, "password")
XCTAssertEqual(redisConfiguration.database, 0)
}

func testDeferredHostnameResolution() throws {
let config = try RedisConfiguration(
hostname: "nonexistent.hostname",
port: 6379,
pool: .init(connectionRetryTimeout: .milliseconds(100))
)

XCTAssertTrue(config.hasUnresolvedHostname)

let localhostConfig = try RedisConfiguration(
hostname: "localhost",
port: 6379
)

XCTAssertFalse(localhostConfig.hasUnresolvedHostname)
}

func testHostnameResolutionRetry() throws {
let config = try RedisConfiguration(
hostname: "nonexistent.hostname",
port: 6379
)

let app = Application(.testing)
defer { app.shutdown() }

app.redis.configuration = config

if app.redis.hasUnresolvedHostname {
XCTAssertThrowsError(try app.redis.retryHostnameResolution())
}
}
}

// MARK: Redis extensions
Expand Down