diff --git a/CHANGELOG.md b/CHANGELOG.md index fcab135d7..c4c2818ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Windows Authentication (Kerberos) to SQL Server now works on macOS. The bundled SQL Server driver was built without Kerberos support, so every Windows-auth connect failed as if the credentials were wrong. (#1918) +- MySQL and MariaDB queries no longer fail after about a minute when a longer query timeout is set. The client read timeout now follows the configured query timeout, so a long query or stored procedure runs for the full time you allow. (#1921) +- A dropped MySQL or MariaDB connection no longer silently re-runs a statement that changes data, so a lost connection can no longer run an insert, update, or stored procedure twice. (#1921) - The MongoDB, Oracle, Cassandra, and Elasticsearch plugins failed to install on 0.58 with "Bundle failed to load executable". (#1917) ## [0.58.0] - 2026-07-18 diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 85720d794..3f556a692 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -159,6 +159,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { private let database: String private let sslConfig: SSLConfiguration private let enableCleartextPlugin: Bool + private let queryTimeoutSeconds: Int private let stateLock = NSLock() private var _isConnected: Bool = false @@ -192,7 +193,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { password: String?, database: String, sslConfig: SSLConfiguration, - enableCleartextPlugin: Bool = false + enableCleartextPlugin: Bool = false, + queryTimeoutSeconds: Int = 0 ) { self.host = host self.port = UInt32(port) @@ -201,6 +203,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { self.database = database self.sslConfig = sslConfig self.enableCleartextPlugin = enableCleartextPlugin + self.queryTimeoutSeconds = queryTimeoutSeconds } deinit { @@ -261,10 +264,10 @@ final class MariaDBPluginConnection: @unchecked Sendable { var timeout: UInt32 = 10 mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) - var readTimeout: UInt32 = 30 + var readTimeout = mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeoutSeconds) mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) - var writeTimeout: UInt32 = 30 + var writeTimeout = mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeoutSeconds) mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 69de23420..a780a3525 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -79,7 +79,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { password: config.password, database: _activeDatabase, sslConfig: sslConfig, - enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true" + enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true", + queryTimeoutSeconds: config.additionalFields["queryTimeoutSeconds"].flatMap { Int($0) } ?? 0 ) try await conn.connect() @@ -193,7 +194,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { isTruncated: result.isTruncated, columnMeta: result.columnMeta ) - } catch let error as MariaDBPluginError where !isRetry && isConnectionLostError(error) { + } catch let error as MariaDBPluginError + where !isRetry && isConnectionLostError(error) && mysqlStatementIsReadOnly(query) { try await reconnect() return try await executeWithReconnect(query: query, isRetry: true, rowCap: rowCap) } diff --git a/Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift b/Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift new file mode 100644 index 000000000..640065f36 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift @@ -0,0 +1,13 @@ +// +// MySQLSocketTimeout.swift +// MySQLDriverPlugin +// + +internal let mysqlSocketTimeoutGraceSeconds = 30 + +internal func mysqlSocketTimeoutSeconds(forQueryTimeout queryTimeoutSeconds: Int) -> UInt32 { + guard queryTimeoutSeconds > 0 else { return 0 } + let ceiling = Int(UInt32.max) - mysqlSocketTimeoutGraceSeconds + let clamped = min(queryTimeoutSeconds, ceiling) + return UInt32(clamped + mysqlSocketTimeoutGraceSeconds) +} diff --git a/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift b/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift new file mode 100644 index 000000000..af9d58790 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift @@ -0,0 +1,17 @@ +// +// MySQLStatementClassification.swift +// MySQLDriverPlugin +// + +internal func mysqlStatementIsReadOnly(_ query: String) -> Bool { + let keyword = query + .drop(while: { $0.isWhitespace }) + .prefix(while: { $0.isLetter }) + .uppercased() + switch keyword { + case "SELECT", "SHOW", "DESCRIBE", "DESC": + return true + default: + return false + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index c0e5cade0..0d64b3406 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -532,6 +532,8 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( MySQLQueryTimeoutStatement.swift, + MySQLSocketTimeout.swift, + MySQLStatementClassification.swift, ); target = 5ABCC5A62F43856700EAF3FC /* TableProTests */; }; diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index b627b2bbe..93d010c5d 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -473,6 +473,7 @@ enum DatabaseDriverFactory { } additionalFields["enableCleartextPlugin"] = "true" } + additionalFields["queryTimeoutSeconds"] = String(AppSettingsManager.shared.general.queryTimeoutSeconds) let config = DriverConnectionConfig( host: connection.host, port: connection.port, diff --git a/TableProTests/Plugins/MySQLSocketTimeoutTests.swift b/TableProTests/Plugins/MySQLSocketTimeoutTests.swift new file mode 100644 index 000000000..f715500c2 --- /dev/null +++ b/TableProTests/Plugins/MySQLSocketTimeoutTests.swift @@ -0,0 +1,35 @@ +// +// MySQLSocketTimeoutTests.swift +// TableProTests +// + +import Testing + +@Suite("MySQL Socket Timeout") +struct MySQLSocketTimeoutTests { + @Test("No limit maps to an infinite socket timeout") + func noLimitIsInfinite() { + #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 0) == 0) + } + + @Test("A negative query timeout maps to an infinite socket timeout") + func negativeIsInfinite() { + #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: -5) == 0) + } + + @Test("A finite query timeout adds the grace period") + func finiteAddsGrace() { + #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 60) == 90) + #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 600) == 630) + } + + @Test("The grace period is applied on top of the query timeout") + func graceMatchesConstant() { + #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: 1) == UInt32(1 + mysqlSocketTimeoutGraceSeconds)) + } + + @Test("A very large query timeout clamps without overflowing") + func largeValueClamps() { + #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: Int.max) == UInt32.max) + } +} diff --git a/TableProTests/Plugins/MySQLStatementClassificationTests.swift b/TableProTests/Plugins/MySQLStatementClassificationTests.swift new file mode 100644 index 000000000..f67045a48 --- /dev/null +++ b/TableProTests/Plugins/MySQLStatementClassificationTests.swift @@ -0,0 +1,48 @@ +// +// MySQLStatementClassificationTests.swift +// TableProTests +// + +import Testing + +@Suite("MySQL Statement Classification") +struct MySQLStatementClassificationTests { + @Test("SELECT is read-only") + func selectIsReadOnly() { + #expect(mysqlStatementIsReadOnly("SELECT * FROM users")) + } + + @Test("Leading whitespace and lowercase are handled") + func whitespaceAndCase() { + #expect(mysqlStatementIsReadOnly(" \n select 1")) + } + + @Test("SHOW, DESCRIBE, and DESC are read-only") + func showAndDescribe() { + #expect(mysqlStatementIsReadOnly("SHOW TABLES")) + #expect(mysqlStatementIsReadOnly("DESCRIBE users")) + #expect(mysqlStatementIsReadOnly("DESC users")) + } + + @Test("Mutating statements are not read-only") + func mutatingStatements() { + #expect(!mysqlStatementIsReadOnly("INSERT INTO users VALUES (1)")) + #expect(!mysqlStatementIsReadOnly("UPDATE users SET name = 'a'")) + #expect(!mysqlStatementIsReadOnly("DELETE FROM users")) + #expect(!mysqlStatementIsReadOnly("CALL do_work()")) + #expect(!mysqlStatementIsReadOnly("REPLACE INTO users VALUES (1)")) + #expect(!mysqlStatementIsReadOnly("DROP TABLE users")) + } + + @Test("A statement behind a leading comment is treated conservatively") + func leadingCommentIsConservative() { + #expect(!mysqlStatementIsReadOnly("/* note */ SELECT 1")) + #expect(!mysqlStatementIsReadOnly("-- note\nSELECT 1")) + } + + @Test("An empty statement is not read-only") + func emptyIsNotReadOnly() { + #expect(!mysqlStatementIsReadOnly("")) + #expect(!mysqlStatementIsReadOnly(" ")) + } +}