Skip to content

Commit 715dea5

Browse files
authored
Merge pull request #232 from datlechin/perf/memory-audit-fixes
perf: reduce memory usage by ~80-130 MB per connection
2 parents 835f4f1 + 2fe403a commit 715dea5

33 files changed

Lines changed: 1086 additions & 581 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929

3030
### Changed
3131

32+
- Reduce memory: eliminate dedicated ping driver (~30-50 MB per connection), use main driver for health checks
33+
- Reduce memory: evict inactive native window-tab row data after 5s, re-fetch on focus
34+
- Reduce memory: lazy-load plugin bundles on first use instead of at startup (~20-30 MB saved)
35+
- Reduce memory: remove duplicate sourceQuery string from RowBuffer
36+
- Reduce memory: InMemoryRowProvider references RowBuffer directly instead of copying rows (~3-10 MB per tab)
37+
- Reduce memory: eliminate metadata driver entirely, multiplex all queries on main driver (~30-50 MB per connection)
38+
- Reduce memory: lazy AIChatViewModel initialization (deferred until AI panel is first opened)
39+
- Reduce memory: remove duplicate connections array from ContentView (use ConnectionStorage.shared directly)
40+
- Reduce CPU: consolidate per-editor NSEvent monitors into shared EditorEventRouter singleton (O(n) → O(1) per event)
41+
- Fix tab persistence: aggregate tabs from all windows at quit time instead of last-write-wins per-coordinator save
3242
- Split DatabaseManager.sessionVersion into fine-grained connectionListVersion and connectionStatusVersion to reduce cascade re-renders
3343
- Extract AppState property reads into local lets in view bodies for explicit granular observation tracking
3444
- Reorganized project directory structure: Services, Utilities, Models split into domain-specific subdirectories

TablePro/ContentView.swift

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,12 @@ struct ContentView: View {
1717
let payload: EditorTabPayload?
1818

1919
@State private var currentSession: ConnectionSession?
20-
@State private var connections: [DatabaseConnection] = []
2120
@State private var columnVisibility: NavigationSplitViewVisibility = .all
2221
@State private var showNewConnectionSheet = false
2322
@State private var showEditConnectionSheet = false
2423
@State private var connectionToEdit: DatabaseConnection?
2524
@State private var connectionToDelete: DatabaseConnection?
2625
@State private var showDeleteConfirmation = false
27-
@State private var hasLoaded = false
2826
@State private var rightPanelState: RightPanelState?
2927
@State private var sessionState: SessionStateFactory.SessionState?
3028
@State private var inspectorContext = InspectorContext.empty
@@ -68,9 +66,6 @@ struct ContentView: View {
6866
} message: { connection in
6967
Text("Are you sure you want to delete \"\(connection.name)\"?")
7068
}
71-
.onAppear {
72-
loadConnections()
73-
}
7469
.onReceive(NotificationCenter.default.publisher(for: .newConnection)) { _ in
7570
openWindow(id: "connection-form", value: nil as UUID?)
7671
}
@@ -377,29 +372,14 @@ struct ContentView: View {
377372

378373
// MARK: - Persistence
379374

380-
private func loadConnections() {
381-
guard !hasLoaded else { return }
382-
383-
let saved = storage.loadConnections()
384-
if saved.isEmpty {
385-
connections = DatabaseConnection.sampleConnections
386-
storage.saveConnections(connections)
387-
} else {
388-
connections = saved
389-
}
390-
hasLoaded = true
391-
}
392-
393375
private func deleteConnection(_ connection: DatabaseConnection) {
394376
if DatabaseManager.shared.activeSessions[connection.id] != nil {
395377
Task {
396378
await DatabaseManager.shared.disconnectSession(connection.id)
397379
}
398380
}
399381

400-
connections.removeAll { $0.id == connection.id }
401382
storage.deleteConnection(connection)
402-
storage.saveConnections(connections)
403383
}
404384

405385
private func showAllTablesMetadata() {

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,9 @@ extension DatabaseDriver {
297297
enum DatabaseDriverFactory {
298298
static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver {
299299
let pluginId = connection.type.pluginTypeId
300+
if PluginManager.shared.driverPlugins[pluginId] == nil {
301+
PluginManager.shared.loadPendingPlugins()
302+
}
300303
guard let plugin = PluginManager.shared.driverPlugins[pluginId] else {
301304
throw DatabaseError.connectionFailed(
302305
"\(pluginId) driver plugin not loaded. The plugin may be disabled or missing from the PlugIns directory."

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 2 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,6 @@ final class DatabaseManager {
4040
/// Health monitors for active connections (MySQL/PostgreSQL only)
4141
private var healthMonitors: [UUID: ConnectionHealthMonitor] = [:]
4242

43-
/// Dedicated lightweight drivers used exclusively for health-check pings.
44-
/// Separate from the main driver so pings never queue behind long-running user queries.
45-
private var pingDrivers: [UUID: DatabaseDriver] = [:]
46-
47-
private var metadataCreationTasks: [UUID: Task<Void, Never>] = [:]
48-
4943
/// Current session (computed from currentSessionId)
5044
var currentSession: ConnectionSession? {
5145
guard let sessionId = currentSessionId else { return nil }
@@ -57,22 +51,11 @@ final class DatabaseManager {
5751
currentSession?.driver
5852
}
5953

60-
/// Dedicated driver for metadata queries (columns, FKs, count).
61-
/// Runs on a separate serial queue so metadata fetches don't block the main query.
62-
var activeMetadataDriver: DatabaseDriver? {
63-
currentSession?.metadataDriver
64-
}
65-
6654
/// Resolve the driver for a specific connection (session-scoped, no global state)
6755
func driver(for connectionId: UUID) -> DatabaseDriver? {
6856
activeSessions[connectionId]?.driver
6957
}
7058

71-
/// Resolve the metadata driver for a specific connection
72-
func metadataDriver(for connectionId: UUID) -> DatabaseDriver? {
73-
activeSessions[connectionId]?.metadataDriver
74-
}
75-
7659
/// Resolve a session by explicit connection ID
7760
func session(for connectionId: UUID) -> ConnectionSession? {
7861
activeSessions[connectionId]
@@ -211,33 +194,6 @@ final class DatabaseManager {
211194
await startHealthMonitor(for: connection.id)
212195
}
213196

214-
// Create a dedicated metadata connection in the background so Phase 2
215-
// metadata queries (columns, FKs, count) run in parallel with main queries.
216-
let metaConnection = effectiveConnection
217-
let metaConnectionId = connection.id
218-
let metaTimeout = AppSettingsManager.shared.general.queryTimeoutSeconds
219-
metadataCreationTasks[metaConnectionId] = Task { [weak self] in
220-
guard let self else { return }
221-
defer { self.metadataCreationTasks.removeValue(forKey: metaConnectionId) }
222-
do {
223-
let metaDriver = try DatabaseDriverFactory.createDriver(for: metaConnection)
224-
try await metaDriver.connect()
225-
if metaTimeout > 0 {
226-
try? await metaDriver.applyQueryTimeout(metaTimeout)
227-
}
228-
await self.executeStartupCommands(
229-
connection.startupCommands, on: metaDriver, connectionName: connection.name
230-
)
231-
if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema,
232-
let schemaMetaDriver = metaDriver as? SchemaSwitchable {
233-
try? await schemaMetaDriver.switchSchema(to: savedSchema)
234-
}
235-
activeSessions[metaConnectionId]?.metadataDriver = metaDriver
236-
} catch {
237-
// Non-fatal: Phase 2 falls back to main driver if metadata driver unavailable
238-
Self.logger.warning("Metadata connection failed: \(error.localizedDescription)")
239-
}
240-
}
241197
} catch {
242198
// Close tunnel if connection failed
243199
if connection.sshConfig.enabled {
@@ -282,14 +238,9 @@ final class DatabaseManager {
282238
try? await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
283239
}
284240

285-
// Cancel any in-flight metadata driver creation
286-
metadataCreationTasks[sessionId]?.cancel()
287-
metadataCreationTasks.removeValue(forKey: sessionId)
288-
289241
// Stop health monitoring
290242
await stopHealthMonitor(for: sessionId)
291243

292-
session.metadataDriver?.disconnect()
293244
session.driver?.disconnect()
294245
activeSessions.removeValue(forKey: sessionId)
295246

@@ -318,9 +269,6 @@ final class DatabaseManager {
318269
await stopHealthMonitor(for: sessionId)
319270
}
320271

321-
for task in metadataCreationTasks.values { task.cancel() }
322-
metadataCreationTasks.removeAll()
323-
324272
let sessionIds = Array(activeSessions.keys)
325273
for sessionId in sessionIds {
326274
await disconnectSession(sessionId)
@@ -475,43 +423,15 @@ final class DatabaseManager {
475423
// Stop any existing monitor
476424
await stopHealthMonitor(for: connectionId)
477425

478-
// Create a dedicated lightweight driver for pings so they never
479-
// queue behind long-running user queries on the main driver.
480-
if let session = activeSessions[connectionId] {
481-
let connectionForPing = session.effectiveConnection ?? session.connection
482-
let dedicatedPingDriver: DatabaseDriver
483-
do {
484-
dedicatedPingDriver = try DatabaseDriverFactory.createDriver(for: connectionForPing)
485-
} catch {
486-
Self.logger.warning("Failed to create ping driver for \(connectionId): \(error.localizedDescription)")
487-
return
488-
}
489-
do {
490-
try await dedicatedPingDriver.connect()
491-
pingDrivers[connectionId] = dedicatedPingDriver
492-
} catch {
493-
Self.logger.warning(
494-
"Failed to create dedicated ping driver, will fall back to main driver")
495-
}
496-
}
497-
498426
let monitor = ConnectionHealthMonitor(
499427
connectionId: connectionId,
500428
pingHandler: { [weak self] in
501429
guard let self else { return false }
502-
// Prefer the dedicated ping driver so pings are never blocked
503-
// by long-running user queries on the main driver.
504-
let pingDriver = await self.pingDrivers[connectionId]
505-
let driver: DatabaseDriver
506-
if let pingDriver {
507-
driver = pingDriver
508-
} else if let mainDriver = await self.activeSessions[connectionId]?.driver {
509-
driver = mainDriver
510-
} else {
430+
guard let mainDriver = await self.activeSessions[connectionId]?.driver else {
511431
return false
512432
}
513433
do {
514-
_ = try await driver.execute(query: "SELECT 1")
434+
_ = try await mainDriver.execute(query: "SELECT 1")
515435
return true
516436
} catch {
517437
return false
@@ -527,15 +447,6 @@ final class DatabaseManager {
527447
session.status = .connected
528448
}
529449

530-
// Also reconnect the dedicated ping driver so future pings
531-
// don't fail immediately after a successful main reconnect.
532-
let connectionForPing = session.effectiveConnection ?? session.connection
533-
let newPingDriver = try await MainActor.run {
534-
try DatabaseDriverFactory.createDriver(for: connectionForPing)
535-
}
536-
try await newPingDriver.connect()
537-
await self.replacePingDriver(newPingDriver, for: connectionId)
538-
539450
return true
540451
} catch {
541452
return false
@@ -610,22 +521,11 @@ final class DatabaseManager {
610521
return driver
611522
}
612523

613-
/// Replace the dedicated ping driver for a connection, disconnecting the old one.
614-
private func replacePingDriver(_ newDriver: DatabaseDriver, for connectionId: UUID) {
615-
pingDrivers[connectionId]?.disconnect()
616-
pingDrivers[connectionId] = newDriver
617-
}
618-
619524
/// Stop health monitoring for a connection
620525
private func stopHealthMonitor(for connectionId: UUID) async {
621526
if let monitor = healthMonitors.removeValue(forKey: connectionId) {
622527
await monitor.stopMonitoring()
623528
}
624-
625-
// Disconnect and remove the dedicated ping driver
626-
if let pingDriver = pingDrivers.removeValue(forKey: connectionId) {
627-
pingDriver.disconnect()
628-
}
629529
}
630530

631531
/// Reconnect the current session (called from toolbar Reconnect button)
@@ -650,7 +550,6 @@ final class DatabaseManager {
650550

651551
do {
652552
// Disconnect existing drivers
653-
session.metadataDriver?.disconnect()
654553
session.driver?.disconnect()
655554

656555
// Recreate SSH tunnel if needed and build effective connection
@@ -688,40 +587,6 @@ final class DatabaseManager {
688587
session.effectiveConnection = effectiveConnection
689588
}
690589

691-
// Recreate metadata connection in background
692-
let metaConnection = effectiveConnection
693-
let metaConnectionId = sessionId
694-
let metaTimeout = AppSettingsManager.shared.general.queryTimeoutSeconds
695-
let startupCmds = session.connection.startupCommands
696-
let connName = session.connection.name
697-
metadataCreationTasks[metaConnectionId] = Task { [weak self] in
698-
guard let self else { return }
699-
defer { self.metadataCreationTasks.removeValue(forKey: metaConnectionId) }
700-
do {
701-
let metaDriver = try DatabaseDriverFactory.createDriver(for: metaConnection)
702-
try await metaDriver.connect()
703-
if metaTimeout > 0 {
704-
try? await metaDriver.applyQueryTimeout(metaTimeout)
705-
}
706-
await self.executeStartupCommands(
707-
startupCmds, on: metaDriver, connectionName: connName
708-
)
709-
if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema,
710-
let schemaMetaDriver = metaDriver as? SchemaSwitchable {
711-
try? await schemaMetaDriver.switchSchema(to: savedSchema)
712-
}
713-
// Restore database on metadata driver too for MSSQL
714-
if let savedDatabase = self.activeSessions[metaConnectionId]?.currentDatabase,
715-
let adapter = metaDriver as? PluginDriverAdapter {
716-
try? await adapter.switchDatabase(to: savedDatabase)
717-
}
718-
activeSessions[metaConnectionId]?.metadataDriver = metaDriver
719-
} catch {
720-
Self.logger.warning(
721-
"Metadata reconnection failed: \(error.localizedDescription)")
722-
}
723-
}
724-
725590
// Restart health monitoring
726591
if session.connection.type != .sqlite {
727592
await startHealthMonitor(for: sessionId)

0 commit comments

Comments
 (0)