diff --git a/Sources/Redis/Application.Redis+configuration.swift b/Sources/Redis/Application.Redis+configuration.swift index 98c35b6..335b7d9 100644 --- a/Sources/Redis/Application.Redis+configuration.swift +++ b/Sources/Redis/Application.Redis+configuration.swift @@ -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 + } } diff --git a/Sources/Redis/RedisConfiguration.swift b/Sources/Redis/RedisConfiguration.swift index 9977233..6ffb527 100644 --- a/Sources/Redis/RedisConfiguration.swift +++ b/Sources/Redis/RedisConfiguration.swift @@ -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 @@ -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( @@ -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, diff --git a/Sources/Redis/RedisStorage.swift b/Sources/Redis/RedisStorage.swift index 2d5e452..10e37a3 100644 --- a/Sources/Redis/RedisStorage.swift +++ b/Sources/Redis/RedisStorage.swift @@ -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 { @@ -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) @@ -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 diff --git a/Tests/RedisTests/RedisTests.swift b/Tests/RedisTests/RedisTests.swift index e6cfad5..0bd41e0 100644 --- a/Tests/RedisTests/RedisTests.swift +++ b/Tests/RedisTests/RedisTests.swift @@ -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