From 5fb51c24bd47a47f1d662373ac6ef3126cfa6048 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Mar 2026 11:54:30 +0700 Subject: [PATCH] fix: reduce CPU/RAM on app launch with 10 performance fixes --- CHANGELOG.md | 3 ++ TablePro/AppDelegate.swift | 25 ++++------ .../Autocomplete/SQLCompletionProvider.swift | 2 +- .../Core/Autocomplete/SQLSchemaProvider.swift | 6 +-- .../Database/ConnectionHealthMonitor.swift | 4 ++ .../Core/Database/GeometryWKBParser.swift | 15 +++--- TablePro/Core/Database/RedisDriver.swift | 2 +- .../Core/Storage/QueryHistoryStorage.swift | 4 +- TablePro/Core/Storage/TabStateStorage.swift | 4 +- TablePro/Models/RowProvider.swift | 14 ++---- .../SQLCompletionProviderTests.swift | 14 ++++++ .../Autocomplete/SQLSchemaProviderTests.swift | 46 +++++++++++++++++++ .../ConnectionHealthMonitorTests.swift | 31 +++++++++++++ TableProTests/Models/RowProviderTests.swift | 29 ++++++++++++ 14 files changed, 156 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a713d73a..280e8c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix high CPU/RAM on app launch from blocking storage init, unsynchronized health monitors, and excessive retry loops +- Fix O(n log n) row cache eviction in RowProvider by replacing sorted eviction with O(n) distance-threshold filter +- Fix O(n) string operations in GeometryWKBParser, RedisDriver, and autocomplete scoring by switching to NSString O(1) indexing - Fix slow database switcher loading by replacing N+1 metadata queries with single batched queries (MySQL, PostgreSQL, Redshift) - Fix slow Redis key browsing by pipelining TYPE and TTL commands in a single round trip instead of 3 sequential commands per key - Fix slow SQL export startup by batching COUNT(*) queries via UNION ALL and batching dependent sequence/type lookups diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index e08f21ff..110cbdc7 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -501,17 +501,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } - /// Schedule repeated checks to close any welcome window that - /// SwiftUI creates as part of app activation for a file-open event. - /// Retries up to 5 times with short delays to catch late-restored windows. private func scheduleWelcomeWindowSuppression() { Task { @MainActor [weak self] in - for _ in 0 ..< 5 { - guard let self else { return } - self.closeWelcomeWindowIfMainExists() - try? await Task.sleep(for: .milliseconds(200)) - } + // Single check after a short delay for window creation + try? await Task.sleep(for: .milliseconds(300)) + self?.closeWelcomeWindowIfMainExists() + // One final check after windows settle + try? await Task.sleep(for: .milliseconds(700)) guard let self else { return } + self.closeWelcomeWindowIfMainExists() self.fileOpenSuppressionCount = max(0, self.fileOpenSuppressionCount - 1) if self.fileOpenSuppressionCount == 0 { self.isHandlingFileOpen = false @@ -538,14 +536,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func postSQLFilesWhenReady(urls: [URL]) { Task { @MainActor [weak self] in - for attempt in 0 ..< 10 { - if NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) { - break - } - if attempt == 9 { - Self.logger.warning("postSQLFilesWhenReady: no key main window after retries, posting anyway") - } - try? await Task.sleep(for: .milliseconds(50)) + try? await Task.sleep(for: .milliseconds(100)) + if !NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) { + Self.logger.warning("postSQLFilesWhenReady: no key main window, posting anyway") } NotificationCenter.default.post(name: .openSQLFiles, object: urls) } diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 65f0ac25..806a76f7 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -703,7 +703,7 @@ final class SQLCompletionProvider { } // Shorter names slightly preferred - score += item.label.count + score += (item.label as NSString).length // Fuzzy match penalty — items matched only by fuzzy get demoted if !prefix.isEmpty { diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index d7f25613..0abbe213 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -187,13 +187,13 @@ actor SQLSchemaProvider { isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String? )] = [] + let hasMultipleRefs = references.count > 1 for ref in references { let columns = await getColumns(for: ref.tableName) let refId = ref.identifier for column in columns { - // Include table/alias prefix for clarity when multiple tables - let label = references.count > 1 ? "\(refId).\(column.name)" : column.name - let insertText = references.count > 1 ? "\(refId).\(column.name)" : column.name + let label = hasMultipleRefs ? "\(refId).\(column.name)" : column.name + let insertText = hasMultipleRefs ? "\(refId).\(column.name)" : column.name itemDataBuilder.append( ( diff --git a/TablePro/Core/Database/ConnectionHealthMonitor.swift b/TablePro/Core/Database/ConnectionHealthMonitor.swift index 2698237f..a5b4d358 100644 --- a/TablePro/Core/Database/ConnectionHealthMonitor.swift +++ b/TablePro/Core/Database/ConnectionHealthMonitor.swift @@ -107,6 +107,10 @@ actor ConnectionHealthMonitor { monitoringTask = Task { [weak self] in guard let self else { return } + let initialDelay = Double.random(in: 0 ... 10) + try? await Task.sleep(for: .seconds(initialDelay)) + guard !Task.isCancelled else { return } + while !Task.isCancelled { // Race between the normal ping interval and an early wake-up signal await withTaskGroup(of: Bool.self) { group in diff --git a/TablePro/Core/Database/GeometryWKBParser.swift b/TablePro/Core/Database/GeometryWKBParser.swift index c11e82ba..060b1f10 100644 --- a/TablePro/Core/Database/GeometryWKBParser.swift +++ b/TablePro/Core/Database/GeometryWKBParser.swift @@ -120,9 +120,8 @@ enum GeometryWKBParser { for _ in 0 ..< numGeoms { guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil } if geom.hasPrefix("POINT("), geom.hasSuffix(")") { - let start = geom.index(geom.startIndex, offsetBy: 6) - let end = geom.index(before: geom.endIndex) - points.append(String(geom[start ..< end])) + let ns = geom as NSString + points.append(ns.substring(with: NSRange(location: 6, length: ns.length - 7))) } else { points.append(geom) } @@ -142,9 +141,8 @@ enum GeometryWKBParser { for _ in 0 ..< numGeoms { guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil } if geom.hasPrefix("LINESTRING("), geom.hasSuffix(")") { - let start = geom.index(geom.startIndex, offsetBy: 11) - let end = geom.index(before: geom.endIndex) - lineStrings.append("(\(geom[start ..< end]))") + let ns = geom as NSString + lineStrings.append("(\(ns.substring(with: NSRange(location: 11, length: ns.length - 12))))") } else { lineStrings.append(geom) } @@ -164,9 +162,8 @@ enum GeometryWKBParser { for _ in 0 ..< numGeoms { guard let geom = parseWKBGeometry(data, offset: &offset) else { return nil } if geom.hasPrefix("POLYGON("), geom.hasSuffix(")") { - let start = geom.index(geom.startIndex, offsetBy: 8) - let end = geom.index(before: geom.endIndex) - polygons.append("(\(geom[start ..< end]))") + let ns = geom as NSString + polygons.append("(\(ns.substring(with: NSRange(location: 8, length: ns.length - 9))))") } else { polygons.append(geom) } diff --git a/TablePro/Core/Database/RedisDriver.swift b/TablePro/Core/Database/RedisDriver.swift index 48444e3c..e081ad70 100644 --- a/TablePro/Core/Database/RedisDriver.swift +++ b/TablePro/Core/Database/RedisDriver.swift @@ -435,7 +435,7 @@ final class RedisDriver: DatabaseDriver { for line in infoStr.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("\(dbName):") { - let statsStr = String(trimmed[trimmed.index(trimmed.startIndex, offsetBy: dbName.count + 1)...]) + let statsStr = (trimmed as NSString).substring(from: dbName.count + 1) for stat in statsStr.components(separatedBy: ",") { let parts = stat.components(separatedBy: "=") if parts.count == 2, parts[0] == "keys", let count = Int(parts[1]) { diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 92cdc0e0..2a440ec9 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -52,8 +52,8 @@ final class QueryHistoryStorage { private var insertsSinceCleanup: Int = 0 private init() { - queue.sync { - setupDatabase() + queue.async { [weak self] in + self?.setupDatabase() } } diff --git a/TablePro/Core/Storage/TabStateStorage.swift b/TablePro/Core/Storage/TabStateStorage.swift index 39f9f7d0..ac629649 100644 --- a/TablePro/Core/Storage/TabStateStorage.swift +++ b/TablePro/Core/Storage/TabStateStorage.swift @@ -65,7 +65,9 @@ final class TabStateStorage { decoder = JSONDecoder() createDirectoriesIfNeeded() - migrateFromUserDefaultsIfNeeded() + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.migrateFromUserDefaultsIfNeeded() + } } // MARK: - Public API diff --git a/TablePro/Models/RowProvider.swift b/TablePro/Models/RowProvider.swift index 2bc0bce6..c525f260 100644 --- a/TablePro/Models/RowProvider.swift +++ b/TablePro/Models/RowProvider.swift @@ -185,11 +185,8 @@ final class InMemoryRowProvider: RowProvider { /// Keeps the half closest to `nearIndex` and discards the rest. private func evictCacheIfNeeded(nearIndex: Int) { guard rowCache.count > Self.maxCacheSize / 2 else { return } - let sorted = rowCache.keys.sorted(by: { abs($0 - nearIndex) > abs($1 - nearIndex) }) - let evictCount = rowCache.count - Self.maxCacheSize / 2 - for key in sorted.prefix(evictCount) { - rowCache.removeValue(forKey: key) - } + let halfSize = Self.maxCacheSize / 2 + rowCache = rowCache.filter { abs($0.key - nearIndex) <= halfSize } } } @@ -301,10 +298,7 @@ final class DatabaseRowProvider: RowProvider { /// and discards the rest. private func evictCacheIfNeeded(nearIndex: Int) { guard cache.count > Self.maxCacheSize else { return } - let sorted = cache.keys.sorted(by: { abs($0 - nearIndex) > abs($1 - nearIndex) }) - let evictCount = cache.count - Self.maxCacheSize / 2 - for key in sorted.prefix(evictCount) { - cache.removeValue(forKey: key) - } + let halfSize = Self.maxCacheSize / 2 + cache = cache.filter { abs($0.key - nearIndex) <= halfSize } } } diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index 93192a42..08b1dab5 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -949,4 +949,18 @@ struct SQLCompletionProviderTests { #expect(containsIdx < fuzzyIdx, "Contains matches should rank above fuzzy-only matches") } } + + // MARK: - Performance: NSString.length for label scoring + + @Test("Shorter label scores lower (better) than longer label") + func testShorterLabelScoresBetter() async { + // "IN" (2 chars) should rank above "INSERT" (6 chars) when both match prefix "IN" + let text = "SELECT * FROM users WHERE id IN" + let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) + let inIdx = items.firstIndex { $0.label == "IN" } + let insertIdx = items.firstIndex { $0.label == "INSERT" } + if let inIdx, let insertIdx { + #expect(inIdx < insertIdx, "IN should rank above INSERT for prefix 'IN'") + } + } } diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index a25cc390..89010894 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -283,4 +283,50 @@ struct SQLSchemaProviderTests { #expect(driver.fetchColumnsCallCount == 1) } + + @Test("allColumnsInScope with single reference returns unprefixed names") + func allColumnsInScopeSingleRef() async { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.columnsToReturn = [ + "users": [ + TestFixtures.makeColumnInfo(name: "id"), + TestFixtures.makeColumnInfo(name: "email", dataType: "VARCHAR", isPrimaryKey: false) + ] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + + let ref = TableReference(tableName: "users", alias: nil) + let items = await provider.allColumnsInScope(for: [ref]) + #expect(items.count == 2) + #expect(items[0].label == "id") + #expect(items[1].label == "email") + } + + @Test("allColumnsInScope with multiple references returns prefixed names") + func allColumnsInScopeMultipleRefs() async { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [ + TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableInfo(name: "orders") + ] + driver.columnsToReturn = [ + "users": [TestFixtures.makeColumnInfo(name: "id")], + "orders": [TestFixtures.makeColumnInfo(name: "id")] + ] + + let provider = SQLSchemaProvider() + await provider.loadSchema(using: driver, connection: TestFixtures.makeConnection()) + + let refs = [ + TableReference(tableName: "users", alias: nil), + TableReference(tableName: "orders", alias: nil) + ] + let items = await provider.allColumnsInScope(for: refs) + #expect(items.count == 2) + #expect(items[0].label == "users.id") + #expect(items[1].label == "orders.id") + } } diff --git a/TableProTests/Core/Database/ConnectionHealthMonitorTests.swift b/TableProTests/Core/Database/ConnectionHealthMonitorTests.swift index 2a7de527..9fb3034f 100644 --- a/TableProTests/Core/Database/ConnectionHealthMonitorTests.swift +++ b/TableProTests/Core/Database/ConnectionHealthMonitorTests.swift @@ -120,4 +120,35 @@ struct ConnectionHealthMonitorTests { let name = Notification.Name.connectionHealthStateChanged #expect(name.rawValue == "connectionHealthStateChanged") } + + @Test("Staggered initial delay — no ping fires immediately") + func staggeredInitialDelay() async { + var pingCount = 0 + let lock = NSLock() + + let monitor = ConnectionHealthMonitor( + connectionId: UUID(), + pingHandler: { + lock.lock() + pingCount += 1 + lock.unlock() + return true + }, + reconnectHandler: { true }, + onStateChanged: { _, _ in } + ) + + await monitor.startMonitoring() + + // Wait briefly — with stagger (0-10s) + ping interval (30s), + // no ping should fire in 200ms + try? await Task.sleep(for: .milliseconds(200)) + + await monitor.stopMonitoring() + + lock.lock() + let count = pingCount + lock.unlock() + #expect(count == 0, "No ping should fire immediately due to staggered initial delay") + } } diff --git a/TableProTests/Models/RowProviderTests.swift b/TableProTests/Models/RowProviderTests.swift index 92153172..fc2ed518 100644 --- a/TableProTests/Models/RowProviderTests.swift +++ b/TableProTests/Models/RowProviderTests.swift @@ -511,4 +511,33 @@ struct InMemoryRowProviderTests { #expect(row != nil) #expect(row?.value(at: 0) == "id_0") } + + @Test("Eviction keeps rows closest to access point") + func evictionKeepsClosestRows() { + let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 6000) + for i in 0 ..< 6000 { + let _ = provider.row(at: i) + } + let _ = provider.row(at: 4000) + let nearby = provider.row(at: 4001) + #expect(nearby != nil) + #expect(nearby?.value(at: 0) == "id_4001") + } + + @Test("Eviction preserves data integrity across multiple eviction cycles") + func evictionMultipleCycles() { + let provider = TestFixtures.makeInMemoryRowProvider(rowCount: 12000) + for i in 0 ..< 6000 { + let _ = provider.row(at: i) + } + for i in 6000 ..< 12000 { + let _ = provider.row(at: i) + } + let early = provider.row(at: 100) + #expect(early != nil) + #expect(early?.value(at: 0) == "id_100") + let late = provider.row(at: 11999) + #expect(late != nil) + #expect(late?.value(at: 0) == "id_11999") + } }