fix(ecstore): preserve decommission delete compatibility

This commit is contained in:
cxymds
2026-09-11 22:27:11 +08:00
parent fefe9a538b
commit a1b2fe4dfe
5 changed files with 146 additions and 1 deletions
@@ -779,7 +779,7 @@ fn free_version_physical_topology_generation(api: &ECStore) -> String {
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
}
fn free_version_remote_tuple_matches(candidate: &ObjectInfo, expected: &ObjectInfo) -> std::io::Result<bool> {
pub(crate) fn free_version_remote_tuple_matches(candidate: &ObjectInfo, expected: &ObjectInfo) -> std::io::Result<bool> {
if candidate.transitioned_object.tier != expected.transitioned_object.tier
|| candidate.transitioned_object.name != expected.transitioned_object.name
{
+14
View File
@@ -7470,6 +7470,20 @@ impl PoolMeta {
.is_some_and(is_decommission_suspended)
}
pub(crate) fn has_active_decommission_capacity_reservation(&self, idx: usize) -> bool {
self.pools
.get(idx)
.and_then(|pool| pool.decommission.as_ref())
.is_some_and(|info| {
info.has_decommission_state()
&& is_decommission_active(info.complete, info.failed, info.canceled)
&& info
.capacity_reservation
.as_ref()
.is_some_and(DecommissionCapacityReservation::active)
})
}
pub(crate) fn scanner_pause_backlog_pool_writable(&self, idx: usize) -> bool {
self.pools.get(idx).is_some_and(|pool| {
!pool
+92
View File
@@ -20,6 +20,7 @@ fn to_filemeta_err(err: Error) -> rustfs_filemeta::Error {
err.narrow_to_filemeta().unwrap_or_else(rustfs_filemeta::Error::other)
}
use crate::bucket::lifecycle::bucket_lifecycle_ops::free_version_remote_tuple_matches;
use crate::bucket::metadata_sys::{
get_versioning_config, has_authoritative_never_versioned_state, has_authoritative_never_versioned_state_in,
};
@@ -5118,6 +5119,30 @@ fn merge_object_entry_versions(first: &mut MetaCacheEntry, others: impl Iterator
}
}
}
let mut live_remote_references = HashMap::<String, HashMap<String, Vec<ObjectInfo>>>::new();
for (_, info) in versions.values() {
if !info.transitioned_object.free_version && info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE {
live_remote_references
.entry(info.transitioned_object.tier.clone())
.or_default()
.entry(info.transitioned_object.name.clone())
.or_default()
.push(info.clone());
}
}
// Keep cleanup durable in its source xl.meta, but do not expose it to a
// merged recovery walk while another physical pool still owns the tuple.
versions.retain(|_, (_, info)| {
!info.transitioned_object.free_version
|| !live_remote_references
.get(info.transitioned_object.tier.as_str())
.and_then(|by_name| by_name.get(info.transitioned_object.name.as_str()))
.is_some_and(|candidates| {
candidates
.iter()
.any(|live| free_version_remote_tuple_matches(info, live).unwrap_or(false))
})
});
let mut merged = FileMeta::new();
merged.versions = versions.into_values().map(|(version, _)| version).collect();
merged.versions.sort_by(|a, b| {
@@ -7435,6 +7460,47 @@ mod test {
}
}
fn test_transitioned_meta_entry(name: &str, remote_object: &str, delete_source: bool) -> MetaCacheEntry {
let mut source = FileInfo::new(name, 2, 2);
source.volume = "bucket".to_string();
source.name = name.to_string();
source.version_id = Some(Uuid::from_u128(1));
source.versioned = true;
source.size = 1;
source.mod_time = Some(time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
source.transition_status = rustfs_filemeta::TRANSITION_COMPLETE.to_string();
source.transition_tier = "WARM".to_string();
source.transitioned_objname = remote_object.to_string();
source.transition_version = Some("remote-version".to_string());
source.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
rustfs_utils::http::metadata_compat::insert_str(
&mut source.metadata,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
"00".repeat(32),
);
let mut meta = FileMeta::new();
meta.add_version(source.clone())
.expect("test metadata should accept transitioned source");
if delete_source {
let mut delete = FileInfo {
name: name.to_string(),
version_id: source.version_id,
..Default::default()
};
delete.set_tier_free_version_id(&Uuid::from_u128(2).to_string());
meta.delete_version(&delete)
.expect("transitioned delete should create a free-version owner");
}
let metadata = meta.marshal_msg().expect("test transitioned metadata should marshal");
MetaCacheEntry {
name: name.to_string(),
metadata,
cached: Some(meta),
reusable: false,
}
}
fn test_object_with_delete_marker_meta_entry(
name: &str,
object_mod_time: time::OffsetDateTime,
@@ -10638,6 +10704,32 @@ mod test {
assert_eq!(versions.versions[0].metadata.get("etag").map(String::as_str), Some("same-etag"));
}
#[tokio::test]
async fn merge_entry_channels_defers_free_version_while_same_remote_source_is_live() {
let live = test_transitioned_meta_entry("key", "remote/shared", false);
let free = test_transitioned_meta_entry("key", "remote/shared", true);
for inputs in [vec![live.clone(), free.clone()], vec![free.clone(), live.clone()]] {
let merged = merge_test_object_entries(inputs)
.await
.expect("same remote source and cleanup owner should merge");
let versions = merged
.file_info_versions_with_free_versions("bucket")
.expect("merged transition history should decode");
assert_eq!(versions.versions.len(), 1);
assert!(versions.free_versions.is_empty(), "a live remote reference must defer cleanup discovery");
}
let unrelated = test_transitioned_meta_entry("key", "remote/other", false);
let merged = merge_test_object_entries(vec![free, unrelated])
.await
.expect("unrelated remote references should merge");
let versions = merged
.file_info_versions_with_free_versions("bucket")
.expect("merged transition history should decode");
assert_eq!(versions.versions.len(), 1);
assert_eq!(versions.free_versions.len(), 1, "an unrelated source must not suppress cleanup");
}
#[tokio::test]
async fn merge_entry_channels_rejects_conflicting_version_identity_and_metadata() {
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
+11
View File
@@ -4965,6 +4965,17 @@ impl ECStore {
}
};
if creates_latest_marker && self.is_suspended(pinfo.index).await {
let has_active_reservation = self
.pool_meta
.read()
.await
.has_active_decommission_capacity_reservation(pinfo.index);
if has_active_reservation {
pinfo.index = self.get_pool_idx_no_lock(bucket, object, 0).await?;
}
}
if pinfo.object_info.delete_marker && opts.version_id.is_none() && !creates_latest_marker {
pinfo.object_info.name = decode_dir_object(object);
return Ok(pinfo.object_info);
+28
View File
@@ -677,6 +677,34 @@ impl ECStore {
}
}
if require_all_pool_reads {
let suspended_pools = {
let pool_meta = self.pool_meta.read().await;
(0..self.pools.len())
.map(|idx| pool_meta.is_suspended(idx))
.collect::<Vec<_>>()
};
let candidates = ress
.iter()
.map(|pinfo| LatestObjectInfoCandidate {
info: pinfo.err.is_none().then(|| pinfo.object_info.clone()),
idx: pinfo.index,
err: pinfo.err.clone(),
})
.collect();
let (object_info, index) =
resolve_latest_object_info_candidates_with_pool_state(candidates, &suspended_pools, bucket, object, opts)?;
let pools_with_object = self.pools_with_object(&ress, opts).await;
return Ok((
PoolObjInfo {
index,
object_info,
err: None,
},
pools_with_object,
));
}
ress.sort_by(|a, b| {
let at = a.object_info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH);
let bt = b.object_info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH);