fix: continue transfer after table failure

This commit is contained in:
t8y2 2026-05-28 16:11:29 +08:00
parent 1c03917609
commit d35aeaa08c
10 changed files with 138 additions and 41 deletions

View File

@ -279,9 +279,11 @@ async function startTransfer() {
try {
await api.startTransfer(request, (progress) => {
transferProgress.value.set(progress.table, progress);
transferProgress.value = new Map(transferProgress.value);
currentTable.value = progress.table;
if (progress.table) {
transferProgress.value.set(progress.table, progress);
transferProgress.value = new Map(transferProgress.value);
currentTable.value = progress.table;
}
const nextState = nextTransferTerminalState(
{
@ -317,13 +319,38 @@ function getConnectionType(id: string): DatabaseType {
return store.connections.find((c) => c.id === id)?.db_type ?? "mysql";
}
const processedStatuses = new Set<TransferProgress["status"]>(["tableDone", "done", "error", "cancelled"]);
function formatRowCount(count: number) {
return count.toLocaleString();
}
function formatTableRows(progress: TransferProgress) {
if (typeof progress.totalRows === "number") {
return `${formatRowCount(progress.rowsTransferred)} / ${formatRowCount(progress.totalRows)}`;
}
return formatRowCount(progress.rowsTransferred);
}
const completedTables = computed(
() => [...transferProgress.value.values()].filter((p) => p.status === "tableDone" || p.status === "done").length,
() => [...transferProgress.value.values()].filter((p) => processedStatuses.has(p.status)).length,
);
const failedTables = computed(() => [...transferProgress.value.values()].filter((p) => p.status === "error").length);
const totalTransferred = computed(() =>
[...transferProgress.value.values()].reduce((sum, p) => sum + p.rowsTransferred, 0),
);
const knownTotalRows = computed(() =>
[...transferProgress.value.values()].reduce((sum, p) => sum + (typeof p.totalRows === "number" ? p.totalRows : 0), 0),
);
const overallRowsLabel = computed(() =>
knownTotalRows.value > 0
? `${formatRowCount(totalTransferred.value)} / ${formatRowCount(knownTotalRows.value)}`
: formatRowCount(totalTransferred.value),
);
</script>
<template>
@ -532,10 +559,15 @@ const totalTransferred = computed(() =>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>
{{ t("transfer.overallProgress") }}: {{ completedTables }} / {{ selectedTables.size }}
{{ t("transfer.tables").toLowerCase() }} · {{ totalTransferred.toLocaleString() }}
{{ t("transfer.tables").toLowerCase() }} · {{ overallRowsLabel }}
{{ t("grid.rows", { count: "" }).trim() }}
</span>
<span v-if="overallDone" class="text-green-600 font-medium">{{ t("transfer.completed") }}</span>
<span v-if="overallDone && !failedTables" class="text-green-600 font-medium">{{
t("transfer.completed")
}}</span>
<span v-else-if="overallDone && failedTables" class="text-amber-600 font-medium">
{{ t("transfer.completedWithErrors", { count: failedTables }) }}
</span>
<span v-else-if="overallCancelled" class="text-yellow-600 font-medium">{{ t("transfer.cancelled") }}</span>
<span v-else-if="overallError" class="text-destructive font-medium">{{ t("transfer.failed") }}</span>
</div>
@ -543,7 +575,15 @@ const totalTransferred = computed(() =>
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="overallError ? 'bg-destructive' : overallCancelled ? 'bg-yellow-500' : 'bg-primary'"
:class="
overallError
? 'bg-destructive'
: overallCancelled
? 'bg-yellow-500'
: overallDone && failedTables
? 'bg-amber-500'
: 'bg-primary'
"
:style="{
width: `${selectedTables.size ? (completedTables / selectedTables.size) * 100 : 0}%`,
}"
@ -561,7 +601,7 @@ const totalTransferred = computed(() =>
<template v-if="transferProgress.get(table)">
<template v-if="transferProgress.get(table)!.status === 'running'">
<Loader2 class="w-3 h-3 animate-spin text-primary" />
<span>{{ transferProgress.get(table)!.rowsTransferred.toLocaleString() }}</span>
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
</template>
<template
v-else-if="
@ -570,10 +610,11 @@ const totalTransferred = computed(() =>
"
>
<Check class="w-3 h-3 text-green-500" />
<span>{{ transferProgress.get(table)!.rowsTransferred.toLocaleString() }}</span>
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
</template>
<template v-else-if="transferProgress.get(table)!.status === 'error'">
<X class="w-3 h-3 text-destructive" />
<span>{{ formatTableRows(transferProgress.get(table)!) }}</span>
<span
class="text-destructive truncate max-w-[160px]"
:title="transferProgress.get(table)!.error ?? ''"

View File

@ -1226,6 +1226,7 @@ export default {
progress: "Transferring...",
transferring: "Transferring {table}...",
completed: "Transfer completed",
completedWithErrors: "Completed with {count} failed table | Completed with {count} failed tables",
failed: "Transfer failed",
cancelled: "Transfer cancelled",
rowsTransferred: "{count} rows transferred",

View File

@ -1118,6 +1118,7 @@ export default {
progress: "Transfiriendo...",
transferring: "Transfiriendo {table}...",
completed: "Transferencia completada",
completedWithErrors: "Completada con {count} tabla con error | Completada con {count} tablas con error",
failed: "Error en la transferencia",
cancelled: "Transferencia cancelada",
rowsTransferred: "{count} filas transferidas",

View File

@ -1202,6 +1202,7 @@ export default {
progress: "传输中...",
transferring: "正在传输 {table}...",
completed: "传输完成",
completedWithErrors: "传输完成,{count} 个表失败",
failed: "传输失败",
cancelled: "传输已取消",
rowsTransferred: "已传输 {count} 行",

View File

@ -929,7 +929,7 @@ export async function startTransfer(
es.onmessage = (e) => {
const progress: TransferProgress = JSON.parse(e.data);
onProgress(progress);
if (progress.status === "done" || progress.status === "error" || progress.status === "cancelled") {
if (progress.status === "done" || progress.status === "cancelled") {
es.close();
resolve();
}

View File

@ -1246,20 +1246,24 @@ export async function startTransfer(
request: TransferRequest,
onProgress: (progress: TransferProgress) => void,
): Promise<void> {
const unlisten: UnlistenFn = await listen<TransferProgress>("transfer-progress", (event) => {
if (event.payload.transferId === request.transferId) {
onProgress(event.payload);
if (event.payload.status === "done" || event.payload.status === "error" || event.payload.status === "cancelled") {
unlisten();
}
return new Promise(async (resolve, reject) => {
let unlisten: UnlistenFn | null = null;
try {
unlisten = await listen<TransferProgress>("transfer-progress", (event) => {
if (event.payload.transferId !== request.transferId) return;
onProgress(event.payload);
if (event.payload.status === "done" || event.payload.status === "cancelled") {
unlisten?.();
resolve();
}
});
await invoke("start_transfer", { request });
} catch (e) {
unlisten?.();
reject(e);
}
});
try {
await invoke("start_transfer", { request });
} catch (e) {
unlisten();
throw e;
}
}
export async function cancelTransfer(transferId: string): Promise<void> {

View File

@ -14,6 +14,5 @@ export function nextTransferTerminalState(
): TransferTerminalState {
if (progress.status === "done") return { ...state, done: true };
if (progress.status === "cancelled") return { ...state, cancelled: true };
if (progress.status === "error") return { ...state, error: true };
return state;
}

View File

@ -71,7 +71,28 @@ pub async fn start_transfer(
let tables = req.tables.clone();
for (i, table) in tables.iter().enumerate() {
if transfer::is_cancelled(&req.transfer_id).await {
let progress = transfer::TransferProgress {
transfer_id: req.transfer_id.clone(),
table: table.clone(),
table_index: i,
total_tables: tables.len(),
rows_transferred: 0,
total_rows: None,
status: TransferStatus::Cancelled,
error: None,
};
if let Ok(json) = serde_json::to_string(&progress) {
let _ = tx.send(json);
}
transfer::clear_cancelled(&req.transfer_id).await;
state_clone.remove_sse_channel(&req.transfer_id).await;
return;
}
let tx_clone = tx.clone();
let mut last_rows_transferred = 0_u64;
let mut last_total_rows = None;
let result = transfer::transfer_table(
&app,
&req,
@ -82,6 +103,8 @@ pub async fn start_transfer(
&source_pool_key,
&target_pool_key,
|progress| {
last_rows_transferred = progress.rows_transferred;
last_total_rows = progress.total_rows;
if let Ok(json) = serde_json::to_string(&progress) {
let _ = tx_clone.send(json);
}
@ -96,8 +119,8 @@ pub async fn start_transfer(
table: table.clone(),
table_index: i,
total_tables: tables.len(),
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows.or(Some(last_rows_transferred)),
status: TransferStatus::TableDone,
error: None,
};
@ -111,15 +134,14 @@ pub async fn start_transfer(
table: table.clone(),
table_index: i,
total_tables: tables.len(),
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Error,
error: Some(e),
};
if let Ok(json) = serde_json::to_string(&progress) {
let _ = tx.send(json);
}
break;
}
}
}
@ -139,6 +161,7 @@ pub async fn start_transfer(
let _ = tx.send(json);
}
transfer::clear_cancelled(&req.transfer_id).await;
state_clone.remove_sse_channel(&req.transfer_id).await;
});

View File

@ -2,13 +2,13 @@ import { strict as assert } from "node:assert";
import test from "node:test";
import { nextTransferTerminalState } from "../../apps/desktop/src/lib/transferProgressState.ts";
test("marks transfer as failed when a table progress event reports error", () => {
test("does not mark transfer as failed when only a table progress event reports error", () => {
const state = nextTransferTerminalState(
{ done: false, cancelled: false, error: false },
{ status: "error" },
);
assert.deepEqual(state, { done: false, cancelled: false, error: true });
assert.deepEqual(state, { done: false, cancelled: false, error: false });
});
test("keeps terminal flags for done and cancelled progress events", () => {
@ -21,3 +21,16 @@ test("keeps terminal flags for done and cancelled progress events", () => {
{ done: false, cancelled: true, error: false },
);
});
test("still marks transfer as done after earlier table errors", () => {
const afterError = nextTransferTerminalState(
{ done: false, cancelled: false, error: false },
{ status: "error" },
);
assert.deepEqual(nextTransferTerminalState(afterError, { status: "done" }), {
done: true,
cancelled: false,
error: false,
});
});

View File

@ -54,6 +54,9 @@ pub async fn start_transfer(
log::info!("[transfer] table {}/{}: {}", i + 1, total_tables, table);
let mut last_rows_transferred = 0_u64;
let mut last_total_rows = None;
match dbx_core::transfer::transfer_table(
&state,
&request,
@ -63,7 +66,11 @@ pub async fn start_transfer(
&target_db_type,
&source_pool_key,
&target_pool_key,
|progress| emit_progress(&app, progress),
|progress| {
last_rows_transferred = progress.rows_transferred;
last_total_rows = progress.total_rows;
emit_progress(&app, progress);
},
)
.await
{
@ -76,12 +83,8 @@ pub async fn start_transfer(
table_index: i,
total_tables,
rows_transferred: rows,
total_rows: Some(rows),
status: if i == total_tables - 1 {
TransferStatus::Done
} else {
TransferStatus::TableDone
},
total_rows: last_total_rows.or(Some(rows)),
status: TransferStatus::TableDone,
error: None,
},
);
@ -111,18 +114,29 @@ pub async fn start_transfer(
table: table.clone(),
table_index: i,
total_tables,
rows_transferred: 0,
total_rows: None,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Error,
error: Some(e),
},
);
dbx_core::transfer::clear_cancelled(&transfer_id).await;
return;
}
}
}
emit_progress(
&app,
TransferProgress {
transfer_id: transfer_id.clone(),
table: String::new(),
table_index: total_tables,
total_tables,
rows_transferred: 0,
total_rows: None,
status: TransferStatus::Done,
error: None,
},
);
dbx_core::transfer::clear_cancelled(&transfer_id).await;
});