Skip to content

Commit e1d88fb

Browse files
authored
fix(perf): open tables without waiting behind background schema introspection (#1483)
* feat(sidebar): show all databases on the server as a tree (#139) * fix(sidebar): metadata pool lifecycle, reconnect teardown, and observation cancellation (#139) * feat(sidebar): tree/flat layout option with native styling and large-list perf (#139) * fix(sidebar): tree context-menu targets the clicked database and pool clears canceled tasks (#139) * refactor(sidebar): database-qualified tree row identity and serialized metadata pool (#139) * fix(sidebar): reload stranded tree rows after switching active database (#139) * fix(sidebar): keep tree schema list stable when switching the active schema (#139) * fix(sidebar): load tree routines per schema and skip loads while reconnecting (#139) * fix(sidebar): retry failed tree loads after reconnect instead of blocking loads (#139) * fix(perf): open tables without waiting behind background schema introspection * perf(datagrid): run exact and filtered row counts on the bulk metadata lane (#1483) * refactor(query): extract duplicated table-schema sidecar fetch into one helper (#1483) --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 634c3cb commit e1d88fb

18 files changed

Lines changed: 318 additions & 195 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.
18+
- 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)
1819

1920
## [0.46.0] - 2026-05-28
2021

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, workload: .bulk, { 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, workload: .bulk) { 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 @escaping (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: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,16 @@ import Foundation
99
final class MetadataConnectionPool {
1010
static let shared = MetadataConnectionPool()
1111

12+
enum Workload: Hashable, Sendable {
13+
case interactive
14+
case bulk
15+
}
16+
1217
private struct Key: Hashable, Sendable {
1318
let connectionId: UUID
1419
let database: String
20+
let schema: String?
21+
let workload: Workload
1522
}
1623

1724
@MainActor
@@ -45,17 +52,21 @@ final class MetadataConnectionPool {
4552

4653
private var entries: [Key: Entry] = [:]
4754
private var pending: [Key: Task<Void, Error>] = [:]
48-
private let maxPerConnection = 4
55+
private let maxPerConnection = 6
4956
private let connectTimeoutSeconds: UInt64 = 15
5057

5158
private init() {}
5259

5360
func withDriver<T: Sendable>(
5461
connectionId: UUID,
5562
database: String,
63+
schema: String? = nil,
64+
workload: Workload = .interactive,
5665
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
5766
) async throws -> T {
58-
let entry = try await acquireEntry(connectionId: connectionId, database: database)
67+
let entry = try await acquireEntry(
68+
connectionId: connectionId, database: database, schema: schema, workload: workload
69+
)
5970
entry.inFlightCount += 1
6071
entry.lastUsed = Date()
6172
defer { releaseEntry(entry) }
@@ -88,8 +99,13 @@ final class MetadataConnectionPool {
8899
}
89100
}
90101

91-
private func acquireEntry(connectionId: UUID, database: String) async throws -> Entry {
92-
let key = Key(connectionId: connectionId, database: database)
102+
private func acquireEntry(
103+
connectionId: UUID,
104+
database: String,
105+
schema: String?,
106+
workload: Workload
107+
) async throws -> Entry {
108+
let key = Key(connectionId: connectionId, database: database, schema: schema, workload: workload)
93109
if let entry = entries[key], entry.driver.status == .connected {
94110
return entry
95111
}
@@ -136,6 +152,13 @@ final class MetadataConnectionPool {
136152
)
137153
do {
138154
try await connectWithTimeout(driver: driver, database: key.database)
155+
try? await driver.applyQueryTimeout(AppSettingsManager.shared.general.queryTimeoutSeconds)
156+
await DatabaseManager.shared.executeStartupCommands(
157+
session.connection.startupCommands, on: driver, connectionName: session.connection.name
158+
)
159+
if let schema = key.schema, let switchable = driver as? SchemaSwitchable {
160+
try await switchable.switchSchema(to: schema)
161+
}
139162
} catch {
140163
driver.disconnect()
141164
throw error

TablePro/Core/Services/Query/QueryExecutor.swift

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,7 @@ 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
68-
}
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-
)
66+
try await Self.fetchTableSchema(connectionId: connId, tableName: tableName)
7867
}
7968
}
8069

@@ -174,19 +163,23 @@ final class QueryExecutor {
174163
if let parallelTask {
175164
return try? await parallelTask.value
176165
}
177-
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil }
178166
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)
167+
return try await fetchTableSchema(connectionId: connectionId, tableName: tableName)
184168
} catch {
185169
queryExecutorLog.error("Phase 2 schema fetch failed: \(error.localizedDescription, privacy: .public)")
186170
return nil
187171
}
188172
}
189173

174+
static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> SchemaResult {
175+
try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in
176+
let columns = try await driver.fetchColumns(table: tableName)
177+
let foreignKeys = try await driver.fetchForeignKeys(table: tableName)
178+
let approximateRowCount = try? await driver.fetchApproximateRowCount(table: tableName)
179+
return (columnInfo: columns, fkInfo: foreignKeys, approximateRowCount: approximateRowCount)
180+
}
181+
}
182+
190183
static func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata {
191184
var defaults: [String: String?] = [:]
192185
var fks: [String: ForeignKeyInfo] = [:]

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
}

TablePro/ViewModels/AIChatViewModel+SchemaContext.swift

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,23 @@ extension AIChatViewModel {
2929
await inFlight.value
3030
return
3131
}
32-
guard let connection,
33-
let driver = services.databaseManager.driver(for: connection.id) else { return }
32+
guard let connection else { return }
33+
let connId = connection.id
3434
let task: Task<Void, Never> = Task { [weak self] in
3535
let columns: [ColumnInfo]
3636
do {
37-
columns = try await driver.fetchColumns(table: tableName)
37+
columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in
38+
try await driver.fetchColumns(table: tableName)
39+
}
3840
} catch {
3941
Self.logger.warning("Column fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)")
4042
columns = []
4143
}
4244
let fkMap: [String: [ForeignKeyInfo]]
4345
do {
44-
fkMap = try await driver.fetchForeignKeys(forTables: [tableName])
46+
fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in
47+
try await driver.fetchForeignKeys(forTables: [tableName])
48+
}
4549
} catch {
4650
Self.logger.warning("Foreign key fetch failed for \(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)")
4751
fkMap = [:]
@@ -92,8 +96,8 @@ extension AIChatViewModel {
9296
}
9397

9498
private func runSchemaLoad() async {
95-
guard let connection,
96-
let driver = services.databaseManager.driver(for: connection.id) else { return }
99+
guard let connection else { return }
100+
let connId = connection.id
97101
let settings = services.appSettings.ai
98102
let tablesToFetch = Array(tables.prefix(settings.maxSchemaTables))
99103
guard !tablesToFetch.isEmpty else { return }
@@ -103,7 +107,9 @@ extension AIChatViewModel {
103107
let name = table.name
104108
group.addTask {
105109
do {
106-
let cols = try await driver.fetchColumns(table: name)
110+
let cols = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in
111+
try await driver.fetchColumns(table: name)
112+
}
107113
return (name, cols)
108114
} catch {
109115
Self.logger.warning("Schema column fetch failed for \(name, privacy: .public): \(error.localizedDescription, privacy: .public)")
@@ -121,7 +127,9 @@ extension AIChatViewModel {
121127
let needsFKFetch = tablesToFetch.contains { foreignKeysByTable[$0.name] == nil }
122128
guard needsFKFetch else { return }
123129
do {
124-
let fkMap = try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name))
130+
let fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in
131+
try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name))
132+
}
125133
for (name, fks) in fkMap {
126134
foreignKeysByTable[name] = fks
127135
}

0 commit comments

Comments
 (0)