Skip to content

Commit 08b678b

Browse files
committed
feat(plugin-redis): add Sentinel connection mode (#1021)
1 parent 2648c96 commit 08b678b

10 files changed

Lines changed: 1113 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Redis: Sentinel connection mode. Pick "Sentinel" in the connection form, list the Sentinel nodes, and set the master name; TablePro resolves the current master through the Sentinel quorum and re-resolves automatically on failover (#1021)
13+
1014
### Changed
1115

1216
- Quick Switcher rewritten as a native SwiftUI sheet matching the Database Switcher style. Adds a Recent section per connection.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// HiredisSentinelTransport.swift
3+
// RedisDriverPlugin
4+
//
5+
// Production SentinelTransport backed by short-lived hiredis connections.
6+
//
7+
8+
import Foundation
9+
import OSLog
10+
11+
private let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "HiredisSentinelTransport")
12+
13+
struct HiredisSentinelTransport: SentinelTransport {
14+
let sslConfig: RedisSSLConfig
15+
16+
init(sslConfig: RedisSSLConfig = RedisSSLConfig()) {
17+
self.sslConfig = sslConfig
18+
}
19+
20+
func queryMasterAddress(
21+
masterName: String,
22+
at sentinel: SentinelHostPort,
23+
sentinelUsername: String?,
24+
sentinelPassword: String?
25+
) async throws -> SentinelMasterReply {
26+
let connection = RedisPluginConnection(
27+
host: sentinel.host,
28+
port: sentinel.port,
29+
username: sentinelUsername?.nonEmptyOrNil,
30+
password: sentinelPassword?.nonEmptyOrNil,
31+
database: 0,
32+
sslConfig: sslConfig
33+
)
34+
35+
try await connection.connect()
36+
defer { connection.disconnect() }
37+
38+
let reply = try await connection.executeCommand([
39+
"SENTINEL", "get-master-addr-by-name", masterName,
40+
])
41+
42+
if case .error(let message) = reply {
43+
logger.debug("Sentinel \(sentinel.host):\(sentinel.port) replied with error: \(message)")
44+
throw RedisPluginError(code: 0, message: message)
45+
}
46+
47+
let tokens = Self.extractTokens(from: reply)
48+
return try RedisSentinelResolver.parseMasterReplyTokens(tokens, from: sentinel)
49+
}
50+
51+
static func extractTokens(from reply: RedisReply) -> [String?]? {
52+
switch reply {
53+
case .null:
54+
return nil
55+
case .array(let items):
56+
if items.isEmpty { return nil }
57+
return items.map { item -> String? in
58+
if case .null = item { return nil }
59+
return item.stringValue
60+
}
61+
default:
62+
return [reply.stringValue]
63+
}
64+
}
65+
}
66+
67+
private extension String {
68+
var nonEmptyOrNil: String? { isEmpty ? nil : self }
69+
}

Plugins/RedisDriverPlugin/RedisPlugin.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import TableProPluginKit
1313

1414
final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
1515
static let pluginName = "Redis Driver"
16-
static let pluginVersion = "1.0.0"
16+
static let pluginVersion = "1.1.0"
1717
static let pluginDescription = "Redis support via hiredis"
1818
static let capabilities: [PluginCapability] = [.databaseDriver]
1919

@@ -22,6 +22,46 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
2222
static let iconName = "redis-icon"
2323
static let defaultPort = 6379
2424
static let additionalConnectionFields: [ConnectionField] = [
25+
ConnectionField(
26+
id: "redisMode",
27+
label: String(localized: "Connection Mode"),
28+
defaultValue: "single",
29+
fieldType: .dropdown(options: [
30+
.init(value: "single", label: String(localized: "Single Node")),
31+
.init(value: "sentinel", label: String(localized: "Sentinel")),
32+
]),
33+
section: .connection
34+
),
35+
ConnectionField(
36+
id: "redisSentinelHosts",
37+
label: String(localized: "Sentinel Nodes"),
38+
placeholder: "127.0.0.1:26379",
39+
required: true,
40+
fieldType: .hostList,
41+
section: .connection,
42+
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
43+
),
44+
ConnectionField(
45+
id: "redisSentinelMasterName",
46+
label: String(localized: "Master Group Name"),
47+
placeholder: "mymaster",
48+
defaultValue: "mymaster",
49+
section: .connection,
50+
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
51+
),
52+
ConnectionField(
53+
id: "redisSentinelUsername",
54+
label: String(localized: "Sentinel User"),
55+
section: .connection,
56+
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
57+
),
58+
ConnectionField(
59+
id: "redisSentinelPassword",
60+
label: String(localized: "Sentinel Password"),
61+
fieldType: .secure,
62+
section: .connection,
63+
visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"])
64+
),
2565
ConnectionField(
2666
id: "redisDatabase",
2767
label: String(localized: "Database Index"),

Plugins/RedisDriverPlugin/RedisPluginDriver.swift

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
6565
func connect() async throws {
6666
let sslConfig = RedisSSLConfig(additionalFields: config.additionalFields)
6767
let redisDb = Int(config.additionalFields["redisDatabase"] ?? "") ?? Int(config.database) ?? 0
68+
let (host, port) = try await resolveDataPlaneAddress(sslConfig: sslConfig)
6869

6970
let conn = RedisPluginConnection(
70-
host: config.host,
71-
port: config.port,
71+
host: host,
72+
port: port,
7273
username: config.username.isEmpty ? nil : config.username,
7374
password: config.password.isEmpty ? nil : config.password,
7475
database: redisDb,
@@ -79,6 +80,68 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
7980
redisConnection = conn
8081
}
8182

83+
private func resolveDataPlaneAddress(sslConfig: RedisSSLConfig) async throws -> (String, Int) {
84+
let mode = config.additionalFields["redisMode"] ?? "single"
85+
switch mode {
86+
case "sentinel":
87+
return try await resolveSentinelMaster(sslConfig: sslConfig)
88+
default:
89+
return (config.host, config.port)
90+
}
91+
}
92+
93+
private func resolveSentinelMaster(sslConfig: RedisSSLConfig) async throws -> (String, Int) {
94+
let hostsRaw = config.additionalFields["redisSentinelHosts"] ?? ""
95+
let sentinels = RedisSentinelResolver.parseSentinelHostList(hostsRaw, defaultPort: 26_379)
96+
let masterName = (config.additionalFields["redisSentinelMasterName"] ?? "")
97+
.trimmingCharacters(in: .whitespaces)
98+
let sentinelUsername = config.additionalFields["redisSentinelUsername"]
99+
.map { $0.trimmingCharacters(in: .whitespaces) }
100+
.flatMap { $0.isEmpty ? nil : $0 }
101+
let sentinelPassword = config.additionalFields["redisSentinelPassword"]
102+
.flatMap { $0.isEmpty ? nil : $0 }
103+
104+
let resolver = RedisSentinelResolver(
105+
sentinels: sentinels,
106+
masterName: masterName,
107+
sentinelUsername: sentinelUsername,
108+
sentinelPassword: sentinelPassword,
109+
transport: HiredisSentinelTransport(sslConfig: sslConfig)
110+
)
111+
112+
do {
113+
let address = try await resolver.resolveMaster()
114+
Self.logger.info("Sentinel resolved master \(masterName) to \(address.host):\(address.port)")
115+
return (address.host, address.port)
116+
} catch let error as RedisSentinelResolutionError {
117+
throw Self.makeSentinelError(error)
118+
}
119+
}
120+
121+
private static func makeSentinelError(_ error: RedisSentinelResolutionError) -> RedisPluginError {
122+
switch error {
123+
case .noSentinelsConfigured:
124+
return sentinelError(String(localized: "Sentinel mode requires at least one Sentinel node."))
125+
case .emptyMasterName:
126+
return sentinelError(String(localized: "Sentinel mode requires a master name."))
127+
case .masterUnknown(let name, let tried):
128+
let triedList = tried.map { "\($0.host):\($0.port)" }.joined(separator: ", ")
129+
let template = String(localized: "None of the configured Sentinels know master \"%@\". Tried: %@")
130+
return sentinelError(String(format: template, name, triedList))
131+
case .allSentinelsUnreachable(let attempts):
132+
let attemptsList = attempts.map { "\($0.host):\($0.port)" }.joined(separator: ", ")
133+
let template = String(localized: "All Sentinels were unreachable. Tried: %@")
134+
return sentinelError(String(format: template, attemptsList))
135+
case .malformedReply(let sentinel, let detail):
136+
let template = String(localized: "Malformed Sentinel reply from %@:%d (%@)")
137+
return sentinelError(String(format: template, sentinel.host, sentinel.port, detail))
138+
}
139+
}
140+
141+
private static func sentinelError(_ message: String) -> RedisPluginError {
142+
RedisPluginError(code: 0, message: message)
143+
}
144+
82145
func disconnect() {
83146
redisConnection?.disconnect()
84147
redisConnection = nil
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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

Comments
 (0)