fix(ecstore): emit CommonPrefix for object with same-named prefix in non-recursive listings (#4596)

fix(ecstore): emit CommonPrefix for object with same-named prefix in non-recursive listings (backlog#1042)

On single-disk / consistent deployments a non-recursive scan_dir that finds a/xl.meta classifies `a` as an object and does not descend, so the source never emits the prefix `a/`, dropping the CommonPrefix from delimiter listings even when `a/b` exists. This is the single-disk follow-up to backlog#880 (the multi-disk merge path was fixed in #4563).

Fix: in the scan_dir Ok branch, for a non-recursive listing of a plain object, probe whether `a/` holds a listing entry other than the object itself (new object_prefix_has_sibling_listing_entry, reusing directory_has_visible_listing_entry and skipping the object's own xl.meta and data dirs); if so, additionally schedule the prefix dir `a/`. The upstream fold in from_meta_cache_entries_sorted_infos already emits object `a` as Contents and dir `a/` as a CommonPrefix, so nothing changes there. Leaf objects (the common case) pay a single list_dir and return false immediately.

Verification: new scan_dir unit test (positive: `a` + `a/b` coexist; negative: leaf `c` produces no spurious `c/`); new end-to-end e2e (object `a` + `a/b` with delimiter "/" -> Contents `a` + CommonPrefix `a/`); existing list_objects_duplicates e2e (#1797 / #2439 regressions) stays green; set_disk::read 114 passed.

Note: lib-test compilation depends on #4573 (now merged), which added the allow_inplace_legacy_fallback argument to the codec streaming arity tests after #4560 / backlog#879.

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-09 09:45:25 +08:00
committed by GitHub
parent cb9edd59f5
commit e0eee89a3b
2 changed files with 231 additions and 0 deletions
@@ -133,6 +133,75 @@ mod tests {
env.stop_server();
}
/// Test that a plain object and a same-named prefix coexist in a delimiter listing.
///
/// Bug Reference: backlog#1042 (follow-up to backlog#880).
/// A plain object `a` (no trailing slash) and a sibling object `a/b` under the
/// same-named prefix must BOTH surface when listing with delimiter "/": `a` as a
/// Content and `a/` as a CommonPrefix (S3 delimiter semantics). On single-disk /
/// consistent deployments the source scan classifies `a` as an object and, before
/// the fix, never emitted the prefix `a/`, silently dropping the CommonPrefix.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_object_and_same_named_prefix_coexist() {
init_logging();
info!("Starting test: object `a` and prefix `a/` must coexist under delimiter");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-list-object-prefix-coexist";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
// Plain object `a` (no trailing slash) — must land in Contents.
client
.put_object()
.bucket(bucket)
.key("a")
.body(ByteStream::from_static(b"top"))
.send()
.await
.expect("Failed to create object `a`");
// Sibling object under the same-named prefix — makes `a/` a CommonPrefix.
client
.put_object()
.bucket(bucket)
.key("a/b")
.body(ByteStream::from_static(b"child"))
.send()
.await
.expect("Failed to create object `a/b`");
let result = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.send()
.await
.expect("Failed to list objects");
let keys: Vec<String> = result
.contents()
.iter()
.filter_map(|o| o.key().map(ToOwned::to_owned))
.collect();
let prefixes: Vec<String> = result
.common_prefixes()
.iter()
.filter_map(|p| p.prefix().map(ToOwned::to_owned))
.collect();
info!("Contents: {:?}, CommonPrefixes: {:?}", keys, prefixes);
assert!(keys.iter().any(|k| k == "a"), "object `a` must appear in Contents, got {keys:?}");
assert!(
prefixes.iter().any(|p| p == "a/"),
"prefix `a/` must appear in CommonPrefixes, got {prefixes:?}"
);
env.stop_server();
}
/// Test ensuring that ListObjectsV2 returns unique keys when an explicit directory marker
/// exists under the requested prefix and delimiter is not provided.
///