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
12 changes: 12 additions & 0 deletions .claude/skills/fix-issue/scripts/lib/platform-apple.sh
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,18 @@ platform_step() {
"$APPLE_ROOT/scripts/build-hana.sh" "$(uname -m)" >> "$log" 2>&1 \
&& note "built the HANA helper first: $helper"
fi
helper="$APPLE_ROOT/Native/DamengBridge/lib/libdameng_bridge.a"
if [ "$scheme" = "AllPlugins" ] && [ ! -e "$helper" ]; then
require_tool rustup
"$APPLE_ROOT/scripts/build-dameng.sh" "$(uname -m)" >> "$log" 2>&1
code=$?
if [ $code -ne 0 ]; then
STATUS=FAIL
note "Dameng bridge build failed; see the log"
emit "$log" "$code"
fi
note "built the Dameng bridge first: $helper"
fi
# shellcheck disable=SC2046
xcodebuild -project "$APPLE_ROOT/TablePro.xcodeproj" -scheme "$scheme" \
-configuration Debug build $(xcodebuild_flags) >> "$log" 2>&1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,34 @@ struct RowImportRunnerTests {
#expect(sink.calls == ["insertRows(1)"])
}
}


extension RowImportRunnerTests {
@Test("Skip and Continue propagates cancellation from the sink", arguments: [false, true])
func skipCancellation(taskCancellation: Bool) async {
let sink = MockImportSink()
sink.onInsertRow = { _ in
if taskCancellation { throw CancellationError() }
throw PluginImportCancellationError()
}
do {
_ = try await RowImportRunner.run(configuration: configuration(.skipAndContinue), sink: sink,
progress: makeProgress(), nextBatch: provider([[entry(1), entry(2)]]))
Issue.record("Cancellation must leave the runner")
} catch {
#expect(error is PluginImportCancellationError || error is CancellationError)
}
#expect(sink.calls == ["insertRow"])
}

@Test("Task cancellation in a batch never applies Stop and Commit error policy")
func batchTaskCancellation() async {
let sink = MockImportSink()
sink.onInsertRows = { _ in throw CancellationError() }
await #expect(throws: CancellationError.self) {
try await RowImportRunner.run(configuration: configuration(.stopAndCommit), sink: sink,
progress: makeProgress(), nextBatch: provider([[entry(1)]]))
}
#expect(sink.calls == ["begin", "insertRows(1)", "rollback"])
}
}
29 changes: 18 additions & 11 deletions apple/Plugins/FormatPluginTests/SQLImportTransactionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ private final class RecordingSink: PluginImportDataSink, @unchecked Sendable {

var calls: [Call] = []
var statementsToFail: Set<String> = []
var cancellation: (any Error)?
var executed = 0
var rollbackFails = false
var enableForeignKeysFails = false

let databaseTypeId = "SQLite"

func execute(statement: String) async throws {
executed += 1
if let cancellation { throw cancellation }
if statementsToFail.contains(statement) {
throw FailingStatementError(message: "no such table: nope")
}
Expand Down Expand Up @@ -75,12 +79,6 @@ private final class StatementSource: PluginImportSource, @unchecked Sendable {
func fileSizeBytes() -> Int64 { 0 }
}

/// A transaction is committed at most once and rolled back only while it is open. Rolling back a
/// transaction that already committed fails on the server, and that failure used to be reported to
/// the user in place of the real one (#2314).
///
/// Serialized because `SQLImportPlugin.settings` persists through plugin storage, so two
/// instances in flight at once read each other's error-handling mode.
@Suite("SQL import transaction lifecycle", .serialized)
struct SQLImportTransactionTests {
private func runImport(
Expand All @@ -89,8 +87,6 @@ struct SQLImportTransactionTests {
errorHandling: ImportErrorHandling,
sink: RecordingSink
) async -> (any Error)? {
/// `settings` persists through plugin storage, so a test that writes it changes the
/// developer's own preference unless it puts the value back.
let plugin = SQLImportPlugin()
let original = plugin.settings
defer { plugin.settings = original }
Expand Down Expand Up @@ -124,7 +120,6 @@ struct SQLImportTransactionTests {
#expect(sink.calls.contains(.rollback) == false)
}

/// The rollback would fail, and that failure would replace the statement error the user needs.
@Test("Stop and commit still reports the statement that failed")
func stopAndCommitReportsTheStatement() async throws {
let sink = RecordingSink()
Expand Down Expand Up @@ -157,8 +152,6 @@ struct SQLImportTransactionTests {
#expect(sink.calls.contains(.commit) == false)
}

/// The rows are in the database. Telling the user the import failed invites a re-run and a
/// second copy of every row.
@Test("A committed import is not failed by the foreign key restore")
func committedImportSurvivesAFailedForeignKeyRestore() async {
let sink = RecordingSink()
Expand All @@ -173,3 +166,17 @@ struct SQLImportTransactionTests {
#expect(sink.calls.contains(.rollback) == false)
}
}


extension SQLImportTransactionTests {
@Test("Cancellation is never swallowed by Skip and Continue", arguments: [false, true])
func skipDoesNotSwallowCancellation(taskCancellation: Bool) async {
let sink = RecordingSink()
sink.cancellation = taskCancellation ? CancellationError() : PluginImportCancellationError()
let error = await runImport(["INSERT INTO t VALUES (1)", "INSERT INTO t VALUES (2)"],
errorHandling: .skipAndContinue, sink: sink)
#expect(error is PluginImportCancellationError || error is CancellationError)
#expect(sink.executed == 1)
#expect(!sink.calls.contains(.commit))
}
}
10 changes: 1 addition & 9 deletions apple/Plugins/SQLImportPlugin/SQLImportFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,9 @@
import Foundation
import TableProPluginKit

/// What the user is told when an import fails.
///
/// The cause is the only thing carrying the line number and the failing statement, so a rollback
/// that also fails on the way out is appended to it rather than thrown in its place. Reporting the
/// rollback alone left the user reading "Transaction rollback failed" with nothing about what
/// actually broke, and it turned a cancellation into an error (#2314).
internal enum SQLImportFailure {
internal static func error(cause: any Error, rollbackError: (any Error)?) -> any Error {
if cause is PluginImportCancellationError {
if cause is PluginImportCancellationError || cause is CancellationError {
return cause
}
guard let rollbackError else {
Expand Down Expand Up @@ -45,8 +39,6 @@ internal struct RollbackAlsoFailedError: LocalizedError {
internal let cause: any Error
internal let rollbackError: any Error

/// The database's own message rarely ends in punctuation, so the two are put on separate lines
/// rather than run together into one unreadable sentence.
internal var message: String {
"""
\(cause.localizedDescription)
Expand Down
14 changes: 9 additions & 5 deletions apple/Plugins/SQLImportPlugin/SQLImportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ final class SQLImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugi
try await sink.execute(statement: statement, line: lineNumber)
executedCount += 1
progress.incrementStatement()
} catch is PluginImportCancellationError {
throw PluginImportCancellationError()
} catch is CancellationError {
throw CancellationError()
} catch {
switch errorMode {
case .stopAndRollback:
Expand All @@ -92,14 +96,14 @@ final class SQLImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugi
try await sink.commitTransaction()
didCommit = true
} catch {
Self.logger.warning("Failed to commit partial import: \(error.localizedDescription)")
Self.logger.warning("Failed to commit partial import")
}
}
if settings.disableForeignKeyChecks {
do {
try await sink.enableForeignKeyChecks()
} catch {
Self.logger.warning("Failed to re-enable foreign key checks: \(error.localizedDescription)")
Self.logger.warning("Failed to re-enable foreign key checks")
}
}
throw PluginImportError.statementFailed(
Expand Down Expand Up @@ -134,7 +138,7 @@ final class SQLImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugi
do {
try await sink.enableForeignKeyChecks()
} catch {
Self.logger.warning("Failed to re-enable foreign key checks: \(error.localizedDescription)")
Self.logger.warning("Failed to re-enable foreign key checks")
}
}
} catch {
Expand All @@ -146,7 +150,7 @@ final class SQLImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugi
try await sink.rollbackTransaction()
} catch {
Self.logger.error(
"Import failed: \(importError.localizedDescription). Rollback also failed: \(error.localizedDescription)"
"The import and its rollback both failed"
)
rollbackError = error
}
Expand All @@ -156,7 +160,7 @@ final class SQLImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugi
do {
try await sink.enableForeignKeyChecks()
} catch {
Self.logger.warning("Failed to re-enable foreign key checks: \(error.localizedDescription)")
Self.logger.warning("Failed to re-enable foreign key checks")
}
}

Expand Down
19 changes: 11 additions & 8 deletions apple/Plugins/TableProPluginKit/RowImportRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,9 @@ public enum RowImportRunner {
inserted += batch.count
progress.incrementStatement(by: batch.count)
} catch is PluginImportCancellationError {
/// A Stop keeps its own type. The sink splits one hand-off into several statements and
/// checks cancellation before each, so this catch sees cancellation where it used to see
/// only driver errors; wrapped as a statement failure, Stop-and-Commit committed the
/// prefix already written and recorded the stop as a failed import.
throw PluginImportCancellationError()
} catch is CancellationError {
throw CancellationError()
} catch {
let firstLine = batch.first?.line ?? 0
throw PluginImportError.statementFailed(
Expand All @@ -124,6 +122,10 @@ public enum RowImportRunner {
do {
try await sink.insertRow(entry.row)
inserted += 1
} catch is PluginImportCancellationError {
throw PluginImportCancellationError()
} catch is CancellationError {
throw CancellationError()
} catch {
skipped += 1
if errors.count < configuration.maxRecordedErrors {
Expand All @@ -145,21 +147,22 @@ public enum RowImportRunner {
configuration: Configuration
) async throws -> Never {
if useTransaction {
if configuration.errorHandling == .stopAndCommit, !(error is PluginImportCancellationError) {
if configuration.errorHandling == .stopAndCommit,
!(error is PluginImportCancellationError), !(error is CancellationError) {
do {
try await sink.commitTransaction()
} catch {
logger.warning("Commit of partial import failed: \(error.localizedDescription)")
logger.warning("Commit of partial import failed")
}
} else {
do {
try await sink.rollbackTransaction()
} catch {
logger.warning("Rollback after failed import also failed: \(error.localizedDescription)")
logger.warning("Rollback after failed import also failed")
}
}
}
if error is PluginImportCancellationError { throw error }
if error is PluginImportCancellationError || error is CancellationError { throw error }
if error is PluginImportError { throw error }
throw PluginImportError.importFailed(error.localizedDescription)
}
Expand Down
Loading
Loading