mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 00:17:11 +00:00
test(ilm): cover restore failure expiry and retry (#6637)
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=88ee9684ece0e27294f2b3f0c9c8fe62890feff76aa47279d42dab0af3196fe2
|
sha256-darwin=d6aa36cfaae2c4d8590482c7e47138c5965b335b34a75f50d11ffc3366e9021e
|
||||||
sha256-linux=01fc549f07ccd60ceb58abee8e437eacd028104eb0a107129c3f10b1cf21e519
|
sha256-linux=373ba2e6c2f5dc8508c0119b03068d2e853068f1152ccc06c962c4823c2d660a
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=a183730b7b84232160ce5fdcfc06b5800e15bc79b3e03d43683ab3c7bdc0a23b
|
sha256=294350518743cac8d7c41880a2835216e4b697908d7b0b1bc92b62816d94c59d
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
//! loopback/SSRF restriction (that guard is replication-only), so `hot` can tier
|
//! loopback/SSRF restriction (that guard is replication-only), so `hot` can tier
|
||||||
//! to `cold` over `http://127.0.0.1:<port>`.
|
//! to `cold` over `http://127.0.0.1:<port>`.
|
||||||
//!
|
//!
|
||||||
//! A single test drives the full transition main path and pins the chain
|
//! The hermetic tests drive the transition and restore paths and pin the
|
||||||
//! required by ilm-7:
|
//! chains required by ilm-7 and the restore follow-up:
|
||||||
//! 1. `AddTier(RustFS)` on `hot` targeting `cold` — the real connectivity /
|
//! 1. `AddTier(RustFS)` on `hot` targeting `cold` — the real connectivity /
|
||||||
//! in-use probe runs (no `force`), so this also proves the tier is reachable.
|
//! in-use probe runs (no `force`), so this also proves the tier is reachable.
|
||||||
//! 2. A `Transition Days=0` rule installed before a multipart PUT transitions
|
//! 2. A `Transition Days=0` rule installed before a multipart PUT transitions
|
||||||
@@ -42,6 +42,9 @@
|
|||||||
//! 6. The remote object is present in the cold-tier bucket after transition.
|
//! 6. The remote object is present in the cold-tier bucket after transition.
|
||||||
//! 7. `DeleteObject` on `hot` drives free-version cleanup: the cold-tier copy
|
//! 7. `DeleteObject` on `hot` drives free-version cleanup: the cold-tier copy
|
||||||
//! eventually disappears and the hot object is gone (no local residue).
|
//! eventually disappears and the hot object is gone (no local residue).
|
||||||
|
//! 8. `RestoreObject` copy-back failures clear the in-progress marker, a
|
||||||
|
//! retry serves the object locally until expiry, and expiry leaves the
|
||||||
|
//! remote object available for a second restore.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, local_http_client};
|
use crate::common::{RustFSTestEnvironment, local_http_client};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
@@ -49,7 +52,8 @@ use aws_sdk_s3::error::ProvideErrorMetadata;
|
|||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
||||||
LifecycleRule, LifecycleRuleFilter, NoncurrentVersionTransition, Transition, TransitionStorageClass, VersioningConfiguration,
|
LifecycleRule, LifecycleRuleFilter, NoncurrentVersionTransition, RestoreRequest, Transition, TransitionStorageClass,
|
||||||
|
VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use http::Method;
|
use http::Method;
|
||||||
use http::header::HOST;
|
use http::header::HOST;
|
||||||
@@ -739,6 +743,77 @@ async fn wait_for_transition(client: &Client, bucket: &str, key: &str, deadline:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Poll `HEAD` until the asynchronous copy-back reports a completed restore.
|
||||||
|
async fn wait_for_restore_complete(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
if head
|
||||||
|
.restore()
|
||||||
|
.is_some_and(|restore| restore.contains("ongoing-request=\"false\""))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start.elapsed() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"restore for {bucket}/{key} did not complete within {}s; restore={:?}",
|
||||||
|
deadline.as_secs(),
|
||||||
|
head.restore()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll `HEAD` until the lifecycle restore-expiry action removes restore metadata.
|
||||||
|
async fn wait_for_restore_clear(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
if head.restore().is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start.elapsed() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"restore metadata for {bucket}/{key} was not cleared within {}s; restore={:?}",
|
||||||
|
deadline.as_secs(),
|
||||||
|
head.restore()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll `HEAD` through the failed-copy transition, proving that the request
|
||||||
|
/// first published an in-progress marker and that the failure then removed it.
|
||||||
|
async fn wait_for_restore_failure(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut saw_ongoing = false;
|
||||||
|
loop {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
if head
|
||||||
|
.restore()
|
||||||
|
.is_some_and(|restore| restore.contains("ongoing-request=\"true\""))
|
||||||
|
{
|
||||||
|
saw_ongoing = true;
|
||||||
|
} else if saw_ongoing && head.restore().is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start.elapsed() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"failed restore for {bucket}/{key} did not publish and clear its in-progress marker within {}s; \
|
||||||
|
saw_ongoing={saw_ongoing}, restore={:?}",
|
||||||
|
deadline.as_secs(),
|
||||||
|
head.restore()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll until the cold-tier bucket is empty (remote free-version cleanup done),
|
/// Poll until the cold-tier bucket is empty (remote free-version cleanup done),
|
||||||
/// or fail after `deadline`.
|
/// or fail after `deadline`.
|
||||||
async fn wait_for_cold_tier_empty(cold_client: &Client, deadline: StdDuration) -> TestResult {
|
async fn wait_for_cold_tier_empty(cold_client: &Client, deadline: StdDuration) -> TestResult {
|
||||||
@@ -894,6 +969,143 @@ async fn test_hermetic_transition_main_path() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore a transitioned object through a real RustFS remote tier.
|
||||||
|
///
|
||||||
|
/// The test covers the externally visible copy-back contract that a mock tier
|
||||||
|
/// cannot prove: a failed remote read clears `x-amz-restore`, a retry creates a
|
||||||
|
/// local copy that remains readable while the cold tier is unavailable, expiry
|
||||||
|
/// removes only that local copy, and the same remote object can be restored a
|
||||||
|
/// second time. The accelerated lifecycle clock keeps the expiry assertion
|
||||||
|
/// bounded while the scanner remains enabled.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn test_hermetic_transition_restore_failure_expiry_and_retry() -> TestResult {
|
||||||
|
let mut cold = RustFSTestEnvironment::new().await?;
|
||||||
|
cold.access_key = "restorecoldtieradmin".to_string();
|
||||||
|
cold.secret_key = "restorecoldtiersecret".to_string();
|
||||||
|
cold.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||||
|
let cold_client = cold.create_s3_client();
|
||||||
|
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
|
||||||
|
|
||||||
|
let mut hot = RustFSTestEnvironment::new().await?;
|
||||||
|
hot.start_rustfs_server_with_env(vec![], &[("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_ILM_DEBUG_DAY_SECS", "5")])
|
||||||
|
.await?;
|
||||||
|
let hot_client = hot.create_s3_client();
|
||||||
|
add_rustfs_tier(&hot, &cold).await?;
|
||||||
|
|
||||||
|
hot_client.create_bucket().bucket(SOURCE_BUCKET).send().await?;
|
||||||
|
hot_client
|
||||||
|
.put_bucket_lifecycle_configuration()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(transition_rule()?).build()?)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let data = payload();
|
||||||
|
put_multipart_object(&hot_client, SOURCE_BUCKET, OBJECT_KEY, &data).await?;
|
||||||
|
wait_for_transition(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(90)).await?;
|
||||||
|
let transitioned = hot_client.head_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
transitioned.storage_class().map(|storage_class| storage_class.as_str()),
|
||||||
|
Some(TIER_NAME),
|
||||||
|
"restore fixture must be transitioned before the copy-back request"
|
||||||
|
);
|
||||||
|
assert!(transitioned.restore().is_none(), "transitioned object must not already be restored");
|
||||||
|
assert_eq!(
|
||||||
|
cold_tier_object_count(&cold_client).await?,
|
||||||
|
1,
|
||||||
|
"cold tier should contain one restore candidate"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A failed remote read is accepted asynchronously, but it must not leave
|
||||||
|
// an object permanently advertising an in-progress restore.
|
||||||
|
cold.stop_server();
|
||||||
|
hot_client
|
||||||
|
.restore_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.restore_request(RestoreRequest::builder().days(1).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_restore_failure(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
// Once the tier is available again, the same object can be restored.
|
||||||
|
cold.restart_server_preserving_data(vec![], &[]).await?;
|
||||||
|
hot_client
|
||||||
|
.restore_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.restore_request(RestoreRequest::builder().days(1).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_restore_complete(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
let restored = hot_client.head_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
restored.storage_class().map(|storage_class| storage_class.as_str()),
|
||||||
|
Some(TIER_NAME),
|
||||||
|
"restore must retain the transitioned storage class"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restored
|
||||||
|
.restore()
|
||||||
|
.is_some_and(|restore| restore.contains("ongoing-request=\"false\"")),
|
||||||
|
"completed restore must advertise a finished temporary copy"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A completed restore must be served locally even if the remote tier is
|
||||||
|
// temporarily unavailable.
|
||||||
|
cold.stop_server();
|
||||||
|
let local_get = hot_client.get_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
let local_body = local_get.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(local_body.as_ref(), data.as_slice(), "restored local copy must be byte-identical");
|
||||||
|
|
||||||
|
cold.restart_server_preserving_data(vec![], &[]).await?;
|
||||||
|
|
||||||
|
// The test clock makes the one-day restore expire in roughly five seconds.
|
||||||
|
wait_for_restore_clear(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
let expired = hot_client.head_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
expired.storage_class().map(|storage_class| storage_class.as_str()),
|
||||||
|
Some(TIER_NAME),
|
||||||
|
"restore expiry must not clear the transitioned storage class"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cold_tier_object_count(&cold_client).await?,
|
||||||
|
1,
|
||||||
|
"restore expiry must retain the remote object"
|
||||||
|
);
|
||||||
|
|
||||||
|
// After expiry the local copy is gone, so an unavailable tier must make the
|
||||||
|
// read fail; with the tier back, the original bytes remain readable.
|
||||||
|
cold.stop_server();
|
||||||
|
let expired_get = hot_client.get_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await;
|
||||||
|
assert!(expired_get.is_err(), "expired restore must not leave a local copy behind");
|
||||||
|
cold.restart_server_preserving_data(vec![], &[]).await?;
|
||||||
|
let remote_get = hot_client.get_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
let remote_body = remote_get.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(remote_body.as_ref(), data.as_slice(), "post-expiry GET must read the remote copy");
|
||||||
|
|
||||||
|
// The remote candidate survives expiry and can be restored again.
|
||||||
|
hot_client
|
||||||
|
.restore_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.restore_request(RestoreRequest::builder().days(1).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_restore_complete(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
hot_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_cold_tier_empty(&cold_client, StdDuration::from_secs(90)).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn test_manual_transition_run_black_box_semantics() -> TestResult {
|
async fn test_manual_transition_run_black_box_semantics() -> TestResult {
|
||||||
let mut cold = RustFSTestEnvironment::new().await?;
|
let mut cold = RustFSTestEnvironment::new().await?;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| admin_auth_test | 4 | ✅ |
|
| admin_auth_test | 4 | ✅ |
|
||||||
| admin_iam_crud_test | 3 | ✅ |
|
| admin_iam_crud_test | 3 | ✅ |
|
||||||
|
| admin_mfa_test | 4 | |
|
||||||
| admin_pools_test | 1 | ✅ |
|
| admin_pools_test | 1 | ✅ |
|
||||||
| admin_timeout_regression_test | 1 | 🌙 |
|
| admin_timeout_regression_test | 1 | 🌙 |
|
||||||
| anonymous_access_test | 4 | ✅ |
|
| anonymous_access_test | 4 | ✅ |
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
| checksum_upload_test | 7 | |
|
| checksum_upload_test | 7 | |
|
||||||
| cluster_concurrency_test | 3 | 🌙 |
|
| cluster_concurrency_test | 3 | 🌙 |
|
||||||
| cluster_multidrive_pool_test | 2 | 🌙 |
|
| cluster_multidrive_pool_test | 2 | 🌙 |
|
||||||
| common | 16 | |
|
| common | 17 | |
|
||||||
| compression_test | 6 | ✅ |
|
| compression_test | 6 | ✅ |
|
||||||
| connection_cap_test | 2 | |
|
| connection_cap_test | 2 | |
|
||||||
| console_smoke_test | 1 | ✅ |
|
| console_smoke_test | 1 | ✅ |
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
| heal_erasure_disk_rebuild_test | 4 | 🌙 |
|
| heal_erasure_disk_rebuild_test | 4 | 🌙 |
|
||||||
| inline_fast_path_cluster_test | 16 | |
|
| inline_fast_path_cluster_test | 16 | |
|
||||||
| internode_rpc_signature_e2e_test | 5 | |
|
| internode_rpc_signature_e2e_test | 5 | |
|
||||||
| kms | 48 | |
|
| kms | 50 | |
|
||||||
| leading_slash_key_test | 2 | ✅ |
|
| leading_slash_key_test | 2 | ✅ |
|
||||||
| lifecycle_regression_test | 4 | |
|
| lifecycle_regression_test | 4 | |
|
||||||
| list_buckets_auth_test | 1 | ✅ |
|
| list_buckets_auth_test | 1 | ✅ |
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
| multipart_auth_test | 75 | |
|
| multipart_auth_test | 75 | |
|
||||||
| multipart_storage_class_test | 3 | ✅ |
|
| multipart_storage_class_test | 3 | ✅ |
|
||||||
| namespace_lock_quorum_test | 2 | 🌙 |
|
| namespace_lock_quorum_test | 2 | 🌙 |
|
||||||
| negative_sigv4_test | 6 | ✅ |
|
| negative_sigv4_test | 7 | ✅ |
|
||||||
| notification_startup_regression_test | 2 | |
|
| notification_startup_regression_test | 2 | |
|
||||||
| notification_webhook_test | 3 | ✅ |
|
| notification_webhook_test | 3 | ✅ |
|
||||||
| object_lambda_test | 16 | 🌙 |
|
| object_lambda_test | 16 | 🌙 |
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
| protocols | 16 | 🌙 |
|
| protocols | 16 | 🌙 |
|
||||||
| quota_test | 14 | |
|
| quota_test | 14 | |
|
||||||
| reliability_disk_fault_test | 4 | |
|
| reliability_disk_fault_test | 4 | |
|
||||||
| reliant | 31 | 19 ✅ |
|
| reliant | 43 | 20 ✅ |
|
||||||
| replication_extension_test | 76 | 20 ✅ +56 🌙 |
|
| replication_extension_test | 76 | 20 ✅ +56 🌙 |
|
||||||
| replication_lww_receiver_test | 1 | |
|
| replication_lww_receiver_test | 1 | |
|
||||||
| security_boundary_test | 4 | |
|
| security_boundary_test | 4 | |
|
||||||
@@ -102,4 +103,4 @@
|
|||||||
| tls_hot_reload_test | 1 | ✅ |
|
| tls_hot_reload_test | 1 | ✅ |
|
||||||
| version_id_regression_test | 10 | ✅ |
|
| version_id_regression_test | 10 | ✅ |
|
||||||
|
|
||||||
**Total listed: 591 tests across 83 modules · PR smoke: 163 tests / 36 modules · merge/main full: 467 tests / 74 modules · nightly replication: 56 tests · nightly cluster faults: 29 tests / 7 modules · nightly protocols: 16 tests** · updated 2026-08-23.
|
**Total listed: 619 tests across 86 modules · PR smoke: 165 tests / 36 modules · merge/main full: 495 tests / 77 modules · nightly replication: 56 tests · nightly cluster faults: 29 tests / 7 modules · nightly protocols: 16 tests** · updated 2026-08-26.
|
||||||
|
|||||||
Reference in New Issue
Block a user