Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions Plugins/RedisDriverPlugin/RedisCommandChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 8 additions & 5 deletions Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -50,7 +51,8 @@ extension RedisPluginDriver {
rows: rows,
rowsAffected: 0,
executionTime: Date().timeIntervalSince(startTime),
isTruncated: isTruncated
isTruncated: isTruncated,
statusMessage: statusMessage
)
}

Expand Down Expand Up @@ -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
)
}

Expand Down
9 changes: 6 additions & 3 deletions Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
18 changes: 14 additions & 4 deletions Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}
}
}
3 changes: 3 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions TableProTests/Plugins/RedisConnectionModeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
79 changes: 79 additions & 0 deletions TableProTests/Plugins/RedisKeyspacePageTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading