fix(export): keep MySQL database export snapshots alive
This commit is contained in:
parent
71a3b383d3
commit
9d348f8ef7
|
|
@ -68,7 +68,10 @@ const progressValue = (task: ExportTask) => {
|
|||
};
|
||||
|
||||
const taskTitle = (task: ExportTask) => {
|
||||
if (task.kind === "database-export") return t("exportProgress.databaseExportTitle", { name: task.tableName });
|
||||
if (task.kind === "database-export") {
|
||||
const key = task.databaseExportSource === "scheduled" ? "exportProgress.databaseBackupTitle" : "exportProgress.databaseExportTitle";
|
||||
return t(key, { name: task.tableName });
|
||||
}
|
||||
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
|
||||
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
|
||||
return `${task.tableName}.${task.format}`;
|
||||
|
|
@ -113,6 +116,18 @@ const elapsedText = (task: ExportTask) => {
|
|||
return t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - task.startedAt) });
|
||||
};
|
||||
|
||||
const databaseObjectText = (task: ExportTask) => {
|
||||
if (task.kind !== "database-export" || !isActive(task.status) || !task.currentObject) return "";
|
||||
if (task.preparing || !task.totalObjects) {
|
||||
return t("databaseExport.preparingObject", { object: task.currentObject });
|
||||
}
|
||||
return t("databaseExport.currentTable", {
|
||||
table: task.currentObject,
|
||||
current: (task.objectIndex ?? 0).toLocaleString(),
|
||||
total: task.totalObjects.toLocaleString(),
|
||||
});
|
||||
};
|
||||
|
||||
const statusIcon = (task: ExportTask) => {
|
||||
if (isActive(task.status)) {
|
||||
if (task.kind === "database-export") return DatabaseBackup;
|
||||
|
|
@ -172,7 +187,7 @@ function failureDetailCount(task: ExportTask) {
|
|||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="relative h-8 w-8" :title="triggerTitle" :class="{ 'bg-destructive/10 text-destructive hover:bg-destructive/15': failedCount > 0, 'bg-accent text-primary': failedCount === 0 && hasActive }">
|
||||
<FileDown class="h-4 w-4" />
|
||||
<span v-if="failedCount > 0" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold leading-none text-destructive-foreground"> ! </span>
|
||||
<span v-if="failedCount > 0" class="absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-background" />
|
||||
<span v-else-if="hasActive" class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground">
|
||||
{{ activeCount > 9 ? "9+" : activeCount }}
|
||||
</span>
|
||||
|
|
@ -207,6 +222,10 @@ function failureDetailCount(task: ExportTask) {
|
|||
<div class="h-full bg-green-500 rounded-full" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
<div v-if="databaseObjectText(task)" class="min-w-0 truncate text-muted-foreground" :title="task.currentObject">
|
||||
{{ databaseObjectText(task) }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 text-muted-foreground">
|
||||
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
|
||||
<span v-if="task.kind !== 'data-transfer' && task.startedAt !== undefined" class="ml-1 tabular-nums">{{ elapsedText(task) }}</span>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,96 @@ async function mountPopover() {
|
|||
}
|
||||
|
||||
describe("ExportProgressPopover task duration", () => {
|
||||
it("distinguishes manual exports from scheduled backups and uses a compact failure dot", async () => {
|
||||
const tracker = useExportTracker();
|
||||
tracker.addDatabaseExportTask("manual-export", "app", "/tmp/app.sql");
|
||||
tracker.addDatabaseExportTask("scheduled-backup", "Nightly", "/tmp/backups", "scheduled");
|
||||
tracker.updateDatabaseExportTask("manual-export", {
|
||||
exportId: "manual-export",
|
||||
currentObject: "",
|
||||
objectIndex: 0,
|
||||
totalObjects: 0,
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "Error",
|
||||
error: "export failed",
|
||||
});
|
||||
|
||||
await mountPopover();
|
||||
|
||||
expect(document.body.textContent).toContain("Database export: app");
|
||||
expect(document.body.textContent).toContain("Database backup: Nightly");
|
||||
const failureDot = document.body.querySelector<HTMLSpanElement>("button > span");
|
||||
expect(failureDot?.classList.contains("h-2.5")).toBe(true);
|
||||
expect(failureDot?.classList.contains("w-2.5")).toBe(true);
|
||||
expect(failureDot?.classList.contains("right-0.5")).toBe(true);
|
||||
expect(failureDot?.classList.contains("top-0.5")).toBe(true);
|
||||
expect(failureDot?.textContent?.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("shows the current database export object without replacing the task title", async () => {
|
||||
const tracker = useExportTracker();
|
||||
const task = tracker.addDatabaseExportTask("database-export", "demo_2000_tables", "/tmp/demo.sql");
|
||||
tracker.updateDatabaseExportTask(task.exportId, {
|
||||
exportId: task.exportId,
|
||||
currentObject: "t_0123_with_a_long_descriptive_name",
|
||||
objectIndex: 123,
|
||||
totalObjects: 2000,
|
||||
rowsExported: 456,
|
||||
totalRows: null,
|
||||
status: "Running",
|
||||
error: null,
|
||||
preparing: false,
|
||||
});
|
||||
|
||||
await mountPopover();
|
||||
|
||||
expect(document.body.textContent).toContain("Database export: demo_2000_tables");
|
||||
expect(document.body.textContent).toContain("Current: t_0123_with_a_long_descriptive_name (123/2,000)");
|
||||
expect(document.body.querySelector('[title="t_0123_with_a_long_descriptive_name"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the current object while database export metadata is being prepared", async () => {
|
||||
const tracker = useExportTracker();
|
||||
const task = tracker.addDatabaseExportTask("preparing-export", "demo", "/tmp/demo.sql");
|
||||
tracker.updateDatabaseExportTask(task.exportId, {
|
||||
exportId: task.exportId,
|
||||
currentObject: "t_0001",
|
||||
objectIndex: 0,
|
||||
totalObjects: 0,
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "Running",
|
||||
error: null,
|
||||
preparing: true,
|
||||
});
|
||||
|
||||
await mountPopover();
|
||||
|
||||
expect(document.body.textContent).toContain("Preparing: t_0001");
|
||||
});
|
||||
|
||||
it.each(["Done", "Error", "Cancelled"] as const)("hides stale database object text after the task reaches %s", async (status) => {
|
||||
const tracker = useExportTracker();
|
||||
const task = tracker.addDatabaseExportTask(`terminal-${status}`, "Nightly", "/tmp/backups", "scheduled");
|
||||
tracker.updateDatabaseExportTask(task.exportId, {
|
||||
exportId: task.exportId,
|
||||
currentObject: "Nightly",
|
||||
objectIndex: 2,
|
||||
totalObjects: status === "Error" ? 0 : 2,
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status,
|
||||
error: status === "Error" ? "backup failed" : null,
|
||||
preparing: status === "Error",
|
||||
});
|
||||
|
||||
await mountPopover();
|
||||
|
||||
expect(document.body.textContent).not.toContain("Preparing: Nightly");
|
||||
expect(document.body.textContent).not.toContain("Current: Nightly");
|
||||
});
|
||||
|
||||
it("shows frozen transfer elapsed time and live duration for table tasks", async () => {
|
||||
const tracker = useExportTracker();
|
||||
now = 1_000;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
|
|||
|
||||
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer";
|
||||
export type BackgroundTaskStatus = "Running" | "Writing" | "Done" | "Error" | "Cancelled";
|
||||
export type DatabaseExportSource = "manual" | "scheduled";
|
||||
|
||||
export interface DataTransferFailure {
|
||||
table: string;
|
||||
|
|
@ -21,6 +22,9 @@ export interface ExportTask {
|
|||
totalRows: number | null;
|
||||
status: BackgroundTaskStatus;
|
||||
errorMessage: string | null;
|
||||
databaseExportSource?: DatabaseExportSource;
|
||||
currentObject?: string;
|
||||
preparing?: boolean;
|
||||
objectIndex?: number;
|
||||
totalObjects?: number;
|
||||
overallPercent?: number;
|
||||
|
|
@ -254,7 +258,7 @@ export function useExportTracker() {
|
|||
return task;
|
||||
}
|
||||
|
||||
function addDatabaseExportTask(exportId: string, label: string, filePath: string): ExportTask {
|
||||
function addDatabaseExportTask(exportId: string, label: string, filePath: string, databaseExportSource: DatabaseExportSource = "manual"): ExportTask {
|
||||
const task = reactive<ExportTask>({
|
||||
exportId,
|
||||
kind: "database-export",
|
||||
|
|
@ -265,6 +269,9 @@ export function useExportTracker() {
|
|||
totalRows: null,
|
||||
status: "Running",
|
||||
errorMessage: null,
|
||||
databaseExportSource,
|
||||
currentObject: "",
|
||||
preparing: true,
|
||||
objectIndex: 0,
|
||||
totalObjects: 0,
|
||||
startedAt: Date.now(),
|
||||
|
|
@ -392,10 +399,8 @@ export function useExportTracker() {
|
|||
function updateDatabaseExportTask(exportId: string, progress: api.ExportProgress & { overallPercent?: number }) {
|
||||
const task = taskMap.get(exportId);
|
||||
if (!task) return;
|
||||
// Keep the database label during metadata prefetch; only follow object names while writing.
|
||||
if (!progress.preparing && progress.currentObject) {
|
||||
task.tableName = progress.currentObject;
|
||||
}
|
||||
task.currentObject = progress.currentObject;
|
||||
task.preparing = !!progress.preparing;
|
||||
task.rowsExported = progress.rowsExported;
|
||||
task.totalRows = progress.totalRows;
|
||||
task.status = normalizeExportStatus(progress.status);
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||
activeScheduleIds.add(schedule.id);
|
||||
activeRunIds.add(runId);
|
||||
cancellationRequested.delete(runId);
|
||||
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory);
|
||||
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory, "scheduled");
|
||||
registerTaskCancelHandler(runId, () => cancelRun(runId));
|
||||
|
||||
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
|
||||
|
|
@ -219,27 +219,29 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||
finalStatus = "cancelled";
|
||||
break;
|
||||
}
|
||||
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(schedule.connectionId, database) } : undefined;
|
||||
const databasePlan = buildAllDatabaseExportPlan({
|
||||
databases: [database],
|
||||
schemaAware: connection.db_type === "postgres",
|
||||
schemasByDatabase,
|
||||
});
|
||||
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
|
||||
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
|
||||
for (const item of databasePlan) {
|
||||
if (schedule.tableFilterMode === "all") {
|
||||
scopedDatabasePlan.push(item);
|
||||
continue;
|
||||
}
|
||||
const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name);
|
||||
const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
|
||||
includedTableCount += scope.includedTables.length;
|
||||
if (scope.includedTables.length === 0) continue;
|
||||
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
|
||||
}
|
||||
|
||||
const snapshot = await api.beginDatabaseBackupSnapshot(schedule.connectionId, database);
|
||||
let snapshotCompleted = false;
|
||||
try {
|
||||
const databasePlan = buildAllDatabaseExportPlan({
|
||||
databases: [database],
|
||||
schemaAware: connection.db_type === "postgres",
|
||||
schemasByDatabase: { [database]: snapshot.schemas },
|
||||
});
|
||||
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
|
||||
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
|
||||
for (const item of databasePlan) {
|
||||
if (schedule.tableFilterMode === "all") {
|
||||
scopedDatabasePlan.push(item);
|
||||
continue;
|
||||
}
|
||||
const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name);
|
||||
const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
|
||||
includedTableCount += scope.includedTables.length;
|
||||
if (scope.includedTables.length === 0) continue;
|
||||
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
|
||||
}
|
||||
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
|
|
|
|||
|
|
@ -1603,7 +1603,8 @@ export default {
|
|||
showMore: "Show {count} more",
|
||||
showLess: "Show less",
|
||||
delete: "Remove",
|
||||
databaseExportTitle: "Database backup: {name}",
|
||||
databaseExportTitle: "Database export: {name}",
|
||||
databaseBackupTitle: "Database backup: {name}",
|
||||
sqlFileTitle: "SQL file: {name}",
|
||||
dataTransferTitle: "Data transfer: {name}",
|
||||
objectsCount: "{current} / {total} objects",
|
||||
|
|
|
|||
|
|
@ -1544,7 +1544,8 @@ export default withEnglishFallback({
|
|||
showMore: "Mostrar {count} más",
|
||||
showLess: "Mostrar menos",
|
||||
failedTooltip: "{count} tarea en segundo plano fallida | {count} tareas en segundo plano fallidas",
|
||||
databaseExportTitle: "Copia de seguridad: {name}",
|
||||
databaseExportTitle: "Exportación de base de datos: {name}",
|
||||
databaseBackupTitle: "Copia de seguridad: {name}",
|
||||
sqlFileTitle: "Archivo SQL: {name}",
|
||||
dataTransferTitle: "Transferencia de datos: {name}",
|
||||
objectsCount: "{current} / {total} objetos",
|
||||
|
|
|
|||
|
|
@ -1543,7 +1543,8 @@ export default withEnglishFallback({
|
|||
showMore: "Mostra altri {count}",
|
||||
showLess: "Mostra meno",
|
||||
delete: "Rimuovi",
|
||||
databaseExportTitle: "Backup database: {name}",
|
||||
databaseExportTitle: "Esportazione database: {name}",
|
||||
databaseBackupTitle: "Backup database: {name}",
|
||||
sqlFileTitle: "File SQL: {name}",
|
||||
dataTransferTitle: "Trasferimento dati: {name}",
|
||||
objectsCount: "{current} / {total} oggetti",
|
||||
|
|
|
|||
|
|
@ -1570,7 +1570,8 @@ export default withEnglishFallback({
|
|||
showLess: "少なく表示",
|
||||
delete: "削除",
|
||||
failedTooltip: "{count}件のバックグラウンドタスクが失敗しました",
|
||||
databaseExportTitle: "データベースバックアップ: {name}",
|
||||
databaseExportTitle: "データベースエクスポート: {name}",
|
||||
databaseBackupTitle: "データベースバックアップ: {name}",
|
||||
sqlFileTitle: "SQLファイル: {name}",
|
||||
dataTransferTitle: "データ転送: {name}",
|
||||
objectsCount: "{current} / {total} オブジェクト",
|
||||
|
|
|
|||
|
|
@ -1498,7 +1498,8 @@ export default withEnglishFallback({
|
|||
showMore: "{count}개 더 보기",
|
||||
showLess: "줄여서 보기",
|
||||
delete: "제거",
|
||||
databaseExportTitle: "데이터베이스 백업: {name}",
|
||||
databaseExportTitle: "데이터베이스 내보내기: {name}",
|
||||
databaseBackupTitle: "데이터베이스 백업: {name}",
|
||||
sqlFileTitle: "SQL 파일: {name}",
|
||||
dataTransferTitle: "데이터 이전: {name}",
|
||||
objectsCount: "{current} / {total} 객체",
|
||||
|
|
|
|||
|
|
@ -1545,7 +1545,8 @@ export default withEnglishFallback({
|
|||
showMore: "Mostrar mais {count}",
|
||||
showLess: "Mostrar menos",
|
||||
delete: "Remover",
|
||||
databaseExportTitle: "Backup de banco de dados: {name}",
|
||||
databaseExportTitle: "Exportação de banco de dados: {name}",
|
||||
databaseBackupTitle: "Backup de banco de dados: {name}",
|
||||
sqlFileTitle: "Arquivo SQL: {name}",
|
||||
dataTransferTitle: "Transferência de dados: {name}",
|
||||
objectsCount: "{current} / {total} objetos",
|
||||
|
|
|
|||
|
|
@ -1604,7 +1604,8 @@ export default withEnglishFallback({
|
|||
showMore: "展开其余 {count} 个",
|
||||
showLess: "收起",
|
||||
delete: "删除",
|
||||
databaseExportTitle: "数据库备份:{name}",
|
||||
databaseExportTitle: "数据库导出:{name}",
|
||||
databaseBackupTitle: "数据库备份:{name}",
|
||||
sqlFileTitle: "SQL 文件:{name}",
|
||||
dataTransferTitle: "数据传输:{name}",
|
||||
objectsCount: "{current} / {total} 个对象",
|
||||
|
|
|
|||
|
|
@ -1544,7 +1544,8 @@ export default withEnglishFallback({
|
|||
showMore: "顯示 {count} 個更多",
|
||||
showLess: "顯示較少",
|
||||
delete: "移除",
|
||||
databaseExportTitle: "資料庫備份:{name}",
|
||||
databaseExportTitle: "資料庫匯出:{name}",
|
||||
databaseBackupTitle: "資料庫備份:{name}",
|
||||
sqlFileTitle: "SQL 檔案:{name}",
|
||||
dataTransferTitle: "資料傳輸:{name}",
|
||||
objectsCount: "{current} / {total} 個物件",
|
||||
|
|
|
|||
|
|
@ -1481,6 +1481,12 @@ pub async fn export_database_sql_core(
|
|||
request: &DatabaseExportRequest,
|
||||
on_progress: impl Fn(ExportProgress) + Sync,
|
||||
) -> Result<(), String> {
|
||||
let _snapshot_keep_alive = if let Some(snapshot_session_id) = request.snapshot_session_id.as_deref() {
|
||||
Some(crate::query::keep_manual_transaction_alive(state, snapshot_session_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Emit immediately so the UI is never blank while we list schema metadata.
|
||||
emit_database_export_running(&on_progress, &request.export_id, "", 0, 0, 0, true);
|
||||
|
||||
|
|
|
|||
|
|
@ -3676,6 +3676,63 @@ async fn begin_transaction_session(
|
|||
Ok(txn_session_id)
|
||||
}
|
||||
|
||||
pub struct ManualTransactionKeepAlive {
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
sessions: Arc<tokio::sync::RwLock<std::collections::HashMap<String, TransactionSession>>>,
|
||||
txn_session_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ManualTransactionKeepAlive {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
spawn_txn_idle_watcher_for_sessions(Arc::clone(&self.sessions), self.txn_session_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep an existing transaction session alive while a caller prepares work for
|
||||
/// that session. The caller must retain the returned guard for the full period;
|
||||
/// dropping it restores the normal five-minute idle rollback behavior.
|
||||
pub async fn keep_manual_transaction_alive(
|
||||
state: &AppState,
|
||||
txn_session_id: &str,
|
||||
) -> Result<ManualTransactionKeepAlive, String> {
|
||||
{
|
||||
let mut sessions = state.transaction_sessions.write().await;
|
||||
let session = sessions.get_mut(txn_session_id).ok_or_else(|| {
|
||||
"Transaction session not found or expired; it may have been auto-rolled back due to inactivity".to_string()
|
||||
})?;
|
||||
if !session.busy {
|
||||
session.last_activity = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
let sessions = Arc::clone(&state.transaction_sessions);
|
||||
let keep_alive_sessions = Arc::clone(&sessions);
|
||||
let txn_session_id = txn_session_id.to_string();
|
||||
let keep_alive_txn_session_id = txn_session_id.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
|
||||
loop {
|
||||
tokio::time::sleep(KEEPALIVE_INTERVAL).await;
|
||||
let should_continue = {
|
||||
let mut guard = sessions.write().await;
|
||||
if let Some(session) = guard.get_mut(&keep_alive_txn_session_id) {
|
||||
if !session.busy {
|
||||
session.last_activity = std::time::Instant::now();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(ManualTransactionKeepAlive { task, sessions: keep_alive_sessions, txn_session_id })
|
||||
}
|
||||
|
||||
/// Execute SQL within an existing manual transaction session.
|
||||
pub async fn execute_in_manual_transaction(
|
||||
state: &AppState,
|
||||
|
|
@ -3950,6 +4007,13 @@ async fn rollback_manual_txn_connection(conn: &mut TxnConnection) -> Result<(),
|
|||
/// will see a missing session or a non-expired one and exit harmlessly.
|
||||
fn spawn_txn_idle_watcher(state: &AppState, txn_session_id: String) {
|
||||
let sessions = Arc::clone(&state.transaction_sessions);
|
||||
spawn_txn_idle_watcher_for_sessions(sessions, txn_session_id);
|
||||
}
|
||||
|
||||
fn spawn_txn_idle_watcher_for_sessions(
|
||||
sessions: Arc<tokio::sync::RwLock<std::collections::HashMap<String, TransactionSession>>>,
|
||||
txn_session_id: String,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
tokio::time::sleep(TXN_IDLE_TIMEOUT).await;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
use dbx_core::connection::AppState;
|
||||
use dbx_core::database_export::{begin_database_backup_snapshot_core, export_database_sql_core, DatabaseExportRequest};
|
||||
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use dbx_core::query::{
|
||||
begin_manual_transaction, commit_manual_transaction, execute_in_manual_transaction, rollback_manual_transaction,
|
||||
};
|
||||
use dbx_core::storage::Storage;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn live_config(prefix: &str, db_type: DatabaseType, default_port: u16) -> ConnectionConfig {
|
||||
let host = std::env::var(format!("{prefix}_HOST")).expect("live DB host env var");
|
||||
|
|
@ -103,3 +105,48 @@ async fn live_manual_transaction_mysql_streams_with_row_limit() {
|
|||
rollback_manual_transaction(&state, &txn).await.expect("rollback");
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_MANUAL_TXN_MYSQL_* env vars pointing at readable MySQL with table t_0001"]
|
||||
async fn live_mysql_database_backup_refreshes_an_idle_snapshot_before_export() {
|
||||
let config = live_config("DBX_LIVE_MANUAL_TXN_MYSQL", DatabaseType::Mysql, 3306);
|
||||
let database = config.database.clone().expect("database");
|
||||
let (state, db_path) = app_state_with_config(config.clone()).await;
|
||||
let export_path = std::env::temp_dir().join(format!("dbx-live-backup-{}.sql", uuid::Uuid::new_v4().simple()));
|
||||
|
||||
let snapshot = begin_database_backup_snapshot_core(&state, &config.id, &database).await.expect("begin snapshot");
|
||||
{
|
||||
let mut sessions = state.transaction_sessions.write().await;
|
||||
sessions.get_mut(&snapshot.session_id).expect("snapshot session").last_activity =
|
||||
Instant::now() - Duration::from_secs(301);
|
||||
}
|
||||
|
||||
let request = DatabaseExportRequest {
|
||||
export_id: format!("live-mysql-backup-{}", uuid::Uuid::new_v4().simple()),
|
||||
connection_id: config.id.clone(),
|
||||
database: database.clone(),
|
||||
schema: database.clone(),
|
||||
file_path: export_path.to_string_lossy().to_string(),
|
||||
selected_tables: vec!["t_0001".to_string()],
|
||||
excluded_tables: Vec::new(),
|
||||
include_structure: true,
|
||||
include_data: true,
|
||||
include_objects: false,
|
||||
include_create_database: false,
|
||||
drop_table_if_exists: false,
|
||||
omit_auto_increment: false,
|
||||
fail_on_error: true,
|
||||
snapshot_session_id: Some(snapshot.session_id.clone()),
|
||||
batch_size: 100,
|
||||
};
|
||||
let export_result = export_database_sql_core(&state, &request, |_| {}).await;
|
||||
let rollback_result = rollback_manual_transaction(&state, &snapshot.session_id).await;
|
||||
|
||||
export_result.expect("export through refreshed snapshot");
|
||||
rollback_result.expect("rollback snapshot");
|
||||
let sql = std::fs::read_to_string(&export_path).expect("read exported SQL");
|
||||
assert!(sql.contains("t_0001"));
|
||||
|
||||
let _ = std::fs::remove_file(export_path);
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,3 +337,16 @@ test("scheduled backup history exposes rename and overall percentage controls",
|
|||
assert.match(scheduler, /status: databaseBackupAggregateExportStatus\(progress\.status, false\)/);
|
||||
assert.match(scheduler, /overallPercent: progressPercent/);
|
||||
});
|
||||
|
||||
test("scheduled backups prepare table scope before opening a consistent snapshot", () => {
|
||||
const scheduler = readFileSync("apps/desktop/src/composables/useScheduledDatabaseBackups.ts", "utf8");
|
||||
const exportCore = readFileSync("crates/dbx-core/src/database_export.rs", "utf8");
|
||||
const schemaIndex = scheduler.indexOf("await api.listSchemas(schedule.connectionId, database)");
|
||||
const snapshotIndex = scheduler.indexOf("await api.beginDatabaseBackupSnapshot(schedule.connectionId, database)");
|
||||
const exportIndex = scheduler.indexOf("await runDatabaseExportUntilTerminal(");
|
||||
|
||||
assert.ok(schemaIndex >= 0);
|
||||
assert.ok(snapshotIndex > schemaIndex);
|
||||
assert.ok(exportIndex > snapshotIndex);
|
||||
assert.match(exportCore, /keep_manual_transaction_alive\(state, snapshot_session_id\)\.await/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ test("tracks database export progress and cancels through database export API",
|
|||
});
|
||||
|
||||
assert.equal(task.kind, "database-export");
|
||||
assert.equal(task.tableName, "users");
|
||||
assert.equal(task.tableName, "app");
|
||||
assert.equal(task.currentObject, "users");
|
||||
assert.equal(task.objectIndex, 2);
|
||||
assert.equal(task.totalObjects, 5);
|
||||
assert.equal(tracker.activeCount.value, 1);
|
||||
|
|
|
|||
Loading…
Reference in New Issue