Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Background table-tab eviction now releases unpinned row data without dropping query, pinned, edited or in-flight results.
- The result display cache now enforces its memory budget when a cached row's formatted values grow.
- Undo, redo, a theme change and a display-format change no longer leave the data grid reformatting every cell as you scroll.
- Autocomplete no longer bulk-loads every column of a schema too big to cache, so on those databases column suggestions arrive per table as you reference one.
- A background tab whose column metadata arrived after its rows were freed now reloads on return instead of showing an empty grid.
- A saved connection whose SSH settings were written before the agent socket field existed now loads instead of disappearing.
- Switch Connection and Open Database now open on a narrow window, and after you remove their toolbar button, instead of doing nothing at all.
- A large text value in the row inspector now scrolls in a resizable text view instead of being clipped, and stays selectable and copyable when the row is read-only.
Expand Down
90 changes: 73 additions & 17 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,25 @@ import TableProPluginKit
/// Provides cached database schema information for autocomplete
actor SQLSchemaProvider {
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLSchemaProvider")

/// How many tables' columns the cache holds, and therefore how large a schema is worth
/// preloading in one bulk fetch. The two are one number on purpose: fetching every column of a
/// schema and then keeping a fraction of them is the waste the eager load exists to avoid.
/// Columns are small next to row data, so this sits well above the point where a bulk fetch is
/// still one cheap query.
static let maxCachedTables = 300
// MARK: - Properties

private var tables: [TableInfo] = []
private var columnCache: [String: [ColumnInfo]] = [:]
private var columnAccessOrder: [String] = []
private let maxCachedTables = 50
private var isLoading = false
private var lastLoadError: Error?
private var lastRetryAttempt: Date?
private let retryCooldown: TimeInterval = 30
private var loadTask: Task<Void, Never>?
private var eagerColumnTask: Task<Void, Never>?
private var eagerLoadSchema: String?

struct ColumnMetadataSource: Sendable {
let fetchColumns: @Sendable (_ table: String, _ schema: String?) async throws -> [ColumnInfo]
Expand Down Expand Up @@ -75,6 +82,7 @@ actor SQLSchemaProvider {
Self.logger.info("[schema] loadSchema starting new fetch")
let t0 = Date()
self.cachedDriver = driver
self.eagerLoadSchema = (driver as? SchemaSwitchable)?.currentSchema
if let connection { self.connectionInfo = connection }
isLoading = true
lastLoadError = nil
Expand Down Expand Up @@ -146,7 +154,7 @@ actor SQLSchemaProvider {
}

private func evictIfNeeded() {
while columnAccessOrder.count > maxCachedTables {
while columnAccessOrder.count > Self.maxCachedTables {
let evicted = columnAccessOrder.removeFirst()
columnCache.removeValue(forKey: evicted)
}
Expand Down Expand Up @@ -176,40 +184,62 @@ actor SQLSchemaProvider {
}

func resetForDatabase(_ database: String?, tables newTables: [TableInfo], driver: DatabaseDriver) {
eagerColumnTask?.cancel()
eagerColumnTask = nil
self.tables = newTables
self.columnCache.removeAll()
self.columnAccessOrder.removeAll()
self.fieldPathCache.removeAll()
self.fieldPathTasks.removeAll()
self.cachedDriver = driver
self.eagerLoadSchema = (driver as? SchemaSwitchable)?.currentSchema
self.isLoading = false
self.lastLoadError = nil
startEagerColumnLoad()
}

/// Empties the cache without refilling it. The refresh signal that reaches here also runs a
/// schema reload, and that ends in `resetForDatabase`, which starts the preload; restarting it
/// here as well sends a second whole-schema column query for every refresh.
func clearColumnCache() {
eagerColumnTask?.cancel()
eagerColumnTask = nil
columnCache.removeAll()
columnAccessOrder.removeAll()
fieldPathCache.removeAll()
fieldPathTasks.removeAll()
if cachedDriver != nil {
startEagerColumnLoad()
}
}

// MARK: - Eager Column Loading

/// How many tables the bulk fetch will actually return.
///
/// `tables` is the union of the current schema and every other schema the sidebar has expanded,
/// while `fetchAllColumns()` covers one schema. Counting the union turned the preload off for a
/// ten-table `public` as soon as eight other schemas were open. A table the list does not
/// attribute to any schema counts, which is what a flat engine reports for all of them; zero
/// means the list describes other schemas only, and a fetch sized by it would be a guess.
private var eagerLoadTableCount: Int {
guard let eagerLoadSchema else { return tables.count }
return tables.filter { table in
guard let tableSchema = table.schema else { return true }
return tableSchema.caseInsensitiveCompare(eagerLoadSchema) == .orderedSame
}.count
}

private func startEagerColumnLoad() {
guard !tables.isEmpty else { return }
eagerColumnTask?.cancel()
eagerColumnTask = nil

let tableCount = eagerLoadTableCount
guard tableCount > 0 else { return }
guard tableCount <= Self.maxCachedTables else {
Self.logger.info(
"[schema] eager column load skipped tableCount=\(tableCount) limit=\(Self.maxCachedTables)"
)
return
}
let source = metadataSource
let driver = cachedDriver
guard source != nil || driver != nil else { return }
eagerColumnTask?.cancel()
let tableCount = tables.count
eagerColumnTask = Task(priority: .utility) {
Self.logger.info("[schema] eager column load starting tableCount=\(tableCount)")
do {
Expand All @@ -231,14 +261,35 @@ actor SQLSchemaProvider {
}
}

/// Fills the cache in the order the schema lists its tables, so which tables survive the cache
/// limit is the same on every run. Walking the fetched dictionary took whatever order hashing
/// produced, which made the cached set differ between two loads of the same database.
private func populateColumnCache(_ allColumns: [String: [ColumnInfo]]) {
var pending: [String: [ColumnInfo]] = [:]
pending.reserveCapacity(allColumns.count)
for (tableName, columns) in allColumns {
let key = tableName.lowercased()
guard columnCache[key] == nil else { continue }
guard columnAccessOrder.count < maxCachedTables else { break }
columnCache[key] = columns
columnAccessOrder.append(key)
pending[tableName.lowercased()] = columns
}

for table in tables {
guard let columns = pending.removeValue(forKey: table.name.lowercased()) else { continue }
insertIntoColumnCache(columns, forKey: table.name.lowercased())
}
for key in pending.keys.sorted() {
guard let columns = pending[key] else { continue }
insertIntoColumnCache(columns, forKey: key)
}
}

private func insertIntoColumnCache(_ columns: [ColumnInfo], forKey key: String) {
guard columnCache[key] == nil else { return }
guard columnAccessOrder.count < Self.maxCachedTables else { return }
columnCache[key] = columns
columnAccessOrder.append(key)
}

func waitForEagerColumnLoad() async {
await eagerColumnTask?.value
}

/// Find table name from alias
Expand Down Expand Up @@ -411,8 +462,13 @@ actor SQLSchemaProvider {

/// Values a column is restricted to, when the database declares them (a PostgreSQL enum type,
/// a MongoDB `$jsonSchema` enum). Returns nothing for an ordinary column.
/// Reads only what the column cache already holds. Completion runs on every keystroke, so it
/// must never trigger a schema fetch; the eager column preload is what fills this cache.
///
/// Reads only what the column cache already holds, because completion runs on every keystroke
/// and this must never add a fetch of its own. The cache is filled by the eager preload on a
/// schema small enough to preload, and otherwise by `getColumns`, which column completion on the
/// same statement has already called for every table the statement names. A statement whose
/// value position is reached before any column completion ran therefore offers nothing here
/// until the next request.
func allowedValues(forColumn column: String, in references: [TableReference]) -> [String] {
let name = column.lowercased()
let candidates = references.isEmpty
Expand Down
35 changes: 23 additions & 12 deletions TablePro/Core/DataGrid/RowDisplayBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ final class RowDisplayBox {

@MainActor
final class RowDisplayCache {
private var storage: [RowID: RowDisplayBox] = [:]
/// A box is a reference and callers mutate one in place before handing it back, so the cost
/// recorded at insertion is the only number that still describes what was added. Recomputing it
/// from the box on removal subtracts a different figure than was added and drifts `totalCost`
/// away from the budget it is there to enforce.
private struct Entry {
let box: RowDisplayBox
let cost: Int
}

private var storage: [RowID: Entry] = [:]
private var insertionOrder: [RowID] = []
private var insertionHead: Int = 0
private var totalCost: Int = 0
Expand All @@ -28,16 +37,17 @@ final class RowDisplayCache {
}

func box(forID id: RowID) -> RowDisplayBox? {
storage[id]
storage[id]?.box
}

func setBox(_ box: RowDisplayBox, forID id: RowID, cost: Int) {
func setBox(_ box: RowDisplayBox, forID id: RowID) {
let cost = Self.rowCost(box.values)
if let existing = storage[id] {
totalCost -= rowCost(existing.values)
totalCost -= existing.cost
} else {
insertionOrder.append(id)
}
storage[id] = box
storage[id] = Entry(box: box, cost: cost)
totalCost += cost
evictIfNeeded()
}
Expand All @@ -54,11 +64,12 @@ final class RowDisplayCache {
/// whose content changed in place keeps its id and would otherwise be served
/// its pre-edit text.
func clearValues(forID id: RowID) {
guard let box = storage[id] else { return }
totalCost -= rowCost(box.values)
for index in box.values.indices {
box.values[index] = nil
guard let existing = storage[id] else { return }
totalCost -= existing.cost
for index in existing.box.values.indices {
existing.box.values[index] = nil
}
storage[id] = Entry(box: existing.box, cost: 0)
}

private func evictIfNeeded() {
Expand All @@ -67,7 +78,7 @@ final class RowDisplayCache {
let oldest = insertionOrder[insertionHead]
insertionHead += 1
if let removed = storage.removeValue(forKey: oldest) {
totalCost -= rowCost(removed.values)
totalCost -= removed.cost
}
}
if insertionHead > 10_000 {
Expand All @@ -76,10 +87,10 @@ final class RowDisplayCache {
}
}

private func rowCost(_ values: ContiguousArray<String?>) -> Int {
private static func rowCost(_ values: ContiguousArray<String?>) -> Int {
var total = 0
for value in values {
if let s = value { total &+= s.utf8.count }
if let value { total &+= value.utf8.count }
}
return total
}
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Core/Execution/TabExecutionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ internal struct TabExecutionRegistry {
entries[tabId] != nil
}

/// One tab's whole answer to "is anything running here", the per-tab counterpart of
/// `isAnyExecuting`. Work that cannot claim the tab still runs on it: Fetch All extends the
/// result already on screen, so it registers unclaimed work rather than minting a content epoch
/// that would discard its own rows. Anything deciding whether a tab is idle asks this;
/// `isExecuting` answers the narrower question of whether a claim is outstanding.
internal func isBusy(_ tabId: UUID) -> Bool {
entries[tabId] != nil || unclaimedWork[tabId] != nil
}

/// The window's whole answer to "is anything running here", and the only one.
///
/// The toolbar's indicator, Stop, `Cmd+.` and the disconnect warning all read this rather than
Expand Down
14 changes: 12 additions & 2 deletions TablePro/Models/Query/TabSessionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,22 @@ final class TabSessionRegistry {
session.dataRevision &+= 1
}

/// A mutation of what the tab already holds, so it cannot resurrect a tab that holds nothing.
///
/// Phase-2 metadata (foreign keys, defaults, enum values) lands at background priority long
/// after the load that asked for it, and it is keyed on the result rather than on the rows, so
/// it arrives on tabs that were evicted while it was in flight. Clearing `isEvicted` for a
/// mutation that leaves the buffer empty leaves a tab with no rows that `canAutoLoadTableTab`
/// reads as already loaded, and eviction cannot re-mark it because it has nothing left to lose:
/// the grid stays empty until an explicit refresh.
func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Void) {
let session = ensureSession(for: tabId)
var rows = session.tableRows
mutate(&rows)
session.tableRows = rows
session.isEvicted = false
if !rows.rows.isEmpty {
session.isEvicted = false
}
session.dataRevision &+= 1
}

Expand All @@ -72,7 +82,7 @@ final class TabSessionRegistry {
func evict(for tabId: UUID) {
guard let session = sessions[tabId] else { return }
guard !session.tableRows.rows.isEmpty else { return }
session.tableRows.rows = []
session.tableRows.discardRowsKeepingMetadata()
session.isEvicted = true
session.dataRevision &+= 1
}
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Models/Query/TableRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ struct TableRows: Sendable {
return rows[index]
}

/// Releases the row payload while keeping the schema needed to render and reload the table.
mutating func discardRowsKeepingMetadata() {
rows = []
indexByID = [:]
}

@discardableResult
mutating func edit(row: Int, column: Int, value: PluginCellValue) -> Delta {
guard row >= 0, row < rows.count else { return .none }
Expand Down
Loading
Loading