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/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift index d28597aa9..45d774f39 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 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,6 +295,7 @@ internal final class BigQueryConnection: @unchecked Sendable { func setQueryTimeout(_ seconds: Int) { lock.withLock { _queryTimeoutSeconds = max(seconds, 30) } + _queryTimeout.set(serverTimeoutSeconds: seconds) } init(config: DriverConnectionConfig) { @@ -306,8 +308,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 +838,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 = _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 diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 90e48ba27..39a2e42e3 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 let _queryTimeout = HttpQueryTimeoutBox() 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,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func applyQueryTimeout(_ seconds: Int) async throws { + _queryTimeout.set(serverTimeoutSeconds: seconds) guard seconds > 0 else { return } _ = try await execute(query: "SET max_execution_time = \(seconds)") } @@ -802,7 +804,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } lock.unlock() - let request = try buildRequest(query: query, database: database, queryId: queryId) + var request = try buildRequest(query: query, database: database, queryId: queryId) + request.timeoutInterval = _queryTimeout.requestTimeoutInterval let isSelect = Self.isSelectLikeQuery(query) let (data, response) = try await withTaskCancellationHandler { @@ -861,7 +864,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } 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 = _queryTimeout.requestTimeoutInterval let isSelect = Self.isSelectLikeQuery(query) let (data, response) = try await withTaskCancellationHandler { 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..12db3513a 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,7 @@ final class D1HttpClient: @unchecked Sendable { private var _databaseId: String private var session: URLSession? private var currentTask: URLSessionDataTask? + private let queryTimeout = HttpQueryTimeoutBox() var databaseId: String { get { @@ -156,10 +158,14 @@ final class D1HttpClient: @unchecked Sendable { self._databaseId = databaseId } + func setQueryTimeout(_ seconds: Int) { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + 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) @@ -307,6 +313,7 @@ final class D1HttpClient: @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = method + request.timeoutInterval = queryTimeout.requestTimeoutInterval 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 diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 6dde1b281..690994b28 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -261,6 +261,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { private var _session: URLSession? private var _credentials: AWSCredentials? private var _currentTask: URLSessionDataTask? + private let _queryTimeout = HttpQueryTimeoutBox() private let region: String private let endpointUrl: String private static let logger = Logger(subsystem: "com.TablePro", category: "DynamoDBConnection") @@ -270,6 +271,10 @@ internal final class DynamoDBConnection: @unchecked Sendable { lock.withLock { _session } } + func setQueryTimeout(_ seconds: Int) { + _queryTimeout.set(serverTimeoutSeconds: seconds) + } + init(config: DriverConnectionConfig) { self.config = config self.region = config.additionalFields["awsRegion"] ?? "us-east-1" @@ -298,8 +303,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 +447,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { urlRequest.setValue(hostHeader, forHTTPHeaderField: "Host") signRequest(&urlRequest, body: bodyData, credentials: credentials) + urlRequest.timeoutInterval = _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 diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index 5e66500a9..fbfea0968 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -315,6 +315,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { private var authToken: String? private var _isAuthenticating = false private var apiPrefix = "v3" + private let queryTimeout = HttpQueryTimeoutBox() private static let logger = Logger(subsystem: "com.TablePro", category: "EtcdHttpClient") @@ -322,6 +323,10 @@ internal final class EtcdHttpClient: @unchecked Sendable { self.config = config } + func setQueryTimeout(_ seconds: Int) { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + // MARK: - Base URL private var tlsEnabled: Bool { @@ -347,8 +352,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 { @@ -718,6 +723,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" + request.timeoutInterval = queryTimeout.requestTimeoutInterval 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 diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift index 24080efac..3ccefe59c 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,16 +115,21 @@ final class HranaHttpClient: @unchecked Sendable { private let lock = NSLock() private var session: URLSession? private var currentTask: URLSessionDataTask? + private let queryTimeout = HttpQueryTimeoutBox() init(baseUrl: URL, authToken: String?) { self.baseUrl = baseUrl self.authToken = authToken } + func setQueryTimeout(_ seconds: Int) { + queryTimeout.set(serverTimeoutSeconds: seconds) + } + 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) @@ -211,6 +217,7 @@ final class HranaHttpClient: @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" + 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/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 { diff --git a/Plugins/TableProPluginKit/HttpQueryTimeout.swift b/Plugins/TableProPluginKit/HttpQueryTimeout.swift new file mode 100644 index 000000000..bf231c9f4 --- /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 = Self.bootstrapSeconds, + graceSeconds: Int = Self.defaultGraceSeconds + ) { + self.serverTimeoutSeconds = serverTimeoutSeconds + self.graceSeconds = max(graceSeconds, 0) + } + + public var requestTimeoutInterval: TimeInterval { + guard serverTimeoutSeconds > 0 else { + return TimeInterval(Self.resourceCeilingSeconds) + } + return TimeInterval(serverTimeoutSeconds + graceSeconds) + } + + public static var sessionResourceTimeout: TimeInterval { + TimeInterval(resourceCeilingSeconds) + } + + public static var sessionBootstrapRequestTimeout: TimeInterval { + TimeInterval(bootstrapSeconds) + } +} 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/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( 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)) + } +} 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)) + } +} 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