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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The table structure view has a Triggers tab for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. It lists each trigger with its timing and event (plus enabled state where the engine reports it), with a filter field and sortable columns. Selecting a trigger shows its full definition in a read-only syntax-highlighted viewer. (#1695)
- Traditional Chinese (繁體中文) language in Settings > General with full UI translation

### Changed

- Selecting a Redis namespace in the sidebar key tree now filters the open database view to that prefix, with paging, instead of opening a separate tab limited to one batch of keys. (#1701)

### Fixed

- Redis entries no longer disappear after the connection sits idle. The health check was running `SELECT 1`, which on Redis switches the active database, so a later refresh scanned the wrong database. (#1701)
- Redis key browsing now lists every key in a database or namespace and pages through them correctly. It was reading only the first SCAN batch, so large keyspaces showed a partial, fixed set of keys. (#1701)
- A dropped Redis connection now reconnects on the next command and replays auth and the selected database, instead of failing until the next health check. (#1701)
- DuckDB VARIANT columns now show their value as text instead of an empty cell.

## [0.51.1] - 2026-06-16
Expand Down
184 changes: 113 additions & 71 deletions Plugins/RedisDriverPlugin/RedisPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,68 +171,15 @@ final class RedisPluginConnection: @unchecked Sendable {
try await pluginDispatchAsync(on: queue) { [self] in
logger.debug("Connecting to Redis at \(self.host):\(self.port)")

let connectTimeout = timeval(tv_sec: 10, tv_usec: 0)
guard let ctx = redisConnectWithTimeout(host, Int32(port), connectTimeout) else {
logger.error("Failed to create Redis context")
throw RedisPluginError.connectionFailed
}

if ctx.pointee.err != 0 {
let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in
ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) }
}
logger.error("Redis connection error: \(errMsg)")
let errCode = Int(ctx.pointee.err)
redisFree(ctx)
throw RedisPluginError(code: errCode, message: errMsg)
}

let commandTimeout = timeval(tv_sec: 30, tv_usec: 0)
redisSetTimeout(ctx, commandTimeout)
redisEnableKeepAliveWithInterval(ctx, 60)

stateLock.lock()
self.context = ctx
stateLock.unlock()
try openContextSync(selectDatabase: database)

do {
if sslConfig.isEnabled {
try connectSSL(ctx)
}

if let password = password, !password.isEmpty {
let authArgs: [String]
if let username = username, !username.isEmpty {
authArgs = ["AUTH", username, password]
} else {
authArgs = ["AUTH", password]
}
let reply = try executeCommandSync(authArgs)
if case .error(let msg) = reply {
throw RedisPluginError(code: 1, message: "AUTH failed: \(msg)")
}
}

if database != 0 {
let reply = try executeCommandSync(["SELECT", String(database)])
if case .error(let msg) = reply {
throw RedisPluginError(code: 2, message: "SELECT \(database) failed: \(msg)")
}
}

let pingReply = try executeCommandSync(["PING"])
if case .error(let msg) = pingReply {
throw RedisPluginError(code: 3, message: "PING failed: \(msg)")
}
} catch {
stateLock.lock()
let handle = self.context
self.context = nil
let ssl = self.sslContext
self.sslContext = nil
stateLock.unlock()
if let handle { redisFree(handle) }
if let ssl { redisFreeSSLContext(ssl) }
freeContextSync()
throw error
}

Expand Down Expand Up @@ -335,7 +282,7 @@ final class RedisPluginConnection: @unchecked Sendable {
}
stateLock.unlock()
try checkCancelled()
let result = try executeCommandSync(args)
let result = try executeCommandSyncRetrying(args)
try checkCancelled()
return result
}
Expand All @@ -357,7 +304,7 @@ final class RedisPluginConnection: @unchecked Sendable {
}
stateLock.unlock()
try checkCancelled()
let results = try executePipelineSync(commands)
let results = try executePipelineSyncRetrying(commands)
try checkCancelled()
return results
}
Expand All @@ -381,7 +328,7 @@ final class RedisPluginConnection: @unchecked Sendable {
}
stateLock.unlock()
try checkCancelled()
let reply = try executeCommandSync(["SELECT", String(index)])
let reply = try executeCommandSyncRetrying(["SELECT", String(index)])
if case .error(let msg) = reply {
throw RedisPluginError(code: 2, message: "SELECT \(index) failed: \(msg)")
}
Expand Down Expand Up @@ -457,9 +404,7 @@ private extension RedisPluginConnection {
let result = redisInitiateSSLWithContext(ctx, ssl)
if result != REDIS_OK {
redisFreeSSLContext(ssl)
let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in
ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) }
}
let errMsg = Self.contextErrorMessage(ctx)
if let sslError = Self.classifySSLError(errMsg) {
throw sslError
}
Expand All @@ -470,6 +415,110 @@ private extension RedisPluginConnection {
logger.debug("SSL connection established")
}

static func contextErrorMessage(_ ctx: UnsafeMutablePointer<redisContext>) -> String {
withUnsafePointer(to: &ctx.pointee.errstr) { ptr in
ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) }
}
}

func openContextSync(selectDatabase dbIndex: Int) throws {
let connectTimeout = timeval(tv_sec: 10, tv_usec: 0)
guard let ctx = redisConnectWithTimeout(host, Int32(port), connectTimeout) else {
logger.error("Failed to create Redis context")
throw RedisPluginError.connectionFailed
}

if ctx.pointee.err != 0 {
let errMsg = Self.contextErrorMessage(ctx)
logger.error("Redis connection error: \(errMsg)")
let errCode = Int(ctx.pointee.err)
redisFree(ctx)
throw RedisPluginError(code: errCode, message: errMsg)
}

let commandTimeout = timeval(tv_sec: 30, tv_usec: 0)
redisSetTimeout(ctx, commandTimeout)
redisEnableKeepAliveWithInterval(ctx, 60)

stateLock.lock()
self.context = ctx
stateLock.unlock()

do {
if sslConfig.isEnabled {
try connectSSL(ctx)
}
try authenticateSync()
if dbIndex != 0 {
let reply = try executeCommandSync(["SELECT", String(dbIndex)])
if case .error(let msg) = reply {
throw RedisPluginError(code: 2, message: "SELECT \(dbIndex) failed: \(msg)")
}
}
} catch {
freeContextSync()
throw error
}
}

func authenticateSync() throws {
guard let password, !password.isEmpty else { return }
let authArgs: [String]
if let username, !username.isEmpty {
authArgs = ["AUTH", username, password]
} else {
authArgs = ["AUTH", password]
}
let reply = try executeCommandSync(authArgs)
if case .error(let msg) = reply {
throw RedisPluginError(code: 1, message: "AUTH failed: \(msg)")
}
}

func freeContextSync() {
stateLock.lock()
let handle = context
let ssl = sslContext
context = nil
sslContext = nil
stateLock.unlock()
if let handle { redisFree(handle) }
if let ssl { redisFreeSSLContext(ssl) }
}

func reconnectSync() throws {
guard !isShuttingDown else { throw RedisPluginError.notConnected }
let targetDatabase = currentDatabase()
logger.warning("Redis connection lost; reconnecting to \(self.host):\(self.port), database \(targetDatabase)")
freeContextSync()
try openContextSync(selectDatabase: targetDatabase)
Comment on lines +493 to +494

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep retry path available after a failed reconnect

When a reconnect attempt fails because Redis is still unavailable, freeContextSync() has already cleared context; subsequent public methods hit the guard context != nil check and throw notConnected before executeCommandSyncRetrying can call reconnectSync again. In the common Redis restart case, the connection therefore remains unable to self-heal on the next command after the server is back, which is the path this change is trying to fix.

Useful? React with 👍 / 👎.

stateLock.lock()
_isConnected = true
stateLock.unlock()
}

func isConnectionError(_ error: RedisPluginError) -> Bool {
error.code == Int(REDIS_ERR_EOF) || error.code == Int(REDIS_ERR_IO)
}

func executeCommandSyncRetrying(_ args: [String]) throws -> RedisReply {
do {
return try executeCommandSync(args)
} catch let error as RedisPluginError where isConnectionError(error) && !isShuttingDown {
try reconnectSync()
return try executeCommandSync(args)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid replaying non-idempotent Redis commands

When an EOF/IO is reported after Redis has received a write but before the reply reaches the client, this unconditional retry sends the exact same command again. That affects non-idempotent operations routed through executeCommand (e.g. INCR, LPUSH, SADD, or commands inside a transaction), so a transient disconnect can double-apply data changes instead of surfacing an ambiguous failure. Please only reconnect and retry commands that are known safe/idempotent, or reconnect without replaying the failed mutation.

Useful? React with 👍 / 👎.

}
}

func executePipelineSyncRetrying(_ commands: [[String]]) throws -> [RedisReply] {
do {
return try executePipelineSync(commands)
} catch let error as RedisPluginError where isConnectionError(error) && !isShuttingDown {
try reconnectSync()
return try executePipelineSync(commands)
}
}

func executeCommandSync(_ args: [String]) throws -> RedisReply {
stateLock.lock()
guard let ctx = context else {
Expand All @@ -484,10 +533,7 @@ private extension RedisPluginConnection {
return try withArgvPointers(args: args, lengths: lengths) { argv, argvlen in
guard let rawReply = redisCommandArgv(ctx, argc, argv, argvlen) else {
if ctx.pointee.err != 0 {
let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in
ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) }
}
throw RedisPluginError(code: Int(ctx.pointee.err), message: errMsg)
throw RedisPluginError(code: Int(ctx.pointee.err), message: Self.contextErrorMessage(ctx))
}
throw RedisPluginError(code: -1, message: "No reply from Redis")
}
Expand Down Expand Up @@ -521,9 +567,7 @@ private extension RedisPluginConnection {
if let d = discard { freeReplyObject(d) }
}
let errCode = Int(ctx.pointee.err)
let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in
ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) }
}
let errMsg = Self.contextErrorMessage(ctx)
markDisconnected()
throw RedisPluginError(code: errCode, message: errMsg)
}
Expand All @@ -538,9 +582,7 @@ private extension RedisPluginConnection {
let status = redisGetReply(ctx, &rawReply)
guard status == REDIS_OK, let reply = rawReply else {
let errCode = Int(ctx.pointee.err)
let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in
ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) }
}
let errMsg = Self.contextErrorMessage(ctx)
for _ in (i + 1) ..< commands.count {
var discard: UnsafeMutableRawPointer?
if redisGetReply(ctx, &discard) == REDIS_OK, let d = discard {
Expand Down
2 changes: 0 additions & 2 deletions Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,6 @@ extension RedisPluginDriver {

case .select(let database):
try await conn.selectDatabase(database)
cachedScanPattern = nil
cachedScanKeys = nil
return buildStatusResult("OK", startTime: startTime)

case .configGet(let parameter):
Expand Down
16 changes: 2 additions & 14 deletions Plugins/RedisDriverPlugin/RedisPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

private static let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "RedisPluginDriver")

private static let maxScanKeys = PluginRowLimits.emergencyMax
static let maxKeyBrowseScan = 10_000

var cachedScanPattern: String?
var cachedScanKeys: [String]?

var serverVersion: String? {
redisConnection?.serverVersion()
}
Expand Down Expand Up @@ -83,8 +79,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func disconnect() {
redisConnection?.disconnect()
redisConnection = nil
cachedScanPattern = nil
cachedScanKeys = nil
}

func ping() async throws {
Expand All @@ -101,8 +95,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

func execute(query: String) async throws -> PluginQueryResult {
let startTime = Date()
cachedScanPattern = nil
cachedScanKeys = nil
redisConnection?.resetCancellation()

guard let conn = redisConnection else {
Expand All @@ -125,9 +117,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
redisConnection?.cancelCurrentQuery()
}

func applyQueryTimeout(_ seconds: Int) async throws {
// Redis does not support session-level query timeouts
}
func applyQueryTimeout(_ seconds: Int) async throws {}

// MARK: - Schema Operations

Expand Down Expand Up @@ -415,7 +405,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Streaming

func streamRows(query: String) -> AsyncThrowingStream<PluginStreamElement, Error> {
return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in
let streamTask = Task {
do {
try await self.performStreamRows(query: query, continuation: continuation)
Expand Down Expand Up @@ -582,7 +572,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

batchStart = batchEnd
}

} while cursor != "0"

continuation.finish()
Expand All @@ -604,7 +593,6 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
)
}

// Redis SCAN only supports key pattern matching; sortColumns, columns, and offset are unused
func buildFilteredQuery(
table: String,
filters: [(column: String, op: String, value: String)],
Expand Down
10 changes: 5 additions & 5 deletions Plugins/RedisDriverPlugin/RedisQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ import TableProPluginKit
struct RedisQueryBuilder {
// MARK: - Base Query

/// Build a SCAN command for browsing keys in a namespace.
/// Returns: SCAN 0 MATCH namespace:* COUNT limit
/// Build a key-browse command for a namespace. KEYBROWSE iterates the SCAN cursor
/// to completion (bounded) and honors LIMIT/OFFSET, so paging returns every key.
func buildBaseQuery(
namespace: String,
sortColumns: [(columnIndex: Int, ascending: Bool)] = [],
columns: [String] = [],
limit: Int = 200,
offset: Int = 0
) -> String {
let pattern = namespace.isEmpty ? "*" : "\(namespace)*"
return "SCAN 0 MATCH \"\(pattern)\" COUNT \(limit)"
let pattern = namespace.isEmpty ? nil : "\(namespace)*"
return buildKeyBrowseQuery(pattern: pattern, typeScope: nil, limit: limit, offset: offset)
}

/// Build a key-browse command from filter tuples.
Expand All @@ -40,7 +40,7 @@ struct RedisQueryBuilder {
let typeScope = extractTypeScope(from: filters)

guard pattern != nil || typeScope != nil else {
return buildBaseQuery(namespace: namespace, limit: limit)
return buildBaseQuery(namespace: namespace, limit: limit, offset: offset)
}

return buildKeyBrowseQuery(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset)
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ protocol DatabaseDriver: AnyObject {
/// Test the connection (connect and immediately disconnect)
func testConnection() async throws -> Bool

/// Check the connection is alive without mutating session state
func ping() async throws

// MARK: - Configuration

/// Apply query execution timeout (seconds, 0 = no limit)
Expand Down Expand Up @@ -249,6 +252,10 @@ extension DatabaseDriver {

func fetchTriggers(table: String) async throws -> [TriggerInfo] { [] }

func ping() async throws {
_ = try await execute(query: "SELECT 1")
}

func testConnection() async throws -> Bool {
try await connect()
disconnect()
Expand Down
Loading
Loading