Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Wrong approximate row count for a PostgreSQL or PGlite table outside the current schema.
- Queued queries running on a reopened connection, or stalling it, after a disconnect.
- Redis keys read from the wrong database when switching databases while a load was running.
- "This tab is on" error on a table tab for the previous database when it reloads during a database switch.
- Table on another database opening empty with no error while the connection switches database.

## [0.74.0] - 2026-09-13

Expand Down
9 changes: 2 additions & 7 deletions TablePro/Core/Coordinators/PaginationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,7 @@ final class PaginationCoordinator {
/// would discard its own rows. It registers as unclaimed work instead, which is what keeps
/// the titlebar reporting it, and releases that on every exit including cancellation.
let workToken = parent.tabExecution.beginUnclaimedWork(for: tabId)

let route = DatabaseManager.shared.executionRoute(for: scope)
let isTableTab = parent.tabManager.tabs[idx].tabType == .table

let startedAt = ContinuousClock.Instant.now
let fetchAllTask = Task { [weak self, parent] in
Expand All @@ -314,11 +313,7 @@ final class PaginationCoordinator {
do {
let start = CFAbsoluteTimeGetCurrent()
progressLog.info("[fetchAll] executing full query: \(baseQuery.prefix(100), privacy: .public)")
let result = try await DatabaseManager.shared.withScopedDriver(
scope: scope,
route: route,
cancellation: .cancellableRead
) { driver in
let result = try await parent.withExecutionDriver(scope: scope, isTableTab: isTableTab) { driver in
try await driver.executeUserQuery(
query: baseQuery,
rowCap: nil,
Expand Down
18 changes: 18 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ extension DatabaseManager {
let attemptedDriver = session.driver
await SchemaService.shared.prepareForReload(connectionId: connectionId)
await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId)
/// A connection that stopped answering has most likely taken its pooled connections with it,
/// and a rebuilt tunnel moves every one of them to a new port, so pooled work waits for the
/// replacement rather than dialing what is being torn down.
MetadataConnectionPool.shared.beginTransportReplacement(connectionId: connectionId)
defer { MetadataConnectionPool.shared.endTransportReplacement(connectionId: connectionId) }

do {
guard let result = try await trackOperation(sessionId: connectionId, operation: {
Expand Down Expand Up @@ -355,6 +360,19 @@ extension DatabaseManager {

await SchemaService.shared.prepareForReload(connectionId: sessionId)
await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: sessionId)
/// A pooled connection stands on the effective connection and its own database, so the pool is
/// only held back when this rebuilds a tunnel on a new port or recovers a session that had
/// stopped answering. A database switch over a live, direct connection changes neither, and
/// closing the pool there withdrew an open that another database's table load was waiting on.
let replacesPooledTransport = session.connection.activeTunnelKind != nil || session.liveness != .live
if replacesPooledTransport {
MetadataConnectionPool.shared.beginTransportReplacement(connectionId: sessionId)
}
defer {
if replacesPooledTransport {
MetadataConnectionPool.shared.endTransportReplacement(connectionId: sessionId)
}
}

await stopHealthMonitor(for: sessionId)

Expand Down
130 changes: 100 additions & 30 deletions TablePro/Core/Database/DatabaseManager+ScopedDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ enum ScopedDriverRoute: Equatable {
case unavailable(String)
}

/// What a table read's turn on the session driver came to.
private enum TableReadTurn<T: Sendable>: Sendable {
case ran(T)
/// The route decided once the turn came, which is never the session driver.
case moved(ScopedDriverRoute)
}

extension DatabaseManager {
/// A metadata read needs no transaction, no temp tables and no cancellation handle,
/// so it takes a pooled connection and leaves the shared driver where it is.
Expand Down Expand Up @@ -76,29 +83,7 @@ extension DatabaseManager {
cancellation: DriverCancellationPolicy,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) async throws -> T {
let leased: @Sendable (DatabaseDriver) async throws -> T
if cancellation.isTracked {
let connectionId = scope.connectionId
let token = UUID()
let entry = RunningDriver(driver: nil, policy: cancellation)
leased = { driver in
await MainActor.run {
DatabaseManager.shared.runningDrivers[connectionId, default: [:]][token] =
entry.adopting(driver)
}
do {
let value = try await body(driver)
await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) }
return value
} catch {
await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) }
throw error
}
}
} else {
leased = body
}

let leased = trackedLease(for: scope.connectionId, cancellation: cancellation, body)
switch route {
case .unavailable(let message):
throw DatabaseError.queryFailed(message)
Expand All @@ -111,6 +96,67 @@ extension DatabaseManager {
}
}

/// A table tab's read is a SELECT the app built from the tab's own table, so it depends on
/// nothing the session holds: no transaction, no temp table, no variable. That makes it the one
/// kind of work that can follow a route change it waited through. A database switch on an engine
/// that reconnects to perform one moves the browsed database while holding the gate, so a read
/// queued behind it for the database being left is re-routed once its turn comes, instead of
/// being refused by `pin`. User SQL never comes here: the session it was written against is gone,
/// and a refusal is the right answer for it.
///
/// The gate is left before the read is dispatched again, so a pooled read never holds it. The
/// loop ends: a turn only ever moves the read off the session driver, and neither the pool nor an
/// unavailable route queues on the gate again.
func withTableReadDriver<T: Sendable>(
scope: DatabaseScope,
cancellation: DriverCancellationPolicy,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) async throws -> T {
let leased = trackedLease(for: scope.connectionId, cancellation: cancellation, body)
var route = executionRoute(for: scope)
while true {
switch route {
case .unavailable(let message):
throw DatabaseError.queryFailed(message)
case .pooled:
return try await MetadataConnectionPool.shared.withDriver(scope: scope, leased)
case .sessionDriver:
switch try await withTableReadSessionDriver(scope: scope, leased) {
case .ran(let value):
return value
case .moved(let decided):
route = decided
}
}
}
}

/// Registers the driver a tracked lease runs on for the length of its body, whichever route it
/// took, so Stop reaches the handle the work is actually on.
private func trackedLease<T: Sendable>(
for connectionId: UUID,
cancellation: DriverCancellationPolicy,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) -> @Sendable (DatabaseDriver) async throws -> T {
guard cancellation.isTracked else { return body }
let token = UUID()
let entry = RunningDriver(driver: nil, policy: cancellation)
return { driver in
await MainActor.run {
DatabaseManager.shared.runningDrivers[connectionId, default: [:]][token] =
entry.adopting(driver)
}
do {
let value = try await body(driver)
await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) }
return value
} catch {
await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) }
throw error
}
}
}

internal func releaseRunningDriver(_ token: UUID, for connectionId: UUID) {
runningDrivers[connectionId]?.removeValue(forKey: token)
if runningDrivers[connectionId]?.isEmpty == true {
Expand Down Expand Up @@ -183,26 +229,50 @@ extension DatabaseManager {
scope: DatabaseScope,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) async throws -> T {
try await withSessionDriverTurn(connectionId: scope.connectionId) { driver in
try await pin(driver, to: scope)
return try await body(driver)
}
}

/// The route is asked again through `executionRoute` itself, so it reads the same inputs the
/// caller's first answer did, the browsed database among them. The driver's own connection is no
/// substitute: it carries the database name the connection resolved to, not the one browsed.
private func withTableReadSessionDriver<T: Sendable>(
scope: DatabaseScope,
_ body: @Sendable @escaping (DatabaseDriver) async throws -> T
) async throws -> TableReadTurn<T> {
try await withSessionDriverTurn(connectionId: scope.connectionId) { driver in
let route = executionRoute(for: scope)
guard route == .sessionDriver else { return .moved(route) }
try await pin(driver, to: scope)
return .ran(try await body(driver))
}
}

private func withSessionDriverTurn<R>(
connectionId: UUID,
_ turn: (DatabaseDriver) async throws -> R
) async throws -> R {
/// Outside the gate on purpose. A verification that has to reconnect runs the whole
/// reconnect, which restores the schema and the database on the new driver, and doing that
/// while holding the gate would deadlock the very thing waiting to be pinned.
await verifyBeforeUse(scope.connectionId)
await verifyBeforeUse(connectionId)
/// A check that failed and could not recover left the driver installed and disconnected,
/// so the presence of a driver below is not enough. Refusing here is the point of checking
/// at all: without it the user's own work runs on a handle the app already knows is dead.
guard isUsable(scope.connectionId) else {
guard isUsable(connectionId) else {
throw DatabaseError.notConnected
}
return try await sessionDriverGate.withExclusiveAccess(scope.connectionId) {
try await trackOperation(sessionId: scope.connectionId) {
return try await sessionDriverGate.withExclusiveAccess(connectionId) {
try await trackOperation(sessionId: connectionId) {
try Task.checkCancellation()
/// Asked again once the lease has its turn, because a database switch that held the
/// gate can have left the driver the same way while this lease waited.
guard isUsable(scope.connectionId), let driver = driver(for: scope.connectionId) else {
guard isUsable(connectionId), let driver = driver(for: connectionId) else {
throw DatabaseError.notConnected
}
try await pin(driver, to: scope)
return try await body(driver)
return try await turn(driver)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -623,9 +623,13 @@ final class DatabaseTreeMetadataService: CatalogChangeTarget {
/// A fetch still running on the driver the reconnect replaced answers for the old session, so
/// every key of the connection is superseded before anything suspends and none of those fetches
/// may commit. What is already on screen stays until the new session's own loads replace it.
///
/// The pooled connections are not this service's to close. They stand on the transport rather
/// than on the session driver, and `DatabaseManager`, which rebuilds the transport, holds them
/// back while it does. Closing them on every reconnect withdrew an open a table tab on another
/// database was waiting on, and that tab then showed nothing and no error.
func handleReconnect(connectionId: UUID) async {
supersedeEveryKey(of: connectionId)
MetadataConnectionPool.shared.closeAll(connectionId: connectionId)
SchemaForeignKeyStore.shared.invalidate(connectionId: connectionId)
await resetPending(connectionId: connectionId)
}
Expand Down
Loading
Loading