fix(backup): enhance backup configuration and history management
This commit is contained in:
23 files changed
+623
-92
No files matched your search
@@ -2374,24 +2374,59 @@ pub async fn export_destination_identity_needs_confirmation(
|
||||
Ok(cfg!(target_os = "macos") && matches!(recorded_identity, Some(Some(ExportDestinationIdentity::LegacyDevice(_)))))
|
||||
}
|
||||
|
||||
/// Verifies the nearest persisted destination ancestor before a new child is
|
||||
/// created. Scheduled runs always create new leaf directories, so checking
|
||||
/// only the leaf would otherwise bypass the configured destination's mount
|
||||
/// identity protection.
|
||||
async fn ensure_recorded_export_destination_ancestor(
|
||||
state: &crate::connection::AppState,
|
||||
dir: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
let mut ancestor = dir.parent();
|
||||
while let Some(candidate) = ancestor {
|
||||
let recorded_identity = state
|
||||
.storage
|
||||
.load_state(&export_destination_state_key(candidate))
|
||||
.await?
|
||||
.map(|(bytes, _content_type)| ExportDestinationIdentity::decode(&bytes))
|
||||
.transpose()?;
|
||||
|
||||
if let Some(recorded_identity) = recorded_identity {
|
||||
if !candidate.is_dir() {
|
||||
return Err(format!(
|
||||
"Backup directory {} is missing. Its parent {} was configured or previously used for exports, so dbx will not recreate a child directory automatically -- if this is on a removable or network drive, reconnect it and try again.",
|
||||
dir.display(),
|
||||
candidate.display()
|
||||
));
|
||||
}
|
||||
if let Some(recorded_identity) = recorded_identity {
|
||||
let current_identity = export_destination_identity_for_path(candidate);
|
||||
if recorded_export_destination_identity_mismatch(&recorded_identity, current_identity.as_ref()) {
|
||||
return Err(format!(
|
||||
"Backup directory {} now resolves to a different filesystem than the configured parent {}. Refusing to create a child directory automatically -- make sure the correct removable or network drive is connected before running the backup.",
|
||||
dir.display(),
|
||||
candidate.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
ancestor = candidate.parent();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures `dir` exists for an export destination, without ever silently
|
||||
/// recreating a directory that previously produced a successful export (or
|
||||
/// was recorded via [`record_export_destination_identity`]) and has since
|
||||
/// disappeared. Auto-creating on every run is what the original fix for
|
||||
/// #6109 did, but that is unsafe for a destination on a removable or network
|
||||
/// drive: if the mount is temporarily gone when a run executes, blindly
|
||||
/// recreating the path resurrects it on the local root filesystem and the
|
||||
/// export "succeeds" while silently writing to the wrong disk. A directory
|
||||
/// dbx has never seen before is safe to create (normal first-time
|
||||
/// configuration of a local folder); a directory dbx has seen before but
|
||||
/// that is now missing, or that now resolves to a different filesystem than
|
||||
/// last time, is refused instead. See #6327.
|
||||
/// disappeared. Auto-creating on every run is unsafe for a destination on a
|
||||
/// removable or network drive: a missing mount could be recreated on the
|
||||
/// local filesystem. A new directory is safe to create only after any
|
||||
/// recorded parent destination has been verified. See #6327.
|
||||
///
|
||||
/// Returns the device identity that was just verified (or recorded for a
|
||||
/// newly created directory), if the current platform can determine one, so
|
||||
/// the caller can re-verify it against the file it actually opens -- the
|
||||
/// directory check here and the later `File::create` are separate
|
||||
/// operations, and the mount can change in between.
|
||||
/// Returns the identity that was just verified (or recorded for a newly
|
||||
/// created directory), so the caller can re-verify it against the file it
|
||||
/// actually opens.
|
||||
async fn ensure_export_destination_dir(
|
||||
state: &crate::connection::AppState,
|
||||
dir: &std::path::Path,
|
||||
@@ -2428,6 +2463,11 @@ async fn ensure_export_destination_dir(
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
// A run-specific child directory is intentionally new on every
|
||||
// scheduled backup. Before creating it, still honor a recorded parent
|
||||
// destination so a missing external/NAS mount is never recreated on
|
||||
// the local filesystem merely because this child has no state yet.
|
||||
ensure_recorded_export_destination_ancestor(state, dir).await?;
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create backup directory: {e}"))?;
|
||||
}
|
||||
|
||||
@@ -5921,6 +5961,29 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_export_destination_dir_refuses_a_new_child_when_its_recorded_parent_is_missing() {
|
||||
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-child-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
let state = test_app_state(&scratch).await;
|
||||
|
||||
let destination = scratch.join("mounted-drive").join("backups");
|
||||
std::fs::create_dir_all(&destination).unwrap();
|
||||
record_export_destination_identity(&state, &destination)
|
||||
.await
|
||||
.expect("the configured root should be recorded before scheduled runs begin");
|
||||
|
||||
std::fs::remove_dir_all(scratch.join("mounted-drive")).unwrap();
|
||||
let run_directory = destination.join("dbx-backup__nightly__20260908-220000__12345678");
|
||||
|
||||
let result = ensure_export_destination_dir(&state, &run_directory).await;
|
||||
|
||||
assert!(result.is_err(), "a unique run directory must not recreate a missing configured parent");
|
||||
assert!(!run_directory.exists(), "the run directory must not be created on the local filesystem");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_export_destination_identity_rejects_a_directory_that_disappeared_before_save() {
|
||||
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-eager-new-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
@@ -3752,7 +3752,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_shard_doc_support_falls_back_to_offset_paging() {
|
||||
async fn missing_shard_doc_support_falls_back_to_scroll_paging() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = serve_responses(
|
||||
@@ -3761,7 +3761,10 @@ mod tests {
|
||||
(200, r#"{"id":"pit-1"}"#.to_string()),
|
||||
(400, missing_shard_doc_error_body()),
|
||||
(200, "{}".to_string()),
|
||||
(200, one_hit_response()),
|
||||
(
|
||||
200,
|
||||
r#"{"_scroll_id":"scroll-1","hits":{"total":{"value":42,"relation":"eq"},"hits":[{"_id":"1","_source":{"name":"one"}}]},"_shards":{"total":1,"successful":1,"skipped":0,"failed":0}}"#.to_string(),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
@@ -3774,9 +3777,9 @@ mod tests {
|
||||
assert!(requests[1].starts_with("POST /_search "), "{}", requests[1]);
|
||||
assert!(requests[1].contains("_shard_doc"), "{}", requests[1]);
|
||||
assert!(requests[2].starts_with("DELETE /_pit "), "{}", requests[2]);
|
||||
assert!(requests[3].starts_with("POST /products/_search "), "{}", requests[3]);
|
||||
assert!(requests[3].starts_with("POST /products/_search?scroll=1m "), "{}", requests[3]);
|
||||
assert!(!requests[3].contains("_shard_doc"), "fallback must not sort on _shard_doc: {}", requests[3]);
|
||||
assert!(requests[3].contains(r#""from":0"#), "{}", requests[3]);
|
||||
assert!(!requests[3].contains(r#""from":0"#), "{}", requests[3]);
|
||||
assert_eq!(result.documents.len(), 1);
|
||||
assert_eq!(result.total, 42);
|
||||
|
||||
@@ -3785,11 +3788,14 @@ mod tests {
|
||||
super::EsSearchCursor::Page(page) => page,
|
||||
super::EsSearchCursor::Sql { .. } => panic!("expected page cursor"),
|
||||
};
|
||||
assert_eq!(page.mode, super::EsPageMode::Offset { from: 1 });
|
||||
assert_eq!(
|
||||
page.mode,
|
||||
super::EsPageMode::Scroll { scroll_id: "scroll-1".to_string(), keep_alive: "1m".to_string() }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn offset_fallback_is_remembered_for_the_connection() {
|
||||
async fn scroll_fallback_is_remembered_for_the_connection() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = serve_responses(
|
||||
@@ -3805,17 +3811,13 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
||||
let first = super::find_documents_with_cursor(&client, "products", 1, None, None, None).await.unwrap();
|
||||
let second =
|
||||
super::find_documents_with_cursor(&client, "products", 1, None, None, first.next_cursor.as_deref())
|
||||
.await
|
||||
.unwrap();
|
||||
let _first = super::find_documents_with_cursor(&client, "products", 1, None, None, None).await.unwrap();
|
||||
let second = super::find_documents_with_cursor(&client, "products", 1, None, None, None).await.unwrap();
|
||||
let requests = server.await.unwrap();
|
||||
|
||||
// 探测结果记在连接上:第二次查询不再尝试开 PIT。
|
||||
assert_eq!(requests.len(), 5);
|
||||
assert!(requests[4].starts_with("POST /products/_search "), "{}", requests[4]);
|
||||
assert!(requests[4].contains(r#""from":1"#), "{}", requests[4]);
|
||||
assert!(requests[4].starts_with("POST /products/_search?scroll=1m "), "{}", requests[4]);
|
||||
assert_eq!(second.documents.len(), 1);
|
||||
}
|
||||
|
||||
@@ -3844,7 +3846,7 @@ mod tests {
|
||||
let requests = server.await.unwrap();
|
||||
|
||||
// 偶发 5xx 只回退这一次请求,下一次查询仍旧先试 PIT。
|
||||
assert!(requests[3].starts_with("POST /products/_search "), "{}", requests[3]);
|
||||
assert!(requests[3].starts_with("POST /products/_search?scroll=1m "), "{}", requests[3]);
|
||||
assert!(requests[4].starts_with("POST /products/_pit?keep_alive=1m "), "{}", requests[4]);
|
||||
}
|
||||
|
||||
@@ -3918,7 +3920,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn offset_fallback_keeps_track_total_hits_off_and_breaks_sort_ties() {
|
||||
async fn scroll_fallback_keeps_track_total_hits_off_and_preserves_requested_sort() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = serve_responses(
|
||||
@@ -3939,8 +3941,9 @@ mod tests {
|
||||
let requests = server.await.unwrap();
|
||||
|
||||
let fallback = &requests[3];
|
||||
// 多页 from/size 之间没有快照,需要 `_doc` 打散并列值造成的页边界抖动。
|
||||
assert!(fallback.contains(r#""sort":[{"created_at":{"order":"desc"}},"_doc"]"#), "{fallback}");
|
||||
// Scroll 持有搜索快照,保留用户的排序即可,无需追加 `_doc`。
|
||||
assert!(fallback.starts_with("POST /products/_search?scroll=1m "), "{fallback}");
|
||||
assert!(fallback.contains(r#""sort":[{"created_at":{"order":"desc"}}]"#), "{fallback}");
|
||||
// 回退路径不加 track_total_hits:那会让每页都对整个索引做一次精确计数,
|
||||
// 而这条路径针对的正是老的大集群。
|
||||
assert!(!fallback.contains("track_total_hits"), "{fallback}");
|
||||
@@ -4001,7 +4004,7 @@ mod tests {
|
||||
|
||||
assert!(requests[0].starts_with("POST /products/_pit?keep_alive=1m "), "{}", requests[0]);
|
||||
assert!(requests[1].contains("_shard_doc"), "{}", requests[1]);
|
||||
assert!(requests[3].starts_with("POST /products/_search "), "{}", requests[3]);
|
||||
assert!(requests[3].starts_with("POST /products/_search?scroll=1m "), "{}", requests[3]);
|
||||
assert!(!requests[3].contains("_shard_doc"), "fallback must not sort on _shard_doc: {}", requests[3]);
|
||||
assert_eq!(result.rows.len(), 1);
|
||||
// 分页计划要拿索引真实总数来算总页数。
|
||||
|
||||
Reference in new issue
Block a user