fix(scanner): preserve exact overflow heal versions

This commit is contained in:
马登山
2026-08-23 07:51:21 +08:00
parent bcc2771dcb
commit aa3f16fe08
2 changed files with 57 additions and 25 deletions
+43 -6
View File
@@ -111,6 +111,9 @@ pub struct MetaCacheHealDiscovery {
/// healing disabled; this is an explicit bounded continuation, not a /// healing disabled; this is an explicit bounded continuation, not a
/// version claim. /// version claim.
pub truncated_objects: Vec<String>, pub truncated_objects: Vec<String>,
/// Validated candidates beyond the main cap, retained with exact version
/// identities so callers never fall back to a latest-version request.
pub truncated_candidates: Vec<MetaCacheHealCandidate>,
} }
impl MetaCacheEntry { impl MetaCacheEntry {
@@ -442,6 +445,7 @@ impl MetaCacheEntries {
unverified_count: 0, unverified_count: 0,
truncated: false, truncated: false,
truncated_objects: Vec::with_capacity(MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS.min(limit)), truncated_objects: Vec::with_capacity(MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS.min(limit)),
truncated_candidates: Vec::new(),
}; };
let mut seen: HashMap<(String, Option<Uuid>, MetaCacheHealCandidateKind), usize> = let mut seen: HashMap<(String, Option<Uuid>, MetaCacheHealCandidateKind), usize> =
HashMap::with_capacity(limit.min(self.0.len())); HashMap::with_capacity(limit.min(self.0.len()));
@@ -580,10 +584,10 @@ impl MetaCacheEntries {
{ {
discovery.truncated_objects.push(candidate.object.clone()); discovery.truncated_objects.push(candidate.object.clone());
} }
// The remaining versions in this raw entry cannot add a if discovery.truncated_objects.iter().any(|object| object == &candidate.object) {
// bounded candidate; avoid parsing a very long history discovery.truncated_candidates.push(candidate);
// after the safe continuation has been recorded. }
break; continue;
} else { } else {
entry_seen.insert(key.clone()); entry_seen.insert(key.clone());
seen.insert(key, discovery.candidates.len()); seen.insert(key, discovery.candidates.len());
@@ -775,7 +779,7 @@ fn valid_heal_candidate_name(bucket: &str, entry: &MetaCacheEntry) -> bool {
if bucket.is_empty() if bucket.is_empty()
|| entry.name.is_empty() || entry.name.is_empty()
|| entry.is_dir() || entry.is_dir()
|| entry.name.contains('\\') || (cfg!(windows) && entry.name.contains('\\'))
|| entry.name.chars().any(char::is_control) || entry.name.chars().any(char::is_control)
{ {
return false; return false;
@@ -2130,6 +2134,13 @@ mod tests {
let discovery = entries.discover_heal_candidates("bucket", 5); let discovery = entries.discover_heal_candidates("bucket", 5);
assert!(discovery.candidates.len() <= 5); assert!(discovery.candidates.len() <= 5);
assert!(discovery.truncated, "bounded discovery must expose dropped candidates"); assert!(discovery.truncated, "bounded discovery must expose dropped candidates");
assert!(
discovery
.truncated_candidates
.iter()
.all(|candidate| candidate.version_id.is_some()),
"overflow candidates must retain exact version identities"
);
assert!( assert!(
discovery.truncated_objects.iter().any(|object| object == "object"), discovery.truncated_objects.iter().any(|object| object == "object"),
"bounded discovery must expose an object-level safe continuation" "bounded discovery must expose an object-level safe continuation"
@@ -2165,7 +2176,6 @@ mod tests {
"./object", "./object",
"object/../other", "object/../other",
"object//name", "object//name",
"object\\name",
"object\u{0001}name", "object\u{0001}name",
"object\0name", "object\0name",
] { ] {
@@ -2178,6 +2188,33 @@ mod tests {
); );
} }
#[cfg(windows)]
{
let mut entry = metacache_entry_single_version(400, now, "object\\name");
entry.name = "object\\name".to_string();
assert!(
MetaCacheEntries(vec![Some(entry)])
.discover_heal_candidates("bucket", 5)
.candidates
.is_empty(),
"backslash is a path separator on Windows"
);
}
#[cfg(not(windows))]
{
let mut entry = metacache_entry_single_version(400, now, "object\\name");
entry.name = "object\\name".to_string();
assert_eq!(
MetaCacheEntries(vec![Some(entry)])
.discover_heal_candidates("bucket", 5)
.candidates
.len(),
1,
"backslash is object-key data on Unix"
);
}
for valid_name in ["trailing/", "prefix/object"] { for valid_name in ["trailing/", "prefix/object"] {
let mut entry = metacache_entry_single_version(401, now, valid_name); let mut entry = metacache_entry_single_version(401, now, valid_name);
entry.name = valid_name.to_string(); entry.name = valid_name.to_string();
+14 -19
View File
@@ -1977,40 +1977,35 @@ impl FolderScanner {
found_objects = true; found_objects = true;
} }
// A bounded candidate union may overflow for an // Candidates beyond the main cap remain exact
// object with a very long version history. Keep // version requests; never downgrade them to a
// that overflow explicit and issue one safe, // latest-version (version_id=None) heal.
// versionless inspection request per object so for candidate in discovery.truncated_candidates {
// the dropped versions are not silently treated let version_id = candidate.validated_version().map(|id| id.to_string());
// as absent. This continuation is deliberately let identity = (candidate.object.clone(), version_id.clone(), candidate.kind.clone());
// outside the versioned candidate cap and always
// disables destructive cleanup.
for object in discovery.truncated_objects {
if seen_truncated_objects.len() >= MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS if seen_truncated_objects.len() >= MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS
&& !seen_truncated_objects.contains(&object) && !seen_truncated_objects.contains(&candidate.object)
{ {
continue; continue;
} }
if !seen_truncated_objects.insert(object.clone()) { seen_truncated_objects.insert(candidate.object.clone());
continue;
}
let identity = (object.clone(), None, MetaCacheHealCandidateKind::UnversionedObject);
if !seen_heal_candidates.insert(identity) { if !seen_heal_candidates.insert(identity) {
continue; continue;
} }
let request = build_non_destructive_object_heal_request( let request = build_object_heal_request(
bucket.clone(), bucket.clone(),
object.clone(), candidate.object.clone(),
version_id.clone(),
self.scan_mode, self.scan_mode,
HealChannelPriority::High, HealChannelPriority::High,
); );
(self.update_current_path)(&object).await; (self.update_current_path)(&candidate.object).await;
let admission = self let admission = self
.send_required_scanner_heal_request( .send_required_scanner_heal_request(
PendingScannerHealKind::Object, PendingScannerHealKind::Object,
bucket.clone(), bucket.clone(),
Some(object.clone()), Some(candidate.object.clone()),
None, version_id,
request, request,
) )
.await?; .await?;