mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-16 09:58:21 +00:00
fix(rebalance): harden multipart retry replacement
This commit is contained in:
@@ -3178,10 +3178,12 @@ impl ECStore {
|
||||
entry.name.as_str(),
|
||||
&fivs,
|
||||
&cleanup_preflight_allowed_missing,
|
||||
expected_bucket_incarnation_id,
|
||||
bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
data_movement::SourceCleanupBucketFence {
|
||||
expected_incarnation_id: expected_bucket_incarnation_id,
|
||||
lifecycle_guard: bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
},
|
||||
"decommission",
|
||||
)
|
||||
.await
|
||||
@@ -3341,7 +3343,6 @@ impl ECStore {
|
||||
let lifecycle_config = lifecycle_config.clone();
|
||||
let object_lock_config = object_lock_config.clone();
|
||||
let replication_config = replication_config.clone();
|
||||
let expected_bucket_incarnation_id = expected_bucket_incarnation_id;
|
||||
let entry_error = entry_error.clone();
|
||||
let callback_rx = rx.clone();
|
||||
move |entry: MetaCacheEntry| {
|
||||
|
||||
@@ -294,12 +294,15 @@ pub(crate) fn prepare_tiered_data_movement_file_info(file_info: &mut rustfs_file
|
||||
}
|
||||
|
||||
fn prepare_tiered_data_movement_file_info_for(file_info: &mut rustfs_filemeta::FileInfo, writer_enabled: bool) -> Result<()> {
|
||||
if !writer_enabled {
|
||||
rustfs_utils::http::remove_str(&mut file_info.metadata, SUFFIX_PART_CHECKSUMS);
|
||||
for part in &mut file_info.parts {
|
||||
part.checksums = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
SetDisks::hydrate_selected_fileinfo_part_checksums(file_info).map_err(|_| Error::FileCorrupt)?;
|
||||
let has_part_checksums = file_info
|
||||
.parts
|
||||
.iter()
|
||||
.any(|part| part.checksums.as_ref().is_some_and(|checksums| !checksums.is_empty()));
|
||||
ensure_data_movement_part_checksum_writer_allowed(has_part_checksums, writer_enabled)?;
|
||||
rustfs_utils::http::remove_str(&mut file_info.metadata, SUFFIX_PART_CHECKSUMS);
|
||||
if let Some(encoded) = data_movement_part_checksums(&file_info.parts)? {
|
||||
rustfs_utils::http::insert_str(&mut file_info.metadata, SUFFIX_PART_CHECKSUMS, encoded);
|
||||
@@ -324,15 +327,6 @@ fn data_movement_part_checksum_writer_enabled() -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_data_movement_part_checksum_writer_allowed(has_part_checksums: bool, enabled: bool) -> Result<()> {
|
||||
if has_part_checksums && !enabled {
|
||||
return Err(Error::other(
|
||||
"data movement per-part checksums require the fleet-confirmed writer capability",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_use_multipart_data_movement(object_info: &ObjectInfo, has_part_checksums: bool) -> bool {
|
||||
object_info.is_multipart()
|
||||
|| has_part_checksums
|
||||
@@ -340,7 +334,11 @@ fn should_use_multipart_data_movement(object_info: &ObjectInfo, has_part_checksu
|
||||
|| object_info.parts.first().is_some_and(|part| part.number != 1)
|
||||
}
|
||||
|
||||
fn data_movement_complete_multipart_opts(object_info: &ObjectInfo, src_pool_idx: usize) -> Result<ObjectOptions> {
|
||||
fn data_movement_complete_multipart_opts(
|
||||
object_info: &ObjectInfo,
|
||||
src_pool_idx: usize,
|
||||
preserve_part_checksums: bool,
|
||||
) -> Result<ObjectOptions> {
|
||||
let mut user_defined = HashMap::new();
|
||||
insert_data_movement_checksum(&mut user_defined, object_info);
|
||||
let actual_size = object_info
|
||||
@@ -350,7 +348,7 @@ fn data_movement_complete_multipart_opts(object_info: &ObjectInfo, src_pool_idx:
|
||||
return Err(Error::other("data movement source actual size is unknown"));
|
||||
}
|
||||
rustfs_utils::http::insert_str(&mut user_defined, SUFFIX_ACTUAL_SIZE, actual_size.to_string());
|
||||
if let Some(encoded) = data_movement_part_checksums(&object_info.parts)? {
|
||||
if preserve_part_checksums && let Some(encoded) = data_movement_part_checksums(&object_info.parts)? {
|
||||
rustfs_utils::http::insert_str(&mut user_defined, SUFFIX_PART_CHECKSUMS, encoded);
|
||||
}
|
||||
Ok(ObjectOptions {
|
||||
@@ -391,6 +389,40 @@ pub(crate) fn data_movement_target_precondition() -> HTTPPreconditions {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn can_replace_stale_data_movement_target(target: &ObjectInfo, opts: &ObjectOptions) -> bool {
|
||||
let Some(preconditions) = opts.http_preconditions.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if !opts.data_movement
|
||||
|| preconditions.if_none_match_value() != Some("*")
|
||||
|| preconditions.if_match_value().is_some()
|
||||
|| target.delete_marker
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let rustfs_marker = rustfs_utils::http::internal_key_rustfs(SUFFIX_DATA_MOVED);
|
||||
let minio_marker = format!("{}{SUFFIX_DATA_MOVED}", rustfs_utils::http::MINIO_INTERNAL_PREFIX);
|
||||
if rustfs_utils::http::get_consistent_str(&target.user_defined, SUFFIX_DATA_MOVED) != Some("true")
|
||||
|| target.user_defined.get(&rustfs_marker).map(String::as_str) != Some("true")
|
||||
|| target.user_defined.get(&minio_marker).map(String::as_str) != Some("true")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let version_matches = match (opts.version_id.as_deref(), target.version_id) {
|
||||
(None, None) => true,
|
||||
(Some(expected), Some(actual)) => uuid::Uuid::parse_str(expected).ok() == Some(actual),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
version_matches
|
||||
&& target
|
||||
.mod_time
|
||||
.zip(opts.mod_time)
|
||||
.is_some_and(|(target_time, source_time)| target_time < source_time)
|
||||
}
|
||||
|
||||
fn data_movement_put_object_reader(
|
||||
bucket: &str,
|
||||
object_info: &ObjectInfo,
|
||||
@@ -489,7 +521,7 @@ fn effective_part_actual_size(part: &ObjectPartInfo) -> Option<i64> {
|
||||
.or_else(|| i64::try_from(part.size).ok())
|
||||
}
|
||||
|
||||
fn is_equivalent_data_movement_part(source: &ObjectPartInfo, target: &ObjectPartInfo) -> bool {
|
||||
fn is_equivalent_data_movement_part(source: &ObjectPartInfo, target: &ObjectPartInfo, compare_checksums: bool) -> bool {
|
||||
// Multipart migration rewrites part timestamps.
|
||||
source.number == target.number
|
||||
&& source.etag == target.etag
|
||||
@@ -500,8 +532,9 @@ fn is_equivalent_data_movement_part(source: &ObjectPartInfo, target: &ObjectPart
|
||||
)
|
||||
// A missing target compression index selects the safe full-read fallback.
|
||||
&& (target.index.is_none() || source.index == target.index)
|
||||
&& source.checksums.as_ref().filter(|checksums| !checksums.is_empty())
|
||||
== target.checksums.as_ref().filter(|checksums| !checksums.is_empty())
|
||||
&& (!compare_checksums
|
||||
|| source.checksums.as_ref().filter(|checksums| !checksums.is_empty())
|
||||
== target.checksums.as_ref().filter(|checksums| !checksums.is_empty()))
|
||||
}
|
||||
|
||||
fn data_movement_parts_by_number(parts: &[ObjectPartInfo]) -> Option<BTreeMap<usize, &ObjectPartInfo>> {
|
||||
@@ -516,6 +549,10 @@ fn data_movement_parts_by_number(parts: &[ObjectPartInfo]) -> Option<BTreeMap<us
|
||||
}
|
||||
|
||||
pub(crate) fn are_equivalent_data_movement_parts(source: &[ObjectPartInfo], target: &[ObjectPartInfo]) -> bool {
|
||||
are_equivalent_data_movement_parts_for(source, target, true)
|
||||
}
|
||||
|
||||
fn are_equivalent_data_movement_parts_for(source: &[ObjectPartInfo], target: &[ObjectPartInfo], compare_checksums: bool) -> bool {
|
||||
if source.len() != target.len() {
|
||||
return false;
|
||||
}
|
||||
@@ -530,7 +567,7 @@ pub(crate) fn are_equivalent_data_movement_parts(source: &[ObjectPartInfo], targ
|
||||
source_parts.iter().all(|(number, source_part)| {
|
||||
target_parts
|
||||
.get(number)
|
||||
.is_some_and(|target_part| is_equivalent_data_movement_part(source_part, target_part))
|
||||
.is_some_and(|target_part| is_equivalent_data_movement_part(source_part, target_part, compare_checksums))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -699,7 +736,12 @@ pub(crate) fn is_equivalent_data_movement_metadata(
|
||||
.all(|(key, value)| source.user_defined.get(key) == Some(value))
|
||||
}
|
||||
|
||||
fn is_equivalent_data_movement_object_identity(source: &ObjectInfo, target: &ObjectInfo, compare_mod_time: bool) -> bool {
|
||||
fn is_equivalent_data_movement_object_identity(
|
||||
source: &ObjectInfo,
|
||||
target: &ObjectInfo,
|
||||
compare_mod_time: bool,
|
||||
compare_part_checksums: bool,
|
||||
) -> bool {
|
||||
let (Some(source_actual_size), Some(target_actual_size)) = (effective_actual_size(source), effective_actual_size(target))
|
||||
else {
|
||||
return false;
|
||||
@@ -726,11 +768,11 @@ fn is_equivalent_data_movement_object_identity(source: &ObjectInfo, target: &Obj
|
||||
&& source.transitioned_object.free_version == target.transitioned_object.free_version
|
||||
&& source.transitioned_object.status == target.transitioned_object.status
|
||||
&& source.transition_version_state == target.transition_version_state
|
||||
&& are_equivalent_data_movement_parts(&source.parts, &target.parts)
|
||||
&& are_equivalent_data_movement_parts_for(&source.parts, &target.parts, compare_part_checksums)
|
||||
}
|
||||
|
||||
fn is_equivalent_data_movement_object(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
is_equivalent_data_movement_object_identity(source, target, true)
|
||||
is_equivalent_data_movement_object_identity(source, target, true, true)
|
||||
}
|
||||
|
||||
fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
@@ -743,11 +785,11 @@ fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target:
|
||||
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
||||
}
|
||||
|
||||
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
||||
let identity = data_movement_upload_identity(source);
|
||||
source.mod_time.is_some()
|
||||
&& rustfs_utils::http::get_consistent_str(&target.user_defined, SUFFIX_DATA_MOVEMENT_UPLOAD) == Some(identity.as_str())
|
||||
&& is_equivalent_data_movement_object_identity(source, target, false)
|
||||
&& is_equivalent_data_movement_object_identity(source, target, false, compare_part_checksums)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -895,6 +937,12 @@ pub(crate) enum SourceCleanupError {
|
||||
Storage(#[from] Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct SourceCleanupBucketFence<'a> {
|
||||
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
|
||||
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
||||
}
|
||||
|
||||
fn ensure_source_cleanup_versions_match(
|
||||
expected: &FileInfoVersions,
|
||||
current: &FileInfoVersions,
|
||||
@@ -1007,8 +1055,7 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
object: &str,
|
||||
expected: &FileInfoVersions,
|
||||
allowed_missing: &[SourceCleanupVersionIdentity],
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
bucket_lifecycle_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
bucket_fence: SourceCleanupBucketFence<'_>,
|
||||
op_label: &str,
|
||||
) -> std::result::Result<ObjectInfo, SourceCleanupError> {
|
||||
let cleanup_key = encode_dir_object(object);
|
||||
@@ -1018,7 +1065,10 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
if bucket_lifecycle_guard.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost) {
|
||||
if bucket_fence
|
||||
.lifecycle_guard
|
||||
.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost)
|
||||
{
|
||||
return Err(SourceCleanupError::Storage(Error::other(format!(
|
||||
"{op_label}: bucket incarnation fence was lost before source cleanup"
|
||||
))));
|
||||
@@ -1034,11 +1084,11 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
delete_prefix_object: true,
|
||||
data_movement: true,
|
||||
no_lock: true,
|
||||
expected_bucket_incarnation_id,
|
||||
expected_bucket_incarnation_id: bucket_fence.expected_incarnation_id,
|
||||
..Default::default()
|
||||
};
|
||||
opts.add_namespace_lock_guard(&_guard);
|
||||
if let Some(bucket_lifecycle_guard) = bucket_lifecycle_guard {
|
||||
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
|
||||
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
|
||||
}
|
||||
let result = set.delete_object(bucket, cleanup_key.as_str(), opts).await;
|
||||
@@ -1086,6 +1136,24 @@ fn resolve_data_movement_overwrite_resume_result(
|
||||
source: &ObjectInfo,
|
||||
src_pool_idx: usize,
|
||||
target_pool_idx: usize,
|
||||
) -> Result<bool> {
|
||||
resolve_data_movement_overwrite_resume_result_for(
|
||||
err,
|
||||
target_result,
|
||||
source,
|
||||
src_pool_idx,
|
||||
target_pool_idx,
|
||||
data_movement_part_checksum_writer_enabled(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_data_movement_overwrite_resume_result_for(
|
||||
err: &Error,
|
||||
target_result: Result<Option<ObjectInfo>>,
|
||||
source: &ObjectInfo,
|
||||
src_pool_idx: usize,
|
||||
target_pool_idx: usize,
|
||||
compare_part_checksums: bool,
|
||||
) -> Result<bool> {
|
||||
if !should_check_data_movement_overwrite_resume(err)
|
||||
|| !should_check_data_movement_resume_target(src_pool_idx, target_pool_idx)
|
||||
@@ -1097,11 +1165,11 @@ fn resolve_data_movement_overwrite_resume_result(
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if is_equivalent_data_movement_object(source, &target) {
|
||||
if is_equivalent_data_movement_object_identity(source, &target, true, compare_part_checksums) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if is_data_movement_upload_takeover_target(source, &target) {
|
||||
if is_data_movement_upload_takeover_target(source, &target, compare_part_checksums) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -1115,17 +1183,19 @@ async fn should_treat_data_movement_overwrite_as_complete(
|
||||
bucket: &str,
|
||||
object_info: &ObjectInfo,
|
||||
err: &Error,
|
||||
compare_part_checksums: bool,
|
||||
) -> Result<bool> {
|
||||
if !should_check_data_movement_overwrite_resume(err) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
resolve_data_movement_overwrite_resume_result(
|
||||
resolve_data_movement_overwrite_resume_result_for(
|
||||
err,
|
||||
find_data_movement_target_info(store, target_pool_idx, bucket, object_info).await,
|
||||
object_info,
|
||||
src_pool_idx,
|
||||
target_pool_idx,
|
||||
compare_part_checksums,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1174,9 +1244,7 @@ pub(crate) async fn migrate_object(
|
||||
.iter()
|
||||
.any(|part| part.checksums.as_ref().is_some_and(|checksums| !checksums.is_empty()));
|
||||
|
||||
ensure_data_movement_part_checksum_writer_allowed(has_part_checksums, data_movement_part_checksum_writer_enabled()).map_err(
|
||||
|err| data_movement_stage_error(op_label, "prepare_new_multipart", bucket.as_str(), object_info.name.as_str(), err),
|
||||
)?;
|
||||
let preserve_part_checksums = data_movement_part_checksum_writer_enabled();
|
||||
|
||||
if should_use_multipart_data_movement(&object_info, has_part_checksums) {
|
||||
let mut new_multipart_opts = data_movement_new_multipart_opts(&object_info, pool_idx);
|
||||
@@ -1225,21 +1293,22 @@ pub(crate) async fn migrate_object(
|
||||
err,
|
||||
)
|
||||
})?;
|
||||
let part_opts = ObjectOptions {
|
||||
part_number: Some(part.number),
|
||||
preserve_etag: Some(part.etag.clone()),
|
||||
data_movement: true,
|
||||
src_pool_idx: pool_idx,
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
};
|
||||
let pi = match store
|
||||
.put_object_part_for_data_movement(
|
||||
target_pool_idx,
|
||||
&bucket,
|
||||
&object_info.name,
|
||||
&res.upload_id,
|
||||
part.number,
|
||||
&mut data,
|
||||
&ObjectOptions {
|
||||
preserve_etag: Some(part.etag.clone()),
|
||||
data_movement: true,
|
||||
src_pool_idx: pool_idx,
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
},
|
||||
&part_opts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1265,9 +1334,16 @@ pub(crate) async fn migrate_object(
|
||||
};
|
||||
}
|
||||
|
||||
let mut complete_multipart_opts = data_movement_complete_multipart_opts(&object_info, pool_idx).map_err(|err| {
|
||||
data_movement_stage_error(op_label, "prepare_complete_multipart", bucket.as_str(), object_info.name.as_str(), err)
|
||||
})?;
|
||||
let mut complete_multipart_opts =
|
||||
data_movement_complete_multipart_opts(&object_info, pool_idx, preserve_part_checksums).map_err(|err| {
|
||||
data_movement_stage_error(
|
||||
op_label,
|
||||
"prepare_complete_multipart",
|
||||
bucket.as_str(),
|
||||
object_info.name.as_str(),
|
||||
err,
|
||||
)
|
||||
})?;
|
||||
complete_multipart_opts.expected_bucket_incarnation_id = expected_bucket_incarnation_id;
|
||||
if let Err(err) = store
|
||||
.clone()
|
||||
@@ -1288,6 +1364,7 @@ pub(crate) async fn migrate_object(
|
||||
bucket.as_str(),
|
||||
&object_info,
|
||||
&err,
|
||||
preserve_part_checksums,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -1339,6 +1416,7 @@ pub(crate) async fn migrate_object(
|
||||
bucket.as_str(),
|
||||
&object_info,
|
||||
&abort_err,
|
||||
preserve_part_checksums,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -1442,6 +1520,7 @@ pub(crate) async fn migrate_object(
|
||||
bucket.as_str(),
|
||||
&object_info,
|
||||
&err,
|
||||
preserve_part_checksums,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -1897,10 +1976,17 @@ mod tests {
|
||||
};
|
||||
|
||||
let new_opts = data_movement_new_multipart_opts(&object_info, 0);
|
||||
let complete_opts = data_movement_complete_multipart_opts(&object_info, 0).expect("complete opts should be created");
|
||||
let compatible_opts =
|
||||
data_movement_complete_multipart_opts(&object_info, 0, false).expect("compatible opts should be created");
|
||||
let complete_opts =
|
||||
data_movement_complete_multipart_opts(&object_info, 0, true).expect("complete opts should be created");
|
||||
|
||||
assert!(!rustfs_utils::http::contains_key_str(&new_opts.user_defined, SUFFIX_PART_CHECKSUMS));
|
||||
assert!(rustfs_utils::http::contains_key_str(&new_opts.user_defined, SUFFIX_DATA_MOVEMENT_UPLOAD));
|
||||
assert!(!rustfs_utils::http::contains_key_str(
|
||||
&compatible_opts.user_defined,
|
||||
SUFFIX_PART_CHECKSUMS
|
||||
));
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_consistent_str(&complete_opts.user_defined, SUFFIX_PART_CHECKSUMS),
|
||||
Some(r#"[[2,[["CRC32C","crc32c-value"]]]]"#)
|
||||
@@ -1921,11 +2007,14 @@ mod tests {
|
||||
assert!(!data_movement_part_checksum_writer_enabled_for(true, false));
|
||||
assert!(!data_movement_part_checksum_writer_enabled_for(false, true));
|
||||
assert!(data_movement_part_checksum_writer_enabled_for(true, true));
|
||||
let has_part_checksums = object_info.parts.iter().any(|part| part.checksums.is_some());
|
||||
assert!(has_part_checksums);
|
||||
assert!(ensure_data_movement_part_checksum_writer_allowed(has_part_checksums, false).is_err());
|
||||
assert!(ensure_data_movement_part_checksum_writer_allowed(has_part_checksums, true).is_ok());
|
||||
assert!(ensure_data_movement_part_checksum_writer_allowed(false, false).is_ok());
|
||||
let mut compatible = FileInfo {
|
||||
parts: object_info.parts.as_ref().clone(),
|
||||
..Default::default()
|
||||
};
|
||||
prepare_tiered_data_movement_file_info_for(&mut compatible, false)
|
||||
.expect("disabled sidecar writer should preserve data movement compatibility");
|
||||
assert!(compatible.parts.iter().all(|part| part.checksums.is_none()));
|
||||
assert!(!rustfs_utils::http::contains_key_str(&compatible.metadata, SUFFIX_PART_CHECKSUMS));
|
||||
|
||||
let empty = ObjectInfo {
|
||||
parts: Arc::new(vec![ObjectPartInfo {
|
||||
@@ -1952,12 +2041,16 @@ mod tests {
|
||||
let mut valid = FileInfo {
|
||||
parts: vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
checksums: Some(valid_checksums.clone()),
|
||||
checksums: Some(valid_checksums),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(prepare_tiered_data_movement_file_info_for(&mut valid.clone(), false).is_err());
|
||||
let mut compatible = valid.clone();
|
||||
prepare_tiered_data_movement_file_info_for(&mut compatible, false)
|
||||
.expect("disabled sidecar writer should omit optional part checksums");
|
||||
assert!(compatible.parts.iter().all(|part| part.checksums.is_none()));
|
||||
assert!(!rustfs_utils::http::contains_key_str(&compatible.metadata, SUFFIX_PART_CHECKSUMS));
|
||||
prepare_tiered_data_movement_file_info_for(&mut valid, true).expect("valid legacy part checksums should be encoded");
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_consistent_str(&valid.metadata, SUFFIX_PART_CHECKSUMS),
|
||||
@@ -2308,7 +2401,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let opts = data_movement_complete_multipart_opts(&object_info, 7).expect("complete opts should encode metadata");
|
||||
let opts = data_movement_complete_multipart_opts(&object_info, 7, false).expect("complete opts should encode metadata");
|
||||
|
||||
assert!(opts.versioned);
|
||||
assert!(opts.data_movement);
|
||||
@@ -2387,7 +2480,7 @@ mod tests {
|
||||
|
||||
let put_opts = data_movement_put_object_opts(&object_info, 9);
|
||||
let complete_opts =
|
||||
data_movement_complete_multipart_opts(&object_info, 9).expect("complete opts should encode metadata");
|
||||
data_movement_complete_multipart_opts(&object_info, 9, false).expect("complete opts should encode metadata");
|
||||
|
||||
assert_eq!(
|
||||
put_opts
|
||||
@@ -2406,6 +2499,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stale_data_movement_target_replacement_requires_exact_owned_generation() {
|
||||
let version_id = Uuid::from_u128(41);
|
||||
let source_time = OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(2);
|
||||
let opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: Some(source_time),
|
||||
http_preconditions: Some(data_movement_target_precondition()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(&mut metadata, SUFFIX_DATA_MOVED, "true".to_string());
|
||||
let target = ObjectInfo {
|
||||
version_id: Some(version_id),
|
||||
mod_time: Some(source_time - time::Duration::SECOND),
|
||||
user_defined: Arc::new(metadata),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(can_replace_stale_data_movement_target(&target, &opts));
|
||||
|
||||
let mut client_target = target.clone();
|
||||
client_target.user_defined = Arc::new(HashMap::new());
|
||||
assert!(!can_replace_stale_data_movement_target(&client_target, &opts));
|
||||
|
||||
let mut single_marker = target.clone();
|
||||
Arc::make_mut(&mut single_marker.user_defined)
|
||||
.remove(&format!("{}{SUFFIX_DATA_MOVED}", rustfs_utils::http::MINIO_INTERNAL_PREFIX));
|
||||
assert!(!can_replace_stale_data_movement_target(&single_marker, &opts));
|
||||
|
||||
let mut conflicting_marker = target.clone();
|
||||
Arc::make_mut(&mut conflicting_marker.user_defined).insert(
|
||||
format!("{}{SUFFIX_DATA_MOVED}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
|
||||
"false".to_string(),
|
||||
);
|
||||
assert!(!can_replace_stale_data_movement_target(&conflicting_marker, &opts));
|
||||
|
||||
let mut different_version = target.clone();
|
||||
different_version.version_id = Some(Uuid::from_u128(42));
|
||||
assert!(!can_replace_stale_data_movement_target(&different_version, &opts));
|
||||
|
||||
let mut same_generation = target.clone();
|
||||
same_generation.mod_time = Some(source_time);
|
||||
assert!(!can_replace_stale_data_movement_target(&same_generation, &opts));
|
||||
|
||||
let mut delete_marker = target;
|
||||
delete_marker.delete_marker = true;
|
||||
assert!(!can_replace_stale_data_movement_target(&delete_marker, &opts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_equivalent_data_movement_object_accepts_matching_metadata() {
|
||||
let version_id = Uuid::nil();
|
||||
@@ -2528,8 +2673,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn overwrite_resume_for_target(source: &ObjectInfo, target: ObjectInfo) -> bool {
|
||||
overwrite_resume_for_target_with_checksums(source, target, data_movement_part_checksum_writer_enabled())
|
||||
}
|
||||
|
||||
fn overwrite_resume_for_target_with_checksums(source: &ObjectInfo, target: ObjectInfo, compare_part_checksums: bool) -> bool {
|
||||
let err = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
|
||||
resolve_data_movement_overwrite_resume_result(&err, Ok(Some(target)), source, 0, 1)
|
||||
resolve_data_movement_overwrite_resume_result_for(&err, Ok(Some(target)), source, 0, 1, compare_part_checksums)
|
||||
.expect("overwrite target should be evaluated")
|
||||
}
|
||||
|
||||
@@ -2890,7 +3039,7 @@ mod tests {
|
||||
parts[0].checksums = None;
|
||||
target.parts = Arc::new(parts);
|
||||
|
||||
assert!(!overwrite_resume_for_target(&source, target));
|
||||
assert!(!overwrite_resume_for_target_with_checksums(&source, target, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3032,6 +3181,27 @@ mod tests {
|
||||
assert!(should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overwrite_resume_omits_part_checksums_only_in_compatible_mode() {
|
||||
let source = overwrite_equivalence_source();
|
||||
let mut target = source.clone();
|
||||
let mut target_parts = target.parts.as_ref().clone();
|
||||
for part in &mut target_parts {
|
||||
part.checksums = None;
|
||||
}
|
||||
target.parts = Arc::new(target_parts);
|
||||
let err = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
|
||||
|
||||
assert!(
|
||||
resolve_data_movement_overwrite_resume_result_for(&err, Ok(Some(target.clone())), &source, 0, 1, false)
|
||||
.expect("compatible migration should accept an omitted optional checksum sidecar")
|
||||
);
|
||||
assert!(
|
||||
!resolve_data_movement_overwrite_resume_result_for(&err, Ok(Some(target)), &source, 0, 1, true)
|
||||
.expect("fleet-confirmed migration should compare persisted part checksums")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_upload_accepts_versioned_target_taken_over_by_old_node() {
|
||||
let mut source = overwrite_equivalence_source();
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::meta::{
|
||||
clone_arc_by_index, ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error,
|
||||
rebalance_metadata_not_initialized_error, should_ignore_rebalance_data_usage_cache,
|
||||
};
|
||||
use super::migration::{RebalanceMigrationBackend, migrate_entry_version_with_incarnation};
|
||||
use super::migration::{RebalanceMigrationBackend, migrate_entry_version};
|
||||
use super::worker::{
|
||||
RebalanceEntryCleanupResult, RebalanceEntryTask, load_rebalance_bucket_configs, rebalance_max_attempts,
|
||||
resolve_rebalance_bucket_error, resolve_rebalance_entry_cleanup_delete_result, resolve_rebalance_file_info_versions_result,
|
||||
@@ -223,7 +223,7 @@ impl ECStore {
|
||||
let store = self.clone();
|
||||
async move { store.delete_object(&bucket, &object, opts).await }
|
||||
};
|
||||
let result = migrate_entry_version_with_incarnation(
|
||||
let result = migrate_entry_version(
|
||||
&RebalanceMigrationBackend::new(set.as_ref(), self.as_ref()),
|
||||
bucket.clone(),
|
||||
pool_index,
|
||||
@@ -329,10 +329,12 @@ impl ECStore {
|
||||
entry.name.as_str(),
|
||||
&fivs,
|
||||
&cleanup_preflight_allowed_missing,
|
||||
bucket_configs.bucket_incarnation_id,
|
||||
bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
data_movement::SourceCleanupBucketFence {
|
||||
expected_incarnation_id: bucket_configs.bucket_incarnation_id,
|
||||
lifecycle_guard: bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
},
|
||||
"rebalance",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -136,40 +136,6 @@ impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn migrate_entry_version<Backend, F, Fut, D, DFut>(
|
||||
set: &Backend,
|
||||
bucket: String,
|
||||
pool_index: usize,
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
max_attempts: usize,
|
||||
ignore_data_usage_cache: bool,
|
||||
transfer: F,
|
||||
delete_marker: D,
|
||||
) -> MigrationVersionResult
|
||||
where
|
||||
Backend: MigrationBackend + ?Sized,
|
||||
F: FnMut(usize, String, GetObjectReader) -> Fut + Send,
|
||||
Fut: Future<Output = Result<()>> + Send,
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
{
|
||||
migrate_entry_version_with_incarnation(
|
||||
set,
|
||||
bucket,
|
||||
pool_index,
|
||||
version,
|
||||
version_id,
|
||||
None,
|
||||
max_attempts,
|
||||
ignore_data_usage_cache,
|
||||
transfer,
|
||||
delete_marker,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn migrate_entry_version_with_incarnation<Backend, F, Fut, D, DFut>(
|
||||
set: &Backend,
|
||||
bucket: String,
|
||||
pool_index: usize,
|
||||
|
||||
@@ -29,8 +29,8 @@ use super::meta::{
|
||||
validate_init_rebalance_state, validate_start_rebalance_state,
|
||||
};
|
||||
use super::migration::{
|
||||
MigrationBackend, MigrationVersionResult, migrate_entry_version, migrate_entry_version_with_incarnation,
|
||||
migrate_entry_version_with_retry_wait, rebalance_delete_marker_opts,
|
||||
MigrationBackend, MigrationVersionResult, migrate_entry_version, migrate_entry_version_with_retry_wait,
|
||||
rebalance_delete_marker_opts,
|
||||
};
|
||||
use super::runtime::{should_fail_repeated_rebalance_bucket_defer, source_cleanup_defer_attempt};
|
||||
use super::worker::{
|
||||
@@ -285,7 +285,7 @@ async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
};
|
||||
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let result = migrate_entry_version_with_incarnation(
|
||||
let result = migrate_entry_version(
|
||||
&backend,
|
||||
"bucket".to_string(),
|
||||
0,
|
||||
@@ -336,6 +336,7 @@ async fn test_migrate_entry_version_remote_not_found_is_cleanup_ignored() {
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -372,6 +373,7 @@ async fn test_migrate_entry_version_remote_overwrite_is_not_ignored() {
|
||||
0,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -410,6 +412,7 @@ async fn test_migrate_entry_version_remote_failure_is_reported() {
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -452,6 +455,7 @@ async fn test_migrate_entry_version_deleted_version_routes_delete_through_store_
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -491,6 +495,7 @@ async fn test_migrate_entry_version_deleted_version_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -533,6 +538,7 @@ async fn test_migrate_entry_version_deleted_version_overwrite_is_not_ignored() {
|
||||
1,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -562,6 +568,7 @@ async fn test_migrate_entry_version_reader_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -689,6 +696,7 @@ async fn test_migrate_entry_version_reader_fails_after_retries() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -727,6 +735,7 @@ async fn test_migrate_entry_version_zero_max_attempts_still_attempts_once() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -871,6 +880,7 @@ async fn test_migrate_entry_version_transfer_fails_after_retries() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -909,6 +919,7 @@ async fn test_migrate_entry_version_transfer_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -950,6 +961,7 @@ async fn test_migrate_entry_version_transfer_overwrite_is_not_ignored() {
|
||||
1,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -992,6 +1004,7 @@ async fn test_migrate_entry_version_ignores_data_usage_cache_when_enabled() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
true,
|
||||
&mut transfer,
|
||||
@@ -1034,6 +1047,7 @@ async fn test_migrate_entry_version_data_usage_cache_moves_when_ignore_disabled(
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -2075,6 +2089,7 @@ async fn test_migrate_entry_version_transfer_failure_reports_write_target_stage(
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
1,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -2099,6 +2114,7 @@ async fn test_migrate_entry_version_reader_failure_reports_read_source_stage() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
1,
|
||||
false,
|
||||
&mut transfer,
|
||||
|
||||
@@ -4524,16 +4524,16 @@ impl SetDisks {
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Option<StorageError> {
|
||||
let mut opts = opts.clone();
|
||||
let mut lookup_opts = opts.clone();
|
||||
|
||||
let http_preconditions = opts.http_preconditions?;
|
||||
opts.http_preconditions = None;
|
||||
let http_preconditions = lookup_opts.http_preconditions?;
|
||||
lookup_opts.http_preconditions = None;
|
||||
|
||||
// Never claim a lock here, to avoid deadlock
|
||||
// - If no_lock is false, we must have obtained the lock out side of this function
|
||||
// - If no_lock is true, we should not obtain locks
|
||||
opts.no_lock = true;
|
||||
let oi = self.get_object_info(bucket, object, &opts).await;
|
||||
lookup_opts.no_lock = true;
|
||||
let oi = self.get_object_info(bucket, object, &lookup_opts).await;
|
||||
|
||||
match oi {
|
||||
Ok(oi) => {
|
||||
@@ -4544,7 +4544,9 @@ impl SetDisks {
|
||||
}
|
||||
let if_none_match = http_preconditions.if_none_match_value().map(str::to_owned);
|
||||
let if_match = http_preconditions.if_match_value().map(str::to_owned);
|
||||
if should_prevent_write(&oi, if_none_match, if_match) {
|
||||
if should_prevent_write(&oi, if_none_match, if_match)
|
||||
&& !crate::data_movement::can_replace_stale_data_movement_target(&oi, opts)
|
||||
{
|
||||
return Some(StorageError::PreconditionFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2067,6 +2067,19 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "complete_multipart_upload_outer_lock",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
if upload_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "complete_multipart_upload_commit",
|
||||
@@ -2087,6 +2100,74 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if opts.data_movement
|
||||
&& opts.http_preconditions.is_some()
|
||||
&& let Some(err) = self.check_write_precondition(bucket, object, opts).await
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
if opts.data_movement && opts.http_preconditions.is_some() && !crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
let current = self
|
||||
.get_object_info(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
version_id: opts.version_id.clone(),
|
||||
no_lock: true,
|
||||
metadata_cache_safe: false,
|
||||
versioned: opts.versioned,
|
||||
version_suspended: opts.version_suspended,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match current {
|
||||
Ok(existing) if crate::data_movement::can_replace_stale_data_movement_target(&existing, opts) => {
|
||||
let object_lock_config = opts.object_lock_config_snapshot.as_deref().ok_or_else(|| {
|
||||
Error::other("data movement completion is missing its Object Lock configuration snapshot")
|
||||
})?;
|
||||
if check_object_lock_for_deletion_with_state(object_lock_config.state(), &existing, false)?.is_some() {
|
||||
return Err(StorageError::PrefixAccessDenied(bucket.to_string(), object.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(_) => return Err(StorageError::PreconditionFailed),
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
if object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "complete_multipart_upload_commit",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "complete_multipart_upload_outer_lock",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
if upload_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "complete_multipart_upload_commit",
|
||||
bucket: RUSTFS_META_MULTIPART_BUCKET.to_string(),
|
||||
object: upload_id_path.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
|
||||
let complete_tail_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
|
||||
|
||||
// Crash-consistency injection: hard power loss after the upload is fully
|
||||
@@ -2878,6 +2959,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_data_movement_replacement_fails_before_commit_on_outer_fence_loss() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "data-movement-stale-fence-bucket";
|
||||
make_bucket_on_all(&disk_stores, bucket).await;
|
||||
let old_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
|
||||
let new_time = old_time + time::Duration::SECOND;
|
||||
|
||||
for (object, namespace_lock_fence, bucket_lifecycle_lock_fence) in [
|
||||
("metadata-fence", Some(NamespaceLockFence::lost_for_test()), None),
|
||||
("bucket-fence", None, Some(NamespaceLockFence::lost_for_test())),
|
||||
] {
|
||||
let version_id = Uuid::new_v4();
|
||||
let old_body = format!("old-{object}").into_bytes();
|
||||
let mut old_reader = PutObjReader::from_vec(old_body.clone());
|
||||
set_disks
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut old_reader,
|
||||
&ObjectOptions {
|
||||
data_movement: true,
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: Some(old_time),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed old data movement target");
|
||||
|
||||
let replacement_body = format!("new-{object}").into_bytes();
|
||||
let create_opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (upload_id, parts) =
|
||||
stage_upload_with_create_opts(&set_disks, bucket, object, &replacement_body, &create_opts).await;
|
||||
let complete_opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: Some(new_time),
|
||||
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
|
||||
namespace_lock_fence,
|
||||
bucket_lifecycle_lock_fence,
|
||||
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(
|
||||
ObjectLockConfigState::ConfirmedAbsent,
|
||||
))),
|
||||
..Default::default()
|
||||
};
|
||||
let err = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload_id, parts, &complete_opts)
|
||||
.await
|
||||
.expect_err("a lost outer fence must abort stale target replacement");
|
||||
assert!(matches!(err, StorageError::NamespaceLockQuorumUnavailable { .. }));
|
||||
|
||||
let mut preserved = set_disks
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read target after rejected replacement");
|
||||
let mut preserved_body = Vec::new();
|
||||
preserved
|
||||
.stream
|
||||
.read_to_end(&mut preserved_body)
|
||||
.await
|
||||
.expect("drain target after rejected replacement");
|
||||
assert_eq!(preserved_body, old_body);
|
||||
set_disks
|
||||
.check_upload_id_exists(bucket, object, &upload_id, true)
|
||||
.await
|
||||
.expect("fence loss must leave replacement staging retryable");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn data_movement_complete_accepts_unknown_compressed_part_actual_size() {
|
||||
|
||||
@@ -7998,8 +7998,7 @@ mod transition_upload_integrity_tests {
|
||||
object,
|
||||
&expected,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
crate::data_movement::SourceCleanupBucketFence::default(),
|
||||
"test_data_movement",
|
||||
)
|
||||
.await
|
||||
@@ -8061,8 +8060,10 @@ mod transition_upload_integrity_tests {
|
||||
object,
|
||||
&expected,
|
||||
&[],
|
||||
None,
|
||||
Some(&bucket_guard),
|
||||
crate::data_movement::SourceCleanupBucketFence {
|
||||
expected_incarnation_id: None,
|
||||
lifecycle_guard: Some(&bucket_guard),
|
||||
},
|
||||
"test_data_movement",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1706,6 +1706,106 @@ mod tests {
|
||||
.contains_key(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE)
|
||||
);
|
||||
retry_reader.object_info = retry_source_info.clone();
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, None::<&str>),
|
||||
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED, None::<&str>),
|
||||
],
|
||||
crate::data_movement::migrate_object(
|
||||
store.clone(),
|
||||
0,
|
||||
bucket.clone(),
|
||||
retry_reader,
|
||||
None,
|
||||
"test_data_movement_compatible_retry",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("default multipart migration should not require the checksum sidecar capability");
|
||||
|
||||
let compatible_target = store.pools[1]
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
retry_object,
|
||||
&ObjectOptions {
|
||||
include_part_checksums: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read compatible multipart target metadata");
|
||||
assert_eq!(compatible_target.checksum, retry_source_info.checksum);
|
||||
assert!(compatible_target.parts.iter().all(|part| part.checksums.is_none()));
|
||||
assert!(!rustfs_utils::http::contains_key_str(
|
||||
&compatible_target.user_defined,
|
||||
rustfs_utils::http::SUFFIX_PART_CHECKSUMS
|
||||
));
|
||||
let mut compatible_reader = store.pools[1]
|
||||
.get_object_reader(&bucket, retry_object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("read compatible multipart target body");
|
||||
let mut compatible_body = Vec::new();
|
||||
compatible_reader
|
||||
.stream
|
||||
.read_to_end(&mut compatible_body)
|
||||
.await
|
||||
.expect("drain compatible multipart target body");
|
||||
assert_eq!(compatible_body, retry_source_body);
|
||||
|
||||
let mut compatible_retry_reader = store.pools[0]
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
retry_object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
raw_data_movement_read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read source multipart object for compatible retry");
|
||||
compatible_retry_reader.object_info = retry_source_info.clone();
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, None::<&str>),
|
||||
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED, None::<&str>),
|
||||
],
|
||||
crate::data_movement::migrate_object(
|
||||
store.clone(),
|
||||
0,
|
||||
bucket.clone(),
|
||||
compatible_retry_reader,
|
||||
None,
|
||||
"test_data_movement_compatible_retry",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("compatible multipart migration retry should converge");
|
||||
let compatible_uploads = store.pools[1]
|
||||
.list_multipart_uploads(&bucket, retry_object, None, None, None, 100)
|
||||
.await
|
||||
.expect("list compatible target multipart uploads");
|
||||
assert!(compatible_uploads.uploads.is_empty(), "compatible retry staging must be aborted");
|
||||
|
||||
store.pools[1]
|
||||
.delete_object(&bucket, retry_object, ObjectOptions::default())
|
||||
.await
|
||||
.expect("remove compatible target before fleet-confirmed migration");
|
||||
let mut retry_reader = store.pools[0]
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
retry_object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
raw_data_movement_read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read source multipart object for fleet-confirmed migration");
|
||||
retry_reader.object_info = retry_source_info.clone();
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, Some("true")),
|
||||
@@ -1921,6 +2021,301 @@ mod tests {
|
||||
.expect("join data movement test thread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
fn data_movement_multipart_replaces_only_unlocked_owned_generation() {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.worker_threads(2)
|
||||
.build()
|
||||
.expect("build data movement replacement test runtime");
|
||||
runtime.block_on(async move {
|
||||
let temp_dir = tempfile::tempdir().expect("create data movement replacement store dir");
|
||||
let (_ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
|
||||
temp_dir.path(),
|
||||
"data-movement-stale-replacement",
|
||||
&[4, 4],
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
|
||||
let bucket = format!("dm-stale-replacement-{}", uuid::Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create data movement replacement bucket");
|
||||
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
|
||||
|
||||
let version_id = uuid::Uuid::new_v4();
|
||||
let old_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
|
||||
let new_time = old_time + time::Duration::SECOND;
|
||||
let first_part_size = 5 * 1024 * 1024;
|
||||
let mut old_body = vec![b'a'; first_part_size];
|
||||
old_body.push(b'b');
|
||||
let mut new_body = vec![b'c'; first_part_size];
|
||||
new_body.push(b'd');
|
||||
|
||||
let source_reader = |name: &str, body: Vec<u8>, mod_time, metadata: HashMap<String, String>| {
|
||||
let size = i64::try_from(body.len()).expect("source body size should fit i64");
|
||||
GetObjectReader {
|
||||
stream: Box::new(Cursor::new(body)),
|
||||
object_info: ObjectInfo {
|
||||
bucket: bucket.clone(),
|
||||
name: name.to_string(),
|
||||
version_id: Some(version_id),
|
||||
size,
|
||||
actual_size: size,
|
||||
etag: Some(format!("{name}-multipart-etag-2")),
|
||||
mod_time: Some(mod_time),
|
||||
user_defined: Arc::new(metadata),
|
||||
parts: Arc::new(vec![
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
size: first_part_size,
|
||||
actual_size: i64::try_from(first_part_size).expect("first part size should fit i64"),
|
||||
etag: format!("{name}-part-1"),
|
||||
..Default::default()
|
||||
},
|
||||
ObjectPartInfo {
|
||||
number: 2,
|
||||
size: 1,
|
||||
actual_size: 1,
|
||||
etag: format!("{name}-part-2"),
|
||||
..Default::default()
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
buffered_body: None,
|
||||
body_source: Default::default(),
|
||||
}
|
||||
};
|
||||
|
||||
let replaceable = "replaceable.bin";
|
||||
crate::data_movement::migrate_object(
|
||||
store.clone(),
|
||||
0,
|
||||
bucket.clone(),
|
||||
source_reader(
|
||||
replaceable,
|
||||
old_body.clone(),
|
||||
old_time,
|
||||
HashMap::from([("x-amz-meta-generation".to_string(), "old".to_string())]),
|
||||
),
|
||||
None,
|
||||
"test_stale_target_seed",
|
||||
)
|
||||
.await
|
||||
.expect("seed old migrated target");
|
||||
let seeded_target = store.pools[1]
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
replaceable,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read old migrated target");
|
||||
let replacement_opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: Some(new_time),
|
||||
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
crate::data_movement::can_replace_stale_data_movement_target(&seeded_target, &replacement_opts),
|
||||
"seeded target should be replaceable: {seeded_target:?}"
|
||||
);
|
||||
crate::data_movement::migrate_object(
|
||||
store.clone(),
|
||||
0,
|
||||
bucket.clone(),
|
||||
source_reader(
|
||||
replaceable,
|
||||
new_body.clone(),
|
||||
new_time,
|
||||
HashMap::from([("x-amz-meta-generation".to_string(), "new".to_string())]),
|
||||
),
|
||||
None,
|
||||
"test_stale_target_replace",
|
||||
)
|
||||
.await
|
||||
.expect("newer source generation should replace the old migrated target");
|
||||
|
||||
let replacement = store.pools[1]
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
replaceable,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read replaced target metadata");
|
||||
assert_eq!(replacement.mod_time, Some(new_time));
|
||||
assert_eq!(replacement.user_defined.get("x-amz-meta-generation").map(String::as_str), Some("new"));
|
||||
let mut replacement_reader = store.pools[1]
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
replaceable,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read replaced target body");
|
||||
let mut replacement_body = Vec::new();
|
||||
replacement_reader
|
||||
.stream
|
||||
.read_to_end(&mut replacement_body)
|
||||
.await
|
||||
.expect("drain replaced target body");
|
||||
assert_eq!(replacement_body, new_body);
|
||||
|
||||
let client_target = "client-target.bin";
|
||||
let client_body = b"client-owned exact version".to_vec();
|
||||
let mut client_reader = PutObjReader::from_vec(client_body.clone());
|
||||
store.pools[1]
|
||||
.put_object(
|
||||
&bucket,
|
||||
client_target,
|
||||
&mut client_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: Some(old_time),
|
||||
user_defined: HashMap::from([("x-amz-meta-owner".to_string(), "client".to_string())]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed client-owned exact version");
|
||||
let err = crate::data_movement::migrate_object(
|
||||
store.clone(),
|
||||
0,
|
||||
bucket.clone(),
|
||||
source_reader(client_target, new_body.clone(), new_time, HashMap::new()),
|
||||
None,
|
||||
"test_client_target_reject",
|
||||
)
|
||||
.await
|
||||
.expect_err("data movement must not replace a client-owned exact version");
|
||||
assert!(err.to_string().contains("complete_multipart_upload"), "unexpected migration error: {err}");
|
||||
let mut preserved_reader = store.pools[1]
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
client_target,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read preserved client target");
|
||||
let mut preserved_body = Vec::new();
|
||||
preserved_reader
|
||||
.stream
|
||||
.read_to_end(&mut preserved_body)
|
||||
.await
|
||||
.expect("drain preserved client target");
|
||||
assert_eq!(preserved_body, client_body);
|
||||
|
||||
let retain_until = (OffsetDateTime::now_utc() + time::Duration::days(1))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.expect("retain-until date should format");
|
||||
for (object, mode) in [
|
||||
("compliance-target.bin", s3s::dto::ObjectLockRetentionMode::COMPLIANCE),
|
||||
("governance-target.bin", s3s::dto::ObjectLockRetentionMode::GOVERNANCE),
|
||||
] {
|
||||
let retained_metadata = HashMap::from([
|
||||
(s3s::header::X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string()),
|
||||
(
|
||||
s3s::header::X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
|
||||
retain_until.clone(),
|
||||
),
|
||||
]);
|
||||
crate::data_movement::migrate_object(
|
||||
store.clone(),
|
||||
0,
|
||||
bucket.clone(),
|
||||
source_reader(object, old_body.clone(), old_time, retained_metadata.clone()),
|
||||
None,
|
||||
"test_retained_target_seed",
|
||||
)
|
||||
.await
|
||||
.expect("seed retained migrated target");
|
||||
let err = crate::data_movement::migrate_object(
|
||||
store.clone(),
|
||||
0,
|
||||
bucket.clone(),
|
||||
source_reader(object, new_body.clone(), new_time, retained_metadata),
|
||||
None,
|
||||
"test_retained_target_replace",
|
||||
)
|
||||
.await
|
||||
.expect_err("active retention must block stale target replacement");
|
||||
assert!(err.to_string().contains("complete_multipart_upload"), "unexpected retention error: {err}");
|
||||
|
||||
let mut retained_reader = store.pools[1]
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read retained target");
|
||||
let mut retained_body = Vec::new();
|
||||
retained_reader
|
||||
.stream
|
||||
.read_to_end(&mut retained_body)
|
||||
.await
|
||||
.expect("drain retained target");
|
||||
assert_eq!(retained_body, old_body);
|
||||
}
|
||||
|
||||
for object in [replaceable, client_target, "compliance-target.bin", "governance-target.bin"] {
|
||||
let uploads = store.pools[1]
|
||||
.list_multipart_uploads(&bucket, object, None, None, None, 100)
|
||||
.await
|
||||
.expect("list target multipart staging");
|
||||
assert!(uploads.uploads.is_empty(), "data movement staging must be cleaned for {object}");
|
||||
}
|
||||
});
|
||||
})
|
||||
.expect("spawn data movement replacement test thread")
|
||||
.join()
|
||||
.expect("join data movement replacement test thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn data_movement_delete_marker_retries_converge_safely() {
|
||||
@@ -2275,12 +2670,7 @@ mod tests {
|
||||
let mut source_body = vec![b'a'; first_part_size];
|
||||
source_body.push(b'b');
|
||||
let source_size = i64::try_from(source_body.len()).expect("multipart source size should fit i64");
|
||||
let guard_barrier = crate::store::multipart::DataMovementMultipartGuardBarrier::install(&bucket, 2);
|
||||
let part_barrier = crate::set_disk::MultipartCommitBarrier::install(
|
||||
&bucket,
|
||||
object,
|
||||
crate::set_disk::MultipartCommitPause::PutPartAfterRename,
|
||||
);
|
||||
let completion_barrier = crate::store::multipart::DataMovementMultipartCompletionBarrier::install(&bucket);
|
||||
let migration_store = store.clone();
|
||||
let migration_bucket = bucket.clone();
|
||||
let migration = tokio::spawn(async move {
|
||||
@@ -2325,7 +2715,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
});
|
||||
guard_barrier.wait_until_paused().await;
|
||||
completion_barrier.wait_until_paused().await;
|
||||
let unrelated_set = store.pools[1].get_disks_by_key(object);
|
||||
let original_disks = {
|
||||
let mut disks = unrelated_set.disks.write().await;
|
||||
@@ -2335,9 +2725,7 @@ mod tests {
|
||||
}
|
||||
original
|
||||
};
|
||||
drop(guard_barrier);
|
||||
part_barrier.wait_until_paused().await;
|
||||
drop(part_barrier);
|
||||
drop(completion_barrier);
|
||||
let err = migration
|
||||
.await
|
||||
.expect("multipart migration task should join")
|
||||
|
||||
@@ -67,40 +67,35 @@ fn ensure_multipart_bucket_lifecycle_guard_held(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DataMovementMultipartGuardBarrierState {
|
||||
struct DataMovementMultipartCompletionBarrierState {
|
||||
bucket: String,
|
||||
pause_at_arrival: usize,
|
||||
arrivals: std::sync::atomic::AtomicUsize,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct DataMovementMultipartGuardBarrier {
|
||||
state: Arc<DataMovementMultipartGuardBarrierState>,
|
||||
pub(crate) struct DataMovementMultipartCompletionBarrier {
|
||||
state: Arc<DataMovementMultipartCompletionBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static DATA_MOVEMENT_MULTIPART_GUARD_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<DataMovementMultipartGuardBarrierState>>>,
|
||||
static DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<DataMovementMultipartCompletionBarrierState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl DataMovementMultipartGuardBarrier {
|
||||
pub(crate) fn install(bucket: &str, pause_at_arrival: usize) -> Self {
|
||||
assert!(pause_at_arrival > 0, "data movement multipart guard barrier needs an arrival");
|
||||
let state = Arc::new(DataMovementMultipartGuardBarrierState {
|
||||
impl DataMovementMultipartCompletionBarrier {
|
||||
pub(crate) fn install(bucket: &str) -> Self {
|
||||
let state = Arc::new(DataMovementMultipartCompletionBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
pause_at_arrival,
|
||||
arrivals: std::sync::atomic::AtomicUsize::new(0),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = DATA_MOVEMENT_MULTIPART_GUARD_BARRIER
|
||||
let mut slot = DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("data movement multipart guard barrier mutex should not poison");
|
||||
assert!(slot.is_none(), "data movement multipart guard barrier must be unique");
|
||||
.expect("data movement multipart completion barrier mutex should not poison");
|
||||
assert!(slot.is_none(), "data movement multipart completion barrier must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
@@ -108,18 +103,18 @@ impl DataMovementMultipartGuardBarrier {
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("data movement multipart operation should pass the bucket guard");
|
||||
.expect("data movement multipart operation should reach selected completion");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for DataMovementMultipartGuardBarrier {
|
||||
impl Drop for DataMovementMultipartCompletionBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = DATA_MOVEMENT_MULTIPART_GUARD_BARRIER
|
||||
let mut slot = DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("data movement multipart guard barrier mutex should not poison");
|
||||
.expect("data movement multipart completion barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
@@ -127,22 +122,15 @@ impl Drop for DataMovementMultipartGuardBarrier {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_data_movement_multipart_after_bucket_guard(bucket: &str, opts: &ObjectOptions) {
|
||||
if !opts.data_movement {
|
||||
return;
|
||||
}
|
||||
let barrier = DATA_MOVEMENT_MULTIPART_GUARD_BARRIER
|
||||
async fn pause_data_movement_multipart_before_selected_completion(bucket: &str) {
|
||||
let barrier = DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("data movement multipart guard barrier mutex should not poison")
|
||||
.expect("data movement multipart completion barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
let arrival = barrier.arrivals.fetch_add(1, std::sync::atomic::Ordering::AcqRel) + 1;
|
||||
if arrival != barrier.pause_at_arrival {
|
||||
return;
|
||||
}
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
@@ -259,8 +247,6 @@ impl ECStore {
|
||||
if opts.expected_bucket_incarnation_id != Some(current) {
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
#[cfg(test)]
|
||||
Box::pin(pause_data_movement_multipart_after_bucket_guard(bucket, &opts)).await;
|
||||
Ok((opts, guard))
|
||||
}
|
||||
|
||||
@@ -559,10 +545,12 @@ impl ECStore {
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
part_id: usize,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<PartInfo> {
|
||||
let part_id = opts
|
||||
.part_number
|
||||
.ok_or_else(|| Error::other("targeted multipart upload requires a part number"))?;
|
||||
check_put_object_part_args(bucket, object, upload_id)?;
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart upload requires data_movement options"));
|
||||
@@ -727,7 +715,32 @@ impl ECStore {
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart completion requires data_movement options"));
|
||||
}
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
|
||||
let expected_incarnation_id = opts
|
||||
.expected_bucket_incarnation_id
|
||||
.ok_or_else(|| Error::other("data movement completion is missing its bucket incarnation"))?;
|
||||
let lifecycle_fence = opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::other("data movement completion is missing its bucket lifecycle fence"))?;
|
||||
let snapshot = match opts.object_lock_config_snapshot.as_ref() {
|
||||
Some(snapshot) => Arc::clone(snapshot),
|
||||
None => {
|
||||
self.object_lock_config_snapshot_under_lifecycle_fence(bucket, lifecycle_fence)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
if !snapshot.is_valid_for_destructive_put(self.id, bucket, expected_incarnation_id) {
|
||||
return Err(Error::other(
|
||||
"data movement Object Lock snapshot does not match the target bucket generation",
|
||||
));
|
||||
}
|
||||
snapshot.add_lock_fences(&mut opts);
|
||||
opts.object_lock_config_snapshot = Some(snapshot);
|
||||
}
|
||||
#[cfg(test)]
|
||||
pause_data_movement_multipart_before_selected_completion(bucket).await;
|
||||
let pool = self
|
||||
.pools
|
||||
.get(target_pool_idx)
|
||||
|
||||
@@ -1216,7 +1216,7 @@ impl ECStore {
|
||||
)))
|
||||
}
|
||||
|
||||
async fn object_lock_config_snapshot_under_lifecycle_fence(
|
||||
pub(super) async fn object_lock_config_snapshot_under_lifecycle_fence(
|
||||
&self,
|
||||
bucket: &str,
|
||||
lifecycle_fence: &NamespaceLockFence,
|
||||
@@ -3569,7 +3569,7 @@ mod tests {
|
||||
ReplicationStatusType::Replica.to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP, timestamp.clone());
|
||||
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP, timestamp.clone());
|
||||
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP, timestamp);
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS,
|
||||
|
||||
@@ -729,7 +729,7 @@ impl FileMeta {
|
||||
include_free_versions: bool,
|
||||
all_parts: bool,
|
||||
) -> Result<FileInfo> {
|
||||
self.into_fileinfo_with_part_checksums(
|
||||
self.to_fileinfo_with_part_checksums(
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
@@ -750,7 +750,7 @@ impl FileMeta {
|
||||
read_data: bool,
|
||||
include_free_versions: bool,
|
||||
) -> Result<FileInfo> {
|
||||
self.into_fileinfo_with_part_checksums(
|
||||
self.to_fileinfo_with_part_checksums(
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
@@ -763,7 +763,7 @@ impl FileMeta {
|
||||
)
|
||||
}
|
||||
|
||||
fn into_fileinfo_with_part_checksums(
|
||||
fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
@@ -802,12 +802,8 @@ impl FileMeta {
|
||||
// Known side effect: if a disk holds only free versions and they are
|
||||
// corrupt, `into_fileinfo` falls through to `FileNotFound` (not
|
||||
// `FileCorrupt`), so that disk is not enqueued for heal.
|
||||
match found_free_fi.into_fileinfo_with_part_checksums(
|
||||
volume,
|
||||
path,
|
||||
opts.all_parts,
|
||||
opts.include_part_checksums,
|
||||
) {
|
||||
match found_free_fi.to_fileinfo_with_part_checksums(volume, path, opts.all_parts, opts.include_part_checksums)
|
||||
{
|
||||
Ok(mut free_fi) => {
|
||||
free_fi.is_latest = true;
|
||||
found_free_version = Some(free_fi);
|
||||
@@ -835,7 +831,7 @@ impl FileMeta {
|
||||
|
||||
found = true;
|
||||
|
||||
let mut fi = ver.into_fileinfo_with_part_checksums(volume, path, opts.all_parts, opts.include_part_checksums)?;
|
||||
let mut fi = ver.to_fileinfo_with_part_checksums(volume, path, opts.all_parts, opts.include_part_checksums)?;
|
||||
fi.is_latest = is_latest;
|
||||
|
||||
if let Some(_d) = succ_mod_time {
|
||||
|
||||
@@ -273,9 +273,7 @@ fn transition_version_state_from_bytes(value: Option<&[u8]>) -> Result<Transitio
|
||||
}
|
||||
|
||||
fn transitioned_version_from_bytes(value: Option<&[u8]>, state: TransitionVersionState) -> Option<String> {
|
||||
let Some(value) = value else {
|
||||
return None;
|
||||
};
|
||||
let value = value?;
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -290,7 +288,7 @@ fn transitioned_version_from_bytes(value: Option<&[u8]>, state: TransitionVersio
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_TRANSITION_VERSION_LEN
|
||||
|| value.chars().any(char::is_control)
|
||||
|| Uuid::parse_str(&value).is_ok_and(|id| id.is_nil())
|
||||
|| Uuid::parse_str(value).is_ok_and(|id| id.is_nil())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
@@ -320,34 +318,62 @@ struct DerivedInternalMetadata<'a> {
|
||||
|
||||
impl<'a> DerivedInternalMetadata<'a> {
|
||||
fn from_meta_sys(meta_sys: &'a HashMap<String, Vec<u8>>) -> Result<Self> {
|
||||
let mut metadata = Self::default();
|
||||
let mut canonical = Self::default();
|
||||
let mut legacy = Self::default();
|
||||
for (key, value) in meta_sys {
|
||||
let Some(suffix) = rustfs_utils::http::strip_internal_prefix_preserving_case(key) else {
|
||||
continue;
|
||||
};
|
||||
let slot = if suffix.eq_ignore_ascii_case(SUFFIX_CRC) {
|
||||
&mut metadata.checksum
|
||||
let (canonical_slot, legacy_slot, expected_suffix) = if suffix.eq_ignore_ascii_case(SUFFIX_CRC) {
|
||||
(&mut canonical.checksum, &mut legacy.checksum, SUFFIX_CRC)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_PART_CHECKSUMS) {
|
||||
&mut metadata.part_checksums
|
||||
(&mut canonical.part_checksums, &mut legacy.part_checksums, SUFFIX_PART_CHECKSUMS)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITION_STATUS) {
|
||||
&mut metadata.transition_status
|
||||
(&mut canonical.transition_status, &mut legacy.transition_status, SUFFIX_TRANSITION_STATUS)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_OBJECTNAME) {
|
||||
&mut metadata.transitioned_object
|
||||
(
|
||||
&mut canonical.transitioned_object,
|
||||
&mut legacy.transitioned_object,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME,
|
||||
)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_VERSION_ID) {
|
||||
&mut metadata.transitioned_version
|
||||
(
|
||||
&mut canonical.transitioned_version,
|
||||
&mut legacy.transitioned_version,
|
||||
SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_VERSION_STATE) {
|
||||
&mut metadata.transitioned_version_state
|
||||
(
|
||||
&mut canonical.transitioned_version_state,
|
||||
&mut legacy.transitioned_version_state,
|
||||
SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITION_TIER) {
|
||||
&mut metadata.transition_tier
|
||||
(&mut canonical.transition_tier, &mut legacy.transition_tier, SUFFIX_TRANSITION_TIER)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let slot = if suffix == expected_suffix
|
||||
&& (key.starts_with(RUSTFS_INTERNAL_PREFIX) || key.starts_with(rustfs_utils::http::MINIO_INTERNAL_PREFIX))
|
||||
{
|
||||
canonical_slot
|
||||
} else {
|
||||
legacy_slot
|
||||
};
|
||||
if slot.is_some_and(|current| current != value.as_slice()) {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
*slot = Some(value.as_slice());
|
||||
}
|
||||
Ok(metadata)
|
||||
Ok(Self {
|
||||
checksum: canonical.checksum.or(legacy.checksum),
|
||||
part_checksums: canonical.part_checksums.or(legacy.part_checksums),
|
||||
transition_status: canonical.transition_status.or(legacy.transition_status),
|
||||
transitioned_object: canonical.transitioned_object.or(legacy.transitioned_object),
|
||||
transitioned_version: canonical.transitioned_version.or(legacy.transitioned_version),
|
||||
transitioned_version_state: canonical.transitioned_version_state.or(legacy.transitioned_version_state),
|
||||
transition_tier: canonical.transition_tier.or(legacy.transition_tier),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +557,7 @@ impl FileMetaShallowVersion {
|
||||
self.parse_version_meta()?.into_fileinfo(volume, path, all_parts)
|
||||
}
|
||||
|
||||
pub(super) fn into_fileinfo_with_part_checksums(
|
||||
pub(super) fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
@@ -539,7 +565,7 @@ impl FileMetaShallowVersion {
|
||||
include_part_checksums: bool,
|
||||
) -> Result<FileInfo> {
|
||||
self.parse_version_meta()?
|
||||
.into_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)
|
||||
.to_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,18 +888,16 @@ impl FileMetaVersion {
|
||||
}
|
||||
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
|
||||
self.into_fileinfo_with_part_checksums(volume, path, all_parts, true)
|
||||
self.to_fileinfo_with_part_checksums(volume, path, all_parts, true)
|
||||
}
|
||||
|
||||
pub(super) fn into_fileinfo_with_part_checksums(
|
||||
pub(super) fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
all_parts: bool,
|
||||
include_part_checksums: bool,
|
||||
) -> Result<FileInfo> {
|
||||
// Only the Object arm carries part arrays and can fail the length guard; the
|
||||
// Legacy and Delete arms have no part arrays and stay infallible.
|
||||
let mut fi = match self.version_type {
|
||||
VersionType::Invalid | VersionType::Legacy => {
|
||||
if let Some(ref legacy) = self.legacy_object {
|
||||
@@ -891,14 +915,14 @@ impl FileMetaVersion {
|
||||
self.object
|
||||
.as_ref()
|
||||
.unwrap_or(&default_object)
|
||||
.into_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)?
|
||||
.to_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)?
|
||||
}
|
||||
VersionType::Delete => {
|
||||
let default_marker = MetaDeleteMarker::default();
|
||||
self.delete_marker
|
||||
.as_ref()
|
||||
.unwrap_or(&default_marker)
|
||||
.into_fileinfo(volume, path, all_parts)
|
||||
.into_fileinfo(volume, path, all_parts)?
|
||||
}
|
||||
};
|
||||
fi.uses_legacy_checksum = self.uses_legacy_checksum;
|
||||
@@ -2493,10 +2517,10 @@ impl MetaObject {
|
||||
}
|
||||
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
|
||||
self.into_fileinfo_with_part_checksums(volume, path, all_parts, true)
|
||||
self.to_fileinfo_with_part_checksums(volume, path, all_parts, true)
|
||||
}
|
||||
|
||||
fn into_fileinfo_with_part_checksums(
|
||||
fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
@@ -2908,7 +2932,7 @@ impl MetaDeleteMarker {
|
||||
contains_key_bytes(&self.meta_sys, SUFFIX_FREE_VERSION)
|
||||
}
|
||||
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, _all_parts: bool) -> FileInfo {
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, _all_parts: bool) -> Result<FileInfo> {
|
||||
let metadata = self
|
||||
.meta_sys
|
||||
.clone()
|
||||
@@ -2930,33 +2954,27 @@ impl MetaDeleteMarker {
|
||||
|
||||
if self.free_version() {
|
||||
fi.set_tier_free_version();
|
||||
if let Ok(derived_metadata) = DerivedInternalMetadata::from_meta_sys(&self.meta_sys) {
|
||||
fi.transition_tier = derived_metadata
|
||||
.transition_tier
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| String::from_utf8_lossy(value).to_string())
|
||||
.unwrap_or_default();
|
||||
fi.transitioned_objname = derived_metadata
|
||||
.transitioned_object
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| String::from_utf8_lossy(value).to_string())
|
||||
.unwrap_or_default();
|
||||
fi.transition_version_state = transition_version_state_from_bytes(derived_metadata.transitioned_version_state)
|
||||
.unwrap_or(TransitionVersionState::Unknown);
|
||||
fi.transition_version =
|
||||
transitioned_version_from_bytes(derived_metadata.transitioned_version, fi.transition_version_state);
|
||||
fi.transition_version_id = fi.transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok());
|
||||
if derived_metadata.transitioned_version_state.is_some()
|
||||
&& validate_transition_version_state(fi.transition_version_state, fi.transition_version.as_deref()).is_err()
|
||||
{
|
||||
fi.transition_version = None;
|
||||
fi.transition_version_id = None;
|
||||
fi.transition_version_state = TransitionVersionState::Unknown;
|
||||
}
|
||||
let derived_metadata = DerivedInternalMetadata::from_meta_sys(&self.meta_sys)?;
|
||||
fi.transition_tier = derived_metadata
|
||||
.transition_tier
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| String::from_utf8_lossy(value).to_string())
|
||||
.unwrap_or_default();
|
||||
fi.transitioned_objname = derived_metadata
|
||||
.transitioned_object
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| String::from_utf8_lossy(value).to_string())
|
||||
.unwrap_or_default();
|
||||
fi.transition_version_state = transition_version_state_from_bytes(derived_metadata.transitioned_version_state)?;
|
||||
fi.transition_version =
|
||||
transitioned_version_from_bytes(derived_metadata.transitioned_version, fi.transition_version_state);
|
||||
fi.transition_version_id = fi.transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok());
|
||||
if derived_metadata.transitioned_version_state.is_some() {
|
||||
validate_transition_version_state(fi.transition_version_state, fi.transition_version.as_deref())?;
|
||||
}
|
||||
}
|
||||
|
||||
fi
|
||||
Ok(fi)
|
||||
}
|
||||
|
||||
pub fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
|
||||
@@ -3744,7 +3762,9 @@ mod tests {
|
||||
.is_some_and(|state| state.target_delete_marker_version_ids_corrupt)
|
||||
);
|
||||
|
||||
let roundtrip = MetaDeleteMarker::from(conflicting).into_fileinfo("bucket", "object", false);
|
||||
let roundtrip = MetaDeleteMarker::from(conflicting)
|
||||
.into_fileinfo("bucket", "object", false)
|
||||
.expect("dynamic replication aliases should remain decodable");
|
||||
assert!(
|
||||
roundtrip
|
||||
.replication_state_internal
|
||||
@@ -3904,6 +3924,22 @@ mod tests {
|
||||
assert_eq!(file_info.checksum.as_deref(), Some(checksum.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_fileinfo_prefers_canonical_rewrite_over_stale_mixed_case_alias() {
|
||||
let checksum = vec![0xff, 0x00, 0x80, 0x01];
|
||||
let mut object = MetaObject {
|
||||
meta_sys: HashMap::from([("X-Minio-Internal-crc".to_string(), b"stale".to_vec())]),
|
||||
..Default::default()
|
||||
};
|
||||
insert_bytes(&mut object.meta_sys, SUFFIX_CRC, checksum.clone());
|
||||
|
||||
let file_info = object
|
||||
.into_fileinfo("bucket", "key", false)
|
||||
.expect("canonical rewrites should supersede legacy mixed-case aliases");
|
||||
|
||||
assert_eq!(file_info.checksum.as_deref(), Some(checksum.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_fileinfo_recovers_data_movement_part_checksums() {
|
||||
let mut object = object_with_parts(vec![1], vec![16], vec![16]);
|
||||
@@ -3923,7 +3959,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut deferred = object
|
||||
.into_fileinfo_with_part_checksums("bucket", "key", true, false)
|
||||
.to_fileinfo_with_part_checksums("bucket", "key", true, false)
|
||||
.expect("quorum candidates should retain raw checksum metadata");
|
||||
assert!(deferred.parts[0].checksums.is_none());
|
||||
deferred
|
||||
@@ -4613,7 +4649,7 @@ mod tests {
|
||||
);
|
||||
let persisted_version = get_consistent_bytes(&object.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID);
|
||||
assert_eq!(
|
||||
transitioned_version_from_bytes(persisted_version.as_deref(), TransitionVersionState::Unknown)
|
||||
transitioned_version_from_bytes(persisted_version, TransitionVersionState::Unknown)
|
||||
.and_then(|value| Uuid::parse_str(&value).ok()),
|
||||
Some(id),
|
||||
"UUID exact writes must remain readable by the legacy UUID consumer"
|
||||
@@ -4737,7 +4773,8 @@ mod tests {
|
||||
mod_time: None,
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false);
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("nil tier version should remain an absent remote version");
|
||||
assert_eq!(fi.transition_version_id, None);
|
||||
}
|
||||
|
||||
@@ -4752,7 +4789,8 @@ mod tests {
|
||||
mod_time: None,
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false);
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("legacy binary UUID tier version should decode");
|
||||
assert_eq!(fi.transition_version_id, Some(id));
|
||||
assert_eq!(fi.transition_version, Some(id.to_string()));
|
||||
}
|
||||
@@ -4769,7 +4807,8 @@ mod tests {
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false);
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("opaque tier version should remain readable");
|
||||
|
||||
assert_eq!(fi.transition_version_id, None);
|
||||
assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42"));
|
||||
@@ -4791,7 +4830,8 @@ mod tests {
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false);
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("mixed-case tier aliases should decode");
|
||||
|
||||
assert_eq!(fi.transition_version_id, Some(id));
|
||||
assert_eq!(fi.transition_version, Some(id.to_string()));
|
||||
@@ -4817,7 +4857,8 @@ mod tests {
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false);
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("mixed-case tier aliases should decode");
|
||||
|
||||
assert_eq!(fi.transition_version_id, Some(id));
|
||||
assert_eq!(fi.transition_version, Some(id.to_string()));
|
||||
@@ -4839,17 +4880,15 @@ mod tests {
|
||||
b"target-version".to_vec(),
|
||||
);
|
||||
|
||||
let fi = MetaDeleteMarker {
|
||||
let err = MetaDeleteMarker {
|
||||
version_id: Some(sample_version_id()),
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false);
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect_err("conflicting transition aliases must fail closed");
|
||||
|
||||
assert_eq!(fi.transition_version, None);
|
||||
assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown);
|
||||
assert!(fi.transition_tier.is_empty());
|
||||
assert!(fi.transitioned_objname.is_empty());
|
||||
assert_eq!(err, Error::FileCorrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user