mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-24 13:16:28 +00:00
fix(ecstore): preserve newer writes during data movement (#5798)
This commit is contained in:
@@ -25,7 +25,7 @@ use crate::set_disk::{SetDisks, get_lock_acquire_timeout};
|
|||||||
use crate::storage_api_contracts::{
|
use crate::storage_api_contracts::{
|
||||||
multipart::{CompletePart, MultipartOperations as _},
|
multipart::{CompletePart, MultipartOperations as _},
|
||||||
namespace::NamespaceLocking as _,
|
namespace::NamespaceLocking as _,
|
||||||
object::{ObjectIO as _, ObjectOperations as _},
|
object::{HTTPPreconditions, ObjectIO as _, ObjectOperations as _},
|
||||||
};
|
};
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -228,6 +228,7 @@ fn data_movement_complete_multipart_opts(object_info: &ObjectInfo, src_pool_idx:
|
|||||||
ObjectOptions {
|
ObjectOptions {
|
||||||
versioned: object_info.version_id.is_some(),
|
versioned: object_info.version_id.is_some(),
|
||||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||||
|
http_preconditions: data_movement_unversioned_target_precondition(object_info),
|
||||||
data_movement: true,
|
data_movement: true,
|
||||||
mod_time: object_info.mod_time,
|
mod_time: object_info.mod_time,
|
||||||
preserve_etag: object_info.etag.clone(),
|
preserve_etag: object_info.etag.clone(),
|
||||||
@@ -242,6 +243,7 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
|
|||||||
src_pool_idx,
|
src_pool_idx,
|
||||||
data_movement: true,
|
data_movement: true,
|
||||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||||
|
http_preconditions: data_movement_unversioned_target_precondition(object_info),
|
||||||
mod_time: object_info.mod_time,
|
mod_time: object_info.mod_time,
|
||||||
user_defined: data_movement_user_defined(object_info),
|
user_defined: data_movement_user_defined(object_info),
|
||||||
preserve_etag: object_info.etag.clone(),
|
preserve_etag: object_info.etag.clone(),
|
||||||
@@ -249,6 +251,17 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_unversioned_data_movement_object(object_info: &ObjectInfo) -> bool {
|
||||||
|
object_info.version_id.is_none_or(|version_id| version_id.is_nil())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_movement_unversioned_target_precondition(object_info: &ObjectInfo) -> Option<HTTPPreconditions> {
|
||||||
|
is_unversioned_data_movement_object(object_info).then(|| HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn data_movement_put_object_reader(
|
fn data_movement_put_object_reader(
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
object_info: &ObjectInfo,
|
object_info: &ObjectInfo,
|
||||||
@@ -337,7 +350,7 @@ fn schedule_data_movement_multipart_abort_cleanup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_check_data_movement_overwrite_resume(err: &Error) -> bool {
|
fn should_check_data_movement_overwrite_resume(err: &Error) -> bool {
|
||||||
is_err_data_movement_overwrite(err)
|
is_err_data_movement_overwrite(err) || matches!(err, Error::PreconditionFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn effective_actual_size(info: &ObjectInfo) -> Option<i64> {
|
fn effective_actual_size(info: &ObjectInfo) -> Option<i64> {
|
||||||
@@ -403,6 +416,16 @@ fn is_equivalent_data_movement_object(source: &ObjectInfo, target: &ObjectInfo)
|
|||||||
&& are_equivalent_data_movement_parts(&source.parts, &target.parts)
|
&& are_equivalent_data_movement_parts(&source.parts, &target.parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||||
|
is_unversioned_data_movement_object(source)
|
||||||
|
&& is_unversioned_data_movement_object(target)
|
||||||
|
&& !target.delete_marker
|
||||||
|
&& source
|
||||||
|
.mod_time
|
||||||
|
.zip(target.mod_time)
|
||||||
|
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
struct SourceCleanupPartIdentity {
|
struct SourceCleanupPartIdentity {
|
||||||
number: usize,
|
number: usize,
|
||||||
@@ -701,7 +724,11 @@ fn resolve_data_movement_overwrite_resume_result(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(is_equivalent_data_movement_object(source, &target))
|
if is_equivalent_data_movement_object(source, &target) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn should_treat_data_movement_overwrite_as_complete(
|
async fn should_treat_data_movement_overwrite_as_complete(
|
||||||
@@ -912,7 +939,6 @@ pub(crate) async fn migrate_object(
|
|||||||
bucket.as_str(),
|
bucket.as_str(),
|
||||||
object_info.name.as_str()
|
object_info.name.as_str()
|
||||||
);
|
);
|
||||||
mark_multipart_upload_completed(&abort_multipart_flag);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,6 +957,32 @@ pub(crate) async fn migrate_object(
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
if multipart_result.is_ok() && should_abort_multipart_upload(&abort_multipart_flag) {
|
||||||
|
let abort_result = match store.pools.get(target_pool_idx) {
|
||||||
|
Some(pool) => {
|
||||||
|
pool.abort_multipart_upload(&bucket, &object_info.name, &res.upload_id, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => Err(Error::other(format!(
|
||||||
|
"{op_label}: target pool {target_pool_idx} is out of range while aborting superseded multipart upload"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
if let Err(abort_err) = abort_result
|
||||||
|
&& !is_err_invalid_upload_id(&abort_err)
|
||||||
|
{
|
||||||
|
error!("{op_label}: abort superseded multipart upload err {:?}", &abort_err);
|
||||||
|
schedule_data_movement_multipart_abort_cleanup(
|
||||||
|
store.clone(),
|
||||||
|
target_pool_idx,
|
||||||
|
bucket.clone(),
|
||||||
|
object_info.name.clone(),
|
||||||
|
res.upload_id.clone(),
|
||||||
|
op_label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(primary_err) = multipart_result {
|
if let Err(primary_err) = multipart_result {
|
||||||
if should_abort_multipart_upload(&abort_multipart_flag) {
|
if should_abort_multipart_upload(&abort_multipart_flag) {
|
||||||
return match store
|
return match store
|
||||||
@@ -1245,12 +1297,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_check_data_movement_overwrite_resume_only_for_overwrite_error() {
|
fn test_should_check_data_movement_overwrite_resume_accepts_conflict_errors() {
|
||||||
assert!(should_check_data_movement_overwrite_resume(&Error::DataMovementOverwriteErr(
|
assert!(should_check_data_movement_overwrite_resume(&Error::DataMovementOverwriteErr(
|
||||||
"bucket-a".to_string(),
|
"bucket-a".to_string(),
|
||||||
"object-a".to_string(),
|
"object-a".to_string(),
|
||||||
"version-a".to_string(),
|
"version-a".to_string(),
|
||||||
)));
|
)));
|
||||||
|
assert!(should_check_data_movement_overwrite_resume(&Error::PreconditionFailed));
|
||||||
assert!(!should_check_data_movement_overwrite_resume(&Error::SlowDown));
|
assert!(!should_check_data_movement_overwrite_resume(&Error::SlowDown));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1625,7 +1678,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_data_movement_complete_multipart_opts_preserves_mod_time_version_and_etag() {
|
fn test_data_movement_complete_multipart_opts_preserves_mod_time_version_and_etag() {
|
||||||
let mod_time = OffsetDateTime::now_utc();
|
let mod_time = OffsetDateTime::now_utc();
|
||||||
let version_id = Uuid::nil();
|
let version_id = Uuid::from_u128(7);
|
||||||
let object_info = ObjectInfo {
|
let object_info = ObjectInfo {
|
||||||
version_id: Some(version_id),
|
version_id: Some(version_id),
|
||||||
mod_time: Some(mod_time),
|
mod_time: Some(mod_time),
|
||||||
@@ -1641,11 +1694,12 @@ mod tests {
|
|||||||
assert_eq!(opts.version_id.as_deref(), Some(version_id.to_string().as_str()));
|
assert_eq!(opts.version_id.as_deref(), Some(version_id.to_string().as_str()));
|
||||||
assert_eq!(opts.preserve_etag.as_deref(), Some("etag-value"));
|
assert_eq!(opts.preserve_etag.as_deref(), Some("etag-value"));
|
||||||
assert_eq!(opts.src_pool_idx, 7);
|
assert_eq!(opts.src_pool_idx, 7);
|
||||||
|
assert!(opts.http_preconditions.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_movement_put_object_opts_preserves_version_and_etag() {
|
fn test_data_movement_put_object_opts_preserves_version_and_etag() {
|
||||||
let version_id = Uuid::nil();
|
let version_id = Uuid::from_u128(9);
|
||||||
let object_info = ObjectInfo {
|
let object_info = ObjectInfo {
|
||||||
version_id: Some(version_id),
|
version_id: Some(version_id),
|
||||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
@@ -1663,6 +1717,35 @@ mod tests {
|
|||||||
assert_eq!(opts.src_pool_idx, 9);
|
assert_eq!(opts.src_pool_idx, 9);
|
||||||
assert!(opts.data_movement);
|
assert!(opts.data_movement);
|
||||||
assert_eq!(opts.mod_time, object_info.mod_time);
|
assert_eq!(opts.mod_time, object_info.mod_time);
|
||||||
|
assert!(opts.http_preconditions.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data_movement_unversioned_put_and_complete_require_absent_target() {
|
||||||
|
for version_id in [None, Some(Uuid::nil())] {
|
||||||
|
let object_info = ObjectInfo {
|
||||||
|
version_id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let put_opts = data_movement_put_object_opts(&object_info, 9);
|
||||||
|
let complete_opts = data_movement_complete_multipart_opts(&object_info, 9);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
put_opts
|
||||||
|
.http_preconditions
|
||||||
|
.as_ref()
|
||||||
|
.and_then(HTTPPreconditions::if_none_match_value),
|
||||||
|
Some("*")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
complete_opts
|
||||||
|
.http_preconditions
|
||||||
|
.as_ref()
|
||||||
|
.and_then(HTTPPreconditions::if_none_match_value),
|
||||||
|
Some("*")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1911,6 +1994,154 @@ mod tests {
|
|||||||
assert!(should_resume);
|
assert!(should_resume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_precondition_conflict_accepts_newer_unversioned_target() {
|
||||||
|
for version_id in [None, Some(Uuid::nil())] {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
version_id,
|
||||||
|
size: 128,
|
||||||
|
etag: Some("etag-source".to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
etag: Some("etag-client-write".to_string()),
|
||||||
|
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let should_resume =
|
||||||
|
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||||
|
.expect("precondition conflict target should be evaluated");
|
||||||
|
|
||||||
|
assert!(should_resume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_precondition_conflict_accepts_equivalent_target() {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
size: 128,
|
||||||
|
etag: Some("etag-source".to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let should_resume =
|
||||||
|
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(source.clone())), &source, 0, 1)
|
||||||
|
.expect("equivalent precondition target should be evaluated");
|
||||||
|
|
||||||
|
assert!(should_resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_precondition_conflict_rejects_non_newer_unversioned_target() {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
size: 128,
|
||||||
|
etag: Some("etag-source".to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
etag: Some("etag-conflict".to_string()),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let should_resume =
|
||||||
|
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||||
|
.expect("precondition conflict target should be evaluated");
|
||||||
|
|
||||||
|
assert!(!should_resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_precondition_conflict_rejects_newer_delete_marker() {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
size: 128,
|
||||||
|
etag: Some("etag-source".to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
delete_marker: true,
|
||||||
|
etag: None,
|
||||||
|
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let should_resume =
|
||||||
|
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||||
|
.expect("delete marker conflict should be evaluated");
|
||||||
|
|
||||||
|
assert!(!should_resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overwrite_error_rejects_newer_unversioned_target() {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
size: 128,
|
||||||
|
etag: Some("etag-source".to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
etag: Some("etag-client-write".to_string()),
|
||||||
|
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
let err = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
|
||||||
|
|
||||||
|
let should_resume = resolve_data_movement_overwrite_resume_result(&err, Ok(Some(target)), &source, 0, 1)
|
||||||
|
.expect("pool-selection overwrite must require target equivalence");
|
||||||
|
|
||||||
|
assert!(!should_resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_precondition_conflict_rejects_newer_versioned_target() {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
size: 128,
|
||||||
|
etag: Some("etag-source".to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
version_id: Some(Uuid::from_u128(2)),
|
||||||
|
etag: Some("etag-conflict".to_string()),
|
||||||
|
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let should_resume =
|
||||||
|
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||||
|
.expect("versioned conflict target should be evaluated");
|
||||||
|
|
||||||
|
assert!(!should_resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_precondition_conflict_rejects_versioned_source_with_unversioned_target() {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
version_id: Some(Uuid::from_u128(1)),
|
||||||
|
size: 128,
|
||||||
|
etag: Some("etag-source".to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
version_id: None,
|
||||||
|
etag: Some("etag-conflict".to_string()),
|
||||||
|
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let should_resume =
|
||||||
|
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||||
|
.expect("versioned source conflict should be evaluated");
|
||||||
|
|
||||||
|
assert!(!should_resume);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rebalance_overwrite_resume_accepts_equivalent_target_version() {
|
fn test_rebalance_overwrite_resume_accepts_equivalent_target_version() {
|
||||||
let source = ObjectInfo {
|
let source = ObjectInfo {
|
||||||
|
|||||||
@@ -972,8 +972,9 @@ impl SetDisks {
|
|||||||
|
|
||||||
let mut object_lock_guard = None;
|
let mut object_lock_guard = None;
|
||||||
let mut bucket_lifecycle_guard = None;
|
let mut bucket_lifecycle_guard = None;
|
||||||
|
let deferred_data_movement_precondition = opts.data_movement && opts.http_preconditions.is_some();
|
||||||
|
|
||||||
if opts.http_preconditions.is_some() {
|
if opts.http_preconditions.is_some() && !deferred_data_movement_precondition {
|
||||||
if !opts.no_lock {
|
if !opts.no_lock {
|
||||||
if let Some(expected_incarnation_id) = opts.expected_bucket_incarnation_id
|
if let Some(expected_incarnation_id) = opts.expected_bucket_incarnation_id
|
||||||
&& opts.bucket_lifecycle_lock_fence.is_none()
|
&& opts.bucket_lifecycle_lock_fence.is_none()
|
||||||
@@ -1330,6 +1331,10 @@ impl SetDisks {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pause_put_object_commit(bucket, object, PutObjectCommitPause::AfterNamespace).await;
|
pause_put_object_commit(bucket, object, PutObjectCommitPause::AfterNamespace).await;
|
||||||
|
|
||||||
|
if deferred_data_movement_precondition && let Some(err) = self.check_write_precondition(bucket, object, opts).await {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate ordinary PUT timestamps under the commit lock so version
|
// Generate ordinary PUT timestamps under the commit lock so version
|
||||||
// ordering follows durable commit ordering when writers queued on
|
// ordering follows durable commit ordering when writers queued on
|
||||||
// the same object. Internal callers with an explicit timestamp keep
|
// the same object. Internal callers with an explicit timestamp keep
|
||||||
@@ -5780,7 +5785,7 @@ mod transition_commit_failure_tests {
|
|||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use rustfs_filemeta::{RestoreStatusOps as _, parse_restore_obj_status};
|
use rustfs_filemeta::{RestoreStatusOps as _, parse_restore_obj_status};
|
||||||
use s3s::dto::RestoreRequest;
|
use s3s::dto::RestoreRequest;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
fn restore_operation_id_metadata(operation_id: Uuid) -> HashMap<String, String> {
|
fn restore_operation_id_metadata(operation_id: Uuid) -> HashMap<String, String> {
|
||||||
let mut metadata = HashMap::new();
|
let mut metadata = HashMap::new();
|
||||||
@@ -8721,6 +8726,87 @@ mod put_object_tmp_cleanup_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn data_movement_precondition_is_rechecked_at_commit() {
|
||||||
|
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
let bucket = "data-movement-commit-precondition";
|
||||||
|
let object = "object";
|
||||||
|
for disk in &disk_stores {
|
||||||
|
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||||
|
}
|
||||||
|
|
||||||
|
let migration_body = vec![b'm'; 64 * 1024];
|
||||||
|
let split = migration_body.len() / 2;
|
||||||
|
let (mut source, stream) = tokio::io::duplex(64);
|
||||||
|
let hash_reader = HashReader::from_stream(
|
||||||
|
stream,
|
||||||
|
i64::try_from(migration_body.len()).expect("migration body length should fit i64"),
|
||||||
|
i64::try_from(migration_body.len()).expect("migration body length should fit i64"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("migration hash reader should be created");
|
||||||
|
let migration_store = Arc::clone(&set_disks);
|
||||||
|
let migration = tokio::spawn(async move {
|
||||||
|
let mut reader = PutObjReader::new(hash_reader);
|
||||||
|
migration_store
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
data_movement: true,
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
source
|
||||||
|
.write_all(&migration_body[..split])
|
||||||
|
.await
|
||||||
|
.expect("migration should consume the first half before commit");
|
||||||
|
let mut client_reader = PutObjReader::from_vec(b"new client body".to_vec());
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
set_disks.put_object(bucket, object, &mut client_reader, &ObjectOptions::default()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("client write must not wait for the migration body")
|
||||||
|
.expect("client write should commit while migration waits for the remaining source body");
|
||||||
|
let barrier = PutObjectCommitBarrier::install(bucket, object, PutObjectCommitPause::AfterNamespace);
|
||||||
|
source
|
||||||
|
.write_all(&migration_body[split..])
|
||||||
|
.await
|
||||||
|
.expect("migration should consume the remaining source body");
|
||||||
|
drop(source);
|
||||||
|
barrier.wait_until_paused().await;
|
||||||
|
barrier.release();
|
||||||
|
|
||||||
|
let err = migration
|
||||||
|
.await
|
||||||
|
.expect("migration task should join")
|
||||||
|
.expect_err("migration must recheck the target after acquiring its commit lock");
|
||||||
|
assert_eq!(err, StorageError::PreconditionFailed);
|
||||||
|
|
||||||
|
let mut reader = set_disks
|
||||||
|
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("client object should remain readable");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("client object should drain");
|
||||||
|
assert_eq!(body, b"new client body");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn metadata_copy_no_lock_aborts_after_outer_namespace_lock_loss() {
|
async fn metadata_copy_no_lock_aborts_after_outer_namespace_lock_loss() {
|
||||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
|||||||
@@ -611,6 +611,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use rustfs_config::server_config::KVS;
|
use rustfs_config::server_config::KVS;
|
||||||
|
use rustfs_filemeta::ObjectPartInfo;
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
use rustfs_protos::{TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase};
|
use rustfs_protos::{TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -1153,6 +1154,145 @@ mod tests {
|
|||||||
(instance_ctx, store, shutdown)
|
(instance_ctx, store, shutdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn data_movement_conflicts_preserve_newer_target_and_abort_staging() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create data movement store dir");
|
||||||
|
let (_ctx, store, _shutdown) =
|
||||||
|
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "data-movement-conflict-convergence", &[4, 4]))
|
||||||
|
.await;
|
||||||
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
|
||||||
|
let bucket = format!("data-movement-conflict-{}", uuid::Uuid::new_v4());
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create data movement bucket");
|
||||||
|
let source_mod_time = OffsetDateTime::UNIX_EPOCH;
|
||||||
|
let target_mod_time = source_mod_time + time::Duration::SECOND;
|
||||||
|
|
||||||
|
let object = "single-object";
|
||||||
|
let target_body = b"newer client body".to_vec();
|
||||||
|
let mut target_reader = PutObjReader::from_vec(target_body.clone());
|
||||||
|
store.pools[1]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&mut target_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
mod_time: Some(target_mod_time),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("write newer single-part target");
|
||||||
|
|
||||||
|
let source_body = b"stale migration body".to_vec();
|
||||||
|
crate::data_movement::migrate_object(
|
||||||
|
store.clone(),
|
||||||
|
0,
|
||||||
|
bucket.clone(),
|
||||||
|
GetObjectReader {
|
||||||
|
stream: Box::new(Cursor::new(source_body.clone())),
|
||||||
|
object_info: ObjectInfo {
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
name: object.to_string(),
|
||||||
|
size: i64::try_from(source_body.len()).expect("single source size should fit i64"),
|
||||||
|
actual_size: i64::try_from(source_body.len()).expect("single source size should fit i64"),
|
||||||
|
etag: Some("0123456789abcdef0123456789abcdef".to_string()),
|
||||||
|
mod_time: Some(source_mod_time),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
buffered_body: None,
|
||||||
|
body_source: Default::default(),
|
||||||
|
},
|
||||||
|
"test_data_movement",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("newer single-part target should converge migration");
|
||||||
|
|
||||||
|
let mut reader = store
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("read converged single-part target");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader.stream.read_to_end(&mut body).await.expect("drain single-part target");
|
||||||
|
assert_eq!(body, target_body);
|
||||||
|
|
||||||
|
let multipart_object = "multipart-object";
|
||||||
|
let multipart_target_body = b"newer multipart client body".to_vec();
|
||||||
|
let mut multipart_target_reader = PutObjReader::from_vec(multipart_target_body.clone());
|
||||||
|
store.pools[1]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
multipart_object,
|
||||||
|
&mut multipart_target_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
mod_time: Some(target_mod_time),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("write newer multipart target");
|
||||||
|
|
||||||
|
let first_part_size = 5 * 1024 * 1024;
|
||||||
|
let mut multipart_source_body = vec![b'a'; first_part_size];
|
||||||
|
multipart_source_body.push(b'b');
|
||||||
|
let multipart_source_size = i64::try_from(multipart_source_body.len()).expect("multipart source size should fit i64");
|
||||||
|
crate::data_movement::migrate_object(
|
||||||
|
store.clone(),
|
||||||
|
0,
|
||||||
|
bucket.clone(),
|
||||||
|
GetObjectReader {
|
||||||
|
stream: Box::new(Cursor::new(multipart_source_body)),
|
||||||
|
object_info: ObjectInfo {
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
name: multipart_object.to_string(),
|
||||||
|
size: multipart_source_size,
|
||||||
|
actual_size: multipart_source_size,
|
||||||
|
etag: Some("source-multipart-etag-2".to_string()),
|
||||||
|
mod_time: Some(source_mod_time),
|
||||||
|
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: "source-part-1".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ObjectPartInfo {
|
||||||
|
number: 2,
|
||||||
|
size: 1,
|
||||||
|
actual_size: 1,
|
||||||
|
etag: "source-part-2".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
buffered_body: None,
|
||||||
|
body_source: Default::default(),
|
||||||
|
},
|
||||||
|
"test_data_movement",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("newer multipart target should converge migration");
|
||||||
|
|
||||||
|
let uploads = store.pools[1]
|
||||||
|
.list_multipart_uploads(&bucket, multipart_object, None, None, None, 100)
|
||||||
|
.await
|
||||||
|
.expect("list target pool multipart uploads");
|
||||||
|
assert!(uploads.uploads.is_empty(), "superseded migration staging must be aborted");
|
||||||
|
|
||||||
|
let mut reader = store
|
||||||
|
.get_object_reader(&bucket, multipart_object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("read converged multipart target");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader.stream.read_to_end(&mut body).await.expect("drain multipart target");
|
||||||
|
assert_eq!(body, multipart_target_body);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
async fn tier_delete_journal_count(store: Arc<crate::store::ECStore>) -> usize {
|
async fn tier_delete_journal_count(store: Arc<crate::store::ECStore>) -> usize {
|
||||||
store
|
store
|
||||||
|
|||||||
Reference in New Issue
Block a user