fix(ecstore): harden object-prefix probe and parse markers before forward_past (#4600)

This commit is contained in:
Zhengchao An
2026-07-09 12:07:37 +08:00
committed by GitHub
parent 76306d7b02
commit 579cdf52dc
4 changed files with 643 additions and 144 deletions
@@ -133,75 +133,6 @@ 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.
///
@@ -267,4 +198,121 @@ mod tests {
env.stop_server();
}
/// Test ensuring that a plain object and a same-named prefix coexist in
/// delimiter listings on a single-disk deployment.
///
/// Bug Reference: backlog#880 / backlog#1042
/// On a single disk, object `a` and its children `a/...` share one backing
/// directory, so the non-recursive scan used to classify `a` as an object
/// and never produce the prefix entry `a/`. Delimiter="/" listings then
/// returned Contents `a` but silently dropped CommonPrefix `a/`.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_object_and_same_named_prefix_coexist() {
init_logging();
info!("Starting test: ListObjectsV2 should return both object `a` and CommonPrefix `a/`");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
.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");
for (key, body) in [
("a", ByteStream::from_static(b"object body")),
("a/b", ByteStream::from_static(b"child body")),
("plain", ByteStream::from_static(b"no children")),
] {
client
.put_object()
.bucket(bucket)
.key(key)
.body(body)
.send()
.await
.unwrap_or_else(|err| panic!("Failed to create test object {key}: {err}"));
}
let result = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.send()
.await
.expect("Failed to list objects");
let keys: Vec<&str> = result.contents().iter().filter_map(|object| object.key()).collect();
let prefixes: Vec<&str> = result.common_prefixes().iter().filter_map(|prefix| prefix.prefix()).collect();
info!("Contents: {:?}, CommonPrefixes: {:?}", keys, prefixes);
assert_eq!(keys, vec!["a", "plain"], "objects `a` and `plain` must both stay in Contents");
assert_eq!(
prefixes,
vec!["a/"],
"prefix `a/` must be listed and `plain/` must not appear as a phantom prefix"
);
// Children are still reachable under the prefix.
let nested = client
.list_objects_v2()
.bucket(bucket)
.prefix("a/")
.delimiter("/")
.send()
.await
.expect("Failed to list objects under prefix");
let nested_keys: Vec<&str> = nested.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(nested_keys, vec!["a/b"]);
// Pagination must keep both entries across page boundaries: `a` sorts
// before `a/`, so a one-key page splits them.
let page1 = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.max_keys(1)
.send()
.await
.expect("Failed to list first page");
let page1_keys: Vec<&str> = page1.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(page1_keys, vec!["a"]);
assert_eq!(page1.is_truncated(), Some(true), "one-key first page must be truncated");
let mut token = page1.next_continuation_token().map(ToOwned::to_owned);
let mut remaining_keys = Vec::new();
let mut remaining_prefixes = Vec::new();
while let Some(continuation) = token {
let page = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.max_keys(1)
.continuation_token(continuation)
.send()
.await
.expect("Failed to list continuation page");
remaining_keys.extend(
page.contents()
.iter()
.filter_map(|object| object.key().map(ToOwned::to_owned)),
);
remaining_prefixes.extend(
page.common_prefixes()
.iter()
.filter_map(|prefix| prefix.prefix().map(ToOwned::to_owned)),
);
token = page.next_continuation_token().map(ToOwned::to_owned);
}
assert_eq!(remaining_prefixes, vec!["a/".to_string()], "prefix `a/` must survive pagination");
assert_eq!(remaining_keys, vec!["plain".to_string()]);
env.stop_server();
}
}
@@ -1029,4 +1029,67 @@ mod tests {
env.stop_server();
}
/// Test that continuation pages do not skip keys sorting between the marker
/// and the marker plus the cursor tag.
///
/// Bug Reference: backlog#1047
/// The V2 continuation token appends a `[rustfs_cache:...]` cursor tag to the
/// last returned key. The orchestration layer passed that raw string to
/// `forward_past`, so any key whose byte after the shared stem sorts below
/// `[` (0x5B) — `.`, `/`, `-`, digits, uppercase — was silently dropped on
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
/// returned `zz` and `a.txt` was never listed.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
init_logging();
info!("Starting test: continuation must not skip keys sorting below the cursor tag");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
.await
.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-continuation-marker-stem";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
let expected_keys = ["a", "a.txt", "zz"];
for key in expected_keys {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"content"))
.send()
.await
.unwrap_or_else(|err| panic!("Failed to create test object {key}: {err}"));
}
let mut collected: Vec<String> = Vec::new();
let mut token: Option<String> = None;
loop {
let mut request = client.list_objects_v2().bucket(bucket).max_keys(1);
if let Some(continuation) = token.take() {
request = request.continuation_token(continuation);
}
let page = request.send().await.expect("Failed to list objects");
collected.extend(
page.contents()
.iter()
.filter_map(|object| object.key().map(ToOwned::to_owned)),
);
token = page.next_continuation_token().map(ToOwned::to_owned);
if token.is_none() {
break;
}
assert!(collected.len() <= expected_keys.len() + 1, "pagination did not converge: {collected:?}");
}
assert_eq!(collected, expected_keys, "every key must survive pagination in order");
env.stop_server();
}
}
+437 -69
View File
@@ -3773,7 +3773,7 @@ impl LocalDisk {
write_metacache_obj(out, &meta).await?;
let file_meta = if opts.limit > 0 || opts.recursive {
let file_meta = if opts.limit > 0 || opts.recursive || !is_dir_obj {
FileMeta::load(&res).ok()
} else {
None
@@ -3783,15 +3783,16 @@ impl LocalDisk {
*objs_returned += 1;
}
if opts.recursive {
let mut dir_to_skip = HashSet::new();
if let Some(file_meta) = file_meta.as_ref()
&& let Ok(data_dirs) = file_meta.get_data_dirs()
{
for data_dir in data_dirs.iter().flatten() {
dir_to_skip.insert(data_dir.to_string());
}
let mut dir_to_skip = HashSet::new();
if let Some(file_meta) = file_meta.as_ref()
&& let Ok(data_dirs) = file_meta.get_data_dirs()
{
for data_dir in data_dirs.iter().flatten() {
dir_to_skip.insert(data_dir.to_string());
}
}
if opts.recursive {
let mut dir_name = meta.name.clone();
if !dir_name.ends_with(SLASH_SEPARATOR) {
dir_name.push_str(SLASH_SEPARATOR);
@@ -3802,23 +3803,20 @@ impl LocalDisk {
true,
if dir_to_skip.is_empty() { None } else { Some(dir_to_skip) },
);
} else if !is_dir_obj {
// backlog#1042: in a non-recursive (delimiter) listing a plain
// object `a` can ALSO be a prefix `a/` when sibling objects such
// as `a/b` live under it. The object was emitted above (it becomes
// Contents); emit the prefix dir `a/` too so the upstream fold
// surfaces it as a CommonPrefix (matching S3 delimiter semantics).
// The probe only reports true when `a/` holds a listing entry
// other than object `a` itself, so leaf objects (the common case)
// schedule nothing and pay a single list_dir.
if self
.object_prefix_has_sibling_listing_entry(&opts.bucket, &meta.name, res.as_ref())
} else if !is_dir_obj
&& self
.object_dir_has_listable_child(&opts.bucket, &meta.name, &dir_to_skip, opts.incl_deleted)
.await?
{
let mut dir_name = meta.name.clone();
dir_name.push_str(SLASH_SEPARATOR);
schedule_dir(&mut dir_stack, dir_name, true, None);
}
{
// A plain object `a` shares its backing directory with any
// children `a/...`, and non-recursive walks never descend into
// it — so the prefix `a/` must be produced here or delimiter
// listings lose the CommonPrefix (backlog#1042). Dir-marker
// objects are excluded: their logical children live in a
// separate real directory entry handled above.
let mut dir_name = meta.name.clone();
dir_name.push_str(SLASH_SEPARATOR);
schedule_dir(&mut dir_stack, dir_name, true, None);
}
}
Err(err) => {
@@ -3829,7 +3827,9 @@ impl LocalDisk {
meta.name.push_str(SLASH_SEPARATOR);
if opts.recursive
|| opts.incl_deleted
|| self.directory_has_visible_listing_entry(&opts.bucket, &meta.name).await?
|| self
.directory_has_listing_entry(&opts.bucket, &meta.name, opts.incl_deleted)
.await?
{
schedule_dir(&mut dir_stack, meta.name, false, None);
}
@@ -3889,7 +3889,71 @@ impl LocalDisk {
Ok(())
}
async fn directory_has_visible_listing_entry(&self, bucket: &str, dir_name: &str) -> Result<bool> {
/// Whether the backing directory of plain object `object_name` also holds
/// listable children (`object_name/...`), beyond the object's own storage
/// internals: its `xl.meta` and the version data dirs in `data_dirs`.
///
/// With `incl_deleted`, a child subtree counts as soon as it holds any
/// object metadata (versioned listings surface delete markers too);
/// otherwise the child must have a visible listing entry.
async fn object_dir_has_listable_child(
&self,
bucket: &str,
object_name: &str,
data_dirs: &HashSet<String>,
incl_deleted: bool,
) -> Result<bool> {
// The backing dir usually holds only the xl.meta plus the version data
// dirs, so a read bounded just past that count decides the common case
// without materializing large child sets: an under-filled batch proves
// the listing is complete. Only an inconclusive full batch (candidates
// present but none listable yet) falls back to the unbounded read.
let bounded = i32::try_from(data_dirs.len() + 2).unwrap_or(-1);
let mut probed = HashSet::new();
for count in [bounded, -1] {
let entries = match self.list_dir("", bucket, object_name, count).await {
Ok(entries) => entries,
Err(err) => {
if err == DiskError::VolumeNotFound || err == Error::FileNotFound {
return Ok(false);
}
return Err(err);
}
};
let complete = count < 0 || entries.len() < count as usize;
for entry in entries {
let Some(child) = entry.strip_suffix(SLASH_SEPARATOR) else {
// Plain files (the object's own xl.meta) can never hold children.
continue;
};
if child.is_empty() || data_dirs.contains(child) || !probed.insert(child.to_owned()) {
continue;
}
let child_path = path_join_buf(&[object_name, child]);
if self.directory_has_listing_entry(bucket, &child_path, incl_deleted).await? {
return Ok(true);
}
}
if complete {
break;
}
}
Ok(false)
}
/// Whether anything under `dir_name` would appear in a listing. With
/// `incl_deleted`, any `xl.meta` counts (versioned listings surface
/// delete-marker-only objects too); otherwise the metadata must hold a
/// visible version.
async fn directory_has_listing_entry(&self, bucket: &str, dir_name: &str, incl_deleted: bool) -> Result<bool> {
let mut stack = vec![dir_name.trim_matches('/').to_owned()];
while let Some(current) = stack.pop() {
@@ -3913,6 +3977,10 @@ impl LocalDisk {
for entry in entries {
if entry == STORAGE_FORMAT_FILE {
if incl_deleted {
return Ok(true);
}
let metadata_path = path_join_buf(&[current.as_str(), STORAGE_FORMAT_FILE]);
match self.read_metadata(bucket, metadata_path.as_str()).await {
Ok(metadata) => {
@@ -3958,48 +4026,6 @@ impl LocalDisk {
Ok(false)
}
/// backlog#1042 helper for non-recursive (delimiter) listings. Given an object
/// directory `obj_dir` (e.g. `a`, which holds `a/xl.meta`), decide whether it
/// ALSO acts as a prefix — i.e. contains a listing entry other than the object
/// itself, such as a sibling object `a/b`. The object's own `xl.meta` and data
/// dirs belong to the object, not to a child prefix, so they are skipped.
/// Returns false for leaf objects (the common case), so they incur only a
/// single `list_dir` and no descent.
async fn object_prefix_has_sibling_listing_entry(&self, bucket: &str, obj_dir: &str, metadata: &[u8]) -> Result<bool> {
let mut data_dirs_to_skip = HashSet::new();
if let Ok(file_meta) = FileMeta::load(metadata)
&& let Ok(data_dirs) = file_meta.get_data_dirs()
{
for data_dir in data_dirs.iter().flatten() {
data_dirs_to_skip.insert(data_dir.to_string());
}
}
let entries = match self.list_dir("", bucket, obj_dir, -1).await {
Ok(entries) => entries,
Err(err) if err == DiskError::VolumeNotFound || err == Error::FileNotFound => return Ok(false),
Err(err) => return Err(err),
};
for entry in entries {
// Only child directories can introduce a sibling listing entry; the
// object's own `xl.meta` (a plain file) is not a child prefix.
if !entry.ends_with(SLASH_SEPARATOR) {
continue;
}
let child = entry.trim_end_matches(SLASH_SEPARATOR);
if child.is_empty() || data_dirs_to_skip.contains(child) {
continue;
}
let child_path = path_join_buf(&[obj_dir, child]);
if self.directory_has_visible_listing_entry(bucket, &child_path).await? {
return Ok(true);
}
}
Ok(false)
}
}
pub struct ScanGuard(pub Arc<AtomicU32>);
@@ -8774,6 +8800,348 @@ mod test {
);
}
// Regression for backlog#1042: on a single disk, a plain object `a` and its
// children `a/...` share one backing directory, so the non-recursive scan
// must produce both the object entry `a` and the prefix entry `a/` — while
// an object whose directory only holds its own xl.meta and data dirs must
// not grow a phantom prefix.
#[tokio::test]
async fn test_scan_dir_nonrecursive_object_with_children_emits_prefix() {
use rustfs_filemeta::MetacacheReader;
use tempfile::tempdir;
fn visible_object_metadata(name: &str, version_id: &str, data_dir: Option<&str>) -> Vec<u8> {
let mut fm = FileMeta::default();
let mut fi = FileInfo::new(name, 1, 1);
fi.version_id = Some(Uuid::parse_str(version_id).expect("test version id should parse"));
fi.data_dir = data_dir.map(|dir| Uuid::parse_str(dir).expect("test data dir should parse"));
fi.mod_time = Some(OffsetDateTime::now_utc());
fm.add_version(fi).expect("object metadata should be valid");
fm.marshal_msg().expect("visible metadata should encode")
}
fn hidden_object_metadata(name: &str, version_id: &str) -> Vec<u8> {
let mut fm = FileMeta::default();
fm.add_version(FileInfo {
name: name.to_owned(),
deleted: true,
version_id: Some(Uuid::parse_str(version_id).expect("test version id should parse")),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
})
.expect("delete marker metadata should be valid");
fm.marshal_msg().expect("hidden metadata should encode")
}
async fn scan_entries(disk: &LocalDisk, bucket: &str, incl_deleted: bool) -> Vec<(String, bool)> {
let (reader, mut writer) = tokio::io::duplex(65536);
let mut out = MetacacheWriter::new(&mut writer);
let opts = WalkDirOptions {
bucket: bucket.to_string(),
base_dir: "".to_string(),
recursive: false,
incl_deleted,
limit: 1000,
..Default::default()
};
let mut objs_returned = 0;
disk.scan_dir("".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false, None)
.await
.expect("scan_dir should succeed");
out.close().await.expect("metacache writer should close");
drop(out);
drop(writer);
let mut reader = MetacacheReader::new(reader);
reader
.read_all()
.await
.expect("scan output should decode")
.into_iter()
.map(|entry| (entry.name, !entry.metadata.is_empty()))
.collect()
}
let dir = tempdir().expect("tempdir should be created");
let bucket = "test-bucket";
let bucket_dir = dir.path().join(bucket);
// `alpha` object with a data dir, plus a real child `alpha/beta`.
let alpha_data_dir = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
fs::create_dir_all(bucket_dir.join("alpha").join(alpha_data_dir))
.await
.expect("alpha data dir should be created");
fs::write(bucket_dir.join("alpha").join(alpha_data_dir).join("part.1"), b"data")
.await
.expect("alpha part should be written");
fs::write(
bucket_dir.join("alpha").join(STORAGE_FORMAT_FILE),
visible_object_metadata("alpha", "11111111-1111-1111-1111-111111111111", Some(alpha_data_dir)),
)
.await
.expect("alpha metadata should be written");
fs::create_dir_all(bucket_dir.join("alpha/beta"))
.await
.expect("alpha child dir should be created");
fs::write(
bucket_dir.join("alpha/beta").join(STORAGE_FORMAT_FILE),
visible_object_metadata("alpha/beta", "22222222-2222-2222-2222-222222222222", None),
)
.await
.expect("alpha child metadata should be written");
// `gamma` object whose only extra child is hidden by a delete marker.
fs::create_dir_all(bucket_dir.join("gamma/hidden"))
.await
.expect("gamma child dir should be created");
fs::write(
bucket_dir.join("gamma").join(STORAGE_FORMAT_FILE),
visible_object_metadata("gamma", "33333333-3333-3333-3333-333333333333", None),
)
.await
.expect("gamma metadata should be written");
fs::write(
bucket_dir.join("gamma/hidden").join(STORAGE_FORMAT_FILE),
hidden_object_metadata("gamma/hidden", "44444444-4444-4444-4444-444444444444"),
)
.await
.expect("gamma child metadata should be written");
// `plain` object with only its own storage internals: no prefix expected.
let plain_data_dir = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
fs::create_dir_all(bucket_dir.join("plain").join(plain_data_dir))
.await
.expect("plain data dir should be created");
fs::write(bucket_dir.join("plain").join(plain_data_dir).join("part.1"), b"data")
.await
.expect("plain part should be written");
// Data dirs can hold xl.meta-bearing subdirectories (multipart layout);
// the probe must skip the whole data dir, not just non-metadata files.
fs::create_dir_all(bucket_dir.join("plain").join(plain_data_dir).join("seg"))
.await
.expect("plain data subdir should be created");
fs::write(
bucket_dir
.join("plain")
.join(plain_data_dir)
.join("seg")
.join(STORAGE_FORMAT_FILE),
visible_object_metadata("plain-part", "88888888-8888-8888-8888-888888888888", None),
)
.await
.expect("plain data subdir metadata should be written");
fs::write(
bucket_dir.join("plain").join(STORAGE_FORMAT_FILE),
visible_object_metadata("plain", "55555555-5555-5555-5555-555555555555", Some(plain_data_dir)),
)
.await
.expect("plain metadata should be written");
// `zeta` sorts last so its prefix is flushed by the final drain, not the
// in-loop flush that `alpha/` exercises.
fs::create_dir_all(bucket_dir.join("zeta/child"))
.await
.expect("zeta child dir should be created");
fs::write(
bucket_dir.join("zeta").join(STORAGE_FORMAT_FILE),
visible_object_metadata("zeta", "66666666-6666-6666-6666-666666666666", None),
)
.await
.expect("zeta metadata should be written");
fs::write(
bucket_dir.join("zeta/child").join(STORAGE_FORMAT_FILE),
visible_object_metadata("zeta/child", "77777777-7777-7777-7777-777777777777", None),
)
.await
.expect("zeta child metadata should be written");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let entries = scan_entries(&disk, bucket, false).await;
let names: Vec<&str> = entries.iter().map(|(name, _)| name.as_str()).collect();
assert_eq!(names, vec!["alpha", "alpha/", "gamma", "plain", "zeta", "zeta/"]);
assert!(entries.iter().any(|(name, has_meta)| name == "alpha" && *has_meta));
assert!(entries.iter().any(|(name, has_meta)| name == "alpha/" && !*has_meta));
assert!(entries.iter().any(|(name, has_meta)| name == "zeta/" && !*has_meta));
// Versioned listings surface delete-marker-only children, so `gamma/`
// appears; data dirs still never masquerade as prefixes.
let versioned_entries = scan_entries(&disk, bucket, true).await;
let versioned_names: Vec<&str> = versioned_entries.iter().map(|(name, _)| name.as_str()).collect();
assert_eq!(versioned_names, vec!["alpha", "alpha/", "gamma", "gamma/", "plain", "zeta", "zeta/"]);
}
// Preserve the explicit dir-marker semantics: a marker object `folder/` and
// the real directory `folder/` still collapse to a single prefix entry; the
// marker itself must not trigger the object-dir child probe.
#[tokio::test]
async fn test_scan_dir_nonrecursive_dir_marker_prefix_not_duplicated() {
use rustfs_filemeta::MetacacheReader;
use tempfile::tempdir;
fn visible_object_metadata(name: &str, version_id: &str) -> Vec<u8> {
let mut fm = FileMeta::default();
let mut fi = FileInfo::new(name, 1, 1);
fi.version_id = Some(Uuid::parse_str(version_id).expect("test version id should parse"));
fi.mod_time = Some(OffsetDateTime::now_utc());
fm.add_version(fi).expect("object metadata should be valid");
fm.marshal_msg().expect("visible metadata should encode")
}
let dir = tempdir().expect("tempdir should be created");
let bucket = "test-bucket";
let bucket_dir = dir.path().join(bucket);
fs::create_dir_all(bucket_dir.join(format!("folder{GLOBAL_DIR_SUFFIX}")))
.await
.expect("marker dir should be created");
fs::write(
bucket_dir
.join(format!("folder{GLOBAL_DIR_SUFFIX}"))
.join(STORAGE_FORMAT_FILE),
visible_object_metadata("folder/", "11111111-1111-1111-1111-111111111111"),
)
.await
.expect("marker metadata should be written");
fs::create_dir_all(bucket_dir.join("folder/nested"))
.await
.expect("real child dir should be created");
fs::write(
bucket_dir.join("folder/nested").join(STORAGE_FORMAT_FILE),
visible_object_metadata("folder/nested", "22222222-2222-2222-2222-222222222222"),
)
.await
.expect("real child metadata should be written");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let (reader, mut writer) = tokio::io::duplex(65536);
let mut out = MetacacheWriter::new(&mut writer);
let opts = WalkDirOptions {
bucket: bucket.to_string(),
base_dir: "".to_string(),
recursive: false,
limit: 1000,
..Default::default()
};
let mut objs_returned = 0;
disk.scan_dir("".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false, None)
.await
.expect("scan_dir should succeed");
out.close().await.expect("metacache writer should close");
drop(out);
drop(writer);
let mut reader = MetacacheReader::new(reader);
let entries = reader.read_all().await.expect("scan output should decode");
let marker_objects = entries
.iter()
.filter(|entry| entry.name == "folder/" && !entry.metadata.is_empty())
.count();
let prefix_dirs = entries
.iter()
.filter(|entry| entry.name == "folder/" && entry.metadata.is_empty())
.count();
assert_eq!(marker_objects, 1, "dir marker object should be reported exactly once");
assert_eq!(prefix_dirs, 1, "prefix dir should be reported exactly once");
// No other entries may leak from the marker (e.g. a malformed `folder//`
// from probing the marker's encoded directory).
assert_eq!(entries.len(), 2, "scan must emit exactly the marker object and the prefix dir");
}
// The prefix synthesized for an object with children is dropped when the
// page limit is hit on the object itself, and must be re-derived on the
// forward_to resume of the next page.
#[tokio::test]
async fn test_scan_dir_limit_boundary_resumes_synthesized_prefix() {
use rustfs_filemeta::MetacacheReader;
use tempfile::tempdir;
fn visible_object_metadata(name: &str, version_id: &str) -> Vec<u8> {
let mut fm = FileMeta::default();
let mut fi = FileInfo::new(name, 1, 1);
fi.version_id = Some(Uuid::parse_str(version_id).expect("test version id should parse"));
fi.mod_time = Some(OffsetDateTime::now_utc());
fm.add_version(fi).expect("object metadata should be valid");
fm.marshal_msg().expect("visible metadata should encode")
}
async fn scan_names(disk: &LocalDisk, bucket: &str, limit: i32, forward_to: Option<String>) -> Vec<String> {
let (reader, mut writer) = tokio::io::duplex(65536);
let mut out = MetacacheWriter::new(&mut writer);
let opts = WalkDirOptions {
bucket: bucket.to_string(),
base_dir: "".to_string(),
recursive: false,
limit,
forward_to,
..Default::default()
};
let mut objs_returned = 0;
disk.scan_dir("".to_string(), "".to_string(), &opts, &mut out, &mut objs_returned, false, None)
.await
.expect("scan_dir should succeed");
out.close().await.expect("metacache writer should close");
drop(out);
drop(writer);
let mut reader = MetacacheReader::new(reader);
reader
.read_all()
.await
.expect("scan output should decode")
.into_iter()
.map(|entry| entry.name)
.collect()
}
let dir = tempdir().expect("tempdir should be created");
let bucket = "test-bucket";
let bucket_dir = dir.path().join(bucket);
fs::create_dir_all(bucket_dir.join("a/b"))
.await
.expect("object dirs should be created");
fs::write(
bucket_dir.join("a").join(STORAGE_FORMAT_FILE),
visible_object_metadata("a", "11111111-1111-1111-1111-111111111111"),
)
.await
.expect("object metadata should be written");
fs::write(
bucket_dir.join("a/b").join(STORAGE_FORMAT_FILE),
visible_object_metadata("a/b", "22222222-2222-2222-2222-222222222222"),
)
.await
.expect("child metadata should be written");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
// Page 1: `a` consumes the whole limit, the pending `a/` is dropped.
let first_page = scan_names(&disk, bucket, 1, None).await;
assert_eq!(first_page, vec!["a".to_string()]);
// Page 2: resuming at `a/` re-derives the prefix from the object entry.
let resumed = scan_names(&disk, bucket, 1000, Some("a/".to_string())).await;
assert!(
resumed.contains(&"a/".to_string()),
"resumed scan must re-derive the synthesized prefix, got {resumed:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_scan_dir_propagates_metadata_read_errors() {
+26 -6
View File
@@ -3721,7 +3721,7 @@ impl ECStore {
) -> Result<ListObjectsInfo> {
let max_keys = normalize_max_keys(max_keys);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let opts = ListPathOptions {
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
separator: delimiter.clone(),
@@ -3732,6 +3732,11 @@ impl ECStore {
ask_disks: list_objects_quorum_from_env(),
..Default::default()
};
// The request marker still carries the `[rustfs_cache:...]` cursor tag;
// strip it before any name comparison (notably `forward_past`), or every
// key sorting between `<marker>` and `<marker>[` is silently skipped on
// the continuation page (backlog#1047).
opts.parse_marker();
if let Some(mode) = list_objects_index_mode_from_env()
&& let Some(result) = self
.clone()
@@ -3846,7 +3851,7 @@ impl ECStore {
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
// Always request max_keys + 1 to detect if there are more results
let opts = ListPathOptions {
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
separator: delimiter.clone(),
@@ -3858,6 +3863,9 @@ impl ECStore {
include_marker: has_version_marker,
..Default::default()
};
// Strip the `[rustfs_cache:...]` cursor tag before any name comparison
// (notably `forward_past`) — see backlog#1047.
opts.parse_marker();
let mut list_result = self
.list_path(&opts)
@@ -4996,7 +5004,7 @@ impl Sets {
) -> Result<ListObjectsInfo> {
let max_keys = normalize_max_keys(max_keys);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let opts = ListPathOptions {
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
separator: delimiter.clone(),
@@ -5006,6 +5014,9 @@ impl Sets {
ask_disks: list_objects_quorum_from_env(),
..Default::default()
};
// Strip the `[rustfs_cache:...]` cursor tag before any name comparison
// (notably `forward_past`) — see backlog#1047.
opts.parse_marker();
if !opts.prefix.is_empty() && max_keys == 1 && opts.marker.is_none() {
match self
@@ -5106,7 +5117,7 @@ impl Sets {
};
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let opts = ListPathOptions {
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
separator: delimiter.clone(),
@@ -5118,6 +5129,9 @@ impl Sets {
include_marker: has_version_marker,
..Default::default()
};
// Strip the `[rustfs_cache:...]` cursor tag before any name comparison
// (notably `forward_past`) — see backlog#1047.
opts.parse_marker();
let mut list_result = self
.list_path(&opts)
@@ -5798,7 +5812,7 @@ impl SetDisks {
) -> Result<ListObjectsInfo> {
let max_keys = normalize_max_keys(max_keys);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let opts = ListPathOptions {
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
separator: delimiter.clone(),
@@ -5808,6 +5822,9 @@ impl SetDisks {
ask_disks: list_objects_quorum_from_env(),
..Default::default()
};
// Strip the `[rustfs_cache:...]` cursor tag before any name comparison
// (notably `forward_past`) — see backlog#1047.
opts.parse_marker();
if !opts.prefix.is_empty() && max_keys == 1 && opts.marker.is_none() {
match self
@@ -5907,7 +5924,7 @@ impl SetDisks {
};
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let opts = ListPathOptions {
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
separator: delimiter.clone(),
@@ -5919,6 +5936,9 @@ impl SetDisks {
include_marker: has_version_marker,
..Default::default()
};
// Strip the `[rustfs_cache:...]` cursor tag before any name comparison
// (notably `forward_past`) — see backlog#1047.
opts.parse_marker();
let mut list_result = self
.list_path_result(&opts)