Improved progress display
Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported.
This commit is contained in:
@@ -1569,6 +1569,9 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
@State private var database = ""
|
||||
@State private var isWorking = false
|
||||
@State private var progress = 0.0
|
||||
@State private var transferredRows: Int64 = 0
|
||||
@State private var currentTableRows: Int64 = 0
|
||||
@State private var currentTableExpected: Int64 = 0
|
||||
@State private var status = "Ready"
|
||||
@State private var operationTask: Task<Void, Never>?
|
||||
@State private var showingImportConfirmation = false
|
||||
@@ -1707,6 +1710,12 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
if isWorking && selectedMode == 0 && currentTableExpected > 0 {
|
||||
ProgressView(value: Double(currentTableRows), total: Double(currentTableExpected))
|
||||
Text("Current table: \(currentTableRows.formatted()) / \(currentTableExpected.formatted()) rows · Total exported: \(transferredRows.formatted())")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1975,6 +1984,9 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
exportSession = dedicatedProfile
|
||||
isWorking = true
|
||||
progress = 0
|
||||
transferredRows = 0
|
||||
currentTableRows = 0
|
||||
currentTableExpected = 0
|
||||
let tables = availableTables.filter { selectedTables.contains($0) }
|
||||
let tempURL = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).sql.tmp")
|
||||
var handle: FileHandle?
|
||||
@@ -2090,12 +2102,18 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
let expectedCount = Int64(countText) else {
|
||||
throw WorkbenchTransferError.message("Cannot count rows in \(table)")
|
||||
}
|
||||
currentTableRows = 0
|
||||
currentTableExpected = expectedCount
|
||||
try write("-- Streamed snapshot: \(expectedCount) rows; order: \(usableIndex ?? "server scan")\n")
|
||||
let select = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified)" +
|
||||
(orderBy.map { " ORDER BY " + $0 } ?? "")
|
||||
let outputHandle = handle!
|
||||
let prefix = Data("INSERT INTO \(qualified) (\(names)) VALUES ".utf8)
|
||||
let suffix = Data(";\n".utf8)
|
||||
let completedBeforeTable = transferredRows
|
||||
let completedTables = index
|
||||
let totalUnits = max(tables.count + views.count, 1)
|
||||
var streamedInTable: Int64 = 0
|
||||
let written = try await NativeMySQLRunner.stream(
|
||||
profile: dedicatedProfile,
|
||||
database: database,
|
||||
@@ -2105,7 +2123,21 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
try outputHandle.write(contentsOf: prefix)
|
||||
try outputHandle.write(contentsOf: Data(literal.utf8))
|
||||
try outputHandle.write(contentsOf: suffix)
|
||||
streamedInTable += 1
|
||||
if streamedInTable % 500 == 0 || streamedInTable == expectedCount {
|
||||
let completed = streamedInTable
|
||||
DispatchQueue.main.async {
|
||||
currentTableRows = completed
|
||||
transferredRows = completedBeforeTable + completed
|
||||
progress = (Double(completedTables) +
|
||||
Double(completed) / Double(max(expectedCount, 1))) /
|
||||
Double(totalUnits)
|
||||
status = "Exporting \(table): \(completed.formatted()) / \(expectedCount.formatted()) rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
currentTableRows = written
|
||||
transferredRows = completedBeforeTable + written
|
||||
guard written == expectedCount else {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Table \(table): expected \(expectedCount) rows, streamed \(written). Backup discarded."
|
||||
@@ -2113,6 +2145,8 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
}
|
||||
status = "Exported \(table): \(written) rows"
|
||||
}
|
||||
currentTableExpected = 0
|
||||
currentTableRows = 0
|
||||
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
|
||||
}
|
||||
// Views are exported after base tables so dependencies exist on restore.
|
||||
@@ -2199,7 +2233,7 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
do {
|
||||
try WorkbenchBackupIntegrity.createSidecar(for: url)
|
||||
progress = 1
|
||||
status = "Export complete and SHA-256 verified: \(tables.count) tables, \(views.count) views"
|
||||
status = "Export complete and SHA-256 verified: \(tables.count) tables, \(views.count) views, \(transferredRows.formatted()) rows"
|
||||
} catch {
|
||||
progress = 1
|
||||
status = "SQL export complete, but checksum creation failed: \(error.localizedDescription)"
|
||||
@@ -2237,30 +2271,30 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
}.value
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
let sql = try String(
|
||||
contentsOf: url,
|
||||
encoding: .utf8
|
||||
)
|
||||
let fileSize = (try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value ?? 0
|
||||
var statementNumber = 0
|
||||
var lastLine = 1
|
||||
let reader = try WorkbenchStreamingSQLReader(url: url)
|
||||
defer { reader.close() }
|
||||
|
||||
let statements = try WorkbenchSQLImportParser.parse(sql)
|
||||
|
||||
guard !statements.isEmpty else {
|
||||
status = "No SQL statements found"
|
||||
return
|
||||
}
|
||||
|
||||
for (index, statement) in statements.enumerated() {
|
||||
while let entry = try reader.nextStatement() {
|
||||
try Task.checkCancellation()
|
||||
|
||||
status = "Executing statement \(index + 1) of \(statements.count)"
|
||||
|
||||
_ = try await execute(statement)
|
||||
|
||||
progress = Double(index + 1) /
|
||||
Double(statements.count)
|
||||
statementNumber += 1
|
||||
lastLine = entry.line
|
||||
status = "Importing statement \(statementNumber) · line \(lastLine)"
|
||||
do {
|
||||
_ = try await execute(entry.sql)
|
||||
} catch {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Statement \(statementNumber), line \(lastLine): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
if statementNumber % 10 == 0 || reader.bytesRead >= fileSize {
|
||||
progress = fileSize > 0 ? min(1, Double(reader.bytesRead) / Double(fileSize)) : 1
|
||||
}
|
||||
}
|
||||
|
||||
status = "Import complete: \(statements.count) statements"
|
||||
progress = 1
|
||||
status = "Import complete: \(statementNumber) statements"
|
||||
|
||||
model.loadDatabases()
|
||||
|
||||
@@ -2467,3 +2501,129 @@ private enum WorkbenchBackupIntegrity {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Phase 11: bounded-memory SQL dump reader
|
||||
// Keeps only the current SQL statement in memory. A single very large
|
||||
// INSERT still requires enough memory to hold that statement.
|
||||
final class WorkbenchStreamingSQLReader {
|
||||
struct Entry { let sql: String; let line: Int }
|
||||
private let handle: FileHandle
|
||||
private var bytes = Data()
|
||||
private var reachedEOF = false
|
||||
private(set) var bytesRead: Int64 = 0
|
||||
private var lineNumber = 0
|
||||
private var statementStart = 1
|
||||
private var statement = ""
|
||||
private var delimiter = ";"
|
||||
private var quote: Character?
|
||||
private var escaped = false
|
||||
private var blockComment = false
|
||||
private var lineComment = false
|
||||
private var pending: [Entry] = []
|
||||
|
||||
init(url: URL) throws { handle = try FileHandle(forReadingFrom: url) }
|
||||
func close() { try? handle.close() }
|
||||
|
||||
func nextStatement() throws -> Entry? {
|
||||
while pending.isEmpty {
|
||||
guard let line = try readLine() else {
|
||||
if quote != nil || blockComment {
|
||||
throw WorkbenchTransferError.message("Unterminated quote or comment near line \(lineNumber)")
|
||||
}
|
||||
let tail = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
statement = ""
|
||||
if !tail.isEmpty { return Entry(sql: tail, line: statementStart) }
|
||||
return nil
|
||||
}
|
||||
lineNumber += 1
|
||||
try consume(line)
|
||||
}
|
||||
return pending.removeFirst()
|
||||
}
|
||||
|
||||
private func readLine() throws -> String? {
|
||||
while true {
|
||||
if let end = bytes.firstIndex(of: 10) {
|
||||
let data = bytes.prefix(through: end)
|
||||
bytes.removeSubrange(...end)
|
||||
guard let line = String(data: data, encoding: .utf8) else {
|
||||
throw WorkbenchTransferError.message("Invalid UTF-8 at line \(lineNumber + 1)")
|
||||
}
|
||||
return line
|
||||
}
|
||||
if reachedEOF {
|
||||
guard !bytes.isEmpty else { return nil }
|
||||
let data = bytes
|
||||
bytes.removeAll(keepingCapacity: true)
|
||||
guard let line = String(data: data, encoding: .utf8) else {
|
||||
throw WorkbenchTransferError.message("Invalid UTF-8 at end of SQL file")
|
||||
}
|
||||
return line
|
||||
}
|
||||
let chunk = try handle.read(upToCount: 64 * 1024) ?? Data()
|
||||
bytesRead += Int64(chunk.count)
|
||||
if chunk.isEmpty { reachedEOF = true }
|
||||
else { bytes.append(chunk) }
|
||||
}
|
||||
}
|
||||
|
||||
private func consume(_ line: String) throws {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if quote == nil && !blockComment && statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
trimmed.uppercased().hasPrefix("DELIMITER ") {
|
||||
let token = String(trimmed.dropFirst(10)).trimmingCharacters(in: .whitespaces)
|
||||
guard !token.isEmpty && !token.contains(where: { $0.isWhitespace }) else {
|
||||
throw WorkbenchTransferError.message("Invalid DELIMITER at line \(lineNumber)")
|
||||
}
|
||||
delimiter = token
|
||||
return
|
||||
}
|
||||
let chars = Array(line)
|
||||
let delim = Array(delimiter)
|
||||
var i = 0
|
||||
lineComment = false
|
||||
while i < chars.count {
|
||||
let ch = chars[i]
|
||||
let next: Character? = i + 1 < chars.count ? chars[i + 1] : nil
|
||||
if lineComment { break }
|
||||
if blockComment {
|
||||
statement.append(ch)
|
||||
if ch == "*" && next == "/" {
|
||||
statement.append("/"); i += 2; blockComment = false
|
||||
} else { i += 1 }
|
||||
continue
|
||||
}
|
||||
if let q = quote {
|
||||
statement.append(ch)
|
||||
if escaped { escaped = false }
|
||||
else if ch == "\\" && q != "`" { escaped = true }
|
||||
else if ch == q {
|
||||
if next == q { statement.append(ch); i += 1 }
|
||||
else { quote = nil }
|
||||
}
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if ch == "'" || ch == "\"" || ch == "`" {
|
||||
quote = ch; statement.append(ch); i += 1; continue
|
||||
}
|
||||
if ch == "#" || (ch == "-" && next == "-" &&
|
||||
(i + 2 >= chars.count || chars[i + 2].isWhitespace)) {
|
||||
lineComment = true; break
|
||||
}
|
||||
if ch == "/" && next == "*" {
|
||||
statement.append("/*"); blockComment = true; i += 2; continue
|
||||
}
|
||||
if i + delim.count <= chars.count && Array(chars[i..<(i + delim.count)]) == delim {
|
||||
let sql = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !sql.isEmpty { pending.append(Entry(sql: sql, line: statementStart)) }
|
||||
statement = ""; i += delim.count; statementStart = lineNumber; continue
|
||||
}
|
||||
if statement.isEmpty && !ch.isWhitespace { statementStart = lineNumber }
|
||||
statement.append(ch)
|
||||
i += 1
|
||||
}
|
||||
if !statement.isEmpty { statement.append("\n") }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user