From 4f36070cb2d3f3730651f18edb50b07d661dbb54 Mon Sep 17 00:00:00 2001 From: tonghs Date: Mon, 11 May 2026 16:58:59 +0800 Subject: [PATCH 1/3] fix(plugins): preserve plugin's connection config in withBranding --- .../Core/Plugins/PluginMetadataRegistry.swift | 2 +- .../PluginMetadataRegistryBrandingTests.swift | 123 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 TableProTests/Core/Plugins/PluginMetadataRegistryBrandingTests.swift diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index c9c61a2c2..f26af2bb0 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -176,7 +176,7 @@ struct PluginMetadataSnapshot: Sendable { editorLanguage: editorLanguage, connectionMode: connectionMode, supportsDatabaseSwitching: supportsDatabaseSwitching, supportsColumnReorder: supportsColumnReorder, - capabilities: capabilities, schema: schema, editor: editor, connection: source.connection + capabilities: capabilities, schema: schema, editor: editor, connection: connection ) } diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryBrandingTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryBrandingTests.swift new file mode 100644 index 000000000..c5a340699 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryBrandingTests.swift @@ -0,0 +1,123 @@ +// +// PluginMetadataRegistryBrandingTests.swift +// TableProTests +// +// Locks in the fix that withBranding preserves only visual identity +// (displayName, iconName, brandColorHex), not the entire connection config. +// Regression guard for: a plugin's freshly declared additionalConnectionFields +// must not be clobbered by the existing registry default's connection block +// during register(..., preserveIcon: true). +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("PluginMetadataSnapshot branding preservation") +struct PluginMetadataRegistryBrandingTests { + private static let pluginField = ConnectionField( + id: "newPluginField", + label: "New Plugin Field", + defaultValue: "x", + section: .connection + ) + + private static let existingField = ConnectionField( + id: "oldDefaultField", + label: "Old Default Field", + defaultValue: "y", + section: .connection + ) + + private static func snapshot( + displayName: String, + iconName: String, + brandColorHex: String, + defaultPort: Int, + fields: [ConnectionField] + ) -> PluginMetadataSnapshot { + PluginMetadataSnapshot( + displayName: displayName, iconName: iconName, defaultPort: defaultPort, + requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "brandtest", parameterStyle: .questionMark, + navigationModel: .inPlace, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: false, urlSchemes: ["brandtest"], postConnectActions: [], + brandColorHex: brandColorHex, queryLanguageName: "Q", editorLanguage: .bash, + connectionMode: .network, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: .defaults, schema: .defaults, editor: .defaults, + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: fields, + category: .other, + tagline: "" + ) + ) + } + + @Test("withBranding takes branding from source but keeps self's connection config") + func withBrandingKeepsSelfConnection() { + let plugin = Self.snapshot( + displayName: "PluginName", + iconName: "plugin-icon", + brandColorHex: "#111111", + defaultPort: 7_000, + fields: [Self.pluginField] + ) + let existing = Self.snapshot( + displayName: "ExistingName", + iconName: "existing-icon", + brandColorHex: "#999999", + defaultPort: 8_000, + fields: [Self.existingField] + ) + + let merged = plugin.withBranding(from: existing) + + #expect(merged.displayName == "ExistingName") + #expect(merged.iconName == "existing-icon") + #expect(merged.brandColorHex == "#999999") + + let mergedIds = merged.connection.additionalConnectionFields.map(\.id) + #expect(mergedIds == ["newPluginField"]) + + #expect(merged.defaultPort == 7_000) + } + + @Test("register with preserveIcon keeps the plugin's new fields") + func registerPreserveIconKeepsPluginFields() { + let registry = PluginMetadataRegistry.shared + let typeId = "BrandTestPluginType" + + guard registry.snapshot(forTypeId: typeId) == nil else { + Issue.record("Test type \(typeId) unexpectedly in registry defaults") + return + } + + let existing = Self.snapshot( + displayName: "BrandTest", + iconName: "existing-icon", + brandColorHex: "#111111", + defaultPort: 7_000, + fields: [Self.existingField] + ) + registry.register(snapshot: existing, forTypeId: typeId) + + let pluginSnapshot = Self.snapshot( + displayName: "WrongName", + iconName: "wrong-icon", + brandColorHex: "#222222", + defaultPort: 7_000, + fields: [Self.pluginField] + ) + registry.register(snapshot: pluginSnapshot, forTypeId: typeId, preserveIcon: true) + + let resolved = registry.snapshot(forTypeId: typeId) + #expect(resolved?.iconName == "existing-icon") + #expect(resolved?.displayName == "BrandTest") + #expect(resolved?.connection.additionalConnectionFields.map(\.id) == ["newPluginField"]) + + registry.unregister(typeId: typeId) + } +} From 2648c962bae80fc26f4ff304319d31b4323ff50d Mon Sep 17 00:00:00 2001 From: tonghs Date: Mon, 11 May 2026 16:59:05 +0800 Subject: [PATCH 2/3] fix(connection-form): respect hostList visibility and fix port placeholder formatting --- TablePro/Views/Connection/HostListFieldRow.swift | 2 +- TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift | 2 +- .../Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift | 4 +++- .../ConnectionForm/ViewModels/NetworkPaneViewModel.swift | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index a2b752bbf..3e5b95895 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -23,7 +23,7 @@ struct HostListFieldRow: View { LabeledContent { List(selection: $selectedId) { ForEach(entries) { entry in - TextField("", text: bindingForEntry(entry), prompt: Text("hostname:\(defaultPort)")) + TextField("", text: bindingForEntry(entry), prompt: Text(verbatim: "hostname:\(defaultPort)")) .tag(entry.id) } } diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index bbeb0feb9..e816fed4c 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -117,7 +117,7 @@ struct GeneralPaneView: View { let connectionFields = coordinator.network.connectionFields if coordinator.network.hasHostListField { ForEach(connectionFields, id: \.id) { field in - if case .hostList = field.fieldType { + if case .hostList = field.fieldType, coordinator.network.isFieldVisible(field) { HostListFieldRow( label: field.label, placeholder: field.placeholder, diff --git a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift index 14120a3d1..e525c2284 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift @@ -72,7 +72,9 @@ final class AuthPaneViewModel { let type = coordinator?.value?.network.type ?? .mysql let registry = PluginManager.shared.additionalConnectionFields(for: type) let defaultValue = registry.first { $0.id == rule.fieldId }?.defaultValue ?? "" - let currentValue = additionalFieldValues[rule.fieldId] ?? defaultValue + let currentValue = additionalFieldValues[rule.fieldId] + ?? coordinator?.value?.network.additionalFieldValues[rule.fieldId] + ?? defaultValue return rule.values.contains(currentValue) } diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index ae0b1bd81..7c44184c6 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -29,8 +29,8 @@ final class NetworkPaneViewModel { var hasHostListField: Bool { connectionFields.contains { field in - if case .hostList = field.fieldType { return true } - return false + guard case .hostList = field.fieldType else { return false } + return isFieldVisible(field) } } From 08b678b6fb9eee53d47933419702afafadbd363e Mon Sep 17 00:00:00 2001 From: tonghs Date: Mon, 11 May 2026 16:59:11 +0800 Subject: [PATCH 3/3] feat(plugin-redis): add Sentinel connection mode (#1021) --- CHANGELOG.md | 4 + .../HiredisSentinelTransport.swift | 69 ++++ Plugins/RedisDriverPlugin/RedisPlugin.swift | 42 +- .../RedisDriverPlugin/RedisPluginDriver.swift | 67 +++- .../RedisSentinelResolver.swift | 159 ++++++++ ...ginMetadataRegistry+RegistryDefaults.swift | 40 ++ TablePro/Resources/Localizable.xcstrings | 342 ++++++++++++++++ .../RedisSentinelResolver.swift | 1 + .../Plugins/RedisSentinelResolverTests.swift | 367 ++++++++++++++++++ docs/databases/redis.mdx | 28 +- 10 files changed, 1113 insertions(+), 6 deletions(-) create mode 100644 Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift create mode 100644 Plugins/RedisDriverPlugin/RedisSentinelResolver.swift create mode 120000 TableProTests/PluginTestSources/RedisSentinelResolver.swift create mode 100644 TableProTests/Plugins/RedisSentinelResolverTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 92de536c0..78c51ce4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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) + ### Changed - Quick Switcher rewritten as a native SwiftUI sheet matching the Database Switcher style. Adds a Recent section per connection. diff --git a/Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift b/Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift new file mode 100644 index 000000000..d5177c18e --- /dev/null +++ b/Plugins/RedisDriverPlugin/HiredisSentinelTransport.swift @@ -0,0 +1,69 @@ +// +// HiredisSentinelTransport.swift +// RedisDriverPlugin +// +// Production SentinelTransport backed by short-lived hiredis connections. +// + +import Foundation +import OSLog + +private let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "HiredisSentinelTransport") + +struct HiredisSentinelTransport: SentinelTransport { + let sslConfig: RedisSSLConfig + + init(sslConfig: RedisSSLConfig = RedisSSLConfig()) { + self.sslConfig = sslConfig + } + + func queryMasterAddress( + masterName: String, + at sentinel: SentinelHostPort, + sentinelUsername: String?, + sentinelPassword: String? + ) async throws -> SentinelMasterReply { + let connection = RedisPluginConnection( + host: sentinel.host, + port: sentinel.port, + username: sentinelUsername?.nonEmptyOrNil, + password: sentinelPassword?.nonEmptyOrNil, + database: 0, + sslConfig: sslConfig + ) + + try await connection.connect() + defer { connection.disconnect() } + + let reply = try await connection.executeCommand([ + "SENTINEL", "get-master-addr-by-name", masterName, + ]) + + if case .error(let message) = reply { + logger.debug("Sentinel \(sentinel.host):\(sentinel.port) replied with error: \(message)") + throw RedisPluginError(code: 0, message: message) + } + + let tokens = Self.extractTokens(from: reply) + return try RedisSentinelResolver.parseMasterReplyTokens(tokens, from: sentinel) + } + + static func extractTokens(from reply: RedisReply) -> [String?]? { + switch reply { + case .null: + return nil + case .array(let items): + if items.isEmpty { return nil } + return items.map { item -> String? in + if case .null = item { return nil } + return item.stringValue + } + default: + return [reply.stringValue] + } + } +} + +private extension String { + var nonEmptyOrNil: String? { isEmpty ? nil : self } +} diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index e4ffa17ea..4df236fc5 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -13,7 +13,7 @@ import TableProPluginKit final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "Redis Driver" - static let pluginVersion = "1.0.0" + static let pluginVersion = "1.1.0" static let pluginDescription = "Redis support via hiredis" static let capabilities: [PluginCapability] = [.databaseDriver] @@ -22,6 +22,46 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { static let iconName = "redis-icon" static let defaultPort = 6379 static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "redisMode", + label: String(localized: "Connection Mode"), + defaultValue: "single", + fieldType: .dropdown(options: [ + .init(value: "single", label: String(localized: "Single Node")), + .init(value: "sentinel", label: String(localized: "Sentinel")), + ]), + section: .connection + ), + ConnectionField( + id: "redisSentinelHosts", + label: String(localized: "Sentinel Nodes"), + placeholder: "127.0.0.1:26379", + required: true, + fieldType: .hostList, + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"]) + ), + ConnectionField( + id: "redisSentinelMasterName", + label: String(localized: "Master Group Name"), + placeholder: "mymaster", + defaultValue: "mymaster", + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"]) + ), + ConnectionField( + id: "redisSentinelUsername", + label: String(localized: "Sentinel User"), + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"]) + ), + ConnectionField( + id: "redisSentinelPassword", + label: String(localized: "Sentinel Password"), + fieldType: .secure, + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: "redisMode", values: ["sentinel"]) + ), ConnectionField( id: "redisDatabase", label: String(localized: "Database Index"), diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 1681d6511..cf5f748bf 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -65,10 +65,11 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func connect() async throws { let sslConfig = RedisSSLConfig(additionalFields: config.additionalFields) let redisDb = Int(config.additionalFields["redisDatabase"] ?? "") ?? Int(config.database) ?? 0 + let (host, port) = try await resolveDataPlaneAddress(sslConfig: sslConfig) let conn = RedisPluginConnection( - host: config.host, - port: config.port, + host: host, + port: port, username: config.username.isEmpty ? nil : config.username, password: config.password.isEmpty ? nil : config.password, database: redisDb, @@ -79,6 +80,68 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { redisConnection = conn } + private func resolveDataPlaneAddress(sslConfig: RedisSSLConfig) async throws -> (String, Int) { + let mode = config.additionalFields["redisMode"] ?? "single" + switch mode { + case "sentinel": + return try await resolveSentinelMaster(sslConfig: sslConfig) + default: + return (config.host, config.port) + } + } + + private func resolveSentinelMaster(sslConfig: RedisSSLConfig) async throws -> (String, Int) { + let hostsRaw = config.additionalFields["redisSentinelHosts"] ?? "" + let sentinels = RedisSentinelResolver.parseSentinelHostList(hostsRaw, defaultPort: 26_379) + let masterName = (config.additionalFields["redisSentinelMasterName"] ?? "") + .trimmingCharacters(in: .whitespaces) + let sentinelUsername = config.additionalFields["redisSentinelUsername"] + .map { $0.trimmingCharacters(in: .whitespaces) } + .flatMap { $0.isEmpty ? nil : $0 } + let sentinelPassword = config.additionalFields["redisSentinelPassword"] + .flatMap { $0.isEmpty ? nil : $0 } + + let resolver = RedisSentinelResolver( + sentinels: sentinels, + masterName: masterName, + sentinelUsername: sentinelUsername, + sentinelPassword: sentinelPassword, + transport: HiredisSentinelTransport(sslConfig: sslConfig) + ) + + do { + let address = try await resolver.resolveMaster() + Self.logger.info("Sentinel resolved master \(masterName) to \(address.host):\(address.port)") + return (address.host, address.port) + } catch let error as RedisSentinelResolutionError { + throw Self.makeSentinelError(error) + } + } + + private static func makeSentinelError(_ error: RedisSentinelResolutionError) -> RedisPluginError { + switch error { + case .noSentinelsConfigured: + return sentinelError(String(localized: "Sentinel mode requires at least one Sentinel node.")) + case .emptyMasterName: + return sentinelError(String(localized: "Sentinel mode requires a master name.")) + case .masterUnknown(let name, let tried): + let triedList = tried.map { "\($0.host):\($0.port)" }.joined(separator: ", ") + let template = String(localized: "None of the configured Sentinels know master \"%@\". Tried: %@") + return sentinelError(String(format: template, name, triedList)) + case .allSentinelsUnreachable(let attempts): + let attemptsList = attempts.map { "\($0.host):\($0.port)" }.joined(separator: ", ") + let template = String(localized: "All Sentinels were unreachable. Tried: %@") + return sentinelError(String(format: template, attemptsList)) + case .malformedReply(let sentinel, let detail): + let template = String(localized: "Malformed Sentinel reply from %@:%d (%@)") + return sentinelError(String(format: template, sentinel.host, sentinel.port, detail)) + } + } + + private static func sentinelError(_ message: String) -> RedisPluginError { + RedisPluginError(code: 0, message: message) + } + func disconnect() { redisConnection?.disconnect() redisConnection = nil diff --git a/Plugins/RedisDriverPlugin/RedisSentinelResolver.swift b/Plugins/RedisDriverPlugin/RedisSentinelResolver.swift new file mode 100644 index 000000000..bdf27eaea --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisSentinelResolver.swift @@ -0,0 +1,159 @@ +// +// RedisSentinelResolver.swift +// RedisDriverPlugin +// +// Resolves the current Redis master address by querying a list of Sentinel nodes. +// Pure Swift; transport I/O is abstracted behind SentinelTransport so the resolution +// algorithm is unit-testable without hiredis. +// + +import Foundation + +struct SentinelHostPort: Equatable, Sendable, Hashable { + let host: String + let port: Int +} + +enum SentinelMasterReply: Equatable, Sendable { + case masterUnknown + case address(SentinelHostPort) +} + +enum RedisSentinelResolutionError: Error, Equatable { + case noSentinelsConfigured + case emptyMasterName + case masterUnknown(masterName: String, triedSentinels: [SentinelHostPort]) + case allSentinelsUnreachable(attempts: [SentinelHostPort]) + case malformedReply(SentinelHostPort, detail: String) +} + +protocol SentinelTransport: Sendable { + func queryMasterAddress( + masterName: String, + at sentinel: SentinelHostPort, + sentinelUsername: String?, + sentinelPassword: String? + ) async throws -> SentinelMasterReply +} + +final class RedisSentinelResolver: @unchecked Sendable { + private let sentinels: [SentinelHostPort] + private let masterName: String + private let sentinelUsername: String? + private let sentinelPassword: String? + private let transport: SentinelTransport + + init( + sentinels: [SentinelHostPort], + masterName: String, + sentinelUsername: String?, + sentinelPassword: String?, + transport: SentinelTransport + ) { + self.sentinels = sentinels + self.masterName = masterName + self.sentinelUsername = sentinelUsername + self.sentinelPassword = sentinelPassword + self.transport = transport + } + + func resolveMaster() async throws -> SentinelHostPort { + guard !sentinels.isEmpty else { + throw RedisSentinelResolutionError.noSentinelsConfigured + } + guard !masterName.isEmpty else { + throw RedisSentinelResolutionError.emptyMasterName + } + + var unreachable: [SentinelHostPort] = [] + var saidUnknown: [SentinelHostPort] = [] + + for sentinel in sentinels { + do { + let reply = try await transport.queryMasterAddress( + masterName: masterName, + at: sentinel, + sentinelUsername: sentinelUsername, + sentinelPassword: sentinelPassword + ) + switch reply { + case .address(let address): + return address + case .masterUnknown: + saidUnknown.append(sentinel) + } + } catch { + unreachable.append(sentinel) + } + } + + if !saidUnknown.isEmpty { + throw RedisSentinelResolutionError.masterUnknown( + masterName: masterName, + triedSentinels: saidUnknown + ) + } + throw RedisSentinelResolutionError.allSentinelsUnreachable(attempts: unreachable) + } + + static func parseMasterReplyTokens( + _ tokens: [String?]?, + from sentinel: SentinelHostPort + ) throws -> SentinelMasterReply { + guard let tokens else { + return .masterUnknown + } + guard tokens.count == 2 else { + throw RedisSentinelResolutionError.malformedReply( + sentinel, + detail: "expected 2-element array, got \(tokens.count)" + ) + } + guard let host = tokens[0], !host.isEmpty else { + throw RedisSentinelResolutionError.malformedReply(sentinel, detail: "missing host") + } + guard let portString = tokens[1], let port = parsePort(portString) else { + throw RedisSentinelResolutionError.malformedReply( + sentinel, + detail: "invalid port \(tokens[1] ?? "nil")" + ) + } + return .address(SentinelHostPort(host: host, port: port)) + } + + static func parseSentinelHostList(_ raw: String, defaultPort: Int) -> [SentinelHostPort] { + raw.split(separator: ",").compactMap { part in + let trimmed = part.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + return parseSingleHost(trimmed, defaultPort: defaultPort) + } + } + + private static func parsePort(_ string: String) -> Int? { + guard let port = Int(string), (1...65_535).contains(port) else { return nil } + return port + } + + private static func parseSingleHost(_ entry: String, defaultPort: Int) -> SentinelHostPort? { + if entry.hasPrefix("[") { + guard let closing = entry.firstIndex(of: "]") else { return nil } + let host = String(entry[entry.index(after: entry.startIndex).. SentinelMasterReply + + private let factory: ReplyFactory + private(set) var calls: [SentinelHostPort] = [] + + init(factory: @escaping ReplyFactory) { + self.factory = factory + } + + func queryMasterAddress( + masterName: String, + at sentinel: SentinelHostPort, + sentinelUsername: String?, + sentinelPassword: String? + ) async throws -> SentinelMasterReply { + calls.append(sentinel) + return try await factory(sentinel) + } + + func recordedCalls() -> [SentinelHostPort] { calls } +} + +private struct StubError: Error, Equatable { + let label: String +} + +private let sentinelA = SentinelHostPort(host: "10.0.0.1", port: 26_379) +private let sentinelB = SentinelHostPort(host: "10.0.0.2", port: 26_379) +private let sentinelC = SentinelHostPort(host: "10.0.0.3", port: 26_379) + +@Suite("Redis Sentinel Resolver - iteration") +struct RedisSentinelResolverIterationTests { + @Test("Returns the first sentinel's reply when it has an address") + func returnsFirstSentinelReply() async throws { + let master = SentinelHostPort(host: "10.0.0.5", port: 6_379) + let transport = FakeSentinelTransport { _ in .address(master) } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + let resolved = try await resolver.resolveMaster() + + #expect(resolved == master) + let calls = await transport.recordedCalls() + #expect(calls == [sentinelA]) + } + + @Test("Falls over to the next sentinel when earlier ones throw") + func failsOverToNextSentinel() async throws { + let master = SentinelHostPort(host: "10.0.0.5", port: 6_379) + let transport = FakeSentinelTransport { sentinel in + if sentinel == sentinelA { throw StubError(label: "down") } + return .address(master) + } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB, sentinelC], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + let resolved = try await resolver.resolveMaster() + + #expect(resolved == master) + let calls = await transport.recordedCalls() + #expect(calls == [sentinelA, sentinelB]) + } + + @Test("All sentinels throwing produces allSentinelsUnreachable with full list") + func allSentinelsUnreachable() async throws { + let transport = FakeSentinelTransport { _ in throw StubError(label: "down") } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB, sentinelC], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.allSentinelsUnreachable( + attempts: [sentinelA, sentinelB, sentinelC] + )) { + _ = try await resolver.resolveMaster() + } + } + + @Test("All sentinels saying masterUnknown produces masterUnknown, not unreachable") + func masterUnknownTakesPrecedenceOverUnreachable() async throws { + let transport = FakeSentinelTransport { _ in .masterUnknown } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.masterUnknown( + masterName: "mymaster", + triedSentinels: [sentinelA, sentinelB] + )) { + _ = try await resolver.resolveMaster() + } + } + + @Test("Mixed unknown and unreachable still surfaces as masterUnknown") + func mixedUnknownAndUnreachableSurfacesAsMasterUnknown() async throws { + let transport = FakeSentinelTransport { sentinel in + if sentinel == sentinelA { throw StubError(label: "down") } + return .masterUnknown + } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA, sentinelB], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.masterUnknown( + masterName: "mymaster", + triedSentinels: [sentinelB] + )) { + _ = try await resolver.resolveMaster() + } + } + + @Test("IPv6 master address passes through unchanged") + func ipv6MasterPassesThrough() async throws { + let master = SentinelHostPort(host: "fd00::1", port: 6_379) + let transport = FakeSentinelTransport { _ in .address(master) } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + let resolved = try await resolver.resolveMaster() + + #expect(resolved == master) + } + + @Test("Empty sentinel list short-circuits without calling transport") + func emptySentinelList() async throws { + let transport = FakeSentinelTransport { _ in + Issue.record("Transport should not be invoked") + return .masterUnknown + } + let resolver = RedisSentinelResolver( + sentinels: [], + masterName: "mymaster", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.noSentinelsConfigured) { + _ = try await resolver.resolveMaster() + } + let calls = await transport.recordedCalls() + #expect(calls.isEmpty) + } + + @Test("Empty master name short-circuits without calling transport") + func emptyMasterName() async throws { + let transport = FakeSentinelTransport { _ in + Issue.record("Transport should not be invoked") + return .masterUnknown + } + let resolver = RedisSentinelResolver( + sentinels: [sentinelA], + masterName: "", + sentinelUsername: nil, + sentinelPassword: nil, + transport: transport + ) + + await #expect(throws: RedisSentinelResolutionError.emptyMasterName) { + _ = try await resolver.resolveMaster() + } + let calls = await transport.recordedCalls() + #expect(calls.isEmpty) + } + + @Test("Sentinel credentials are forwarded to the transport") + func credentialsAreForwarded() async throws { + actor Capture { + var seenUsername: String? + var seenPassword: String? + func set(_ user: String?, _ pass: String?) { + seenUsername = user + seenPassword = pass + } + } + let capture = Capture() + + final class CapturingTransport: SentinelTransport, @unchecked Sendable { + let capture: Capture + init(capture: Capture) { self.capture = capture } + func queryMasterAddress( + masterName: String, + at sentinel: SentinelHostPort, + sentinelUsername: String?, + sentinelPassword: String? + ) async throws -> SentinelMasterReply { + await capture.set(sentinelUsername, sentinelPassword) + return .address(SentinelHostPort(host: "10.0.0.5", port: 6_379)) + } + } + + let resolver = RedisSentinelResolver( + sentinels: [sentinelA], + masterName: "mymaster", + sentinelUsername: "sentineluser", + sentinelPassword: "s3cret", + transport: CapturingTransport(capture: capture) + ) + + _ = try await resolver.resolveMaster() + + let user = await capture.seenUsername + let pass = await capture.seenPassword + #expect(user == "sentineluser") + #expect(pass == "s3cret") + } +} + +@Suite("Redis Sentinel Resolver - reply parsing") +struct RedisSentinelReplyParsingTests { + private let origin = SentinelHostPort(host: "10.0.0.1", port: 26_379) + + @Test("Two-element string array becomes an address") + func twoElementArray() throws { + let reply = try RedisSentinelResolver.parseMasterReplyTokens( + ["10.0.0.5", "6379"], + from: origin + ) + #expect(reply == .address(SentinelHostPort(host: "10.0.0.5", port: 6_379))) + } + + @Test("Nil tokens means master unknown") + func nilTokensMeansUnknown() throws { + let reply = try RedisSentinelResolver.parseMasterReplyTokens(nil, from: origin) + #expect(reply == .masterUnknown) + } + + @Test("Wrong arity throws malformedReply") + func wrongArityThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply( + origin, + detail: "expected 2-element array, got 1" + )) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["10.0.0.5"], from: origin) + } + } + + @Test("Non-numeric port throws malformedReply") + func nonNumericPortThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply( + origin, + detail: "invalid port banana" + )) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["10.0.0.5", "banana"], from: origin) + } + } + + @Test("Port out of range throws malformedReply") + func portOutOfRangeThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply( + origin, + detail: "invalid port 70000" + )) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["10.0.0.5", "70000"], from: origin) + } + } + + @Test("Empty host throws malformedReply") + func emptyHostThrows() { + #expect(throws: RedisSentinelResolutionError.malformedReply(origin, detail: "missing host")) { + _ = try RedisSentinelResolver.parseMasterReplyTokens(["", "6379"], from: origin) + } + } +} + +@Suite("Redis Sentinel Resolver - hostList parsing") +struct RedisSentinelHostListParsingTests { + @Test("Comma-separated host:port entries parse in order") + func basicCommaSeparated() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "10.0.0.1:26379,10.0.0.2:26379", + defaultPort: 26_379 + ) + #expect(parsed == [ + SentinelHostPort(host: "10.0.0.1", port: 26_379), + SentinelHostPort(host: "10.0.0.2", port: 26_379), + ]) + } + + @Test("Entries without an explicit port get the default") + func defaultPortApplied() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "sentinel-a,sentinel-b:26380", + defaultPort: 26_379 + ) + #expect(parsed == [ + SentinelHostPort(host: "sentinel-a", port: 26_379), + SentinelHostPort(host: "sentinel-b", port: 26_380), + ]) + } + + @Test("Whitespace around entries is trimmed; empty segments are skipped") + func whitespaceAndEmpties() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + " 10.0.0.1:26379 , ,10.0.0.2 ", + defaultPort: 26_379 + ) + #expect(parsed == [ + SentinelHostPort(host: "10.0.0.1", port: 26_379), + SentinelHostPort(host: "10.0.0.2", port: 26_379), + ]) + } + + @Test("IPv6 address in brackets with port") + func ipv6Bracketed() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "[fd00::1]:26379", + defaultPort: 26_379 + ) + #expect(parsed == [SentinelHostPort(host: "fd00::1", port: 26_379)]) + } + + @Test("IPv6 address in brackets without port uses default") + func ipv6BracketedDefaultPort() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "[fd00::1]", + defaultPort: 26_379 + ) + #expect(parsed == [SentinelHostPort(host: "fd00::1", port: 26_379)]) + } + + @Test("Bare IPv6 address with multiple colons falls through to host-only") + func bareIpv6FallsThroughAsHostOnly() { + let parsed = RedisSentinelResolver.parseSentinelHostList( + "fd00::1", + defaultPort: 26_379 + ) + #expect(parsed == [SentinelHostPort(host: "fd00::1", port: 26_379)]) + } +} diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index bdbc36267..d11036c71 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -22,8 +22,9 @@ TablePro supports Redis 6.0 and later. Keys are grouped by colon-separated names | Field | Default | Notes | |-------|---------|-------| -| **Host** | `localhost` | | -| **Port** | `6379` | | +| **Connection Mode** | `Single Node` | Choose `Sentinel` for HA deployments | +| **Host** | `localhost` | Single Node only | +| **Port** | `6379` | Single Node only | | **Password** | - | Leave empty for local dev | | **Database** | `0` | 0-15 | | **Key Separator** | `:` | Groups keys by prefix in sidebar | @@ -32,6 +33,27 @@ TablePro supports Redis 6.0 and later. Keys are grouped by colon-separated names Open URLs like `redis://:password@host:6379/0` or `rediss://` (TLS) from your browser. See [Connection URL Reference](/databases/connection-urls#redis). +## Connection Modes + +### Single Node + +Default. Connects directly to one Redis instance using the Host and Port fields. + +### Sentinel + +For HA deployments fronted by [Redis Sentinel](https://redis.io/docs/management/sentinel/). TablePro queries the Sentinel quorum for the current master address, then opens the data connection to it. On a dropped connection, the health monitor re-queries Sentinel and reconnects to whichever node is the new master. + +| Field | Notes | +|-------|-------| +| **Sentinel Nodes** | One or more `host:port` entries. Default port `26379`. | +| **Master Group Name** | The name configured in `sentinel.conf` (defaults to `mymaster`). | +| **Sentinel User** | Optional. Only set if Sentinel has its own ACL user (Redis 6.2+). | +| **Sentinel Password** | Optional. Only set if Sentinel has its own AUTH. | + +The Host and Port fields are ignored in Sentinel mode. The Username and Password under the Authentication section are used for the data plane (master and replicas), which is assumed to share one credential across the master set. + +If every Sentinel is unreachable, or none of them know the master name, you get a connection error naming which Sentinels were tried. + ## Example Configurations **Local**: host `localhost:6379`, no password @@ -99,6 +121,6 @@ DBSIZE **Timeout**: Verify host/port, check network and firewall, whitelist IP for cloud-hosted Redis. -**Limitations**: Cluster mode unsupported, Pub/Sub and Streams limited in grid (work in CLI), large keys paginated. +**Limitations**: Cluster mode not yet supported (planned, see [issue #1021](https://github.com/TableProApp/TablePro/issues/1021)), Pub/Sub and Streams limited in grid (work in CLI), large keys paginated. **Performance**: Use namespace browsing for filtering, use `SCAN` instead of `KEYS` in CLI, check memory with `INFO memory`.