|
| 1 | +// |
| 2 | +// RedisSentinelResolver.swift |
| 3 | +// RedisDriverPlugin |
| 4 | +// |
| 5 | +// Resolves the current Redis master address by querying a list of Sentinel nodes. |
| 6 | +// Pure Swift; transport I/O is abstracted behind SentinelTransport so the resolution |
| 7 | +// algorithm is unit-testable without hiredis. |
| 8 | +// |
| 9 | + |
| 10 | +import Foundation |
| 11 | + |
| 12 | +struct SentinelHostPort: Equatable, Sendable, Hashable { |
| 13 | + let host: String |
| 14 | + let port: Int |
| 15 | +} |
| 16 | + |
| 17 | +enum SentinelMasterReply: Equatable, Sendable { |
| 18 | + case masterUnknown |
| 19 | + case address(SentinelHostPort) |
| 20 | +} |
| 21 | + |
| 22 | +enum RedisSentinelResolutionError: Error, Equatable { |
| 23 | + case noSentinelsConfigured |
| 24 | + case emptyMasterName |
| 25 | + case masterUnknown(masterName: String, triedSentinels: [SentinelHostPort]) |
| 26 | + case allSentinelsUnreachable(attempts: [SentinelHostPort]) |
| 27 | + case malformedReply(SentinelHostPort, detail: String) |
| 28 | +} |
| 29 | + |
| 30 | +protocol SentinelTransport: Sendable { |
| 31 | + func queryMasterAddress( |
| 32 | + masterName: String, |
| 33 | + at sentinel: SentinelHostPort, |
| 34 | + sentinelUsername: String?, |
| 35 | + sentinelPassword: String? |
| 36 | + ) async throws -> SentinelMasterReply |
| 37 | +} |
| 38 | + |
| 39 | +final class RedisSentinelResolver: @unchecked Sendable { |
| 40 | + private let sentinels: [SentinelHostPort] |
| 41 | + private let masterName: String |
| 42 | + private let sentinelUsername: String? |
| 43 | + private let sentinelPassword: String? |
| 44 | + private let transport: SentinelTransport |
| 45 | + |
| 46 | + init( |
| 47 | + sentinels: [SentinelHostPort], |
| 48 | + masterName: String, |
| 49 | + sentinelUsername: String?, |
| 50 | + sentinelPassword: String?, |
| 51 | + transport: SentinelTransport |
| 52 | + ) { |
| 53 | + self.sentinels = sentinels |
| 54 | + self.masterName = masterName |
| 55 | + self.sentinelUsername = sentinelUsername |
| 56 | + self.sentinelPassword = sentinelPassword |
| 57 | + self.transport = transport |
| 58 | + } |
| 59 | + |
| 60 | + func resolveMaster() async throws -> SentinelHostPort { |
| 61 | + guard !sentinels.isEmpty else { |
| 62 | + throw RedisSentinelResolutionError.noSentinelsConfigured |
| 63 | + } |
| 64 | + guard !masterName.isEmpty else { |
| 65 | + throw RedisSentinelResolutionError.emptyMasterName |
| 66 | + } |
| 67 | + |
| 68 | + var unreachable: [SentinelHostPort] = [] |
| 69 | + var saidUnknown: [SentinelHostPort] = [] |
| 70 | + |
| 71 | + for sentinel in sentinels { |
| 72 | + do { |
| 73 | + let reply = try await transport.queryMasterAddress( |
| 74 | + masterName: masterName, |
| 75 | + at: sentinel, |
| 76 | + sentinelUsername: sentinelUsername, |
| 77 | + sentinelPassword: sentinelPassword |
| 78 | + ) |
| 79 | + switch reply { |
| 80 | + case .address(let address): |
| 81 | + return address |
| 82 | + case .masterUnknown: |
| 83 | + saidUnknown.append(sentinel) |
| 84 | + } |
| 85 | + } catch { |
| 86 | + unreachable.append(sentinel) |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + if !saidUnknown.isEmpty { |
| 91 | + throw RedisSentinelResolutionError.masterUnknown( |
| 92 | + masterName: masterName, |
| 93 | + triedSentinels: saidUnknown |
| 94 | + ) |
| 95 | + } |
| 96 | + throw RedisSentinelResolutionError.allSentinelsUnreachable(attempts: unreachable) |
| 97 | + } |
| 98 | + |
| 99 | + static func parseMasterReplyTokens( |
| 100 | + _ tokens: [String?]?, |
| 101 | + from sentinel: SentinelHostPort |
| 102 | + ) throws -> SentinelMasterReply { |
| 103 | + guard let tokens else { |
| 104 | + return .masterUnknown |
| 105 | + } |
| 106 | + guard tokens.count == 2 else { |
| 107 | + throw RedisSentinelResolutionError.malformedReply( |
| 108 | + sentinel, |
| 109 | + detail: "expected 2-element array, got \(tokens.count)" |
| 110 | + ) |
| 111 | + } |
| 112 | + guard let host = tokens[0], !host.isEmpty else { |
| 113 | + throw RedisSentinelResolutionError.malformedReply(sentinel, detail: "missing host") |
| 114 | + } |
| 115 | + guard let portString = tokens[1], let port = parsePort(portString) else { |
| 116 | + throw RedisSentinelResolutionError.malformedReply( |
| 117 | + sentinel, |
| 118 | + detail: "invalid port \(tokens[1] ?? "nil")" |
| 119 | + ) |
| 120 | + } |
| 121 | + return .address(SentinelHostPort(host: host, port: port)) |
| 122 | + } |
| 123 | + |
| 124 | + static func parseSentinelHostList(_ raw: String, defaultPort: Int) -> [SentinelHostPort] { |
| 125 | + raw.split(separator: ",").compactMap { part in |
| 126 | + let trimmed = part.trimmingCharacters(in: .whitespaces) |
| 127 | + guard !trimmed.isEmpty else { return nil } |
| 128 | + return parseSingleHost(trimmed, defaultPort: defaultPort) |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + private static func parsePort(_ string: String) -> Int? { |
| 133 | + guard let port = Int(string), (1...65_535).contains(port) else { return nil } |
| 134 | + return port |
| 135 | + } |
| 136 | + |
| 137 | + private static func parseSingleHost(_ entry: String, defaultPort: Int) -> SentinelHostPort? { |
| 138 | + if entry.hasPrefix("[") { |
| 139 | + guard let closing = entry.firstIndex(of: "]") else { return nil } |
| 140 | + let host = String(entry[entry.index(after: entry.startIndex)..<closing]) |
| 141 | + guard !host.isEmpty else { return nil } |
| 142 | + let afterBracket = entry.index(after: closing) |
| 143 | + if afterBracket == entry.endIndex { |
| 144 | + return SentinelHostPort(host: host, port: defaultPort) |
| 145 | + } |
| 146 | + guard entry[afterBracket] == ":" else { return nil } |
| 147 | + let portString = String(entry[entry.index(after: afterBracket)...]) |
| 148 | + guard let port = parsePort(portString) else { return nil } |
| 149 | + return SentinelHostPort(host: host, port: port) |
| 150 | + } |
| 151 | + if let lastColon = entry.lastIndex(of: ":"), !entry[..<lastColon].contains(":") { |
| 152 | + let host = String(entry[..<lastColon]) |
| 153 | + let portString = String(entry[entry.index(after: lastColon)...]) |
| 154 | + guard !host.isEmpty, let port = parsePort(portString) else { return nil } |
| 155 | + return SentinelHostPort(host: host, port: port) |
| 156 | + } |
| 157 | + return SentinelHostPort(host: entry, port: defaultPort) |
| 158 | + } |
| 159 | +} |
0 commit comments