mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-19 02:56:18 +00:00
fix(ecstore): invalidate metadata cache after ILM transition persists (#4951)
A duplicate transition task admitted after the winner released its in-flight claim (#4839) re-reads the version before uploading, but on unversioned buckets that read could hit a stale pre-transition entry in the 2s-TTL GET metadata cache: transition_object never invalidated the cache after delete_object_version persisted transition_status=complete and freed the local data. The stale hit defeated the TRANSITION_COMPLETE early-return, so the duplicate streamed the already-deleted local data to the remote tier (NotFound reader errors + rejected duplicate tier PUT with UnexpectedContent). Invalidate the cache right after the transitioned metadata is persisted, matching the other metadata-mutating paths, and add a regression test that runs a duplicate transition against an already-transitioned version and asserts no second tier upload and unchanged remote object metadata. Fixes #4827
This commit is contained in:
@@ -2354,6 +2354,14 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
self.record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
|
self.record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete_object_version persisted transition_status=complete and freed the
|
||||||
|
// local data, but does not touch the GET metadata cache. Drop any cached
|
||||||
|
// pre-transition entry so a late duplicate transition task (or a plain GET)
|
||||||
|
// re-reads the fresh state; a stale hit here defeats the TRANSITION_COMPLETE
|
||||||
|
// early-return above and streams the already-deleted local data to the
|
||||||
|
// remote tier again (rustfs/rustfs#4827).
|
||||||
|
self.invalidate_get_object_metadata_cache(bucket, object).await;
|
||||||
|
|
||||||
for disk in disks.iter() {
|
for disk in disks.iter() {
|
||||||
if let Some(disk) = disk {
|
if let Some(disk) = disk {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use super::storage_api::test::contract::{
|
|||||||
};
|
};
|
||||||
use super::storage_api::test::ecfs::FS;
|
use super::storage_api::test::ecfs::FS;
|
||||||
use super::storage_api::test::object_utils::to_s3s_etag;
|
use super::storage_api::test::object_utils::to_s3s_etag;
|
||||||
use super::storage_api::test::runtime::{MockWarmBackend, register_mock_tier as register_mock_tier_util};
|
use super::storage_api::test::runtime::{MockWarmBackend, MockWarmOp, register_mock_tier as register_mock_tier_util};
|
||||||
use super::storage_api::test::{
|
use super::storage_api::test::{
|
||||||
ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints, StorageObjectInfo as ObjectInfo,
|
ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints, StorageObjectInfo as ObjectInfo,
|
||||||
StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader,
|
StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader,
|
||||||
@@ -903,6 +903,100 @@ async fn get_transitioned_object_uses_remote_codec_fallback_path() {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for rustfs/rustfs#4827: a duplicate transition task that runs
|
||||||
|
/// after the winning transition completed must return early without a second
|
||||||
|
/// tier upload and without clobbering the winner's remote object / metadata.
|
||||||
|
///
|
||||||
|
/// The duplicate models the residual race left after the in-flight enqueue dedup
|
||||||
|
/// (#4839): a stale-snapshot task admitted after the winner released its claim.
|
||||||
|
/// On an unversioned bucket the duplicate's metadata re-read is eligible for the
|
||||||
|
/// GET metadata cache; before the fix the cache still held the pre-transition
|
||||||
|
/// entry (transition_object never invalidated it after persisting), so the
|
||||||
|
/// TRANSITION_COMPLETE early-return never fired and the duplicate streamed the
|
||||||
|
/// already-deleted local data to the tier again (NotFound reader errors +
|
||||||
|
/// rejected duplicate tier PUT).
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||||
|
#[serial]
|
||||||
|
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||||
|
async fn duplicate_transition_task_skips_already_transitioned_version() {
|
||||||
|
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||||
|
|
||||||
|
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||||
|
let backend = register_mock_tier(&tier_name).await;
|
||||||
|
|
||||||
|
let bucket = format!("test-api-dup-transition-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||||
|
let object = "test/duplicate-transition.txt";
|
||||||
|
let payload = b"duplicate transition tasks must not re-upload";
|
||||||
|
|
||||||
|
// Unversioned bucket: the duplicate's re-read goes through the GET metadata
|
||||||
|
// cache (versioned reads bypass it), which is the stale-read window under test.
|
||||||
|
(*ecstore)
|
||||||
|
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("Failed to create unversioned test bucket");
|
||||||
|
|
||||||
|
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||||
|
let transition_opts = ObjectOptions {
|
||||||
|
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||||
|
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||||
|
tier: tier_name.clone(),
|
||||||
|
etag: uploaded.etag.clone().unwrap_or_default(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
version_id: uploaded.version_id.map(|version| version.to_string()),
|
||||||
|
mod_time: uploaded.mod_time,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Winner: transitions the object, persists transition_status=complete, and
|
||||||
|
// deletes the local data.
|
||||||
|
ecstore
|
||||||
|
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||||
|
.await
|
||||||
|
.expect("winning transition should succeed");
|
||||||
|
|
||||||
|
let puts_after_winner: Vec<String> = backend
|
||||||
|
.op_log()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|op| match op {
|
||||||
|
MockWarmOp::Put { object } => Some(object),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(puts_after_winner.len(), 1, "winner must upload exactly one tier object");
|
||||||
|
let winner_remote_object = puts_after_winner[0].clone();
|
||||||
|
|
||||||
|
// Duplicate task with the same stale snapshot (same mod_time / etag), run
|
||||||
|
// immediately so the pre-fix stale metadata-cache entry is still live. It
|
||||||
|
// must observe transition_status=complete and return Ok without uploading.
|
||||||
|
ecstore
|
||||||
|
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||||
|
.await
|
||||||
|
.expect("duplicate transition must return early without error");
|
||||||
|
|
||||||
|
let puts_after_duplicate = backend
|
||||||
|
.op_log()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter(|op| matches!(op, MockWarmOp::Put { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(puts_after_duplicate, 1, "duplicate transition must not attempt a second tier upload");
|
||||||
|
|
||||||
|
// The winner's remote object and local metadata must be intact.
|
||||||
|
assert!(backend.contains(&winner_remote_object).await);
|
||||||
|
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||||
|
.await
|
||||||
|
.expect("object must remain transitioned after duplicate task");
|
||||||
|
assert_eq!(info.transitioned_object.status, "complete");
|
||||||
|
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||||
|
assert_eq!(
|
||||||
|
info.transitioned_object.name, winner_remote_object,
|
||||||
|
"duplicate transition must not repoint metadata at a new remote object"
|
||||||
|
);
|
||||||
|
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, payload);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||||
#[serial]
|
#[serial]
|
||||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ pub(crate) mod runtime {
|
|||||||
// The mock (and the tier types it used to need) now live in ecstore behind
|
// The mock (and the tier types it used to need) now live in ecstore behind
|
||||||
// the `test-util` feature.
|
// the `test-util` feature.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use crate::storage::storage_api::ecstore_tier::test_util::{MockWarmBackend, register_mock_tier};
|
pub(crate) use crate::storage::storage_api::ecstore_tier::test_util::{MockWarmBackend, MockWarmOp, register_mock_tier};
|
||||||
|
|
||||||
pub(crate) fn set_global_storage_class(cfg: StorageClassConfig) {
|
pub(crate) fn set_global_storage_class(cfg: StorageClassConfig) {
|
||||||
crate::storage::storage_api::ecstore_config::set_global_storage_class(cfg);
|
crate::storage::storage_api::ecstore_config::set_global_storage_class(cfg);
|
||||||
|
|||||||
Reference in New Issue
Block a user