mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 20:06:37 +00:00
e6fc661162
Split the 4242-line resume.rs (46% inline tests) into a canonical foo.rs + foo/ module tree with zero behavior change: - resume.rs (~1020): state file constants, PersistThrottle, ResumeState, ResumeManager core (constructors, load/discovery, progress mutators, ordinary persistence) plus root re-exports - resume/replacement.rs (~690): replacement-intent/proof types and the ResumeManager replacement-lifecycle methods - resume/checkpoint.rs (~350): ResumeCheckpoint + CheckpointManager - resume/utils.rs (~310): ResumeUtils statics - resume/tests.rs (~1980): the inline test module as a child module All module paths are unchanged (heal::resume::CheckpointManager and friends resolve through root re-exports), so no consumer inside or outside the crate changes. Items defined in child modules keep module-private visibility; only the ten cross-module helpers gain pub(super), which is not part of the crate API. Code is moved verbatim apart from those visibility markers, four super::storage_api path fixes, and the new per-module import headers. Co-authored-by: heihutu <heihutu@gmail.com>
1982 lines
76 KiB
Rust
1982 lines
76 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use super::checkpoint::CURRENT_CHECKPOINT_SCHEMA;
|
|
use super::replacement::ReplacementCompletionProof;
|
|
use super::*;
|
|
|
|
async fn schema_test_disk() -> (tempfile::TempDir, DiskStore) {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
|
|
let temp_dir = tempfile::TempDir::new().expect("create schema test directory");
|
|
let endpoint = Endpoint::try_from(temp_dir.path().to_string_lossy().as_ref()).expect("create schema test disk endpoint");
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("create schema test disk");
|
|
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
|
Err(error) => panic!("create metadata volume: {error}"),
|
|
}
|
|
match disk.make_volume(&format!("{RUSTFS_META_BUCKET}/{BUCKET_META_PREFIX}")).await {
|
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
|
Err(error) => panic!("create resume metadata volume: {error}"),
|
|
}
|
|
|
|
(temp_dir, disk)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resume_state_creation() {
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let buckets = vec!["bucket1".to_string(), "bucket2".to_string()];
|
|
let state = ResumeState::new(task_id.clone(), "erasure_set".to_string(), "pool_0_set_0".to_string(), buckets);
|
|
|
|
assert_eq!(state.task_id, task_id);
|
|
assert_eq!(state.task_type, "erasure_set");
|
|
assert!(!state.completed);
|
|
assert_eq!(state.processed_objects, 0);
|
|
assert_eq!(state.pending_buckets.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn replacement_intent_binds_a_generation_before_format() {
|
|
let state = ResumeState::replacement_intent(
|
|
"generation-a".to_string(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket-a".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
);
|
|
|
|
assert_eq!(state.replacement_generation.as_deref(), Some("generation-a"));
|
|
assert_eq!(state.replacement_phase, ReplacementPhase::Intent);
|
|
assert_eq!(state.replacement_targets, ["replacement-a"]);
|
|
assert!(state.resume_cursor.is_none(), "a new replacement must start from the beginning");
|
|
|
|
let mut state = state;
|
|
state.complete_bucket("bucket-a");
|
|
assert_eq!(
|
|
state.replacement_buckets,
|
|
["bucket-a"],
|
|
"recovery must retain the original positional bucket plan"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_terminal_phases_are_durable() {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
use tempfile::TempDir;
|
|
|
|
let temp_dir = TempDir::new().expect("create replacement phase test directory");
|
|
let endpoint = Endpoint::try_from(temp_dir.path().to_string_lossy().as_ref()).expect("create test disk endpoint");
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("create test disk");
|
|
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
|
Err(err) => panic!("create metadata volume for replacement phase test: {err}"),
|
|
}
|
|
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
manager
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect("completion and verified phase must persist together");
|
|
|
|
let verified = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
|
.await
|
|
.expect("verified phase must survive a restart")
|
|
.get_state()
|
|
.await;
|
|
assert!(verified.completed);
|
|
assert_eq!(verified.replacement_phase, ReplacementPhase::Verified);
|
|
|
|
let resumed = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["ignored-after-restart".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("verified replacement must be reopenable for terminal cleanup");
|
|
assert_eq!(
|
|
resumed.get_state().await.replacement_buckets,
|
|
["bucket"],
|
|
"terminal recovery must preserve the original generation bucket plan"
|
|
);
|
|
|
|
manager
|
|
.mark_replacement_cleanup_pending()
|
|
.await
|
|
.expect("cleanup-pending phase must persist after marker removal");
|
|
let cleanup_pending = ResumeManager::load_replacement_intent(disk, &task_id)
|
|
.await
|
|
.expect("cleanup-pending phase must survive a restart")
|
|
.get_state()
|
|
.await;
|
|
assert!(cleanup_pending.completed);
|
|
assert_eq!(cleanup_pending.replacement_phase, ReplacementPhase::CleanupPending);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_proof_before_verified_state_is_reconciled_after_restart() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
|
|
let proof = ReplacementCompletionProof::from_state(&manager.get_state().await, 42)
|
|
.expect("active replacement state should build a completion proof");
|
|
let proof_path = replacement_completion_proof_path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
proof_path.to_str().expect("completion proof path must be UTF-8"),
|
|
serde_json::to_vec(&proof)
|
|
.expect("completion proof fixture should serialize")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("proof-first crash fixture should persist");
|
|
|
|
let recovered = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
|
.await
|
|
.expect("matching completion proof must prevent another rebuild")
|
|
.get_state()
|
|
.await;
|
|
assert!(recovered.completed);
|
|
assert_eq!(recovered.replacement_phase, ReplacementPhase::Verified);
|
|
assert_eq!(recovered.last_update, proof.verified_at);
|
|
|
|
let records = ResumeUtils::get_replacement_recovery_records(&disk)
|
|
.await
|
|
.expect("reconciled replacement state should be observable");
|
|
assert_eq!(records.len(), 1);
|
|
assert_eq!(records[0].state, ReplacementRecoveryState::CleanupPending);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_proof_conflicting_with_active_state_fails_closed_after_restart() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
|
|
let mut proof = ReplacementCompletionProof::from_state(&manager.get_state().await, 42)
|
|
.expect("active replacement state should build a completion proof");
|
|
proof.set_disk_id = "pool_0_set_1".to_string();
|
|
let proof_path = replacement_completion_proof_path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
proof_path.to_str().expect("completion proof path must be UTF-8"),
|
|
serde_json::to_vec(&proof)
|
|
.expect("completion proof fixture should serialize")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("conflicting proof fixture should persist");
|
|
|
|
let error = match ResumeManager::load_replacement_intent(disk, &task_id).await {
|
|
Ok(_) => panic!("a mismatched proof must not permit another rebuild"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(error.to_string().contains("does not match active intent"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_intent_is_not_an_ordinary_resumable_task() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist in its isolated namespace");
|
|
|
|
assert!(
|
|
!ResumeManager::has_resume_state(&disk, &task_id).await,
|
|
"an old ordinary-resume lookup must not discover a replacement intent"
|
|
);
|
|
assert!(ResumeManager::has_replacement_intent(&disk, &task_id).await);
|
|
assert!(
|
|
!ResumeUtils::get_resumable_tasks(&disk)
|
|
.await
|
|
.expect("ordinary resume listing should succeed")
|
|
.contains(&task_id),
|
|
"the old filename enumeration must not return replacement work"
|
|
);
|
|
assert_eq!(
|
|
ResumeUtils::get_replacement_intent_tasks(&disk)
|
|
.await
|
|
.expect("replacement intent listing should succeed"),
|
|
vec![task_id.clone()]
|
|
);
|
|
assert_eq!(
|
|
ResumeManager::load_replacement_intent(disk, &task_id)
|
|
.await
|
|
.expect("new replacement reader should load the isolated state")
|
|
.get_state()
|
|
.await,
|
|
manager.get_state().await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_intent_recovers_from_torn_publication_before_formatting() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let intent_path = ResumeStateFile::ReplacementIntent.path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
intent_path.to_str().expect("replacement intent path must be UTF-8"),
|
|
b"{torn replacement intent".as_slice().into(),
|
|
)
|
|
.await
|
|
.expect("torn replacement intent fixture should persist");
|
|
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("a retry must atomically replace only a torn pre-format intent");
|
|
|
|
let recovered = ResumeManager::load_replacement_intent(disk, &task_id)
|
|
.await
|
|
.expect("recovered intent should be readable after restart")
|
|
.get_state()
|
|
.await;
|
|
assert_eq!(recovered, manager.get_state().await);
|
|
assert_eq!(recovered.replacement_phase, ReplacementPhase::Intent);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn torn_intent_recovery_cas_preserves_a_concurrent_valid_binding() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let intent_path = ResumeStateFile::ReplacementIntent.path(&task_id);
|
|
let intent_path = intent_path.to_str().expect("replacement intent path must be UTF-8");
|
|
let torn = EcstoreDiskBytes::from_static(b"{torn replacement intent");
|
|
disk.write_all(RUSTFS_META_BUCKET, intent_path, torn)
|
|
.await
|
|
.expect("torn intent fixture should persist");
|
|
|
|
let expected = ResumeManager::torn_replacement_intent_bytes(&disk, &task_id)
|
|
.await
|
|
.expect("torn intent should be recoverable before a seal exists")
|
|
.expect("torn intent bytes should be retained as the CAS precondition");
|
|
let winner = ResumeState::replacement_intent(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_winner".to_string(),
|
|
vec!["winner-bucket".to_string()],
|
|
vec!["replacement-winner".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-winner".to_string(),
|
|
canonical_path: "/mnt/replacement-winner".to_string(),
|
|
physical_device_ids: vec!["device-winner".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
);
|
|
let winner_bytes = EcstoreDiskBytes::from(serde_json::to_vec(&winner).expect("winner intent should serialize"));
|
|
disk.write_all(RUSTFS_META_BUCKET, intent_path, winner_bytes.clone())
|
|
.await
|
|
.expect("concurrent valid intent fixture should persist");
|
|
|
|
let loser = ResumeManager {
|
|
disk: disk.clone(),
|
|
state: Arc::new(RwLock::new(ResumeState::replacement_intent(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_loser".to_string(),
|
|
vec!["loser-bucket".to_string()],
|
|
vec!["replacement-loser".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-loser".to_string(),
|
|
canonical_path: "/mnt/replacement-loser".to_string(),
|
|
physical_device_ids: vec!["device-loser".to_string()],
|
|
filesystem_identity: "4:5:6".to_string(),
|
|
}],
|
|
))),
|
|
throttle: Mutex::new(PersistThrottle::new()),
|
|
state_file: ResumeStateFile::ReplacementIntent,
|
|
};
|
|
let error = match loser.publish_new_replacement_intent(Some(expected)).await {
|
|
Ok(()) => panic!("a stale torn-intent recovery must not overwrite a concurrent valid binding"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(error.to_string().contains("changed before publication"));
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, intent_path)
|
|
.await
|
|
.expect("concurrent valid intent must remain durable"),
|
|
winner_bytes
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_intent_does_not_recreate_torn_state_after_seal_publication() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let intent_path = ResumeStateFile::ReplacementIntent.path(&task_id);
|
|
let intent_path = intent_path.to_str().expect("replacement intent path must be UTF-8");
|
|
let torn = b"{torn replacement intent";
|
|
disk.write_all(RUSTFS_META_BUCKET, intent_path, torn.as_slice().into())
|
|
.await
|
|
.expect("torn replacement intent fixture should persist");
|
|
let marker_path = replacement_intent_seal_path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
marker_path.to_str().expect("replacement seal path must be UTF-8"),
|
|
b"sealed".as_slice().into(),
|
|
)
|
|
.await
|
|
.expect("replacement seal fixture should persist");
|
|
|
|
let error = match ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => panic!("a seal means a torn state may have crossed the format boundary"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(error.to_string().contains("Failed to deserialize resume state"));
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, intent_path)
|
|
.await
|
|
.expect("torn intent must remain for operator recovery"),
|
|
torn.as_slice()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_intent_migrates_from_legacy_resume_filename() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let legacy = ResumeState::replacement_intent(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
);
|
|
let legacy_path = ResumeStateFile::Ordinary.path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
legacy_path.to_str().expect("legacy resume path must be UTF-8"),
|
|
serde_json::to_vec(&legacy)
|
|
.expect("serialize legacy replacement state")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("write legacy replacement state");
|
|
|
|
let migrated = ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
|
.await
|
|
.expect("new binary should migrate a legacy replacement state");
|
|
assert_eq!(migrated.get_state().await, legacy);
|
|
assert!(
|
|
!ResumeManager::has_resume_state(&disk, &task_id).await,
|
|
"migration must remove the old-binary-visible state only after the new state is durable"
|
|
);
|
|
assert!(ResumeManager::has_replacement_intent(&disk, &task_id).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ordinary_targeted_resume_is_not_migrated_as_a_replacement_intent() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
)
|
|
.await
|
|
.expect("ordinary targeted resume should persist");
|
|
manager
|
|
.set_replacement_targets(vec!["manual-target".to_string()])
|
|
.await
|
|
.expect("ordinary targeted resume should retain its target filter");
|
|
|
|
assert!(!ResumeManager::has_replacement_intent(&disk, &task_id).await);
|
|
assert!(ResumeManager::load_replacement_intent(disk.clone(), &task_id).await.is_err());
|
|
assert!(ResumeManager::has_resume_state(&disk, &task_id).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_isolated_replacement_intent_is_reported_as_unknown() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let malformed = ResumeState::new(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
);
|
|
let path = ResumeStateFile::ReplacementIntent.path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
path.to_str().expect("isolated replacement path must be UTF-8"),
|
|
serde_json::to_vec(&malformed)
|
|
.expect("malformed replacement fixture should serialize")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("malformed isolated replacement state should persist");
|
|
|
|
let records = ResumeUtils::get_replacement_recovery_records(&disk)
|
|
.await
|
|
.expect("isolated replacement listing should succeed");
|
|
assert_eq!(records.len(), 1);
|
|
assert_eq!(records[0].task_id, task_id);
|
|
assert_eq!(records[0].state, ReplacementRecoveryState::Unknown);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_migration_moves_flat_replacement_artifacts_to_dedicated_directory() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let mut state = ResumeState::replacement_intent(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
);
|
|
state.mark_completed();
|
|
state.replacement_phase = ReplacementPhase::Verified;
|
|
let legacy_intent = ResumeStateFile::LegacyReplacementIntent.path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
legacy_intent.to_str().expect("legacy intent path must be UTF-8"),
|
|
serde_json::to_vec(&state)
|
|
.expect("serialize legacy replacement intent")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("write legacy replacement intent");
|
|
let proof = ReplacementCompletionProof::from_state(&state, state.last_update).expect("build legacy completion proof");
|
|
let legacy_proof = legacy_replacement_completion_proof_path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
legacy_proof.to_str().expect("legacy proof path must be UTF-8"),
|
|
serde_json::to_vec(&proof).expect("serialize legacy completion proof").into(),
|
|
)
|
|
.await
|
|
.expect("write legacy completion proof");
|
|
|
|
ResumeUtils::migrate_legacy_replacement_records(&disk)
|
|
.await
|
|
.expect("startup migration should move flat replacement artifacts");
|
|
|
|
assert_eq!(
|
|
ResumeUtils::get_replacement_intent_tasks(&disk)
|
|
.await
|
|
.expect("dedicated intent listing should succeed"),
|
|
vec![task_id.clone()]
|
|
);
|
|
assert_eq!(
|
|
ResumeManager::load_replacement_completion_proof(disk.clone(), &task_id)
|
|
.await
|
|
.expect("dedicated completion proof should be readable"),
|
|
proof
|
|
);
|
|
assert!(matches!(
|
|
disk.read_all(RUSTFS_META_BUCKET, legacy_intent.to_str().expect("legacy intent path must be UTF-8"))
|
|
.await,
|
|
Err(DiskError::FileNotFound)
|
|
));
|
|
assert!(matches!(
|
|
disk.read_all(RUSTFS_META_BUCKET, legacy_proof.to_str().expect("legacy proof path must be UTF-8"))
|
|
.await,
|
|
Err(DiskError::FileNotFound)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_migration_moves_ordinary_replacement_resume_to_dedicated_directory() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let state = ResumeState::replacement_intent(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
);
|
|
let legacy_resume = ResumeStateFile::Ordinary.path(&task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
legacy_resume.to_str().expect("legacy resume path must be UTF-8"),
|
|
serde_json::to_vec(&state)
|
|
.expect("serialize legacy ordinary replacement state")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("write legacy ordinary replacement state");
|
|
|
|
ResumeUtils::migrate_legacy_replacement_records(&disk)
|
|
.await
|
|
.expect("startup migration should move ordinary replacement state");
|
|
|
|
assert_eq!(
|
|
ResumeUtils::get_replacement_intent_tasks(&disk)
|
|
.await
|
|
.expect("dedicated replacement listing should succeed"),
|
|
vec![task_id.clone()]
|
|
);
|
|
assert_eq!(
|
|
ResumeManager::load_replacement_intent(disk.clone(), &task_id)
|
|
.await
|
|
.expect("migrated replacement intent should be readable")
|
|
.get_state()
|
|
.await,
|
|
state
|
|
);
|
|
assert!(matches!(
|
|
disk.read_all(RUSTFS_META_BUCKET, legacy_resume.to_str().expect("legacy resume path must be UTF-8"))
|
|
.await,
|
|
Err(DiskError::FileNotFound)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_migration_reports_corrupt_ordinary_replacement_candidate() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let legacy_resume = ResumeStateFile::Ordinary.path(&task_id);
|
|
let legacy_resume = legacy_resume.to_str().expect("legacy resume path must be UTF-8");
|
|
disk.write_all(RUSTFS_META_BUCKET, legacy_resume, b"{corrupt replacement resume".as_slice().into())
|
|
.await
|
|
.expect("corrupt legacy replacement candidate should persist");
|
|
|
|
let error = ResumeUtils::migrate_legacy_replacement_records(&disk)
|
|
.await
|
|
.expect_err("a corrupt UUID-named legacy candidate must fail closed");
|
|
|
|
assert!(replacement_recovery_error_requires_block(&error));
|
|
assert!(error.to_string().contains("replacement recovery corruption"));
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, legacy_resume)
|
|
.await
|
|
.expect("corrupt legacy state must remain for operator recovery"),
|
|
b"{corrupt replacement resume".as_slice()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_migration_reports_corrupt_flat_replacement_intent() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let legacy_intent = ResumeStateFile::LegacyReplacementIntent.path(&task_id);
|
|
let legacy_intent = legacy_intent.to_str().expect("legacy intent path must be UTF-8");
|
|
disk.write_all(RUSTFS_META_BUCKET, legacy_intent, b"{corrupt replacement intent".as_slice().into())
|
|
.await
|
|
.expect("corrupt legacy replacement intent should persist");
|
|
|
|
let error = ResumeUtils::migrate_legacy_replacement_records(&disk)
|
|
.await
|
|
.expect_err("a corrupt flat legacy intent must fail closed");
|
|
|
|
assert!(replacement_recovery_error_requires_block(&error));
|
|
assert!(error.to_string().contains("replacement recovery corruption"));
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, legacy_intent)
|
|
.await
|
|
.expect("corrupt legacy intent must remain for operator recovery"),
|
|
b"{corrupt replacement intent".as_slice()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_migration_preserves_conflicting_dedicated_and_legacy_state() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("dedicated replacement intent should persist");
|
|
let dedicated_path = ResumeStateFile::ReplacementIntent.path(&task_id);
|
|
let dedicated_bytes = disk
|
|
.read_all(RUSTFS_META_BUCKET, dedicated_path.to_str().expect("dedicated intent path must be UTF-8"))
|
|
.await
|
|
.expect("dedicated replacement intent should be readable");
|
|
|
|
let legacy_state = ResumeState::replacement_intent(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_1".to_string(),
|
|
vec!["other-bucket".to_string()],
|
|
vec!["replacement-b".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-b".to_string(),
|
|
canonical_path: "/mnt/replacement-b".to_string(),
|
|
physical_device_ids: vec!["device-b".to_string()],
|
|
filesystem_identity: "4:5:6".to_string(),
|
|
}],
|
|
);
|
|
let legacy_path = ResumeStateFile::LegacyReplacementIntent.path(&task_id);
|
|
let legacy_bytes = serde_json::to_vec(&legacy_state).expect("serialize conflicting legacy replacement state");
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
legacy_path.to_str().expect("legacy intent path must be UTF-8"),
|
|
legacy_bytes.clone().into(),
|
|
)
|
|
.await
|
|
.expect("conflicting legacy replacement intent should persist");
|
|
|
|
let error = ResumeUtils::migrate_legacy_replacement_records(&disk)
|
|
.await
|
|
.expect_err("conflicting replacement states must fail closed");
|
|
assert!(error.to_string().contains("conflicting legacy state"));
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, dedicated_path.to_str().expect("dedicated intent path must be UTF-8"),)
|
|
.await
|
|
.expect("dedicated state must remain after conflict"),
|
|
dedicated_bytes
|
|
);
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, legacy_path.to_str().expect("legacy intent path must be UTF-8"),)
|
|
.await
|
|
.expect("legacy state must remain after conflict"),
|
|
legacy_bytes
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_migration_preserves_conflicting_dedicated_and_legacy_proof() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("dedicated replacement intent should persist");
|
|
manager
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect("dedicated completion proof should persist");
|
|
let dedicated_path = replacement_completion_proof_path(&task_id);
|
|
let dedicated_bytes = disk
|
|
.read_all(RUSTFS_META_BUCKET, dedicated_path.to_str().expect("dedicated proof path must be UTF-8"))
|
|
.await
|
|
.expect("dedicated completion proof should be readable");
|
|
|
|
let mut legacy_proof = ReplacementCompletionProof::from_state(&manager.get_state().await, 42)
|
|
.expect("legacy completion proof fixture should build");
|
|
legacy_proof.set_disk_id = "pool_0_set_1".to_string();
|
|
let legacy_path = legacy_replacement_completion_proof_path(&task_id);
|
|
let legacy_bytes = serde_json::to_vec(&legacy_proof).expect("serialize conflicting legacy completion proof");
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
legacy_path.to_str().expect("legacy proof path must be UTF-8"),
|
|
legacy_bytes.clone().into(),
|
|
)
|
|
.await
|
|
.expect("conflicting legacy completion proof should persist");
|
|
|
|
let error = ResumeUtils::migrate_legacy_replacement_records(&disk)
|
|
.await
|
|
.expect_err("conflicting completion proofs must fail closed");
|
|
assert!(error.to_string().contains("conflicts with legacy proof"));
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, dedicated_path.to_str().expect("dedicated proof path must be UTF-8"),)
|
|
.await
|
|
.expect("dedicated proof must remain after conflict"),
|
|
dedicated_bytes
|
|
);
|
|
assert_eq!(
|
|
disk.read_all(RUSTFS_META_BUCKET, legacy_path.to_str().expect("legacy proof path must be UTF-8"),)
|
|
.await
|
|
.expect("legacy proof must remain after conflict"),
|
|
legacy_bytes
|
|
);
|
|
let error = match ResumeManager::load_replacement_intent(disk.clone(), &task_id).await {
|
|
Ok(_) => panic!("a conflicting legacy proof must not be ignored during recovery"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(error.to_string().contains("conflicts with legacy proof"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_discovery_does_not_read_ordinary_resume_directory() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
for _ in 0..3 {
|
|
ResumeManager::new(
|
|
disk.clone(),
|
|
ResumeUtils::generate_task_id(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
)
|
|
.await
|
|
.expect("ordinary resume state should persist");
|
|
}
|
|
let corrupt_task_id = ResumeUtils::generate_task_id();
|
|
let corrupt_path = ResumeStateFile::Ordinary.path(&corrupt_task_id);
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
corrupt_path.to_str().expect("ordinary resume path must be UTF-8"),
|
|
b"not-json".to_vec().into(),
|
|
)
|
|
.await
|
|
.expect("corrupt ordinary resume state should persist");
|
|
|
|
let replacement_task_id = ResumeUtils::generate_task_id();
|
|
ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
replacement_task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist in the dedicated directory");
|
|
|
|
assert_eq!(
|
|
ResumeUtils::get_replacement_intent_tasks(&disk)
|
|
.await
|
|
.expect("dedicated replacement listing should not parse ordinary JSON"),
|
|
vec![replacement_task_id]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_replacement_recovery_directory_is_not_an_error() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
assert!(
|
|
ResumeUtils::get_replacement_intent_tasks(&disk)
|
|
.await
|
|
.expect("missing recovery directory should be empty")
|
|
.is_empty()
|
|
);
|
|
assert!(
|
|
ResumeUtils::get_replacement_recovery_records(&disk)
|
|
.await
|
|
.expect("missing recovery directory should have no records")
|
|
.is_empty()
|
|
);
|
|
ResumeUtils::cleanup_expired_states(&disk, 0)
|
|
.await
|
|
.expect("missing recovery directory should not block expiry cleanup");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_completion_proof_survives_resume_cleanup() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let identity = ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
};
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![identity.clone()],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
manager
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect("verified replacement must persist proof before completion");
|
|
|
|
let proof = ResumeManager::load_replacement_completion_proof(disk.clone(), &task_id)
|
|
.await
|
|
.expect("completion proof must be readable from the survivor anchor");
|
|
assert_eq!(proof.task_id, task_id);
|
|
assert_eq!(proof.replacement_generation, proof.task_id);
|
|
assert_eq!(proof.set_disk_id, "pool_0_set_0");
|
|
assert_eq!(proof.replacement_targets, ["replacement-a"]);
|
|
assert_eq!(proof.replacement_target_identities, vec![identity]);
|
|
assert!(proof.verified_at > 0);
|
|
|
|
manager.cleanup().await.expect("resume cleanup should succeed");
|
|
assert!(
|
|
!ResumeManager::has_replacement_intent(&disk, &proof.task_id).await,
|
|
"completion cleanup must remove the resumable state"
|
|
);
|
|
assert_eq!(
|
|
ResumeManager::load_replacement_completion_proof(disk, &proof.task_id)
|
|
.await
|
|
.expect("survivor proof must outlive resume cleanup"),
|
|
proof
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_recovery_records_distinguish_active_and_proven_completion() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
|
|
let active = ResumeUtils::get_replacement_recovery_records(&disk)
|
|
.await
|
|
.expect("active replacement record should be readable");
|
|
assert_eq!(active.len(), 1);
|
|
assert_eq!(active[0].task_id, task_id);
|
|
assert_eq!(active[0].state, ReplacementRecoveryState::WaitingForReplacement);
|
|
assert_eq!(active[0].generation.as_deref(), Some(task_id.as_str()));
|
|
|
|
manager
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect("completion proof should persist");
|
|
|
|
let cleanup_pending = ResumeUtils::get_replacement_recovery_records(&disk)
|
|
.await
|
|
.expect("cleanup-pending replacement record should be readable");
|
|
assert_eq!(cleanup_pending.len(), 1);
|
|
assert_eq!(cleanup_pending[0].state, ReplacementRecoveryState::CleanupPending);
|
|
|
|
manager.cleanup().await.expect("resume state cleanup should succeed");
|
|
|
|
let completed = ResumeUtils::get_replacement_recovery_records(&disk)
|
|
.await
|
|
.expect("completion proof should remain readable");
|
|
assert_eq!(completed.len(), 1);
|
|
assert_eq!(completed[0].state, ReplacementRecoveryState::Completed);
|
|
assert!(completed[0].verified_at.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_completion_write_failure_cannot_mark_completed() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk,
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
let proof_path = replacement_completion_proof_path(&task_id)
|
|
.to_str()
|
|
.expect("completion proof path must be UTF-8")
|
|
.to_string();
|
|
let _failure = ReplacementProofWriteFailure::install(proof_path, DiskError::DiskAccessDenied);
|
|
|
|
let error = manager
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect_err("completion must fail closed when durable proof cannot be written");
|
|
assert!(error.to_string().contains("Failed to save replacement completion proof"));
|
|
let state = manager.get_state().await;
|
|
assert!(!state.completed, "a failed proof write must not produce a completed state");
|
|
assert_eq!(state.replacement_phase, ReplacementPhase::Intent);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_completion_repairs_torn_proof_without_wedging_rebuild() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let identity = ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
};
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![identity.clone()],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
manager
|
|
.mark_replacement_rebuilding(vec![identity])
|
|
.await
|
|
.expect("replacement fixture should enter rebuilding before proof publication");
|
|
let proof_path = replacement_completion_proof_path(&task_id);
|
|
let proof_path = proof_path.to_str().expect("completion proof path must be UTF-8");
|
|
disk.write_all(RUSTFS_META_BUCKET, proof_path, b"{torn completion proof".as_slice().into())
|
|
.await
|
|
.expect("torn proof fixture should persist");
|
|
|
|
manager
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect("a rebuilding generation must replace only its torn completion proof");
|
|
|
|
let proof = ResumeManager::load_replacement_completion_proof(disk, &task_id)
|
|
.await
|
|
.expect("repaired completion proof should be durable and readable");
|
|
assert_eq!(proof.task_id, task_id);
|
|
assert_eq!(proof.replacement_generation, proof.task_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_completion_does_not_replace_a_valid_mismatched_proof() {
|
|
let (_temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
let mut conflicting = ReplacementCompletionProof::from_state(&manager.get_state().await, 1)
|
|
.expect("replacement state should build a proof fixture");
|
|
conflicting.set_disk_id = "pool_0_set_1".to_string();
|
|
let proof_path = replacement_completion_proof_path(&task_id);
|
|
let proof_path = proof_path.to_str().expect("completion proof path must be UTF-8");
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
proof_path,
|
|
serde_json::to_vec(&conflicting)
|
|
.expect("proof fixture should serialize")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("conflicting proof fixture should persist");
|
|
|
|
let error = manager
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect_err("a distinct durable generation binding must not be overwritten");
|
|
assert!(error.to_string().contains("does not match task"));
|
|
assert_eq!(
|
|
ResumeManager::load_replacement_completion_proof(disk, &task_id)
|
|
.await
|
|
.expect("valid conflicting proof should remain intact"),
|
|
conflicting
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cleanup_expired_states_keeps_all_durable_replacement_phases() {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
use tempfile::TempDir;
|
|
|
|
let temp_dir = TempDir::new().expect("create replacement expiry test directory");
|
|
let endpoint = Endpoint::try_from(temp_dir.path().to_string_lossy().as_ref()).expect("create test disk endpoint");
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("create test disk");
|
|
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
|
Err(err) => panic!("create metadata volume for replacement expiry test: {err}"),
|
|
}
|
|
|
|
let target = ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
};
|
|
let intent_task_id = ResumeUtils::generate_task_id();
|
|
let rebuilding_task_id = ResumeUtils::generate_task_id();
|
|
let verified_task_id = ResumeUtils::generate_task_id();
|
|
let cleanup_pending_task_id = ResumeUtils::generate_task_id();
|
|
let abandoned_task_id = ResumeUtils::generate_task_id();
|
|
let ordinary_task_id = ResumeUtils::generate_task_id();
|
|
let intent = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
intent_task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![target.clone()],
|
|
)
|
|
.await
|
|
.expect("replacement intent should persist");
|
|
let rebuilding = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
rebuilding_task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![target.clone()],
|
|
)
|
|
.await
|
|
.expect("replacement rebuilding state should persist");
|
|
rebuilding
|
|
.mark_replacement_rebuilding(vec![target.clone()])
|
|
.await
|
|
.expect("replacement rebuilding phase should persist");
|
|
let verified = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
verified_task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![target.clone()],
|
|
)
|
|
.await
|
|
.expect("replacement verified state should persist");
|
|
verified
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect("replacement verified phase should persist");
|
|
let cleanup_pending = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
cleanup_pending_task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![target.clone()],
|
|
)
|
|
.await
|
|
.expect("replacement cleanup-pending state should persist");
|
|
cleanup_pending
|
|
.mark_replacement_completed_and_verified()
|
|
.await
|
|
.expect("replacement completion should persist");
|
|
cleanup_pending
|
|
.mark_replacement_cleanup_pending()
|
|
.await
|
|
.expect("replacement cleanup-pending phase should persist");
|
|
let abandoned = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
abandoned_task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
vec!["replacement-a".to_string()],
|
|
vec![target],
|
|
)
|
|
.await
|
|
.expect("replacement abandoned state should persist");
|
|
abandoned
|
|
.abandon_replacement_intent()
|
|
.await
|
|
.expect("replacement abandoned phase should persist");
|
|
let ordinary = ResumeManager::new(
|
|
disk.clone(),
|
|
ordinary_task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
)
|
|
.await
|
|
.expect("ordinary resume state should persist");
|
|
|
|
for manager in [&intent, &rebuilding, &verified, &cleanup_pending, &abandoned, &ordinary] {
|
|
manager.state.write().await.last_update = 0;
|
|
manager.save_state_strict().await.expect("persist expired resume state");
|
|
}
|
|
|
|
ResumeUtils::cleanup_expired_states(&disk, 0)
|
|
.await
|
|
.expect("replacement expiry cleanup should complete");
|
|
|
|
for (task_id, expected_phase) in [
|
|
(intent_task_id.as_str(), ReplacementPhase::Intent),
|
|
(rebuilding_task_id.as_str(), ReplacementPhase::Rebuilding),
|
|
(verified_task_id.as_str(), ReplacementPhase::Verified),
|
|
(cleanup_pending_task_id.as_str(), ReplacementPhase::CleanupPending),
|
|
] {
|
|
let state = ResumeManager::load_replacement_intent(disk.clone(), task_id)
|
|
.await
|
|
.expect("durable replacement state must survive expiry cleanup")
|
|
.get_state()
|
|
.await;
|
|
assert_eq!(state.replacement_phase, expected_phase);
|
|
}
|
|
assert!(
|
|
!ResumeManager::has_replacement_intent(&disk, &abandoned_task_id).await,
|
|
"an abandoned replacement must expire"
|
|
);
|
|
assert!(
|
|
!ResumeManager::has_resume_state(&disk, &ordinary_task_id).await,
|
|
"an ordinary expired resume must expire"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resume_state_progress() {
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let buckets = vec!["bucket1".to_string()];
|
|
let mut state = ResumeState::new(task_id, "erasure_set".to_string(), "pool_0_set_0".to_string(), buckets);
|
|
|
|
state.update_progress(10, 8, 1, 1);
|
|
assert_eq!(state.processed_objects, 10);
|
|
assert_eq!(state.successful_objects, 8);
|
|
assert_eq!(state.failed_objects, 1);
|
|
assert_eq!(state.skipped_objects, 1);
|
|
|
|
let progress = state.get_progress_percentage();
|
|
assert_eq!(progress, 0.0); // total_objects is 0
|
|
|
|
state.total_objects = 100;
|
|
let progress = state.get_progress_percentage();
|
|
assert_eq!(progress, 10.0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacement_intent_rejects_a_new_mount_at_the_same_endpoint() {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
use tempfile::TempDir;
|
|
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let disk_path = temp_dir.path().join("resume_disk");
|
|
std::fs::create_dir_all(&disk_path).unwrap();
|
|
let endpoint = Endpoint::try_from(disk_path.to_string_lossy().as_ref()).unwrap();
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let _ = disk.make_volume(RUSTFS_META_BUCKET).await;
|
|
let _ = disk.make_volume(&format!("{RUSTFS_META_BUCKET}/{BUCKET_META_PREFIX}")).await;
|
|
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let targets = vec!["replacement-a".to_string()];
|
|
let first = ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: vec!["device-a".to_string()],
|
|
filesystem_identity: "1:2:3".to_string(),
|
|
};
|
|
ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket-a".to_string()],
|
|
targets.clone(),
|
|
vec![first.clone()],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let reused = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket-b".to_string()],
|
|
targets.clone(),
|
|
vec![first.clone()],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
reused.get_state().await.replacement_buckets,
|
|
["bucket-a"],
|
|
"retries must keep the first generation's bucket plan"
|
|
);
|
|
|
|
let mut second = ReplacementTargetIdentity {
|
|
endpoint: "replacement-a".to_string(),
|
|
canonical_path: "/mnt/replacement-a".to_string(),
|
|
physical_device_ids: first.physical_device_ids.clone(),
|
|
filesystem_identity: first.filesystem_identity.clone(),
|
|
};
|
|
for changed_identity in [
|
|
{
|
|
second.physical_device_ids = vec!["device-b".to_string()];
|
|
second.clone()
|
|
},
|
|
{
|
|
second.physical_device_ids = first.physical_device_ids.clone();
|
|
second.filesystem_identity = "4:5:6".to_string();
|
|
second.clone()
|
|
},
|
|
{
|
|
second.filesystem_identity = first.filesystem_identity.clone();
|
|
second.canonical_path = "/mnt/replacement-b".to_string();
|
|
second.clone()
|
|
},
|
|
] {
|
|
let result = ResumeManager::new_replacement_intent(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket-a".to_string()],
|
|
targets.clone(),
|
|
vec![changed_identity],
|
|
)
|
|
.await;
|
|
assert!(result.is_err(), "a new mounted instance must not reuse the old replacement cursor");
|
|
}
|
|
temp_dir.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn reset_for_retry_clears_progress_but_keeps_retry_budget() {
|
|
// backlog#855 / #799 B6: a retry must re-scan from the start without
|
|
// spending the retry budget's identity.
|
|
let buckets = vec!["bucket1".to_string(), "bucket2".to_string()];
|
|
let mut state = ResumeState::new("t".to_string(), "erasure_set".to_string(), "pool_0_set_0".to_string(), buckets);
|
|
state.update_progress(10, 8, 2, 0);
|
|
state.complete_bucket("bucket1");
|
|
state.increment_retry();
|
|
state.mark_completed();
|
|
|
|
state.reset_for_retry();
|
|
|
|
assert!(!state.completed, "retry must un-complete the task");
|
|
assert_eq!(state.completed_buckets.len(), 0, "all buckets must be re-scanned");
|
|
assert_eq!(state.processed_objects, 0);
|
|
assert_eq!(state.successful_objects, 0);
|
|
assert_eq!(state.failed_objects, 0);
|
|
assert_eq!(state.skipped_objects, 0);
|
|
assert_eq!(state.retry_count, 1, "retry budget must be preserved");
|
|
}
|
|
|
|
#[test]
|
|
fn can_retry_is_bounded_by_max_retries() {
|
|
let mut state = ResumeState::new("t".to_string(), "erasure_set".to_string(), "pool_0_set_0".to_string(), vec![]);
|
|
assert!(state.can_retry());
|
|
for _ in 0..state.max_retries {
|
|
assert!(state.can_retry());
|
|
state.increment_retry();
|
|
}
|
|
assert!(!state.can_retry(), "retries must stop after max_retries");
|
|
}
|
|
|
|
#[test]
|
|
fn checkpoint_reset_for_retry_rewinds_position_and_clears_sets() {
|
|
let mut checkpoint = ResumeCheckpoint::new("task".to_string());
|
|
checkpoint.update_position(3, 42);
|
|
checkpoint.add_processed_object("bucket/a".to_string());
|
|
checkpoint.add_failed_object("bucket/b".to_string());
|
|
checkpoint.add_skipped_object("bucket/c".to_string());
|
|
|
|
checkpoint.reset_for_retry();
|
|
|
|
assert_eq!(checkpoint.current_bucket_index, 0);
|
|
assert_eq!(checkpoint.current_object_index, 0);
|
|
assert!(checkpoint.processed_objects.is_empty());
|
|
assert!(checkpoint.failed_objects.is_empty());
|
|
assert!(checkpoint.skipped_objects.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resume_state_bucket_completion() {
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let buckets = vec!["bucket1".to_string(), "bucket2".to_string()];
|
|
let mut state = ResumeState::new(task_id, "erasure_set".to_string(), "pool_0_set_0".to_string(), buckets);
|
|
|
|
assert_eq!(state.pending_buckets.len(), 2);
|
|
assert_eq!(state.completed_buckets.len(), 0);
|
|
|
|
state.complete_bucket("bucket1");
|
|
assert_eq!(state.pending_buckets.len(), 1);
|
|
assert_eq!(state.completed_buckets.len(), 1);
|
|
assert!(state.completed_buckets.contains(&"bucket1".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_checkpoint_object_sets_dedupe_and_prune() {
|
|
let mut checkpoint = ResumeCheckpoint::new("task".to_string());
|
|
checkpoint.add_processed_object("bucket/a".to_string());
|
|
checkpoint.add_processed_object("bucket/a".to_string());
|
|
checkpoint.add_skipped_object("bucket/b".to_string());
|
|
checkpoint.add_failed_object("bucket/c".to_string());
|
|
assert_eq!(checkpoint.processed_objects.len(), 1);
|
|
assert!(checkpoint.processed_objects.contains("bucket/a"));
|
|
|
|
checkpoint.complete_page(2, 2000);
|
|
assert_eq!(checkpoint.current_bucket_index, 2);
|
|
assert_eq!(checkpoint.current_object_index, 2000);
|
|
assert!(checkpoint.processed_objects.is_empty());
|
|
assert!(checkpoint.skipped_objects.is_empty());
|
|
assert!(checkpoint.failed_objects.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_checkpoint_loads_legacy_vec_format() {
|
|
// Checkpoints written before the HashSet migration stored the object
|
|
// lists as JSON arrays (possibly with duplicates); they must still load.
|
|
let legacy = r#"{
|
|
"task_id": "t1",
|
|
"checkpoint_time": 1700000000,
|
|
"current_bucket_index": 1,
|
|
"current_object_index": 42,
|
|
"processed_objects": ["a", "b", "a"],
|
|
"failed_objects": [],
|
|
"skipped_objects": ["c"]
|
|
}"#;
|
|
let checkpoint: ResumeCheckpoint = serde_json::from_str(legacy).unwrap();
|
|
assert_eq!(checkpoint.current_object_index, 42);
|
|
assert_eq!(checkpoint.processed_objects.len(), 2);
|
|
assert!(checkpoint.processed_objects.contains("a"));
|
|
assert!(checkpoint.skipped_objects.contains("c"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_compose_key_injective_with_adversarial_keys() {
|
|
// Length-prefixing must keep the encoding injective even when keys
|
|
// contain the delimiter, embedded nulls, or look like a composed key.
|
|
assert_ne!(compose_key("a\0b", None), compose_key("a", Some("b")));
|
|
assert_ne!(compose_key("3:xy", None), compose_key("x", Some("y")));
|
|
assert_ne!(compose_key("a:b", None), compose_key("a", Some("b")));
|
|
assert_ne!(compose_key("", Some("x")), compose_key("x", None));
|
|
// Identical inputs must produce identical keys (stable identity).
|
|
assert_eq!(compose_key("obj", Some("v1")), compose_key("obj", Some("v1")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_composite_key_dedup_distinguishes_versions() {
|
|
// Two versions of the same object must be distinct dedup identities, and
|
|
// the delete-marker/nil (None) version must not collide with a real one.
|
|
let mut checkpoint = ResumeCheckpoint::new("task".to_string());
|
|
checkpoint.add_processed_object(compose_key("obj", Some("v1")));
|
|
checkpoint.add_processed_object(compose_key("obj", Some("v2")));
|
|
checkpoint.add_processed_object(compose_key("obj", None));
|
|
assert_eq!(checkpoint.processed_objects.len(), 3);
|
|
assert!(checkpoint.processed_objects.contains(&compose_key("obj", Some("v1"))));
|
|
assert!(checkpoint.processed_objects.contains(&compose_key("obj", Some("v2"))));
|
|
assert!(checkpoint.processed_objects.contains(&compose_key("obj", None)));
|
|
// A different object with the same version id is still distinct.
|
|
assert!(!checkpoint.processed_objects.contains(&compose_key("other", Some("v1"))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resumestate_schema_v0_discarded_on_load() {
|
|
let (temp_dir, disk) = schema_test_disk().await;
|
|
|
|
// Legacy snapshot: no schema_version, a stale positional cursor and progress.
|
|
let legacy = r#"{
|
|
"task_id": "old-task",
|
|
"task_type": "erasure_set",
|
|
"set_disk_id": "pool_0_set_0",
|
|
"start_time": 1700000000,
|
|
"last_update": 1700000000,
|
|
"completed": true,
|
|
"total_objects": 100,
|
|
"processed_objects": 50,
|
|
"successful_objects": 40,
|
|
"failed_objects": 10,
|
|
"skipped_objects": 0,
|
|
"current_bucket": null,
|
|
"current_object": null,
|
|
"completed_buckets": ["b1"],
|
|
"pending_buckets": [],
|
|
"error_message": null,
|
|
"retry_count": 1,
|
|
"max_retries": 3,
|
|
"resume_cursor": "v1:stale-token"
|
|
}"#;
|
|
let task_id = "00000000-0000-4000-8000-000000000001";
|
|
let legacy = legacy.replace("old-task", task_id);
|
|
let file_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_STATE_FILE}");
|
|
disk.write_all(RUSTFS_META_BUCKET, &file_path, legacy.as_bytes().to_vec().into())
|
|
.await
|
|
.expect("write legacy resume state");
|
|
|
|
let manager = ResumeManager::load_from_disk(disk.clone(), task_id).await.unwrap();
|
|
let state = manager.get_state().await;
|
|
assert_eq!(state.schema_version, CURRENT_RESUME_SCHEMA, "schema must be stamped current");
|
|
assert_eq!(state.resume_cursor, None, "stale cursor must be cleared");
|
|
assert_eq!(state.processed_objects, 0);
|
|
assert_eq!(state.successful_objects, 0);
|
|
assert_eq!(state.failed_objects, 0);
|
|
assert!(!state.completed);
|
|
temp_dir.close().expect("remove schema test directory");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_checkpoint_schema_v4_discarded_on_load() {
|
|
let (temp_dir, disk) = schema_test_disk().await;
|
|
|
|
// The previous checkpoint schema is unsafe once its paired resume
|
|
// state is discarded: retaining either position would skip work.
|
|
let task_id = "00000000-0000-4000-8000-000000000002";
|
|
let legacy = r#"{
|
|
"schema_version": 4,
|
|
"task_id": "00000000-0000-4000-8000-000000000002",
|
|
"checkpoint_time": 1700000000,
|
|
"current_bucket_index": 2,
|
|
"current_object_index": 500,
|
|
"processed_objects": ["a", "b"],
|
|
"failed_objects": ["c"],
|
|
"skipped_objects": ["d"]
|
|
}"#;
|
|
let file_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_CHECKPOINT_FILE}");
|
|
disk.write_all(RUSTFS_META_BUCKET, &file_path, legacy.as_bytes().to_vec().into())
|
|
.await
|
|
.expect("write legacy checkpoint");
|
|
|
|
let manager = CheckpointManager::load_from_disk(disk.clone(), task_id).await.unwrap();
|
|
let checkpoint = manager.get_checkpoint().await;
|
|
assert_eq!(checkpoint.schema_version, CURRENT_CHECKPOINT_SCHEMA, "schema must be stamped current");
|
|
assert_eq!(checkpoint.current_bucket_index, 0, "stale bucket position must be reset");
|
|
assert_eq!(checkpoint.current_object_index, 0, "stale position must be reset");
|
|
assert!(checkpoint.processed_objects.is_empty());
|
|
assert!(checkpoint.failed_objects.is_empty());
|
|
assert!(checkpoint.skipped_objects.is_empty());
|
|
temp_dir.close().expect("remove schema test directory");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn current_normal_resume_schema_preserves_progress() {
|
|
let (temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let mut state = ResumeState::new(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket-b".to_string()],
|
|
);
|
|
state.resume_cursor = Some("opaque-marker".to_string());
|
|
state.processed_objects = 7;
|
|
state.successful_objects = 6;
|
|
state.failed_objects = 1;
|
|
state.completed_buckets = vec!["bucket-a".to_string()];
|
|
let file_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_STATE_FILE}");
|
|
let state_data = serde_json::to_vec(&state).expect("serialize current normal resume state");
|
|
disk.write_all(RUSTFS_META_BUCKET, &file_path, state_data.into())
|
|
.await
|
|
.expect("write current normal resume state");
|
|
|
|
let restored = ResumeManager::load_from_disk(disk.clone(), &task_id)
|
|
.await
|
|
.expect("load current normal resume state")
|
|
.get_state()
|
|
.await;
|
|
|
|
assert_eq!(restored.schema_version, CURRENT_RESUME_SCHEMA);
|
|
assert_eq!(restored.resume_cursor.as_deref(), Some("opaque-marker"));
|
|
assert_eq!(restored.processed_objects, 7);
|
|
assert_eq!(restored.successful_objects, 6);
|
|
assert_eq!(restored.failed_objects, 1);
|
|
assert_eq!(restored.completed_buckets, ["bucket-a"]);
|
|
assert!(restored.replacement_targets.is_empty());
|
|
assert_eq!(restored.replacement_generation, None);
|
|
assert_eq!(restored.replacement_phase, ReplacementPhase::None);
|
|
temp_dir.close().expect("remove schema test directory");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn future_resume_and_checkpoint_schemas_are_rejected() {
|
|
let (temp_dir, disk) = schema_test_disk().await;
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let mut state = ResumeState::new(task_id.clone(), "erasure_set".to_string(), "pool_0_set_0".to_string(), Vec::new());
|
|
state.schema_version = CURRENT_RESUME_SCHEMA + 1;
|
|
let state_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_STATE_FILE}");
|
|
let state_data = serde_json::to_vec(&state).expect("serialize future resume state");
|
|
disk.write_all(RUSTFS_META_BUCKET, &state_path, state_data.into())
|
|
.await
|
|
.expect("write future resume state");
|
|
|
|
let resume_error = match ResumeManager::load_from_disk(disk.clone(), &task_id).await {
|
|
Ok(_) => panic!("future resume schema must not load"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(matches!(resume_error, Error::TaskExecutionFailed { .. }));
|
|
assert!(resume_error.to_string().contains("newer than supported schema"));
|
|
|
|
let mut checkpoint = ResumeCheckpoint::new(task_id.clone());
|
|
checkpoint.schema_version = CURRENT_CHECKPOINT_SCHEMA + 1;
|
|
let checkpoint_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_CHECKPOINT_FILE}");
|
|
let checkpoint_data = serde_json::to_vec(&checkpoint).expect("serialize future checkpoint");
|
|
disk.write_all(RUSTFS_META_BUCKET, &checkpoint_path, checkpoint_data.into())
|
|
.await
|
|
.expect("write future checkpoint");
|
|
|
|
let checkpoint_error = match CheckpointManager::load_from_disk(disk.clone(), &task_id).await {
|
|
Ok(_) => panic!("future checkpoint schema must not load"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(matches!(checkpoint_error, Error::TaskExecutionFailed { .. }));
|
|
assert!(checkpoint_error.to_string().contains("newer than supported schema"));
|
|
temp_dir.close().expect("remove schema test directory");
|
|
}
|
|
|
|
#[test]
|
|
fn test_persist_throttle_batches_until_threshold() {
|
|
let mut throttle = PersistThrottle::new();
|
|
for _ in 0..PERSIST_EVERY_MUTATIONS - 1 {
|
|
assert!(!throttle.record(), "must not flush below the mutation threshold");
|
|
}
|
|
assert!(throttle.record(), "must flush at the mutation threshold");
|
|
throttle.mark_saved();
|
|
assert!(!throttle.record(), "counter must reset after a save");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn completion_persists_immediately_and_cleanup_propagates_delete_errors() {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
use tempfile::TempDir;
|
|
|
|
let temp_dir = TempDir::new().expect("create resume persistence test directory");
|
|
let endpoint = Endpoint::try_from(temp_dir.path().to_string_lossy().as_ref()).expect("create test disk endpoint");
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("create resume persistence test disk");
|
|
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
|
Err(err) => panic!("create metadata volume for resume persistence test: {err}"),
|
|
}
|
|
|
|
let task_id = ResumeUtils::generate_task_id();
|
|
let manager = ResumeManager::new(
|
|
disk.clone(),
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
)
|
|
.await
|
|
.expect("create resume manager");
|
|
manager
|
|
.update_progress(1, 1, 0, 0)
|
|
.await
|
|
.expect("buffer progress below the persistence threshold");
|
|
manager.mark_completed().await.expect("persist completed resume state");
|
|
|
|
let persisted = ResumeManager::load_from_disk(disk.clone(), &task_id)
|
|
.await
|
|
.expect("reload completed resume state")
|
|
.get_state()
|
|
.await;
|
|
assert!(persisted.completed, "completion must be persisted without waiting for the throttle");
|
|
assert_eq!(persisted.processed_objects, 1, "the completion write must include buffered progress");
|
|
|
|
let state_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_STATE_FILE}");
|
|
let failure = ResumeDeleteFailure::install(state_path, DiskError::DiskAccessDenied);
|
|
let error = manager
|
|
.cleanup()
|
|
.await
|
|
.expect_err("resume cleanup must propagate a real delete failure");
|
|
assert!(matches!(error, Error::Disk(DiskError::DiskAccessDenied)));
|
|
drop(failure);
|
|
manager.cleanup().await.expect("resume cleanup must be retryable");
|
|
manager
|
|
.cleanup()
|
|
.await
|
|
.expect("missing resume files must be idempotent success");
|
|
|
|
let checkpoint = CheckpointManager::new(disk.clone(), task_id.clone())
|
|
.await
|
|
.expect("create checkpoint manager");
|
|
let checkpoint_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_CHECKPOINT_FILE}");
|
|
let failure = ResumeDeleteFailure::install(checkpoint_path, DiskError::DiskAccessDenied);
|
|
let error = checkpoint
|
|
.cleanup()
|
|
.await
|
|
.expect_err("checkpoint cleanup must propagate a real delete failure");
|
|
assert!(matches!(error, Error::Disk(DiskError::DiskAccessDenied)));
|
|
drop(failure);
|
|
checkpoint.cleanup().await.expect("checkpoint cleanup must be retryable");
|
|
checkpoint
|
|
.cleanup()
|
|
.await
|
|
.expect("missing checkpoint must be idempotent success");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn checkpoint_rejects_a_task_id_mismatched_to_its_file_name() {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
use tempfile::TempDir;
|
|
|
|
let temp_dir = TempDir::new().expect("create checkpoint binding test directory");
|
|
let endpoint =
|
|
Endpoint::try_from(temp_dir.path().to_string_lossy().as_ref()).expect("create checkpoint binding test endpoint");
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("create checkpoint binding test disk");
|
|
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
|
Err(err) => panic!("create checkpoint binding metadata volume: {err}"),
|
|
}
|
|
|
|
let requested_task_id = ResumeUtils::generate_task_id();
|
|
let checkpoint = ResumeCheckpoint::new(ResumeUtils::generate_task_id());
|
|
let checkpoint_path = format!("{BUCKET_META_PREFIX}/{requested_task_id}_{RESUME_CHECKPOINT_FILE}");
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
&checkpoint_path,
|
|
serde_json::to_vec(&checkpoint)
|
|
.expect("serialize mismatched checkpoint")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("persist mismatched checkpoint");
|
|
|
|
let error = match CheckpointManager::load_from_disk(disk, &requested_task_id).await {
|
|
Ok(_) => panic!("checkpoint task id must be bound to its file name"),
|
|
Err(error) => error,
|
|
};
|
|
|
|
assert!(error.to_string().contains("does not match"));
|
|
temp_dir.close().expect("remove checkpoint binding test directory");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resume_utils() {
|
|
let task_id1 = ResumeUtils::generate_task_id();
|
|
let task_id2 = ResumeUtils::generate_task_id();
|
|
|
|
assert_ne!(task_id1, task_id2);
|
|
assert_eq!(task_id1.len(), 36); // UUID length
|
|
assert_eq!(task_id2.len(), 36);
|
|
assert!(validate_resume_task_id(&task_id1).is_ok());
|
|
assert!(validate_resume_task_id(&format!("pool_0_set_0_{task_id1}")).is_err());
|
|
assert!(validate_resume_task_id(&task_id1.to_uppercase()).is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_resumable_tasks_integration() {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
use tempfile::TempDir;
|
|
|
|
// Create a temporary directory for testing
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let disk_path = temp_dir.path().join("test_disk");
|
|
std::fs::create_dir_all(&disk_path).unwrap();
|
|
|
|
// Create a local disk for testing
|
|
let endpoint = Endpoint::try_from(disk_path.to_string_lossy().as_ref()).unwrap();
|
|
let disk_option = DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
};
|
|
let disk = new_disk(&endpoint, &disk_option).await.unwrap();
|
|
|
|
// Create necessary directories first (ignore if already exist)
|
|
let _ = disk.make_volume(RUSTFS_META_BUCKET).await;
|
|
let _ = disk.make_volume(&format!("{RUSTFS_META_BUCKET}/{BUCKET_META_PREFIX}")).await;
|
|
|
|
// Create some test resume state files
|
|
let task_ids = vec![
|
|
ResumeUtils::generate_task_id(),
|
|
ResumeUtils::generate_task_id(),
|
|
ResumeUtils::generate_task_id(),
|
|
];
|
|
|
|
// Save resume state files for each task
|
|
for task_id in &task_ids {
|
|
let state = ResumeState::new(
|
|
task_id.clone(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket1".to_string(), "bucket2".to_string()],
|
|
);
|
|
|
|
let state_data = serde_json::to_vec(&state).unwrap();
|
|
let file_path = format!("{BUCKET_META_PREFIX}/{task_id}_{RESUME_STATE_FILE}");
|
|
|
|
disk.write_all(RUSTFS_META_BUCKET, &file_path, state_data.into())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
// Also create some non-resume state files to test filtering
|
|
let non_resume_files = vec![
|
|
"other_file.txt",
|
|
"task4_ahm_checkpoint.json",
|
|
"task5_ahm_progress.json",
|
|
"_ahm_resume_state.json", // Invalid: empty task ID
|
|
"not-a-uuid_ahm_resume_state.json",
|
|
"00000000-0000-4000-8000-000000000001_extra_ahm_resume_state.json",
|
|
];
|
|
|
|
for file_name in non_resume_files {
|
|
let file_path = format!("{BUCKET_META_PREFIX}/{file_name}");
|
|
disk.write_all(RUSTFS_META_BUCKET, &file_path, b"test data".to_vec().into())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
// Now call get_resumable_tasks to see if it finds the correct files
|
|
let found_task_ids = ResumeUtils::get_resumable_tasks(&disk).await.unwrap();
|
|
|
|
// Verify that only the valid resume state files are found
|
|
assert_eq!(found_task_ids.len(), 3);
|
|
for task_id in &task_ids {
|
|
assert!(found_task_ids.contains(task_id), "Task ID {task_id} not found");
|
|
}
|
|
|
|
// Verify that invalid files are not included
|
|
assert!(!found_task_ids.contains(&"".to_string()));
|
|
assert!(!found_task_ids.contains(&"task4".to_string()));
|
|
assert!(!found_task_ids.contains(&"task5".to_string()));
|
|
assert!(!found_task_ids.contains(&"not-a-uuid".to_string()));
|
|
|
|
let error = match ResumeManager::load_from_disk(disk.clone(), "../not-a-uuid").await {
|
|
Ok(_) => panic!("a traversal-like task id must be rejected before reading metadata"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(matches!(
|
|
error,
|
|
Error::TaskExecutionFailed { message } if message == "Invalid resume task id"
|
|
));
|
|
|
|
// Clean up
|
|
temp_dir.close().unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_state_rejects_filename_and_json_task_id_mismatch() {
|
|
use super::super::{DiskOption, Endpoint, new_disk};
|
|
use tempfile::TempDir;
|
|
|
|
let temp_dir = TempDir::new().expect("create resume mismatch test directory");
|
|
let endpoint = Endpoint::try_from(temp_dir.path().to_string_lossy().as_ref()).expect("create test disk endpoint");
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("create resume mismatch test disk");
|
|
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
|
Err(error) => panic!("create metadata volume for resume mismatch test: {error}"),
|
|
}
|
|
|
|
let filename_task_id = ResumeUtils::generate_task_id();
|
|
let state = ResumeState::new(
|
|
ResumeUtils::generate_task_id(),
|
|
"erasure_set".to_string(),
|
|
"pool_0_set_0".to_string(),
|
|
vec!["bucket".to_string()],
|
|
);
|
|
let path = format!("{BUCKET_META_PREFIX}/{filename_task_id}_{RESUME_STATE_FILE}");
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
&path,
|
|
serde_json::to_vec(&state).expect("serialize mismatched resume state").into(),
|
|
)
|
|
.await
|
|
.expect("write mismatched resume state");
|
|
|
|
let error = match ResumeManager::load_from_disk(disk.clone(), &filename_task_id).await {
|
|
Ok(_) => panic!("resume state task id must match its filename"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(matches!(
|
|
error,
|
|
Error::TaskExecutionFailed { message } if message == "Resume state task id does not match filename"
|
|
));
|
|
|
|
let checkpoint_filename_task_id = ResumeUtils::generate_task_id();
|
|
let checkpoint = ResumeCheckpoint::new(ResumeUtils::generate_task_id());
|
|
let checkpoint_path = format!("{BUCKET_META_PREFIX}/{checkpoint_filename_task_id}_{RESUME_CHECKPOINT_FILE}");
|
|
disk.write_all(
|
|
RUSTFS_META_BUCKET,
|
|
&checkpoint_path,
|
|
serde_json::to_vec(&checkpoint)
|
|
.expect("serialize mismatched resume checkpoint")
|
|
.into(),
|
|
)
|
|
.await
|
|
.expect("write mismatched resume checkpoint");
|
|
|
|
let error = match CheckpointManager::load_from_disk(disk, &checkpoint_filename_task_id).await {
|
|
Ok(_) => panic!("resume checkpoint task id must match its filename"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(matches!(
|
|
error,
|
|
Error::TaskExecutionFailed { message } if message == "Resume checkpoint task id does not match filename"
|
|
));
|
|
}
|