diff --git a/.config/ecstore-required-tests.json b/.config/ecstore-required-tests.json index 07321d052..feaaca441 100644 --- a/.config/ecstore-required-tests.json +++ b/.config/ecstore-required-tests.json @@ -229,7 +229,7 @@ { "invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence", "suite": "rustfs-ecstore", - "name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence" + "name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_listing_preserves_inputs" }, { "invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer", diff --git a/crates/ecstore/src/store/list_objects.rs b/crates/ecstore/src/store/list_objects.rs index 00b397ef2..13e4a3049 100644 --- a/crates/ecstore/src/store/list_objects.rs +++ b/crates/ecstore/src/store/list_objects.rs @@ -5355,7 +5355,7 @@ mod test { } #[tokio::test] - async fn empty_delimiter_listing_hides_and_purges_committed_delete_residue() { + async fn empty_delimiter_listing_hides_and_preserves_committed_delete_residue() { use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks}; use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions}; @@ -5400,15 +5400,22 @@ mod test { assert!(result.objects.is_empty()); assert!(result.prefixes.is_empty(), "delete residue must not surface as a prefix"); for dir in &dirs { + let residue = dir + .path() + .join(bucket) + .join("metrics/2026/object.parquet") + .join(data_dir.to_string()); + assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale"); assert!( - !dir.path().join(bucket).join("metrics").join("2026").exists(), - "the empty delimiter listing should reclaim the committed delete residue under it" + residue + .join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)) + .exists() ); } } #[tokio::test] - async fn empty_delimiter_listing_of_residue_ancestor_hides_and_purges_whole_tree() { + async fn empty_delimiter_listing_of_residue_ancestor_hides_and_preserves_whole_tree() { use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks}; use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions}; @@ -5441,9 +5448,7 @@ mod test { .expect("committed delete marker should be written"); } - // Browsing an ancestor of the deleted key must not show the empty - // date folders, and the empty result reclaims the whole residue tree - // in one pass instead of one level per listing. + // Browsing an ancestor must not expose the deleted key's date folders. let result = store .clone() .list_objects_generic(bucket, "metrics/", None, Some("/".to_owned()), 1000, false) @@ -5452,15 +5457,22 @@ mod test { assert!(result.objects.is_empty()); assert!(result.prefixes.is_empty(), "ancestors of delete residue must not surface as prefixes"); for dir in &dirs { + let residue = dir + .path() + .join(bucket) + .join("metrics/kubelet/2026/08/28/23/74992556388248657933757.parquet") + .join(data_dir.to_string()); + assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale"); assert!( - !dir.path().join(bucket).join("metrics").exists(), - "the empty delimiter listing should reclaim the committed delete residue tree under it" + residue + .join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)) + .exists() ); } } #[tokio::test] - async fn empty_delimiter_listing_of_mixed_residue_ancestor_reclaims_only_committed_subtree() { + async fn empty_delimiter_listing_of_mixed_residue_ancestor_preserves_every_subtree() { use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks}; use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions}; @@ -5533,9 +5545,18 @@ mod test { assert!(result.prefixes.is_empty(), "residue subtrees may not surface as prefixes"); for dir in &dirs { let metrics = dir.path().join(bucket).join("metrics"); - assert!( - !metrics.join("cpu").exists(), - "the committed subtree must be reclaimed even though a sibling subtree is blocked" + let committed = metrics.join("cpu/2026/08/28/22/a.parquet").join(committed_dir.to_string()); + assert_eq!( + tokio::fs::read(committed.join("part.1")) + .await + .expect("LIST preserves committed part"), + b"stale" + ); + assert_eq!( + tokio::fs::read(committed.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP)) + .await + .expect("LIST preserves backup"), + b"rollback metadata" ); assert!( metrics @@ -5543,7 +5564,7 @@ mod test { .join(unmarked_dir.to_string()) .join("part.1") .exists(), - "residue without a committed marker must survive the purge" + "residue without a committed marker must survive the listing" ); assert_eq!( tokio::fs::read( diff --git a/crates/ecstore/src/store/list_objects/differential_tests.rs b/crates/ecstore/src/store/list_objects/differential_tests.rs index 11085b633..27d851846 100644 --- a/crates/ecstore/src/store/list_objects/differential_tests.rs +++ b/crates/ecstore/src/store/list_objects/differential_tests.rs @@ -755,7 +755,7 @@ async fn list_objects_shared_corpus_versions_delete_markers_and_free_versions() } #[tokio::test] -async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence() { +async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() { for authoritative in [false, true] { let fixture = Fixture::new( &format!("list-corpus-orphan-{authoritative}"), @@ -774,10 +774,9 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden let result = fixture.page("ghost/", None, None, 1).await; assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated); for dir in &fixture.dirs { - assert_eq!( - dir.path().join(&fixture.bucket).join("ghost").exists(), - !authoritative, - "unknown metadata must fail closed" + assert!( + dir.path().join(&fixture.bucket).join("ghost/nested/leaf").exists(), + "LIST must preserve orphan input" ); assert!( dir.path() @@ -785,7 +784,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden .join("legal/child") .join(STORAGE_FORMAT_FILE) .exists(), - "purge must preserve legal siblings" + "LIST must preserve legal siblings" ); } fixture.assert_walk("legal/", None, None, 1).await; @@ -796,7 +795,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden .join("legal/child") .join(STORAGE_FORMAT_FILE) .exists(), - "an orphan in the same subtree must not cause the legitimate child to be purged" + "an orphan in the same subtree must not affect a legitimate child" ); } if authoritative { @@ -811,17 +810,15 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden .bucket_metadata_sys() .expect("authoritative fixture has a metadata system"); let guard = metadata_sys.write().await; - assert!( - timeout(Duration::from_millis(100), fixture.page("timed-out/", None, None, 1)) - .await - .is_err(), - "blocked authoritative evidence must time out before cleanup can classify the prefix as empty" - ); + let result = timeout(Duration::from_secs(2), fixture.page("timed-out/", None, None, 1)) + .await + .expect("LIST must not wait for cleanup authorization"); + assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated); drop(guard); for dir in &fixture.dirs { assert!( dir.path().join(&fixture.bucket).join("timed-out").exists(), - "a timed-out evidence check must preserve its orphan input" + "LIST must preserve its orphan input while metadata is locked" ); } }