diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b580eeb02..a8823dcefc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off. - **Local only** connections taking edits and deletions made on another device. +- Redis Cluster through a tunnel failing to connect with advice to set Connection Mode to Cluster. +- Redis `SCAN` typed in a query tab showing one page of keys with no next cursor to continue from. - etcd `lease revoke`, `auth disable` and user or role deletion skipping confirmation, and list commands gated as writes. - Mac Alert (Full), Safe Mode and Safe Mode (Full) levels dropped to Alert by any edit to the connection on iOS. - `pg_terminate_backend`, `nextval` and other state-changing calls treated as reads by Safe Mode and external clients. diff --git a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift index 7165dbae0a..1fdd1fac5f 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift @@ -20,6 +20,23 @@ struct RedisKeyspacePage: Sendable { let isIncomplete: Bool var isFinished: Bool { cursor == RedisClusterCursor.start } + + var nextCursorNotice: String? { + guard !isFinished else { return nil } + return String(format: String(localized: "Next cursor: %@. The scan has not finished."), cursor) + } +} + +struct RedisScanPageOutcome: Equatable, Sendable { + let keys: [String] + let isTruncated: Bool + let statusMessage: String? + + init(page: RedisKeyspacePage, rowLimit: Int) { + keys = Array(page.keys.prefix(rowLimit)) + isTruncated = page.isIncomplete || page.keys.count > rowLimit + statusMessage = page.nextCursorNotice + } } protocol RedisCommandChannel: AnyObject, Sendable { diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift index 60dc2e4021..b7305ef957 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift @@ -37,10 +37,11 @@ extension RedisPluginDriver { keys: [String], connection conn: any RedisCommandChannel, startTime: Date, - isTruncated: Bool = false + isTruncated: Bool = false, + statusMessage: String? = nil ) async throws -> PluginQueryResult { guard !keys.isEmpty else { - return buildEmptyKeyResult(startTime: startTime) + return buildEmptyKeyResult(startTime: startTime, statusMessage: statusMessage) } let rows = try await buildKeySummaryRows(keys: keys, connection: conn) @@ -50,7 +51,8 @@ extension RedisPluginDriver { rows: rows, rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime), - isTruncated: isTruncated + isTruncated: isTruncated, + statusMessage: statusMessage ) } @@ -124,13 +126,14 @@ extension RedisPluginDriver { } } - func buildEmptyKeyResult(startTime: Date) -> PluginQueryResult { + func buildEmptyKeyResult(startTime: Date, statusMessage: String? = nil) -> PluginQueryResult { PluginQueryResult( columns: Self.keyBrowseColumns, columnTypeNames: Self.keyBrowseColumnTypeNames, rows: [], rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + statusMessage: statusMessage ) } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift index 6d4f25e4d4..2d6050c764 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift @@ -79,10 +79,13 @@ extension RedisPluginDriver { connection conn: any RedisCommandChannel, startTime: Date ) async throws -> PluginQueryResult { - let capped = Array(page.keys.prefix(PluginRowLimits.emergencyMax)) - let truncated = page.isIncomplete || page.keys.count > PluginRowLimits.emergencyMax + let outcome = RedisScanPageOutcome(page: page, rowLimit: PluginRowLimits.emergencyMax) return try await buildKeyBrowseResult( - keys: capped, connection: conn, startTime: startTime, isTruncated: truncated + keys: outcome.keys, + connection: conn, + startTime: startTime, + isTruncated: outcome.isTruncated, + statusMessage: outcome.statusMessage ) } } diff --git a/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift b/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift index b0169bc30f..35ab15d13a 100644 --- a/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift +++ b/Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift @@ -74,15 +74,16 @@ enum RedisTopologyDiagnostics { /// only thing left to check is whether this is the kind of node the mode expects. /// /// `isTunneled` matters because a tunnel rewrites the connection to Standalone against the - /// first address in the list. For Cluster that address is a data node and everything works; - /// for Sentinel it is a Sentinel, and telling the user to pick Sentinel mode would be advice - /// they have already followed. + /// first address in the list, whatever mode the user picked. For Cluster that address is a + /// data node that serves its own slots, and no mode choice can do better through one forwarded + /// port; for Sentinel it is a Sentinel, and telling the user to pick Sentinel mode would be + /// advice they have already followed. static func mismatch( expected: RedisConnectionMode, actual: RedisServerMode, isTunneled: Bool = false ) -> String? { - if isTunneled, actual == .sentinel { return sentinelThroughTunnelMessage } + if isTunneled { return tunneledMismatch(actual: actual) } switch (expected, actual) { case (.standalone, .cluster): return String( @@ -102,4 +103,13 @@ enum RedisTopologyDiagnostics { return nil } } + + private static func tunneledMismatch(actual: RedisServerMode) -> String? { + switch actual { + case .sentinel: + return sentinelThroughTunnelMessage + case .standalone, .cluster: + return nil + } + } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4732c63b49..fa8d08167b 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -184811,6 +184811,9 @@ }, "Verify CA needs a CA certificate. On the connection's Network tab, choose the CA certificate that signed the server's certificate, or set SSL Mode to Verify Identity." : { + }, + "Next cursor: %@. The scan has not finished." : { + } }, "version" : "1.1" diff --git a/TableProTests/Plugins/RedisConnectionModeTests.swift b/TableProTests/Plugins/RedisConnectionModeTests.swift index 4016a178c8..cc4b10def8 100644 --- a/TableProTests/Plugins/RedisConnectionModeTests.swift +++ b/TableProTests/Plugins/RedisConnectionModeTests.swift @@ -172,6 +172,11 @@ struct RedisTopologyDiagnosticsTests { @Test("A cluster seed through a tunnel is left alone, because it is a data node") func clusterThroughTunnelIsFine() { + #expect(RedisTopologyDiagnostics.mismatch(expected: .standalone, actual: .cluster, isTunneled: true) == nil) + } + + @Test("A standalone server through a tunnel is left alone") + func standaloneThroughTunnelIsFine() { #expect(RedisTopologyDiagnostics.mismatch(expected: .standalone, actual: .standalone, isTunneled: true) == nil) } diff --git a/TableProTests/Plugins/RedisKeyspacePageTests.swift b/TableProTests/Plugins/RedisKeyspacePageTests.swift new file mode 100644 index 0000000000..8023a0bb2d --- /dev/null +++ b/TableProTests/Plugins/RedisKeyspacePageTests.swift @@ -0,0 +1,79 @@ +// +// RedisKeyspacePageTests.swift +// TableProTests +// + +import Foundation +import Testing + +struct RedisKeyspacePageTests { + @Test("A page that stops mid-scan names the cursor to continue from") + func unfinishedPageNamesNextCursor() throws { + let page = RedisKeyspacePage(cursor: "17", keys: ["a"], isIncomplete: false) + let notice = try #require(page.nextCursorNotice) + #expect(notice.contains("17")) + } + + @Test("A page that ends the scan has nothing to continue") + func finishedPageHasNoNotice() { + let page = RedisKeyspacePage(cursor: RedisClusterCursor.start, keys: ["a"], isIncomplete: false) + #expect(page.nextCursorNotice == nil) + } + + @Test("An empty page mid-scan still names the cursor, because later pages can hold keys") + func emptyUnfinishedPageNamesNextCursor() throws { + let page = RedisKeyspacePage(cursor: "4096", keys: [], isIncomplete: false) + let notice = try #require(page.nextCursorNotice) + #expect(notice.contains("4096")) + } + + @Test("A cluster cursor is named whole, so it can be passed back to SCAN") + func clusterCursorIsNamedWhole() throws { + let cursor = RedisClusterCursor.encode(nodeId: "07c37dfeb235213a872192d90877d0cd55635b91", nodeCursor: "28") + let page = RedisKeyspacePage(cursor: cursor, keys: ["a"], isIncomplete: false) + let notice = try #require(page.nextCursorNotice) + #expect(notice.contains(cursor)) + } + + @Test("A page that stops mid-scan reaches the result as a status message, not as a truncated result") + func unfinishedPageOutcomeCarriesCursorWithoutTruncating() throws { + let page = RedisKeyspacePage(cursor: "17", keys: ["a", "b"], isIncomplete: false) + let outcome = RedisScanPageOutcome(page: page, rowLimit: 5_000_000) + let message = try #require(outcome.statusMessage) + #expect(message.contains("17")) + #expect(outcome.keys == ["a", "b"]) + #expect(!outcome.isTruncated) + } + + @Test("An empty page mid-scan still reaches the result with its cursor") + func emptyUnfinishedPageOutcomeCarriesCursor() throws { + let page = RedisKeyspacePage(cursor: "4096", keys: [], isIncomplete: false) + let outcome = RedisScanPageOutcome(page: page, rowLimit: 5_000_000) + let message = try #require(outcome.statusMessage) + #expect(message.contains("4096")) + #expect(outcome.keys.isEmpty) + } + + @Test("A page that ends the scan reaches the result with no status message") + func finishedPageOutcomeHasNoStatusMessage() { + let page = RedisKeyspacePage(cursor: RedisClusterCursor.start, keys: ["a"], isIncomplete: false) + let outcome = RedisScanPageOutcome(page: page, rowLimit: 5_000_000) + #expect(outcome.statusMessage == nil) + #expect(!outcome.isTruncated) + } + + @Test("A page over the row limit is capped and marked truncated") + func pageOverRowLimitIsCappedAndTruncated() { + let page = RedisKeyspacePage(cursor: "9", keys: ["a", "b", "c"], isIncomplete: false) + let outcome = RedisScanPageOutcome(page: page, rowLimit: 2) + #expect(outcome.keys == ["a", "b"]) + #expect(outcome.isTruncated) + } + + @Test("A page whose walk restarted a node is marked truncated") + func incompletePageIsTruncated() { + let page = RedisKeyspacePage(cursor: RedisClusterCursor.start, keys: ["a"], isIncomplete: true) + let outcome = RedisScanPageOutcome(page: page, rowLimit: 5_000_000) + #expect(outcome.isTruncated) + } +}