From fa95003baa36f7475845c2dc4a8100383c36d4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:28:18 +0700 Subject: [PATCH 01/11] feat(plugins): add HttpQueryTimeout helper to TableProPluginKit --- .../TableProPluginKit/HttpQueryTimeout.swift | 33 ++++++++++ .../Plugins/HttpQueryTimeoutTests.swift | 64 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 Plugins/TableProPluginKit/HttpQueryTimeout.swift create mode 100644 TableProTests/Plugins/HttpQueryTimeoutTests.swift diff --git a/Plugins/TableProPluginKit/HttpQueryTimeout.swift b/Plugins/TableProPluginKit/HttpQueryTimeout.swift new file mode 100644 index 000000000..ab4e1bd0e --- /dev/null +++ b/Plugins/TableProPluginKit/HttpQueryTimeout.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct HttpQueryTimeout: Sendable, Equatable { + public static let bootstrapSeconds: Int = 60 + public static let defaultGraceSeconds: Int = 30 + public static let resourceCeilingSeconds: Int = 3_600 + + public let serverTimeoutSeconds: Int + public let graceSeconds: Int + + public init( + serverTimeoutSeconds: Int = HttpQueryTimeout.bootstrapSeconds, + graceSeconds: Int = HttpQueryTimeout.defaultGraceSeconds + ) { + self.serverTimeoutSeconds = serverTimeoutSeconds + self.graceSeconds = max(graceSeconds, 0) + } + + public var requestTimeoutInterval: TimeInterval { + guard serverTimeoutSeconds > 0 else { + return TimeInterval(HttpQueryTimeout.resourceCeilingSeconds) + } + return TimeInterval(serverTimeoutSeconds + graceSeconds) + } + + public static var sessionResourceTimeout: TimeInterval { + TimeInterval(resourceCeilingSeconds) + } + + public static var sessionBootstrapRequestTimeout: TimeInterval { + TimeInterval(bootstrapSeconds) + } +} diff --git a/TableProTests/Plugins/HttpQueryTimeoutTests.swift b/TableProTests/Plugins/HttpQueryTimeoutTests.swift new file mode 100644 index 000000000..9e82e6228 --- /dev/null +++ b/TableProTests/Plugins/HttpQueryTimeoutTests.swift @@ -0,0 +1,64 @@ +// +// HttpQueryTimeoutTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("HttpQueryTimeout") +struct HttpQueryTimeoutTests { + @Test("Default values match the documented bootstrap policy") + func defaultsMatchBootstrap() { + let timeout = HttpQueryTimeout() + #expect(timeout.serverTimeoutSeconds == HttpQueryTimeout.bootstrapSeconds) + #expect(timeout.graceSeconds == HttpQueryTimeout.defaultGraceSeconds) + #expect(timeout.requestTimeoutInterval == TimeInterval(60 + 30)) + } + + @Test("Positive server timeout adds grace to request interval") + func positiveServerTimeoutAddsGrace() { + let timeout = HttpQueryTimeout(serverTimeoutSeconds: 600, graceSeconds: 30) + #expect(timeout.requestTimeoutInterval == TimeInterval(630)) + } + + @Test("Custom grace is honored") + func customGrace() { + let timeout = HttpQueryTimeout(serverTimeoutSeconds: 120, graceSeconds: 5) + #expect(timeout.requestTimeoutInterval == TimeInterval(125)) + } + + @Test("Zero server timeout falls back to the resource ceiling") + func zeroServerTimeoutUsesCeiling() { + let timeout = HttpQueryTimeout(serverTimeoutSeconds: 0) + #expect(timeout.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds)) + } + + @Test("Negative server timeout is treated as unlimited") + func negativeServerTimeoutUsesCeiling() { + let timeout = HttpQueryTimeout(serverTimeoutSeconds: -1) + #expect(timeout.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds)) + } + + @Test("Negative grace is clamped to zero") + func negativeGraceClamped() { + let timeout = HttpQueryTimeout(serverTimeoutSeconds: 60, graceSeconds: -10) + #expect(timeout.graceSeconds == 0) + #expect(timeout.requestTimeoutInterval == TimeInterval(60)) + } + + @Test("Static session timeouts expose the documented ceilings") + func staticSessionTimeouts() { + #expect(HttpQueryTimeout.sessionBootstrapRequestTimeout == TimeInterval(60)) + #expect(HttpQueryTimeout.sessionResourceTimeout == TimeInterval(3_600)) + } + + @Test("URLRequest.timeoutInterval can be assigned from the helper") + func appliesToUrlRequest() { + var request = URLRequest(url: URL(string: "https://example.com")!) + let timeout = HttpQueryTimeout(serverTimeoutSeconds: 300) + request.timeoutInterval = timeout.requestTimeoutInterval + #expect(request.timeoutInterval == TimeInterval(330)) + } +} From 8073ebedb0b025e068800868f9dec40076e8e5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:29:24 +0700 Subject: [PATCH 02/11] =?UTF-8?q?fix(coordinator):=20pass=20through=20Sett?= =?UTF-8?q?ings=20=E2=86=92=20Query=20timeout=20=3D=200=20to=20drivers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Database/DatabaseManager+Health.swift | 30 ++++++++----------- .../Database/DatabaseManager+Sessions.swift | 16 ++++------ 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index f7ee93088..d469f7d02 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -139,16 +139,13 @@ extension DatabaseManager { throw error } - // Apply timeout (best-effort) let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds - if timeoutSeconds > 0 { - do { - try await driver.applyQueryTimeout(timeoutSeconds) - } catch { - Self.logger.warning( - "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)" - ) - } + do { + try await driver.applyQueryTimeout(timeoutSeconds) + } catch { + Self.logger.warning( + "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)" + ) } await executeStartupCommands( @@ -237,16 +234,13 @@ extension DatabaseManager { ) try await driver.connect() - // Apply timeout (best-effort) let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds - if timeoutSeconds > 0 { - do { - try await driver.applyQueryTimeout(timeoutSeconds) - } catch { - Self.logger.warning( - "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)" - ) - } + do { + try await driver.applyQueryTimeout(timeoutSeconds) + } catch { + Self.logger.warning( + "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)" + ) } await executeStartupCommands( diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index c0d526721..97d72fa24 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -102,16 +102,12 @@ extension DatabaseManager { try await driver.connect() let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds - if timeoutSeconds > 0 { - do { - try await driver.applyQueryTimeout(timeoutSeconds) - } catch { - // Best-effort: some PostgreSQL-compatible databases like Aurora DSQL - // don't support SET statement_timeout. - Self.logger.warning( - "Query timeout not supported for \(connection.name): \(error.localizedDescription)" - ) - } + do { + try await driver.applyQueryTimeout(timeoutSeconds) + } catch { + Self.logger.warning( + "Query timeout not supported for \(connection.name): \(error.localizedDescription)" + ) } await executeStartupCommands( From 92b3fdacc3834cb174533dd62a9a611793ae6915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:30:30 +0700 Subject: [PATCH 03/11] =?UTF-8?q?fix(plugin-clickhouse):=20respect=20Setti?= =?UTF-8?q?ngs=20=E2=86=92=20Query=20timeout=20in=20HTTP=20transport=20(#1?= =?UTF-8?q?267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClickHousePlugin.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 90e48ba27..58b2b5236 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -145,6 +145,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private var currentTask: URLSessionDataTask? private var _currentDatabase: String private var _lastQueryId: String? + private var _queryTimeout = HttpQueryTimeout() private static let logger = Logger(subsystem: "com.TablePro", category: "ClickHousePluginDriver") @@ -196,8 +197,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func connect() async throws { let urlConfig = URLSessionConfiguration.default - urlConfig.timeoutIntervalForRequest = 30 - urlConfig.timeoutIntervalForResource = 300 + urlConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout + urlConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout lock.lock() if let delegate = ClickHouseTLSDelegate.make(for: config.ssl) { @@ -732,6 +733,9 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func applyQueryTimeout(_ seconds: Int) async throws { + lock.lock() + _queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) + lock.unlock() guard seconds > 0 else { return } _ = try await execute(query: "SET max_execution_time = \(seconds)") } @@ -800,9 +804,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { if let queryId { _lastQueryId = queryId } + let timeoutInterval = _queryTimeout.requestTimeoutInterval lock.unlock() - let request = try buildRequest(query: query, database: database, queryId: queryId) + var request = try buildRequest(query: query, database: database, queryId: queryId) + request.timeoutInterval = timeoutInterval let isSelect = Self.isSelectLikeQuery(query) let (data, response) = try await withTaskCancellationHandler { @@ -859,9 +865,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { if let queryId { _lastQueryId = queryId } + let timeoutInterval = _queryTimeout.requestTimeoutInterval lock.unlock() - let request = try buildRequest(query: query, database: database, queryId: queryId, params: params) + var request = try buildRequest(query: query, database: database, queryId: queryId, params: params) + request.timeoutInterval = timeoutInterval let isSelect = Self.isSelectLikeQuery(query) let (data, response) = try await withTaskCancellationHandler { From f874c8b82bdce61af69ec0a6b2b05fe826ced3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:31:42 +0700 Subject: [PATCH 04/11] =?UTF-8?q?fix(plugin-bigquery):=20respect=20Setting?= =?UTF-8?q?s=20=E2=86=92=20Query=20timeout=20in=20HTTP=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BigQueryConnection.swift | 16 +++++++++++----- Plugins/BigQueryDriverPlugin/Info.plist | 2 ++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift index d28597aa9..65f1d6ea9 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift @@ -284,6 +284,7 @@ internal final class BigQueryConnection: @unchecked Sendable { private var _currentJobId: String? private var _currentJobLocation: String? private var _queryTimeoutSeconds: Int = 300 + private var _queryTimeout = HttpQueryTimeout() private let location: String? private static let logger = Logger(subsystem: "com.TablePro", category: "BigQueryConnection") private static let baseUrl = "https://bigquery.googleapis.com/bigquery/v2" @@ -293,7 +294,10 @@ internal final class BigQueryConnection: @unchecked Sendable { } func setQueryTimeout(_ seconds: Int) { - lock.withLock { _queryTimeoutSeconds = max(seconds, 30) } + lock.withLock { + _queryTimeoutSeconds = max(seconds, 30) + _queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) + } } init(config: DriverConnectionConfig) { @@ -306,8 +310,8 @@ internal final class BigQueryConnection: @unchecked Sendable { let authProvider = try createAuthProvider() let sessionConfig = URLSessionConfiguration.default - sessionConfig.timeoutIntervalForRequest = 60 - sessionConfig.timeoutIntervalForResource = 300 + sessionConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout + sessionConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout let urlSession = URLSession(configuration: sessionConfig) lock.withLock { @@ -836,8 +840,10 @@ internal final class BigQueryConnection: @unchecked Sendable { _ request: URLRequest, session: URLSession ) async throws -> (Data, URLResponse) { - try await withCheckedThrowingContinuation { continuation in - let task = session.dataTask(with: request) { [weak self] data, response, error in + var timedRequest = request + timedRequest.timeoutInterval = lock.withLock { _queryTimeout.requestTimeoutInterval } + return try await withCheckedThrowingContinuation { continuation in + let task = session.dataTask(with: timedRequest) { [weak self] data, response, error in self?.lock.withLock { self?._currentTask = nil } if let error { if (error as? URLError)?.code == .cancelled { diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index f0137ccd7..b542d52ba 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -4,5 +4,7 @@ TableProPluginKitVersion 12 + TableProMinAppVersion + 0.42.0 From 8f10d71124976f569e074a38f5ba763c6ddf64ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:32:54 +0700 Subject: [PATCH 05/11] =?UTF-8?q?fix(plugin-dynamodb):=20respect=20Setting?= =?UTF-8?q?s=20=E2=86=92=20Query=20timeout=20in=20HTTP=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift | 11 +++++++++-- .../DynamoDBDriverPlugin/DynamoDBPluginDriver.swift | 4 +++- Plugins/DynamoDBDriverPlugin/Info.plist | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 6dde1b281..842caa17c 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -9,6 +9,7 @@ import CommonCrypto import Foundation import os import TableProPluginKit +import TableProPluginKit // MARK: - DynamoDB Attribute Value @@ -261,6 +262,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { private var _session: URLSession? private var _credentials: AWSCredentials? private var _currentTask: URLSessionDataTask? + private var _queryTimeout = HttpQueryTimeout() private let region: String private let endpointUrl: String private static let logger = Logger(subsystem: "com.TablePro", category: "DynamoDBConnection") @@ -270,6 +272,10 @@ internal final class DynamoDBConnection: @unchecked Sendable { lock.withLock { _session } } + func setQueryTimeout(_ seconds: Int) { + lock.withLock { _queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) } + } + init(config: DriverConnectionConfig) { self.config = config self.region = config.additionalFields["awsRegion"] ?? "us-east-1" @@ -298,8 +304,8 @@ internal final class DynamoDBConnection: @unchecked Sendable { func connect() async throws { let credentials = try resolveCredentials() let sessionConfig = URLSessionConfiguration.default - sessionConfig.timeoutIntervalForRequest = 30 - sessionConfig.timeoutIntervalForResource = 60 + sessionConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout + sessionConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout let urlSession = URLSession(configuration: sessionConfig) lock.withLock { @@ -442,6 +448,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { urlRequest.setValue(hostHeader, forHTTPHeaderField: "Host") signRequest(&urlRequest, body: bodyData, credentials: credentials) + urlRequest.timeoutInterval = lock.withLock { _queryTimeout.requestTimeoutInterval } let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift index 78c99a243..17423515a 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift @@ -197,7 +197,9 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send connection?.cancelCurrentRequest() } - func applyQueryTimeout(_ seconds: Int) async throws {} + func applyQueryTimeout(_ seconds: Int) async throws { + connection?.setQueryTimeout(seconds) + } // MARK: - Schema Operations diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index f0137ccd7..b542d52ba 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -4,5 +4,7 @@ TableProPluginKitVersion 12 + TableProMinAppVersion + 0.42.0 From c3f7b73498967875e2dbd15af48626c2e9a54068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:34:10 +0700 Subject: [PATCH 06/11] =?UTF-8?q?fix(plugin-etcd):=20respect=20Settings=20?= =?UTF-8?q?=E2=86=92=20Query=20timeout=20in=20HTTP=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/EtcdDriverPlugin/EtcdHttpClient.swift | 13 +++++++++++-- Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift | 4 +++- Plugins/EtcdDriverPlugin/Info.plist | 2 ++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index 5e66500a9..6ade1493e 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -315,6 +315,13 @@ internal final class EtcdHttpClient: @unchecked Sendable { private var authToken: String? private var _isAuthenticating = false private var apiPrefix = "v3" + private var queryTimeout = HttpQueryTimeout() + + func setQueryTimeout(_ seconds: Int) { + lock.lock() + queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) + lock.unlock() + } private static let logger = Logger(subsystem: "com.TablePro", category: "EtcdHttpClient") @@ -347,8 +354,8 @@ internal final class EtcdHttpClient: @unchecked Sendable { let tlsMode = config.additionalFields["etcdTlsMode"] ?? "Disabled" let urlConfig = URLSessionConfiguration.default - urlConfig.timeoutIntervalForRequest = 30 - urlConfig.timeoutIntervalForResource = 300 + urlConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout + urlConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout let delegate: URLSessionDelegate? switch tlsMode { @@ -710,6 +717,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { } let token = authToken let generation = sessionGeneration + let timeoutInterval = queryTimeout.requestTimeoutInterval lock.unlock() guard let url = URL(string: "\(baseUrl)/\(path)") else { @@ -718,6 +726,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" + request.timeoutInterval = timeoutInterval request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token { request.setValue(token, forHTTPHeaderField: "Authorization") diff --git a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift index f0b18fc69..2ccdf799b 100644 --- a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift +++ b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift @@ -264,7 +264,9 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable { httpClient?.cancelCurrentRequest() } - func applyQueryTimeout(_ seconds: Int) async throws {} + func applyQueryTimeout(_ seconds: Int) async throws { + httpClient?.setQueryTimeout(seconds) + } // MARK: - Schema Operations diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index f0137ccd7..b542d52ba 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -4,5 +4,7 @@ TableProPluginKitVersion 12 + TableProMinAppVersion + 0.42.0 From 3fd88ca4133b22e05c8d60879eb109d81f8c9690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:35:35 +0700 Subject: [PATCH 07/11] =?UTF-8?q?fix(plugin-cloudflare-d1):=20respect=20Se?= =?UTF-8?q?ttings=20=E2=86=92=20Query=20timeout=20in=20HTTP=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CloudflareD1PluginDriver.swift | 7 +++++++ .../CloudflareD1DriverPlugin/D1HttpClient.swift | 14 ++++++++++++-- Plugins/CloudflareD1DriverPlugin/Info.plist | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 67a253bfd..92cf379bb 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -182,6 +182,13 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable lock.unlock() } + func applyQueryTimeout(_ seconds: Int) async throws { + lock.lock() + let client = httpClient + lock.unlock() + client?.setQueryTimeout(seconds) + } + // MARK: - Streaming func streamRows(query: String) -> AsyncThrowingStream { diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index b821b98dd..a8895fb47 100644 --- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -5,6 +5,7 @@ import Foundation import os +import TableProPluginKit // MARK: - API Response Types @@ -136,6 +137,13 @@ final class D1HttpClient: @unchecked Sendable { private var _databaseId: String private var session: URLSession? private var currentTask: URLSessionDataTask? + private var queryTimeout = HttpQueryTimeout() + + func setQueryTimeout(_ seconds: Int) { + lock.lock() + queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) + lock.unlock() + } var databaseId: String { get { @@ -158,8 +166,8 @@ final class D1HttpClient: @unchecked Sendable { func createSession() { let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 300 + config.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout + config.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout lock.lock() session = URLSession(configuration: config) @@ -303,10 +311,12 @@ final class D1HttpClient: @unchecked Sendable { lock.unlock() throw D1HttpError(message: String(localized: "Not connected to database")) } + let timeoutInterval = queryTimeout.requestTimeoutInterval lock.unlock() var request = URLRequest(url: url) request.httpMethod = method + request.timeoutInterval = timeoutInterval request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = body diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index f0137ccd7..b542d52ba 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -4,5 +4,7 @@ TableProPluginKitVersion 12 + TableProMinAppVersion + 0.42.0 From 49fd2540dfd97cfca9d0b486864112ff29f1f9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:36:45 +0700 Subject: [PATCH 08/11] =?UTF-8?q?fix(plugin-libsql):=20respect=20Settings?= =?UTF-8?q?=20=E2=86=92=20Query=20timeout=20in=20HTTP=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/LibSQLDriverPlugin/HranaHttpClient.swift | 14 ++++++++++++-- Plugins/LibSQLDriverPlugin/Info.plist | 2 ++ .../LibSQLDriverPlugin/LibSQLPluginDriver.swift | 7 +++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift index 24080efac..889f95417 100644 --- a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift +++ b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift @@ -5,6 +5,7 @@ import Foundation import os +import TableProPluginKit // MARK: - Hrana Protocol Types @@ -114,6 +115,13 @@ final class HranaHttpClient: @unchecked Sendable { private let lock = NSLock() private var session: URLSession? private var currentTask: URLSessionDataTask? + private var queryTimeout = HttpQueryTimeout() + + func setQueryTimeout(_ seconds: Int) { + lock.lock() + queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) + lock.unlock() + } init(baseUrl: URL, authToken: String?) { self.baseUrl = baseUrl @@ -122,8 +130,8 @@ final class HranaHttpClient: @unchecked Sendable { func createSession() { let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 300 + config.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout + config.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout lock.lock() session = URLSession(configuration: config) @@ -207,10 +215,12 @@ final class HranaHttpClient: @unchecked Sendable { lock.unlock() throw HranaHttpError(message: String(localized: "Not connected to database")) } + let timeoutInterval = queryTimeout.requestTimeoutInterval lock.unlock() var request = URLRequest(url: url) request.httpMethod = "POST" + request.timeoutInterval = timeoutInterval request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index f0137ccd7..b542d52ba 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -4,5 +4,7 @@ TableProPluginKitVersion 12 + TableProMinAppVersion + 0.42.0 diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index d84e1071e..7a09d1419 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -161,6 +161,13 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { lock.unlock() } + func applyQueryTimeout(_ seconds: Int) async throws { + lock.lock() + let client = httpClient + lock.unlock() + client?.setQueryTimeout(seconds) + } + // MARK: - Streaming func streamRows(query: String) -> AsyncThrowingStream { From 77400ff47d1f05274cce0ea1d430a33b3a6aa2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:37:52 +0700 Subject: [PATCH 09/11] docs(changelog): record HTTP transport timeout fix --- CHANGELOG.md | 1 + docs/customization/settings.mdx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4922f82..912a90c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, and DynamoDB: long-running queries no longer fail at 30 seconds when Settings > Query timeout is set higher. The HTTP transport now uses the configured query timeout plus a 30-second grace, so the server's `max_execution_time` (or equivalent) fires before the client gives up. Setting "No limit" raises the transport ceiling to 1 hour. (#1267) - AI Chat: DeepSeek V4 thinking content (`reasoning_content`) is now captured during streaming and passed back in subsequent turns, fixing 400 errors when using deepseek-v4-pro or deepseek-v4-flash. - MongoDB: the connection form now shows a Username field. It was hidden for databases where authentication is optional, so connections to auth-enabled servers saved with no credentials and every query failed with "requires authentication" even though the connection looked healthy. - SQL import dropped statements when the database executed them slower than the file was parsed, so a re-imported export could fail with errors like "relation does not exist". The parser now waits for each statement to be consumed before reading more. (#1264) diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index cecd29c74..563461388 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -36,7 +36,7 @@ System (default), English, Tiếng Việt, Türkçe. Changing the language requi Maximum seconds a query runs before cancellation. Default 60, range 0-600 (0 means no limit). -Enforced at the database level: `statement_timeout` (PostgreSQL), `max_execution_time` (MySQL), `max_statement_time` (MariaDB), `sqlite3_busy_timeout` (SQLite). Applies on new connections; change requires reconnect. +Enforced at the database level where supported: `statement_timeout` (PostgreSQL), `max_execution_time` (MySQL, ClickHouse), `max_statement_time` (MariaDB), `sqlite3_busy_timeout` (SQLite). HTTP-based drivers (ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, DynamoDB) also bound the HTTP request timeout to the configured value plus a 30-second grace, so the server-side error fires first. Setting "No limit" raises the HTTP transport ceiling to 1 hour. Applies on new connections; change requires reconnect. ### Software Update From cd6e9cad28eac5d3799420d8239c0f7b58825ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 11:56:09 +0700 Subject: [PATCH 10/11] refactor(plugins): clean up review nits in HTTP timeout patch --- Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift | 12 ++++++------ .../DynamoDBDriverPlugin/DynamoDBConnection.swift | 1 - Plugins/EtcdDriverPlugin/EtcdHttpClient.swift | 12 ++++++------ Plugins/LibSQLDriverPlugin/HranaHttpClient.swift | 10 +++++----- Plugins/TableProPluginKit/HttpQueryTimeout.swift | 6 +++--- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index a8895fb47..c32407625 100644 --- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -139,12 +139,6 @@ final class D1HttpClient: @unchecked Sendable { private var currentTask: URLSessionDataTask? private var queryTimeout = HttpQueryTimeout() - func setQueryTimeout(_ seconds: Int) { - lock.lock() - queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) - lock.unlock() - } - var databaseId: String { get { lock.lock() @@ -164,6 +158,12 @@ final class D1HttpClient: @unchecked Sendable { self._databaseId = databaseId } + func setQueryTimeout(_ seconds: Int) { + lock.lock() + queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) + lock.unlock() + } + func createSession() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 842caa17c..76771228f 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -9,7 +9,6 @@ import CommonCrypto import Foundation import os import TableProPluginKit -import TableProPluginKit // MARK: - DynamoDB Attribute Value diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index 6ade1493e..1ee288bb6 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -317,18 +317,18 @@ internal final class EtcdHttpClient: @unchecked Sendable { private var apiPrefix = "v3" private var queryTimeout = HttpQueryTimeout() - func setQueryTimeout(_ seconds: Int) { - lock.lock() - queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) - lock.unlock() - } - private static let logger = Logger(subsystem: "com.TablePro", category: "EtcdHttpClient") init(config: DriverConnectionConfig) { self.config = config } + func setQueryTimeout(_ seconds: Int) { + lock.lock() + queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) + lock.unlock() + } + // MARK: - Base URL private var tlsEnabled: Bool { diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift index 889f95417..a975fa0c9 100644 --- a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift +++ b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift @@ -117,17 +117,17 @@ final class HranaHttpClient: @unchecked Sendable { private var currentTask: URLSessionDataTask? private var queryTimeout = HttpQueryTimeout() + init(baseUrl: URL, authToken: String?) { + self.baseUrl = baseUrl + self.authToken = authToken + } + func setQueryTimeout(_ seconds: Int) { lock.lock() queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) lock.unlock() } - init(baseUrl: URL, authToken: String?) { - self.baseUrl = baseUrl - self.authToken = authToken - } - func createSession() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout diff --git a/Plugins/TableProPluginKit/HttpQueryTimeout.swift b/Plugins/TableProPluginKit/HttpQueryTimeout.swift index ab4e1bd0e..bf231c9f4 100644 --- a/Plugins/TableProPluginKit/HttpQueryTimeout.swift +++ b/Plugins/TableProPluginKit/HttpQueryTimeout.swift @@ -9,8 +9,8 @@ public struct HttpQueryTimeout: Sendable, Equatable { public let graceSeconds: Int public init( - serverTimeoutSeconds: Int = HttpQueryTimeout.bootstrapSeconds, - graceSeconds: Int = HttpQueryTimeout.defaultGraceSeconds + serverTimeoutSeconds: Int = Self.bootstrapSeconds, + graceSeconds: Int = Self.defaultGraceSeconds ) { self.serverTimeoutSeconds = serverTimeoutSeconds self.graceSeconds = max(graceSeconds, 0) @@ -18,7 +18,7 @@ public struct HttpQueryTimeout: Sendable, Equatable { public var requestTimeoutInterval: TimeInterval { guard serverTimeoutSeconds > 0 else { - return TimeInterval(HttpQueryTimeout.resourceCeilingSeconds) + return TimeInterval(Self.resourceCeilingSeconds) } return TimeInterval(serverTimeoutSeconds + graceSeconds) } From 8b605206bfbd4e033244effa051fd1383505b1f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 15 May 2026 12:01:41 +0700 Subject: [PATCH 11/11] refactor(plugins): absorb HTTP query-timeout state in HttpQueryTimeoutBox --- .../BigQueryConnection.swift | 10 ++-- .../ClickHousePlugin.swift | 12 ++-- .../D1HttpClient.swift | 9 +-- .../DynamoDBConnection.swift | 6 +- Plugins/EtcdDriverPlugin/EtcdHttpClient.swift | 9 +-- .../LibSQLDriverPlugin/HranaHttpClient.swift | 9 +-- .../HttpQueryTimeoutBox.swift | 28 +++++++++ .../Plugins/HttpQueryTimeoutBoxTests.swift | 60 +++++++++++++++++++ 8 files changed, 108 insertions(+), 35 deletions(-) create mode 100644 Plugins/TableProPluginKit/HttpQueryTimeoutBox.swift create mode 100644 TableProTests/Plugins/HttpQueryTimeoutBoxTests.swift diff --git a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift index 65f1d6ea9..45d774f39 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift @@ -284,7 +284,7 @@ internal final class BigQueryConnection: @unchecked Sendable { private var _currentJobId: String? private var _currentJobLocation: String? private var _queryTimeoutSeconds: Int = 300 - private var _queryTimeout = HttpQueryTimeout() + private let _queryTimeout = HttpQueryTimeoutBox() private let location: String? private static let logger = Logger(subsystem: "com.TablePro", category: "BigQueryConnection") private static let baseUrl = "https://bigquery.googleapis.com/bigquery/v2" @@ -294,10 +294,8 @@ internal final class BigQueryConnection: @unchecked Sendable { } func setQueryTimeout(_ seconds: Int) { - lock.withLock { - _queryTimeoutSeconds = max(seconds, 30) - _queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) - } + lock.withLock { _queryTimeoutSeconds = max(seconds, 30) } + _queryTimeout.set(serverTimeoutSeconds: seconds) } init(config: DriverConnectionConfig) { @@ -841,7 +839,7 @@ internal final class BigQueryConnection: @unchecked Sendable { session: URLSession ) async throws -> (Data, URLResponse) { var timedRequest = request - timedRequest.timeoutInterval = lock.withLock { _queryTimeout.requestTimeoutInterval } + timedRequest.timeoutInterval = _queryTimeout.requestTimeoutInterval return try await withCheckedThrowingContinuation { continuation in let task = session.dataTask(with: timedRequest) { [weak self] data, response, error in self?.lock.withLock { self?._currentTask = nil } diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 58b2b5236..39a2e42e3 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -145,7 +145,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private var currentTask: URLSessionDataTask? private var _currentDatabase: String private var _lastQueryId: String? - private var _queryTimeout = HttpQueryTimeout() + private let _queryTimeout = HttpQueryTimeoutBox() private static let logger = Logger(subsystem: "com.TablePro", category: "ClickHousePluginDriver") @@ -733,9 +733,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func applyQueryTimeout(_ seconds: Int) async throws { - lock.lock() - _queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) - lock.unlock() + _queryTimeout.set(serverTimeoutSeconds: seconds) guard seconds > 0 else { return } _ = try await execute(query: "SET max_execution_time = \(seconds)") } @@ -804,11 +802,10 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { if let queryId { _lastQueryId = queryId } - let timeoutInterval = _queryTimeout.requestTimeoutInterval lock.unlock() var request = try buildRequest(query: query, database: database, queryId: queryId) - request.timeoutInterval = timeoutInterval + request.timeoutInterval = _queryTimeout.requestTimeoutInterval let isSelect = Self.isSelectLikeQuery(query) let (data, response) = try await withTaskCancellationHandler { @@ -865,11 +862,10 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { if let queryId { _lastQueryId = queryId } - let timeoutInterval = _queryTimeout.requestTimeoutInterval lock.unlock() var request = try buildRequest(query: query, database: database, queryId: queryId, params: params) - request.timeoutInterval = timeoutInterval + request.timeoutInterval = _queryTimeout.requestTimeoutInterval let isSelect = Self.isSelectLikeQuery(query) let (data, response) = try await withTaskCancellationHandler { diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift index c32407625..12db3513a 100644 --- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift +++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift @@ -137,7 +137,7 @@ final class D1HttpClient: @unchecked Sendable { private var _databaseId: String private var session: URLSession? private var currentTask: URLSessionDataTask? - private var queryTimeout = HttpQueryTimeout() + private let queryTimeout = HttpQueryTimeoutBox() var databaseId: String { get { @@ -159,9 +159,7 @@ final class D1HttpClient: @unchecked Sendable { } func setQueryTimeout(_ seconds: Int) { - lock.lock() - queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) - lock.unlock() + queryTimeout.set(serverTimeoutSeconds: seconds) } func createSession() { @@ -311,12 +309,11 @@ final class D1HttpClient: @unchecked Sendable { lock.unlock() throw D1HttpError(message: String(localized: "Not connected to database")) } - let timeoutInterval = queryTimeout.requestTimeoutInterval lock.unlock() var request = URLRequest(url: url) request.httpMethod = method - request.timeoutInterval = timeoutInterval + request.timeoutInterval = queryTimeout.requestTimeoutInterval request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = body diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 76771228f..690994b28 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -261,7 +261,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { private var _session: URLSession? private var _credentials: AWSCredentials? private var _currentTask: URLSessionDataTask? - private var _queryTimeout = HttpQueryTimeout() + private let _queryTimeout = HttpQueryTimeoutBox() private let region: String private let endpointUrl: String private static let logger = Logger(subsystem: "com.TablePro", category: "DynamoDBConnection") @@ -272,7 +272,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { } func setQueryTimeout(_ seconds: Int) { - lock.withLock { _queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) } + _queryTimeout.set(serverTimeoutSeconds: seconds) } init(config: DriverConnectionConfig) { @@ -447,7 +447,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { urlRequest.setValue(hostHeader, forHTTPHeaderField: "Host") signRequest(&urlRequest, body: bodyData, credentials: credentials) - urlRequest.timeoutInterval = lock.withLock { _queryTimeout.requestTimeoutInterval } + urlRequest.timeoutInterval = _queryTimeout.requestTimeoutInterval let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index 1ee288bb6..fbfea0968 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -315,7 +315,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { private var authToken: String? private var _isAuthenticating = false private var apiPrefix = "v3" - private var queryTimeout = HttpQueryTimeout() + private let queryTimeout = HttpQueryTimeoutBox() private static let logger = Logger(subsystem: "com.TablePro", category: "EtcdHttpClient") @@ -324,9 +324,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { } func setQueryTimeout(_ seconds: Int) { - lock.lock() - queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) - lock.unlock() + queryTimeout.set(serverTimeoutSeconds: seconds) } // MARK: - Base URL @@ -717,7 +715,6 @@ internal final class EtcdHttpClient: @unchecked Sendable { } let token = authToken let generation = sessionGeneration - let timeoutInterval = queryTimeout.requestTimeoutInterval lock.unlock() guard let url = URL(string: "\(baseUrl)/\(path)") else { @@ -726,7 +723,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" - request.timeoutInterval = timeoutInterval + request.timeoutInterval = queryTimeout.requestTimeoutInterval request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token { request.setValue(token, forHTTPHeaderField: "Authorization") diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift index a975fa0c9..3ccefe59c 100644 --- a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift +++ b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift @@ -115,7 +115,7 @@ final class HranaHttpClient: @unchecked Sendable { private let lock = NSLock() private var session: URLSession? private var currentTask: URLSessionDataTask? - private var queryTimeout = HttpQueryTimeout() + private let queryTimeout = HttpQueryTimeoutBox() init(baseUrl: URL, authToken: String?) { self.baseUrl = baseUrl @@ -123,9 +123,7 @@ final class HranaHttpClient: @unchecked Sendable { } func setQueryTimeout(_ seconds: Int) { - lock.lock() - queryTimeout = HttpQueryTimeout(serverTimeoutSeconds: seconds) - lock.unlock() + queryTimeout.set(serverTimeoutSeconds: seconds) } func createSession() { @@ -215,12 +213,11 @@ final class HranaHttpClient: @unchecked Sendable { lock.unlock() throw HranaHttpError(message: String(localized: "Not connected to database")) } - let timeoutInterval = queryTimeout.requestTimeoutInterval lock.unlock() var request = URLRequest(url: url) request.httpMethod = "POST" - request.timeoutInterval = timeoutInterval + request.timeoutInterval = queryTimeout.requestTimeoutInterval request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") diff --git a/Plugins/TableProPluginKit/HttpQueryTimeoutBox.swift b/Plugins/TableProPluginKit/HttpQueryTimeoutBox.swift new file mode 100644 index 000000000..376cc59cd --- /dev/null +++ b/Plugins/TableProPluginKit/HttpQueryTimeoutBox.swift @@ -0,0 +1,28 @@ +import Foundation + +public final class HttpQueryTimeoutBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: HttpQueryTimeout + + public init(_ initial: HttpQueryTimeout = HttpQueryTimeout()) { + self.stored = initial + } + + public func set(serverTimeoutSeconds seconds: Int, graceSeconds grace: Int = HttpQueryTimeout.defaultGraceSeconds) { + lock.lock() + stored = HttpQueryTimeout(serverTimeoutSeconds: seconds, graceSeconds: grace) + lock.unlock() + } + + public var current: HttpQueryTimeout { + lock.lock() + defer { lock.unlock() } + return stored + } + + public var requestTimeoutInterval: TimeInterval { + lock.lock() + defer { lock.unlock() } + return stored.requestTimeoutInterval + } +} diff --git a/TableProTests/Plugins/HttpQueryTimeoutBoxTests.swift b/TableProTests/Plugins/HttpQueryTimeoutBoxTests.swift new file mode 100644 index 000000000..584e4d3d7 --- /dev/null +++ b/TableProTests/Plugins/HttpQueryTimeoutBoxTests.swift @@ -0,0 +1,60 @@ +// +// HttpQueryTimeoutBoxTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("HttpQueryTimeoutBox") +struct HttpQueryTimeoutBoxTests { + @Test("Default-initialized box exposes bootstrap policy") + func defaultBoxIsBootstrap() { + let box = HttpQueryTimeoutBox() + #expect(box.current.serverTimeoutSeconds == HttpQueryTimeout.bootstrapSeconds) + #expect(box.requestTimeoutInterval == TimeInterval(60 + 30)) + } + + @Test("set updates both current and requestTimeoutInterval") + func setUpdatesBoth() { + let box = HttpQueryTimeoutBox() + box.set(serverTimeoutSeconds: 600) + #expect(box.current.serverTimeoutSeconds == 600) + #expect(box.requestTimeoutInterval == TimeInterval(630)) + } + + @Test("set with serverTimeoutSeconds = 0 falls back to resource ceiling") + func setZeroUsesCeiling() { + let box = HttpQueryTimeoutBox() + box.set(serverTimeoutSeconds: 0) + #expect(box.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds)) + } + + @Test("set with custom grace is honored") + func setCustomGrace() { + let box = HttpQueryTimeoutBox() + box.set(serverTimeoutSeconds: 120, graceSeconds: 5) + #expect(box.requestTimeoutInterval == TimeInterval(125)) + } + + @Test("Concurrent set and read does not crash") + func concurrentAccess() async { + let box = HttpQueryTimeoutBox() + await withTaskGroup(of: Void.self) { group in + for index in 0..<32 { + group.addTask { box.set(serverTimeoutSeconds: 30 + index * 10) } + group.addTask { _ = box.requestTimeoutInterval } + } + } + #expect(box.requestTimeoutInterval >= TimeInterval(30)) + } + + @Test("Custom initial timeout is preserved until set is called") + func customInitial() { + let box = HttpQueryTimeoutBox(HttpQueryTimeout(serverTimeoutSeconds: 300, graceSeconds: 15)) + #expect(box.requestTimeoutInterval == TimeInterval(315)) + box.set(serverTimeoutSeconds: 600) + #expect(box.requestTimeoutInterval == TimeInterval(630)) + } +}