fix(ecstore): reach orphan-dir purge on nil-version delete miss (#4189) (#4307)

PR #4220 added a purge for orphan empty-directory trees (folder keys with
no xl.meta) on the delete path, but the guard only accepted
object-not-found. Over the real HTTP DELETE path the guard is never
reached: `del_opts` pins `version_id = Uuid::nil()` for directory keys, so
the missing dir object fails the specific-version lookup with
version-not-found (FileVersionNotFound), not object-not-found. The guard
short-circuits, the store returns the error, and the API layer turns it
into a fake 204 — the ghost folder survives, exactly the #4189 symptom.

The existing unit tests passed because they call
`purge_orphan_dir_object` directly, bypassing the nil-version lookup.

Accept both misses in the guard (extracted as
`should_purge_orphan_dir_on_missing`) so the folder delete actually takes
effect. Non-directory keys and non-miss errors (e.g. quorum failures) are
unaffected; the cross-disk data-safety refusal in
`purge_orphan_dir_object` is unchanged.

Verified end-to-end against a running 4-disk erasure server: DELETE of a
planted orphan `ghost/` tree now purges it on all drives and clears the
listing, a real object under the same prefix is untouched, and a prefix
holding real data on any drive is refused (all drives preserved).

Adds predicate regression tests covering version-not-found /
object-not-found on directory keys, and the negative cases.
This commit is contained in:
Zhengchao An
2026-07-06 20:47:04 +08:00
committed by GitHub
parent be52e35a1f
commit 7975f26b90
+50 -4
View File
@@ -220,6 +220,18 @@ fn should_create_delete_marker_for_missing_object(opts: &ObjectOptions) -> bool
opts.versioned && opts.version_id.is_none() && !opts.delete_marker && !opts.data_movement
}
/// Whether a delete-time lookup miss on a directory key should trigger an orphan
/// empty-directory tree purge (issue #4189).
///
/// The lookup surfaces *version*-not-found here, not object-not-found: `del_opts`
/// pins `version_id = Uuid::nil()` for directory keys, so a missing dir object fails
/// the specific-version lookup. Both misses must be accepted, otherwise the real
/// HTTP delete path (which always sets the nil version) never reaches the purge and
/// the ghost folder survives with a fake 204 — the exact #4189 symptom.
fn should_purge_orphan_dir_on_missing(err: &Error, object: &str) -> bool {
(is_err_object_not_found(err) || is_err_version_not_found(err)) && rustfs_utils::path::is_dir_object(object)
}
fn version_aware_lookup_opts(opts: &ObjectOptions, no_lock: bool) -> ObjectOptions {
let mut lookup_opts = opts.clone();
lookup_opts.no_lock = no_lock;
@@ -957,10 +969,7 @@ impl ECStore {
// disk as an orphan empty-directory tree (issue #4189): listings show
// it as a common prefix, but no regular delete path can remove it.
// Purge the orphan tree so folder deletes actually take effect.
if is_err_object_not_found(&err)
&& rustfs_utils::path::is_dir_object(object)
&& self.purge_orphan_dir_object(bucket, object).await
{
if should_purge_orphan_dir_on_missing(&err, object) && self.purge_orphan_dir_object(bucket, object).await {
return Ok(ObjectInfo {
bucket: bucket.to_owned(),
name: decode_dir_object(object),
@@ -1775,6 +1784,43 @@ mod tests {
assert!(!should_create_delete_marker_for_missing_object(&data_movement));
}
// issue #4189 regression: `del_opts` pins `version_id = Uuid::nil()` on directory
// keys, so deleting a ghost folder over HTTP fails the lookup with *version*-not-found
// (not object-not-found). The orphan-purge guard must accept both misses, or the
// ghost tree survives behind a fake 204 — the exact reported symptom.
#[test]
fn should_purge_orphan_dir_on_version_not_found_for_dir_key() {
assert!(
should_purge_orphan_dir_on_missing(&StorageError::FileVersionNotFound, "ghost/"),
"the real HTTP delete path yields version-not-found on dir keys and must reach the purge"
);
assert!(
should_purge_orphan_dir_on_missing(
&StorageError::VersionNotFound("bucket".into(), "ghost/".into(), Uuid::nil().to_string()),
"ghost/"
),
"typed VersionNotFound on a dir key must also reach the purge"
);
}
#[test]
fn should_purge_orphan_dir_on_object_not_found_for_dir_key() {
assert!(should_purge_orphan_dir_on_missing(&StorageError::FileNotFound, "ghost/"));
assert!(should_purge_orphan_dir_on_missing(
&StorageError::ObjectNotFound("bucket".into(), "ghost/".into()),
"ghost/"
));
}
#[test]
fn should_not_purge_orphan_dir_for_regular_key_or_other_errors() {
// A regular (non-directory) key must never trigger a prefix purge, even on a miss.
assert!(!should_purge_orphan_dir_on_missing(&StorageError::FileVersionNotFound, "regular.txt"));
assert!(!should_purge_orphan_dir_on_missing(&StorageError::FileNotFound, "regular.txt"));
// Non-miss errors (e.g. quorum failures) must not be masked by a purge attempt.
assert!(!should_purge_orphan_dir_on_missing(&StorageError::ErasureReadQuorum, "ghost/"));
}
#[test]
fn resolve_decommission_target_pool_idx_result_passthrough_ok() {
let idx = ECStore::resolve_decommission_target_pool_idx_result(Ok(3), "bucket", "object").unwrap();