Skip to content

Commit 66bf0a7

Browse files
authored
fix: resolve high CPU usage and memory leaks at idle (#368) (#369)
* perf: fix high CPU usage and memory leaks at idle (#368) - Guard updateSession against no-op writes using isContentViewEquivalent - Skip onStateChanged callback for routine healthy↔checking ping cycles - Move lastActiveAt updates through guarded updateSession (no-op when only timestamp changes) - Track all driver operations in queriesInFlight to prevent health monitor TOCTOU races - Fix activeCoordinators memory leak by moving registerForPersistence to markActivated - Replace loadConnections() disk I/O in ContentView.init with in-memory lookup - Change WindowLifecycleMonitor to weak NSWindow references with purgeStaleEntries - Guard redundant .connecting status writes during reconnect attempts - Add [weak self] to nested DispatchQueue.main.async in SQLEditorCoordinator - Move prefetch DB fetch off MainActor with tracked task and dedup guard - Move Keychain migration to Task.detached for non-blocking startup - Move plugin bundle.load() off MainActor via nonisolated static method - Use targeted reloadData for FK columns instead of full table reload - Debounce JSON syntax highlighting with 100ms DispatchWorkItem - Replace O(n²) undo batch index shift with O(n log n) binary search * perf: scope observation to per-connection version counters (HIGH-1) Add connectionStatusVersions dictionary with per-connection counters. ContentView and MainContentView now observe only their connection's counter instead of the global connectionStatusVersion, eliminating cross-connection re-render overhead when multiple connections are open. * fix: address PR review feedback from CodeRabbit - Fix FK column reload to use display-order indices (respects user column reordering) - Remove force-unwrap in JSON highlight debounce (use local variable) - Add purgeStaleEntries to connectionId(for:) and window(for:) in WindowLifecycleMonitor - Fix audit doc date typo and inconsistent checkbox states * chore: exclude performance audit doc from PR * perf: mark internal bookkeeping properties with @ObservationIgnored
1 parent 2a91364 commit 66bf0a7

14 files changed

Lines changed: 394 additions & 115 deletions

TablePro/AppDelegate.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5656
let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords
5757
let previousSyncState = UserDefaults.standard.bool(forKey: KeychainHelper.passwordSyncEnabledKey)
5858
UserDefaults.standard.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey)
59-
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
59+
Task.detached(priority: .utility) {
60+
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
61+
}
6062
if passwordSyncExpected != previousSyncState {
6163
Task.detached(priority: .background) {
6264
KeychainHelper.shared.migratePasswordSyncState(synchronizable: passwordSyncExpected)

TablePro/ContentView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ struct ContentView: View {
4040
if let tableName = payload?.tableName {
4141
defaultTitle = tableName
4242
} else if let connectionId = payload?.connectionId,
43-
let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) {
43+
let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection {
4444
let langName = PluginManager.shared.queryLanguageName(for: connection.type)
4545
defaultTitle = "\(langName) Query"
4646
} else {
@@ -102,7 +102,7 @@ struct ContentView: View {
102102
columnVisibility = .detailOnly
103103
}
104104
}
105-
.onChange(of: DatabaseManager.shared.connectionStatusVersion, initial: true) { _, _ in
105+
.onChange(of: (payload?.connectionId ?? currentSession?.id).flatMap { DatabaseManager.shared.connectionStatusVersions[$0] }, initial: true) { _, _ in
106106
let sessions = DatabaseManager.shared.activeSessions
107107
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
108108
guard let sid = connectionId else {

TablePro/Core/ChangeTracking/DataChangeManager.swift

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ final class DataChangeManager {
8888
return true
8989
}
9090

91+
/// Binary search: count of elements in a sorted array that are strictly less than `target`.
92+
/// Used for O(n log n) batch index shifting instead of O(n²) nested loops.
93+
private static func countLessThan(_ target: Int, in sorted: [Int]) -> Int {
94+
var lo = 0, hi = sorted.count
95+
while lo < hi {
96+
let mid = (lo + hi) / 2
97+
if sorted[mid] < target {
98+
lo = mid + 1
99+
} else {
100+
hi = mid
101+
}
102+
}
103+
return lo
104+
}
105+
91106
/// Undo/redo manager
92107
private let undoManager = DataChangeUndoManager()
93108

@@ -414,18 +429,20 @@ final class DataChangeManager {
414429

415430
pushUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues))
416431

417-
for deletedIndex in validRows.reversed() {
418-
var shiftedIndices = Set<Int>()
419-
for idx in insertedRowIndices {
420-
shiftedIndices.insert(idx > deletedIndex ? idx - 1 : idx)
421-
}
422-
insertedRowIndices = shiftedIndices
432+
// Single-pass shift using binary search instead of O(n²) nested loop
433+
let sortedDeleted = validRows.sorted()
423434

424-
for i in 0..<changes.count {
425-
if changes[i].rowIndex > deletedIndex {
426-
changes[i].rowIndex -= 1
427-
}
428-
}
435+
var newInserted = Set<Int>()
436+
for idx in insertedRowIndices {
437+
let shiftCount = Self.countLessThan(idx, in: sortedDeleted)
438+
newInserted.insert(idx - shiftCount)
439+
}
440+
insertedRowIndices = newInserted
441+
442+
for i in 0..<changes.count {
443+
let rowIndex = changes[i].rowIndex
444+
let shiftCount = Self.countLessThan(rowIndex, in: sortedDeleted)
445+
changes[i].rowIndex = rowIndex - shiftCount
429446
}
430447

431448
rebuildChangeIndex()

TablePro/Core/Database/ConnectionHealthMonitor.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,17 @@ actor ConnectionHealthMonitor {
246246
state = newState
247247

248248
if oldState != newState {
249-
// Skip logging for routine healthy ↔ checking cycle (every 30s)
249+
// Skip logging and callback for routine healthy ↔ checking ping cycles (every 30s).
250+
// These produce no meaningful state change for the UI.
250251
let isRoutineCycle = (oldState == .healthy && newState == .checking)
251252
|| (oldState == .checking && newState == .healthy)
252253
if !isRoutineCycle {
253254
Self.logger.log(
254255
level: logLevel(for: newState),
255256
"Connection \(self.connectionId) health state: \(String(describing: oldState)) -> \(String(describing: newState))"
256257
)
258+
await onStateChanged(connectionId, newState)
257259
}
258-
259-
await onStateChanged(connectionId, newState)
260260
}
261261
}
262262

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,10 @@ extension DatabaseDriver {
311311
enum DatabaseDriverFactory {
312312
static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver {
313313
let pluginId = connection.type.pluginTypeId
314-
if PluginManager.shared.driverPlugins[pluginId] == nil {
314+
// If the plugin isn't registered yet and background loading hasn't finished,
315+
// fall back to synchronous loading for this critical code path.
316+
if PluginManager.shared.driverPlugins[pluginId] == nil,
317+
!PluginManager.shared.hasFinishedInitialLoad {
315318
PluginManager.shared.loadPendingPlugins()
316319
}
317320
guard let plugin = PluginManager.shared.driverPlugins[pluginId] else {

0 commit comments

Comments
 (0)