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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483)
- AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291)
- iOS: a connection's Safe Mode setting now survives relaunch. iCloud sync no longer drops the value, so a connection set to Confirm Writes or Read-Only no longer reverts to Off after reopening the app.
- iOS: running a query that returns a very large result no longer crashes the app. The query editor keeps the first rows it loads, stops before memory runs low, and tells you to add LIMIT to fetch more.
- iOS: Safe Mode "Confirm Writes" now prompts before saving a row edit or inserting a row, matching the query editor. Previously grid edits and inserts saved with no confirmation.

## [0.45.0] - 2026-05-26
Expand Down
12 changes: 12 additions & 0 deletions TableProMobile/TableProMobile/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -3862,6 +3862,9 @@
}
}
}
},
"Showing the first %d rows. Add LIMIT to fetch more." : {

},
"Skip" : {
"localizations" : {
Expand Down Expand Up @@ -4087,6 +4090,12 @@
}
}
}
},
"Stopped at %d rows to stay within memory limits. Add LIMIT to fetch fewer." : {

},
"Stopped. Showing %d rows." : {

},
"Switching..." : {
"extractionState" : "stale",
Expand Down Expand Up @@ -4298,6 +4307,9 @@
}
}
}
},
"The database limited the result. Showing %d rows." : {

},
"The operation violates a database constraint." : {
"localizations" : {
Expand Down
99 changes: 99 additions & 0 deletions TableProMobile/TableProMobile/Models/StreamingResultBuffer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Foundation
import TableProModels

@MainActor
@Observable
final class StreamingResultBuffer {
private(set) var columns: [ColumnInfo] = []
private(set) var window: RowWindow
private(set) var legacyRows: [[String?]] = []
private(set) var rowsAffected: Int?
private(set) var statusMessage: String?
private(set) var truncation: TruncationReason?

@ObservationIgnored private var pendingRows: [Row] = []
@ObservationIgnored private var flushTask: Task<Void, Never>?

private static let flushBatchSize = 200
private static let flushInterval: Duration = .milliseconds(50)

init(capacity: Int) {
self.window = RowWindow(capacity: capacity)
}

var count: Int { legacyRows.count }
var isEmpty: Bool { legacyRows.isEmpty }

func apply(_ element: StreamElement) {
switch element {
case .columns(let cols):
columns = cols
case .row(let row):
pendingRows.append(row)
scheduleFlush()
case .rowsAffected(let count):
flush()
rowsAffected = count
case .statusMessage(let message):
flush()
statusMessage = message
case .truncated(let reason):
flush()
truncation = reason
}
}

func markTruncated(_ reason: TruncationReason) {
truncation = reason
}

func flush() {
flushTask?.cancel()
flushTask = nil
guard !pendingRows.isEmpty else { return }
let legacyBatch = pendingRows.map(\.legacyValues)
window.append(contentsOf: pendingRows)
legacyRows.append(contentsOf: legacyBatch)
if legacyRows.count > window.count {
legacyRows.removeFirst(legacyRows.count - window.count)
}
pendingRows.removeAll(keepingCapacity: true)
}

func cancelFlush() {
flushTask?.cancel()
flushTask = nil
}

func shrink(to maxCount: Int) {
window.shrink(to: maxCount)
guard legacyRows.count > maxCount else { return }
legacyRows.removeFirst(legacyRows.count - maxCount)
}

func reset() {
flushTask?.cancel()
flushTask = nil
columns = []
window.clear()
legacyRows.removeAll(keepingCapacity: true)
rowsAffected = nil
statusMessage = nil
truncation = nil
pendingRows.removeAll(keepingCapacity: true)
}

private func scheduleFlush() {
if pendingRows.count >= Self.flushBatchSize {
flush()
return
}
if flushTask == nil {
flushTask = Task { [weak self] in
try? await Task.sleep(for: Self.flushInterval)
guard !Task.isCancelled else { return }
self?.flush()
}
}
}
}
17 changes: 10 additions & 7 deletions TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,21 @@ final class MemoryPressureMonitor {
guard source == nil else { return }

let newSource = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical],
eventMask: [.normal, .warning, .critical],
queue: .global(qos: .utility)
)

newSource.setEventHandler { [weak self] in
let event = newSource.data
let level: Level = event.contains(.critical) ? .critical : .warning
Self.logger.warning("Memory pressure event: \(String(describing: level), privacy: .public)")
let level: Level
if event.contains(.critical) {
level = .critical
} else if event.contains(.warning) {
level = .warning
} else {
level = .normal
}
Self.logger.log("Memory pressure level: \(String(describing: level), privacy: .public)")
Task { @MainActor in
self?.currentLevel = level
}
Expand All @@ -40,10 +47,6 @@ final class MemoryPressureMonitor {
source = newSource
}

func reset() {
currentLevel = .normal
}

nonisolated func availableMemoryBytes() -> Int {
Int(os_proc_available_memory())
}
Expand Down
102 changes: 21 additions & 81 deletions TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ final class DataBrowserViewModel {

private static let logger = Logger(subsystem: "com.TablePro", category: "DataBrowserViewModel")

private(set) var columns: [ColumnInfo] = []
private(set) var window: RowWindow
private(set) var legacyRows: [[String?]] = []
private let buffer: StreamingResultBuffer
private(set) var totalRows: Int?
private(set) var phase: Phase = .idle
private(set) var rowsAffected: Int?
private(set) var statusMessage: String?
private(set) var executionTime: TimeInterval = 0

var columns: [ColumnInfo] { buffer.columns }
var window: RowWindow { buffer.window }
var legacyRows: [[String?]] { buffer.legacyRows }
var rowsAffected: Int? { buffer.rowsAffected }
var statusMessage: String? { buffer.statusMessage }

private(set) var columnDetails: [ColumnInfo] = []
private(set) var foreignKeys: [ForeignKeyInfo] = []
private(set) var pagination: PaginationState
Expand All @@ -43,16 +45,11 @@ final class DataBrowserViewModel {
@ObservationIgnored private var table: TableInfo?
@ObservationIgnored private var databaseType: DatabaseType = .mysql
@ObservationIgnored private var host: String = ""
@ObservationIgnored private var pendingRows: [Row] = []
@ObservationIgnored private var flushTask: Task<Void, Never>?
@ObservationIgnored private var fetchTask: Task<Void, Never>?
@ObservationIgnored private var searchTask: Task<Void, Never>?

private static let flushBatchSize = 200
private static let flushInterval: Duration = .milliseconds(50)

init(windowCapacity: Int = 1_000) {
self.window = RowWindow(capacity: windowCapacity)
self.buffer = StreamingResultBuffer(capacity: windowCapacity)
self.pagination = PaginationState(pageSize: AppPreferences.defaultPageSize, currentPage: 0)
}

Expand Down Expand Up @@ -377,24 +374,22 @@ final class DataBrowserViewModel {
break
case .warning:
Self.logger.warning("Memory pressure warning: shrinking window to 100 rows")
self.window.shrink(to: 100)
self.shrinkLegacyRows(to: 100)
if !self.legacyRows.isEmpty {
self.buffer.shrink(to: 100)
if !self.buffer.isEmpty {
self.memoryWarning = String(localized: "Results trimmed due to memory pressure.")
}
case .critical:
Self.logger.error("Memory pressure critical: shrinking window to 50 rows and cancelling")
self.window.shrink(to: 50)
self.shrinkLegacyRows(to: 50)
self.buffer.shrink(to: 50)
self.fetchTask?.cancel()
self.memoryWarning = String(localized: "Results trimmed due to memory pressure.")
}
}
}

func handleSystemMemoryWarning() async {
guard !legacyRows.isEmpty else { return }
Self.logger.warning("System memory warning: shrinking window from \(self.legacyRows.count) rows")
guard !buffer.isEmpty else { return }
Self.logger.warning("System memory warning: shrinking window from \(self.buffer.count) rows")
await handlePressure(.warning)
}

Expand All @@ -418,28 +413,25 @@ final class DataBrowserViewModel {
lazyContext: lazyContext
)
phase = .loading
columns = []
window.clear()
legacyRows.removeAll(keepingCapacity: true)
rowsAffected = nil
statusMessage = nil
pendingRows.removeAll(keepingCapacity: true)
buffer.reset()

let start = Date()
let task = Task { [weak self] in
guard let self else { return }
do {
for try await element in driver.executeStreaming(query: query, options: options) {
if Task.isCancelled { break }
self.apply(element: element)
self.buffer.apply(element)
}
self.flushPendingRows()
self.buffer.flush()
self.executionTime = Date().timeIntervalSince(start)
if case .loading = self.phase {
if let reason = self.buffer.truncation {
self.phase = .truncated(reason: reason)
} else if case .loading = self.phase {
self.phase = .loaded
}
} catch {
self.flushPendingRows()
self.buffer.flush()
self.phase = .error(self.classify(error: error))
}
}
Expand All @@ -449,63 +441,11 @@ final class DataBrowserViewModel {

func cancel() {
fetchTask?.cancel()
flushTask?.cancel()
buffer.cancelFlush()
searchTask?.cancel()
flushTask = nil
searchTask = nil
}

private func apply(element: StreamElement) {
switch element {
case .columns(let cols):
columns = cols
case .row(let row):
pendingRows.append(row)
scheduleFlushIfNeeded()
case .rowsAffected(let count):
flushPendingRows()
rowsAffected = count
case .statusMessage(let message):
flushPendingRows()
statusMessage = message
case .truncated(let reason):
flushPendingRows()
phase = .truncated(reason: reason)
}
}

private func scheduleFlushIfNeeded() {
if pendingRows.count >= Self.flushBatchSize {
flushPendingRows()
return
}
if flushTask == nil {
flushTask = Task { [weak self] in
try? await Task.sleep(for: Self.flushInterval)
guard !Task.isCancelled else { return }
self?.flushPendingRows()
}
}
}

private func flushPendingRows() {
flushTask?.cancel()
flushTask = nil
guard !pendingRows.isEmpty else { return }
let legacyBatch = pendingRows.map(\.legacyValues)
window.append(contentsOf: pendingRows)
legacyRows.append(contentsOf: legacyBatch)
if legacyRows.count > window.count {
legacyRows.removeFirst(legacyRows.count - window.count)
}
pendingRows.removeAll(keepingCapacity: true)
}

private func shrinkLegacyRows(to count: Int) {
guard legacyRows.count > count else { return }
legacyRows.removeFirst(legacyRows.count - count)
}

private func classify(error: Error) -> AppError {
let context = ErrorContext(operation: "loadPage", databaseType: databaseType)
return ErrorClassifier.classify(error, context: context)
Expand Down
Loading
Loading