Skip to content

Commit c0505c0

Browse files
committed
fix(perf): open tables without waiting behind background schema introspection
1 parent c88cff5 commit c0505c0

18 files changed

Lines changed: 331 additions & 199 deletions

CHANGELOG.md

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

1212
- The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139)
1313

14+
### Fixed
15+
16+
- Opening a table on a connection with many tables no longer stalls for several seconds while autocomplete and table metadata load. Background schema introspection now runs on separate connections instead of waiting behind, or blocking, the query that fills the grid. (#1483)
17+
1418
## [0.46.0] - 2026-05-28
1519

1620
### Added

TablePro/Core/Autocomplete/SQLSchemaProvider.swift

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,19 @@ actor SQLSchemaProvider {
2525
private var loadTask: Task<Void, Never>?
2626
private var eagerColumnTask: Task<Void, Never>?
2727

28-
// Store a weak driver reference to avoid retaining it after disconnect (MEM-9)
29-
private weak var cachedDriver: (any DatabaseDriver)?
28+
struct ColumnMetadataSource: Sendable {
29+
let fetchColumns: @Sendable (_ table: String) async throws -> [ColumnInfo]
30+
let fetchAllColumns: @Sendable () async throws -> [String: [ColumnInfo]]
31+
}
3032

31-
// Store connection info for reference
33+
private weak var cachedDriver: (any DatabaseDriver)?
34+
private let metadataSource: ColumnMetadataSource?
3235
private var connectionInfo: DatabaseConnection?
3336

37+
init(metadataSource: ColumnMetadataSource? = nil) {
38+
self.metadataSource = metadataSource
39+
}
40+
3441
// MARK: - Public API
3542

3643
/// Load schema from the database (driver should already be connected).
@@ -97,12 +104,15 @@ actor SQLSchemaProvider {
97104
return cached
98105
}
99106

100-
guard let driver = cachedDriver else {
101-
return []
102-
}
103-
104107
do {
105-
let columns = try await driver.fetchColumns(table: tableName)
108+
let columns: [ColumnInfo]
109+
if let metadataSource {
110+
columns = try await metadataSource.fetchColumns(tableName)
111+
} else if let driver = cachedDriver {
112+
columns = try await driver.fetchColumns(table: tableName)
113+
} else {
114+
return []
115+
}
106116
columnCache[key] = columns
107117
columnAccessOrder.append(key)
108118
evictIfNeeded()
@@ -168,13 +178,23 @@ actor SQLSchemaProvider {
168178
// MARK: - Eager Column Loading
169179

170180
private func startEagerColumnLoad() {
171-
guard !tables.isEmpty, let driver = cachedDriver else { return }
181+
guard !tables.isEmpty else { return }
182+
let source = metadataSource
183+
let driver = cachedDriver
184+
guard source != nil || driver != nil else { return }
172185
eagerColumnTask?.cancel()
173186
let tableCount = tables.count
174-
eagerColumnTask = Task {
187+
eagerColumnTask = Task(priority: .utility) {
175188
Self.logger.info("[schema] eager column load starting tableCount=\(tableCount)")
176189
do {
177-
let allColumns = try await driver.fetchAllColumns()
190+
let allColumns: [String: [ColumnInfo]]
191+
if let source {
192+
allColumns = try await source.fetchAllColumns()
193+
} else if let driver {
194+
allColumns = try await driver.fetchAllColumns()
195+
} else {
196+
return
197+
}
178198
guard !Task.isCancelled else { return }
179199
self.populateColumnCache(allColumns)
180200
Self.logger.info("[schema] eager column load complete cachedCount=\(self.columnCache.count)")

TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -261,26 +261,22 @@ extension QueryExecutionCoordinator {
261261

262262
let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql
263263
guard !isNonSQL else { return }
264-
guard let enumDriver = DatabaseManager.shared.driver(for: parent.connectionId) else { return }
265-
Task(priority: .background) { [weak self, parent] in
264+
Task(priority: .utility) { [weak self, parent] in
266265
guard let self else { return }
267266
guard !parent.isTearingDown else { return }
268267

269268
let columnInfo: [ColumnInfo]
270269
if let schema = schemaResult {
271270
columnInfo = schema.columnInfo
272271
} else {
273-
do {
274-
columnInfo = try await enumDriver.fetchColumns(table: tableName)
275-
} catch {
276-
columnInfo = []
277-
}
272+
columnInfo = (try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in
273+
try await driver.fetchColumns(table: tableName)
274+
}) ?? []
278275
}
279276

280277
let columnEnumValues = await parent.fetchEnumValues(
281278
columnInfo: columnInfo,
282279
tableName: tableName,
283-
driver: enumDriver,
284280
connectionType: connectionType
285281
)
286282

@@ -336,10 +332,9 @@ extension QueryExecutionCoordinator {
336332
) {
337333
let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql
338334

339-
Task(priority: .background) { [weak self, parent] in
335+
Task(priority: .utility) { [weak self, parent] in
340336
guard let self else { return }
341337
guard !parent.isTearingDown else { return }
342-
guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { return }
343338

344339
let prepared: (plan: RowCountPlan, sql: String?) = await MainActor.run {
345340
guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return (.skip, nil) }
@@ -366,24 +361,33 @@ extension QueryExecutionCoordinator {
366361
case .clear:
367362
outcome = .clear
368363
case .approximate:
369-
guard let count = try? await driver.fetchApproximateRowCount(table: tableName) else { return }
364+
guard let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in
365+
try await driver.fetchApproximateRowCount(table: tableName)
366+
}) else { return }
370367
outcome = .count(count, isApproximate: true)
371368
case let .filteredNonSQL(filters, logicMode):
372-
if let count = try? await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) {
369+
if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in
370+
try await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode)
371+
}) {
373372
outcome = .count(count, isApproximate: false)
374373
} else {
375374
outcome = .clear
376375
}
377376
case .exactCount:
378377
guard let sql = prepared.sql else { return }
378+
let count: Int?
379379
do {
380-
let result = try await driver.execute(query: sql)
381-
guard let countStr = result.rows.first?.first?.asText, let count = Int(countStr) else { return }
382-
outcome = .count(count, isApproximate: false)
380+
count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId) { driver in
381+
let result = try await driver.execute(query: sql)
382+
guard let countStr = result.rows.first?.first?.asText else { return Int?.none }
383+
return Int(countStr)
384+
}
383385
} catch {
384386
helpersLogger.warning("COUNT query failed for \(tableName): \(error.localizedDescription)")
385387
return
386388
}
389+
guard let count else { return }
390+
outcome = .count(count, isApproximate: false)
387391
}
388392

389393
await MainActor.run {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// DatabaseManager+Metadata.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
extension DatabaseManager {
9+
func withMetadataDriver<T: Sendable>(
10+
connectionId: UUID,
11+
workload: MetadataConnectionPool.Workload = .interactive,
12+
_ body: @Sendable (DatabaseDriver) async throws -> T
13+
) async throws -> T {
14+
guard let session = session(for: connectionId) else {
15+
throw DatabaseError.notConnected
16+
}
17+
return try await MetadataConnectionPool.shared.withDriver(
18+
connectionId: connectionId,
19+
database: session.activeDatabase,
20+
schema: session.currentSchema,
21+
workload: workload,
22+
body
23+
)
24+
}
25+
}

TablePro/Core/Services/Query/MetadataConnectionPool.swift

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,16 @@ import os
1010
final class MetadataConnectionPool {
1111
static let shared = MetadataConnectionPool()
1212

13+
enum Workload: Hashable, Sendable {
14+
case interactive
15+
case bulk
16+
}
17+
1318
private struct Key: Hashable, Sendable {
1419
let connectionId: UUID
1520
let database: String
21+
let schema: String?
22+
let workload: Workload
1623
}
1724

1825
private final class Entry {
@@ -29,7 +36,7 @@ final class MetadataConnectionPool {
2936

3037
private var entries: [Key: Entry] = [:]
3138
private var pending: [Key: Task<Void, Error>] = [:]
32-
private let maxPerConnection = 4
39+
private let maxPerConnection = 6
3340
private let connectTimeoutSeconds: UInt64 = 15
3441
private static let logger = Logger(subsystem: "com.TablePro", category: "MetadataConnectionPool")
3542

@@ -38,21 +45,28 @@ final class MetadataConnectionPool {
3845
func withDriver<T: Sendable>(
3946
connectionId: UUID,
4047
database: String,
48+
schema: String? = nil,
49+
workload: Workload = .interactive,
4150
_ body: @Sendable (DatabaseDriver) async throws -> T
4251
) async throws -> T {
43-
let entry = try await acquireEntry(connectionId: connectionId, database: database)
52+
let entry = try await acquireEntry(
53+
connectionId: connectionId, database: database, schema: schema, workload: workload
54+
)
4455
entry.inFlightCount += 1
4556
entry.lastUsed = Date()
4657
defer { entry.inFlightCount -= 1 }
4758
return try await body(entry.driver)
4859
}
4960

5061
func invalidate(connectionId: UUID, database: String) {
51-
let key = Key(connectionId: connectionId, database: database)
52-
pending[key]?.cancel()
53-
pending.removeValue(forKey: key)
54-
entries[key]?.driver.disconnect()
55-
entries.removeValue(forKey: key)
62+
let matching = Set(entries.keys.filter { $0.connectionId == connectionId && $0.database == database })
63+
.union(pending.keys.filter { $0.connectionId == connectionId && $0.database == database })
64+
for key in matching {
65+
pending[key]?.cancel()
66+
pending.removeValue(forKey: key)
67+
entries[key]?.driver.disconnect()
68+
entries.removeValue(forKey: key)
69+
}
5670
}
5771

5872
func closeAll(connectionId: UUID) {
@@ -72,8 +86,10 @@ final class MetadataConnectionPool {
7286
}
7387
}
7488

75-
private func acquireEntry(connectionId: UUID, database: String) async throws -> Entry {
76-
let key = Key(connectionId: connectionId, database: database)
89+
private func acquireEntry(
90+
connectionId: UUID, database: String, schema: String?, workload: Workload
91+
) async throws -> Entry {
92+
let key = Key(connectionId: connectionId, database: database, schema: schema, workload: workload)
7793
if let entry = entries[key], entry.driver.status == .connected {
7894
return entry
7995
}
@@ -126,6 +142,18 @@ final class MetadataConnectionPool {
126142
)
127143
do {
128144
try await connectWithTimeout(driver: driver, database: key.database)
145+
await DatabaseManager.shared.executeStartupCommands(
146+
session.connection.startupCommands, on: driver, connectionName: session.connection.name
147+
)
148+
if let schema = key.schema, let switchable = driver as? SchemaSwitchable {
149+
do {
150+
try await switchable.switchSchema(to: schema)
151+
} catch {
152+
Self.logger.warning(
153+
"[metadata-pool] schema switch failed connId=\(key.connectionId, privacy: .public) schema=\(schema, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
154+
)
155+
}
156+
}
129157
} catch {
130158
driver.disconnect()
131159
throw error

TablePro/Core/Services/Query/QueryExecutor.swift

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,12 @@ final class QueryExecutor {
6363
var parallelSchemaTask: Task<SchemaResult, Error>?
6464
if fetchSchemaForTable, let tableName, !tableName.isEmpty {
6565
parallelSchemaTask = Task {
66-
guard let driver = DatabaseManager.shared.driver(for: connId) else {
67-
throw DatabaseError.notConnected
66+
try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in
67+
let cols = try await driver.fetchColumns(table: tableName)
68+
let fks = try await driver.fetchForeignKeys(table: tableName)
69+
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
70+
return (columnInfo: cols, fkInfo: fks, approximateRowCount: approxCount)
6871
}
69-
async let cols = driver.fetchColumns(table: tableName)
70-
async let fks = driver.fetchForeignKeys(table: tableName)
71-
let result = try await (columnInfo: cols, fkInfo: fks)
72-
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
73-
return (
74-
columnInfo: result.columnInfo,
75-
fkInfo: result.fkInfo,
76-
approximateRowCount: approxCount
77-
)
7872
}
7973
}
8074

@@ -174,13 +168,13 @@ final class QueryExecutor {
174168
if let parallelTask {
175169
return try? await parallelTask.value
176170
}
177-
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil }
178171
do {
179-
async let cols = driver.fetchColumns(table: tableName)
180-
async let fks = driver.fetchForeignKeys(table: tableName)
181-
let (c, f) = try await (cols, fks)
182-
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
183-
return (columnInfo: c, fkInfo: f, approximateRowCount: approxCount)
172+
return try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in
173+
let cols = try await driver.fetchColumns(table: tableName)
174+
let fks = try await driver.fetchForeignKeys(table: tableName)
175+
let approxCount = try? await driver.fetchApproximateRowCount(table: tableName)
176+
return (columnInfo: cols, fkInfo: fks, approximateRowCount: approxCount)
177+
}
184178
} catch {
185179
queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)")
186180
return nil

TablePro/Core/Services/Query/SchemaProviderRegistry.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,19 @@ final class SchemaProviderRegistry {
6363
if let existing = providers[connectionId] {
6464
return existing
6565
}
66-
let provider = SQLSchemaProvider()
66+
let source = SQLSchemaProvider.ColumnMetadataSource(
67+
fetchColumns: { table in
68+
try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in
69+
try await driver.fetchColumns(table: table)
70+
}
71+
},
72+
fetchAllColumns: {
73+
try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in
74+
try await driver.fetchAllColumns()
75+
}
76+
}
77+
)
78+
let provider = SQLSchemaProvider(metadataSource: source)
6779
providers[connectionId] = provider
6880
return provider
6981
}

0 commit comments

Comments
 (0)