Skip to content

Commit ba30e90

Browse files
committed
Add deferred hostname resolution functionality
- If immediate hostname resolution on creation of RedisConfiguration fails, retry gracefully on pool creation and on boot Issue: 212
1 parent f4ac198 commit ba30e90

File tree

4 files changed

+131
-12
lines changed

4 files changed

+131
-12
lines changed

Sources/Redis/Application.Redis+configuration.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,18 @@ extension Application.Redis {
1515
self.application.redisStorage.use(newConfig, as: self.id)
1616
}
1717
}
18+
19+
/// Attempts to resolve any pending hostname resolutions.
20+
/// This can be used if hostname resolution failed during initial configuration
21+
/// and you want to retry after DNS becomes available
22+
///
23+
/// This only updates the configuration and any existing connection pools are not affected.
24+
public func retryHostnameResolution() throws {
25+
try self.application.redisStorage.retryHostnameResolution(for: self.id)
26+
}
27+
28+
/// Indicates whether this Redis configuration has unresolved hostnames
29+
public var hasUnresolvedHostname: Bool {
30+
return self.configuration?.hasUnresolvedHostname ?? false
31+
}
1832
}

Sources/Redis/RedisConfiguration.swift

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ public struct RedisConfiguration: Sendable {
1616
public var tlsConfiguration: TLSConfiguration?
1717
public var tlsHostname: String?
1818

19+
internal var deferredHostname: String?
20+
internal var deferredPort: Int?
21+
22+
public var hasUnresolvedHostname: Bool {
23+
return deferredHostname != nil
24+
}
25+
1926
public struct PoolOptions: Sendable {
2027
public var maximumConnectionCount: RedisConnectionPoolSize
2128
public var minimumConnectionCount: Int
@@ -83,14 +90,22 @@ public struct RedisConfiguration: Sendable {
8390
) throws {
8491
if database != nil && database! < 0 { throw ValidationError.outOfBoundsDatabaseID }
8592

86-
try self.init(
87-
serverAddresses: [.makeAddressResolvingHost(hostname, port: port)],
88-
password: password,
89-
tlsConfiguration: tlsConfiguration,
90-
tlsHostname: hostname,
91-
database: database,
92-
pool: pool
93-
)
93+
do {
94+
let resolvedAdress = try SocketAddress.makeAddressResolvingHost(hostname, port: port)
95+
self.serverAddresses = [resolvedAdress]
96+
self.deferredHostname = nil
97+
self.deferredPort = nil
98+
} catch {
99+
self.serverAddresses = []
100+
self.deferredHostname = hostname
101+
self.deferredPort = port
102+
}
103+
104+
self.password = password
105+
self.tlsConfiguration = tlsConfiguration
106+
self.tlsHostname = hostname
107+
self.database = database
108+
self.pool = pool
94109
}
95110

96111
public init(
@@ -102,18 +117,48 @@ public struct RedisConfiguration: Sendable {
102117
pool: PoolOptions = .init()
103118
) throws {
104119
self.serverAddresses = serverAddresses
120+
self.deferredHostname = nil
121+
self.deferredPort = nil
105122
self.password = password
106123
self.tlsConfiguration = tlsConfiguration
107124
self.tlsHostname = tlsHostname
108125
self.database = database
109126
self.pool = pool
110127
}
128+
129+
/// Attempts to resolve any pending hostname resolution
130+
/// - Returns: new configuration with resolved addresses, or throws if resolution fails
131+
public func resolveServerAddresses() throws -> RedisConfiguration {
132+
guard let hostname = deferredHostname, let port = deferredPort else {
133+
return self
134+
}
135+
136+
var resolved = self
137+
let resolvedAddress = try SocketAddress.makeAddressResolvingHost(hostname, port: port)
138+
resolved.serverAddresses = [resolvedAddress]
139+
resolved.deferredHostname = nil
140+
resolved.deferredPort = nil
141+
return resolved
142+
}
111143
}
112144

113145
extension RedisConnectionPool.Configuration {
114146
internal init(_ config: RedisConfiguration, defaultLogger: Logger, customClient: ClientBootstrap?) {
147+
// Handle deferred hostname resolution at pool creation time
148+
var addresses = config.serverAddresses
149+
150+
if let hostname = config.deferredHostname, let port = config.deferredPort {
151+
do {
152+
let resolvedAddress = try SocketAddress.makeAddressResolvingHost(hostname, port: port)
153+
addresses = [resolvedAddress]
154+
} catch {
155+
defaultLogger.notice("Hostname '\(hostname)' could not be resolved at pool creation time: \(error). Redis connections will fail until hostname becomes resolvable.")
156+
// Placeholder address so Redis operations fail gracefully
157+
addresses = [try! SocketAddress.makeAddressResolvingHost("0.0.0.0", port: 1)]
158+
}
159+
}
115160
self.init(
116-
initialServerConnectionAddresses: config.serverAddresses,
161+
initialServerConnectionAddresses: addresses,
117162
maximumConnectionCount: config.pool.maximumConnectionCount,
118163
connectionFactoryConfiguration: .init(
119164
connectionInitialDatabase: config.database,

Sources/Redis/RedisStorage.swift

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ final class RedisStorage: Sendable {
5757
}
5858
return pool
5959
}
60+
61+
func retryHostnameResolution(for id: RedisID = .default) throws {
62+
guard let configuration = self.configuration(for: id) else {
63+
throw RedisConfiguration.ValidationError.missingURLHost
64+
}
65+
66+
guard configuration.hasUnresolvedHostname else {
67+
return
68+
}
69+
70+
let resolvedConfiguration = try configuration.resolveServerAddresses()
71+
self.use(resolvedConfiguration, as: id)
72+
}
6073
}
6174

6275
extension RedisStorage {
@@ -73,12 +86,26 @@ extension RedisStorage {
7386
for eventLoop in application.eventLoopGroup.makeIterator() {
7487
redisStorage.box.withLockedValue { storageBox in
7588
for (redisID, configuration) in storageBox.configurations {
89+
// Attempt to resolve any deferred hostnames at boot time
90+
let resolvedConfiguration: RedisConfiguration
91+
if configuration.hasUnresolvedHostname {
92+
do {
93+
resolvedConfiguration = try configuration.resolveServerAddresses()
94+
application.logger.info("Successfully resolved Redis hostname for \(redisID) during application boot")
95+
storageBox.configurations[redisID] = resolvedConfiguration
96+
} catch {
97+
application.logger.warning("Redis hostname resolution failed for \(redisID) during boot: \(error)")
98+
resolvedConfiguration = configuration
99+
}
100+
} else {
101+
resolvedConfiguration = configuration
102+
}
76103

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

79106
let redisTLSClient: ClientBootstrap? = {
80-
guard let tlsConfig = configuration.tlsConfiguration,
81-
let tlsHost = configuration.tlsHostname else { return nil }
107+
guard let tlsConfig = resolvedConfiguration.tlsConfiguration,
108+
let tlsHost = resolvedConfiguration.tlsHostname else { return nil }
82109

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

99126
let newPool = RedisConnectionPool(
100-
configuration: .init(configuration, defaultLogger: application.logger, customClient: redisTLSClient),
127+
configuration: .init(resolvedConfiguration, defaultLogger: application.logger, customClient: redisTLSClient),
101128
boundEventLoop: eventLoop)
102129

103130
newPools[newKey] = newPool

Tests/RedisTests/RedisTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,39 @@ extension RedisTests {
6868
XCTAssertEqual(redisConfiguration.password, "password")
6969
XCTAssertEqual(redisConfiguration.database, 0)
7070
}
71+
72+
func testDeferredHostnameResolution() throws {
73+
let config = try RedisConfiguration(
74+
hostname: "nonexistent.hostname",
75+
port: 6379,
76+
pool: .init(connectionRetryTimeout: .milliseconds(100))
77+
)
78+
79+
XCTAssertTrue(config.hasUnresolvedHostname)
80+
81+
let localhostConfig = try RedisConfiguration(
82+
hostname: "localhost",
83+
port: 6379
84+
)
85+
86+
XCTAssertFalse(localhostConfig.hasUnresolvedHostname)
87+
}
88+
89+
func testHostnameResolutionRetry() throws {
90+
let config = try RedisConfiguration(
91+
hostname: "nonexistent.hostname",
92+
port: 6379
93+
)
94+
95+
let app = Application(.testing)
96+
defer { app.shutdown() }
97+
98+
app.redis.configuration = config
99+
100+
if app.redis.hasUnresolvedHostname {
101+
XCTAssertThrowsError(try app.redis.retryHostnameResolution())
102+
}
103+
}
71104
}
72105

73106
// MARK: Redis extensions

0 commit comments

Comments
 (0)