Skip to content

Commit 1478c1d

Browse files
ldanilekConvex, Inc.
authored and
Convex, Inc.
committed
cancel in-progress snapshot imports (#33998)
1. bring back the snapshot import ui in the `/settings/snapshots` page, which is not linked from anywhere but still works if needed 2. add a cancel button for in-progress imports 3. manually tested that the cancellation works by doing a large import against local big-brain and canceling it from the dashboard before it finishes. 4. the cli showed the error immediately because it's listening to the import status. meanwhile the import continues in the background on the server, but when it gets to the end it throws an error because SnapshotImportModel makes sure you can't transition from Failed => Completed. 5. made sure the state is checked transactionally with finalizing the import, because otherwise the worker ends up committing the import and then throwing an error. 6. added test it's somewhat inefficient to continue the import in the background even after it has been cancelled. we could potentially make it more efficient with a subscription in the worker, but this solution works for now. GitOrigin-RevId: 75fa683872bce02cda6eeccfed66ff44cb38fc42
1 parent 6d4326b commit 1478c1d

File tree

5 files changed

+112
-10
lines changed

5 files changed

+112
-10
lines changed

crates/application/src/snapshot_import/mod.rs

+26
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ impl<RT: Runtime> SnapshotImportExecutor<RT> {
377377
table_mapping_for_import,
378378
usage,
379379
audit_log_event,
380+
Some(snapshot_import.id()),
380381
snapshot_import.requestor.clone(),
381382
)
382383
.await?;
@@ -687,6 +688,7 @@ pub async fn clear_tables<RT: Runtime>(
687688
table_mapping_for_import,
688689
usage,
689690
DeploymentAuditLogEvent::ClearTables,
691+
None,
690692
ImportRequestor::SnapshotImport,
691693
)
692694
.await?;
@@ -841,6 +843,7 @@ async fn finalize_import<RT: Runtime>(
841843
table_mapping_for_import: TableMappingForImport,
842844
usage: FunctionUsageTracker,
843845
audit_log_event: DeploymentAuditLogEvent,
846+
import_id: Option<ResolvedDocumentId>,
844847
requestor: ImportRequestor,
845848
) -> anyhow::Result<(Timestamp, u64)> {
846849
let tables_affected = table_mapping_for_import.tables_affected();
@@ -861,6 +864,29 @@ async fn finalize_import<RT: Runtime>(
861864
"snapshot_import_finalize",
862865
|tx| {
863866
async {
867+
if let Some(import_id) = import_id {
868+
// Only finalize the import if it's in progress.
869+
let mut snapshot_import_model = SnapshotImportModel::new(tx);
870+
let snapshot_import_state =
871+
snapshot_import_model.must_get_state(import_id).await?;
872+
match snapshot_import_state {
873+
ImportState::InProgress { .. } => {},
874+
// This can happen if the import was canceled or somehow retried after
875+
// completion. These errors won't show up to
876+
// the user because they are already terminal states,
877+
// so we won't transition to a new state due to this error.
878+
ImportState::Failed(e) => anyhow::bail!("Import failed: {e}"),
879+
ImportState::Completed { .. } => {
880+
anyhow::bail!("Import already completed")
881+
},
882+
// Indicates a bug -- we shouldn't be finalizing an import that hasn't
883+
// started yet.
884+
ImportState::Uploaded | ImportState::WaitingForConfirmation { .. } => {
885+
anyhow::bail!("Import is not in progress")
886+
},
887+
}
888+
}
889+
864890
let mut documents_deleted = 0;
865891
for tablet_id in table_mapping_for_import.to_delete.keys() {
866892
let namespace = tx.table_mapping().tablet_namespace(*tablet_id)?;

crates/application/src/snapshot_import/tests.rs

+70
Original file line numberDiff line numberDiff line change
@@ -1293,3 +1293,73 @@ async fn run_csv_import(
12931293
.await
12941294
.map(|_| ())
12951295
}
1296+
1297+
#[convex_macro::test_runtime]
1298+
async fn test_cancel_in_progress_import(
1299+
rt: TestRuntime,
1300+
pause_controller: PauseController,
1301+
) -> anyhow::Result<()> {
1302+
let app = Application::new_for_tests(&rt).await?;
1303+
let table_name = "table1";
1304+
let test_csv = r#"
1305+
a,b
1306+
"foo","bar"
1307+
"#;
1308+
1309+
let hold_guard = pause_controller.hold("before_finalize_import");
1310+
1311+
let mut import_fut = run_csv_import(&app, table_name, test_csv).boxed();
1312+
1313+
select! {
1314+
r = import_fut.as_mut().fuse() => {
1315+
anyhow::bail!("import finished before pausing: {r:?}");
1316+
},
1317+
pause_guard = hold_guard.wait_for_blocked().fuse() => {
1318+
let pause_guard = pause_guard.unwrap();
1319+
1320+
// Cancel the import while it's in progress
1321+
let mut tx = app.begin(new_admin_id()).await?;
1322+
let mut import_model = model::snapshot_imports::SnapshotImportModel::new(&mut tx);
1323+
1324+
// Find the in-progress import
1325+
let snapshot_import = import_model.import_in_state(ImportState::InProgress {
1326+
progress_message: String::new(),
1327+
checkpoint_messages: vec![],
1328+
}).await?.context("No in-progress import found")?;
1329+
1330+
import_model.cancel_import(snapshot_import.id()).await?;
1331+
app.commit_test(tx).await?;
1332+
1333+
pause_guard.unpause();
1334+
},
1335+
}
1336+
1337+
let err = import_fut.await.unwrap_err();
1338+
assert!(err.is_bad_request());
1339+
assert!(
1340+
err.msg().contains("Import canceled"),
1341+
"Unexpected error message: {}",
1342+
err.msg()
1343+
);
1344+
1345+
// Verify the import was actually canceled
1346+
let mut tx = app.begin(new_admin_id()).await?;
1347+
let mut import_model = model::snapshot_imports::SnapshotImportModel::new(&mut tx);
1348+
let snapshot_import = import_model
1349+
.import_in_state(ImportState::Failed("Import was canceled".into()))
1350+
.await?
1351+
.context("No failed import found")?;
1352+
assert!(matches!(
1353+
snapshot_import.state.clone(),
1354+
ImportState::Failed(msg) if msg == "Import canceled"
1355+
));
1356+
// Verify no data written
1357+
let table_name = TableName::from_str(table_name)?;
1358+
let table_size = tx
1359+
.must_count(TableNamespace::test_user(), &table_name)
1360+
.await?;
1361+
assert_eq!(table_size, 0);
1362+
assert!(!TableModel::new(&mut tx).table_exists(TableNamespace::test_user(), &table_name));
1363+
1364+
Ok(())
1365+
}

crates/model/src/snapshot_imports/mod.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,11 @@ impl<'a, RT: Runtime> SnapshotImportModel<'a, RT> {
225225
pub async fn cancel_import(&mut self, id: ResolvedDocumentId) -> anyhow::Result<()> {
226226
let current_state = self.must_get_state(id).await?;
227227
match current_state {
228-
ImportState::Uploaded | ImportState::WaitingForConfirmation { .. } => {
228+
ImportState::Uploaded
229+
| ImportState::WaitingForConfirmation { .. }
230+
| ImportState::InProgress { .. } => {
229231
self.fail_import(id, "Import canceled".to_string()).await?
230232
},
231-
// TODO: support cancelling imports in progress
232-
ImportState::InProgress { .. } => anyhow::bail!("Cannot cancel an import in progress"),
233233
ImportState::Completed { .. } => anyhow::bail!(ErrorMetadata::bad_request(
234234
"CannotCancelImport",
235235
"Cannot cancel an import that has completed"

npm-packages/dashboard/src/components/deploymentSettings/SnapshotImport.tsx

+11-7
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,18 @@ function ImportStateBody({
112112
);
113113
case "in_progress":
114114
return (
115-
<div className="flex flex-col">
116-
{snapshotImport.state.checkpoint_messages.map((message: string) => (
115+
<div>
116+
<CancelImportButton importId={snapshotImport._id} />
117+
<div className="flex flex-col">
118+
{snapshotImport.state.checkpoint_messages.map((message: string) => (
119+
<div className="flex items-center gap-2">
120+
<CheckIcon /> {message}
121+
</div>
122+
))}
117123
<div className="flex items-center gap-2">
118-
<CheckIcon /> {message}
124+
<Spinner className="ml-0" />{" "}
125+
{snapshotImport.state.progress_message}
119126
</div>
120-
))}
121-
<div className="flex items-center gap-2">
122-
<Spinner className="ml-0" /> {snapshotImport.state.progress_message}
123127
</div>
124128
</div>
125129
);
@@ -371,7 +375,7 @@ export function SnapshotImport() {
371375
<div className="flex flex-col gap-4">
372376
<div className="flex flex-col gap-4">
373377
<div>
374-
<h3 className="mb-2">Snapshot Import</h3>
378+
<h3 className="mb-2">Snapshot Import and Cloud Restore</h3>
375379
<p className="text-content-primary">
376380
Import tables into your database from a snapshot.{" "}
377381
<Link

npm-packages/dashboard/src/pages/t/[team]/[project]/[deploymentName]/settings/snapshots.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { withAuthenticatedPage } from "lib/withAuthenticatedPage";
22
import { DeploymentSettingsLayout } from "dashboard-common";
33
import { SnapshotExport } from "components/deploymentSettings/SnapshotExport";
4+
import { SnapshotImport } from "components/deploymentSettings/SnapshotImport";
45

56
export { getServerSideProps } from "lib/ssr";
67

78
function SnapshotExportPage() {
89
return (
910
<DeploymentSettingsLayout page="snapshots">
1011
<SnapshotExport />
12+
<SnapshotImport />
1113
</DeploymentSettingsLayout>
1214
);
1315
}

0 commit comments

Comments
 (0)