Skip to main content

rustfs_filemeta/
metacache.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::filemeta::msgp_decode::MAX_MSGP_ELEMENT_SIZE;
16use crate::{
17    Error, FileInfo, FileInfoOpts, FileInfoVersions, FileMeta, FileMetaShallowVersion, Result, VersionType, get_file_info,
18    merge_file_meta_versions, merge_file_meta_versions_with_write_quorum,
19};
20use arc_swap::ArcSwapOption;
21use rmp::Marker;
22use serde::{Deserialize, Serialize};
23use std::cmp::Ordering;
24use std::collections::{HashMap, HashSet};
25use std::str::from_utf8;
26use std::{
27    fmt::Debug,
28    future::Future,
29    pin::Pin,
30    sync::{
31        Arc,
32        atomic::{AtomicU64, Ordering as AtomicOrdering},
33    },
34    time::{Duration, SystemTime, UNIX_EPOCH},
35};
36use time::OffsetDateTime;
37use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
38use tokio::spawn;
39use tokio::sync::Mutex;
40use tracing::{debug, warn};
41use uuid::Uuid;
42
43const SLASH_SEPARATOR: &str = "/";
44pub const MAX_META_CACHE_HEAL_CANDIDATES: usize = 1024;
45/// Keep truncation continuations bounded while still giving the scanner a
46/// safe object-level retry for versions that did not fit in the candidate set.
47pub const MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS: usize = 64;
48
49#[derive(Clone, Debug, Default)]
50pub struct MetadataResolutionParams {
51    pub dir_quorum: usize,
52    pub obj_quorum: usize,
53    pub requested_versions: usize,
54    pub bucket: String,
55    pub strict: bool,
56    /// Number of set drives that were unreachable when the listing snapshot was
57    /// taken. Write-quorum enforcement relaxes each version's required quorum by
58    /// this amount (never below `obj_quorum`): a version legally committed at
59    /// write quorum can have that many of its metadata copies on drives no
60    /// reader could consult, and must not be dropped for it.
61    pub write_quorum_slack: usize,
62    pub candidates: Vec<Vec<FileMetaShallowVersion>>,
63}
64
65#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
66pub struct MetaCacheEntry {
67    /// name is the full name of the object including prefixes
68    pub name: String,
69    /// Metadata. If none is present it is not an object but only a prefix.
70    /// Entries without metadata will only be present in non-recursive scans.
71    pub metadata: Vec<u8>,
72
73    /// cached contains the metadata if decoded.
74    #[serde(skip)]
75    pub cached: Option<FileMeta>,
76
77    /// Indicates the entry can be reused and only one reference to metadata is expected.
78    pub reusable: bool,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, Hash)]
82pub enum MetaCacheHealCandidateKind {
83    Object,
84    DeleteMarker,
85    UnversionedObject,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq, Hash)]
89pub struct MetaCacheHealCandidate {
90    pub object: String,
91    pub version_id: Option<Uuid>,
92    pub kind: MetaCacheHealCandidateKind,
93    /// Number of raw disk entries that carried this validated version.
94    pub replica_count: usize,
95}
96
97impl MetaCacheHealCandidate {
98    pub fn validated_version(&self) -> Option<Uuid> {
99        match self.kind {
100            MetaCacheHealCandidateKind::Object | MetaCacheHealCandidateKind::DeleteMarker => self.version_id,
101            MetaCacheHealCandidateKind::UnversionedObject => None,
102        }
103    }
104
105    pub fn is_unversioned(&self) -> bool {
106        self.kind == MetaCacheHealCandidateKind::UnversionedObject
107    }
108}
109
110#[derive(Clone, Debug, Default, PartialEq, Eq)]
111pub struct MetaCacheHealDiscovery {
112    pub candidates: Vec<MetaCacheHealCandidate>,
113    pub unverified_count: usize,
114    pub truncated: bool,
115    /// Object names whose validated version set exceeded the candidate cap.
116    /// The scanner retries these names without a version and with destructive
117    /// healing disabled; this is an explicit bounded continuation, not a
118    /// version claim.
119    pub truncated_objects: Vec<String>,
120    /// Validated candidates beyond the main cap, retained with exact version
121    /// identities so callers never fall back to a latest-version request.
122    pub truncated_candidates: Vec<MetaCacheHealCandidate>,
123}
124
125impl MetaCacheEntry {
126    pub fn marshal_msg(&self) -> Result<Vec<u8>> {
127        let mut wr = Vec::new();
128        rmp::encode::write_bool(&mut wr, true)?;
129        rmp::encode::write_str(&mut wr, &self.name)?;
130        rmp::encode::write_bin(&mut wr, &self.metadata)?;
131        Ok(wr)
132    }
133
134    pub fn is_dir(&self) -> bool {
135        self.metadata.is_empty() && self.name.ends_with('/')
136    }
137
138    pub fn is_in_dir(&self, dir: &str, separator: &str) -> bool {
139        if dir.is_empty() {
140            let idx = self.name.find(separator);
141            return idx.is_none() || idx.unwrap() == self.name.len() - separator.len();
142        }
143
144        let ext = self.name.trim_start_matches(dir);
145
146        if ext.len() != self.name.len() {
147            let idx = ext.find(separator);
148            return idx.is_none() || idx.unwrap() == ext.len() - separator.len();
149        }
150
151        false
152    }
153
154    pub fn is_object(&self) -> bool {
155        !self.metadata.is_empty()
156    }
157
158    pub fn is_object_dir(&self) -> bool {
159        !self.metadata.is_empty() && self.name.ends_with(SLASH_SEPARATOR)
160    }
161
162    pub fn is_latest_delete_marker(&mut self) -> bool {
163        if let Some(cached) = &self.cached {
164            if cached.versions.is_empty() {
165                return true;
166            }
167            return cached.versions[0].header.version_type == VersionType::Delete;
168        }
169
170        if !FileMeta::is_xl2_v1_format(&self.metadata) {
171            return false;
172        }
173
174        match FileMeta::is_indexed_meta(&self.metadata) {
175            Ok((meta, _inline_data)) => {
176                if !meta.is_empty() {
177                    return FileMeta::is_latest_delete_marker(meta);
178                }
179            }
180            Err(_) => return true,
181        }
182
183        match self.xl_meta() {
184            Ok(res) => {
185                if res.versions.is_empty() {
186                    return true;
187                }
188                res.versions[0].header.version_type == VersionType::Delete
189            }
190            Err(_) => true,
191        }
192    }
193
194    #[tracing::instrument(level = "debug", skip(self))]
195    pub fn to_fileinfo(&self, bucket: &str) -> Result<FileInfo> {
196        if self.is_dir() {
197            return Ok(FileInfo {
198                volume: bucket.to_owned(),
199                name: self.name.clone(),
200                ..Default::default()
201            });
202        }
203
204        if let Some(fm) = &self.cached {
205            if fm.versions.is_empty() {
206                return Ok(FileInfo {
207                    volume: bucket.to_owned(),
208                    name: self.name.clone(),
209                    deleted: true,
210                    is_latest: true,
211                    mod_time: Some(OffsetDateTime::UNIX_EPOCH),
212                    ..Default::default()
213                });
214            }
215
216            let fi = fm.into_fileinfo_without_part_checksums(bucket, self.name.as_str(), "", false, false)?;
217            return Ok(fi);
218        }
219
220        get_file_info(
221            &self.metadata,
222            bucket,
223            self.name.as_str(),
224            "",
225            FileInfoOpts {
226                data: false,
227                include_free_versions: false,
228                include_part_checksums: false,
229            },
230        )
231    }
232
233    pub fn file_info_versions(&self, bucket: &str) -> Result<FileInfoVersions> {
234        if self.is_dir() {
235            return Ok(FileInfoVersions {
236                volume: bucket.to_string(),
237                name: self.name.clone(),
238                versions: vec![FileInfo {
239                    volume: bucket.to_string(),
240                    name: self.name.clone(),
241                    ..Default::default()
242                }],
243                ..Default::default()
244            });
245        }
246
247        let mut fm = FileMeta::new();
248        fm.unmarshal_msg(&self.metadata)?;
249        let mut versions = fm.get_file_info_versions(bucket, self.name.as_str(), false)?;
250        versions.free_versions.clear();
251        Ok(versions)
252    }
253
254    pub fn file_info_versions_with_free_versions(&self, bucket: &str) -> Result<FileInfoVersions> {
255        if self.is_dir() {
256            return Ok(FileInfoVersions {
257                volume: bucket.to_string(),
258                name: self.name.clone(),
259                versions: vec![FileInfo {
260                    volume: bucket.to_string(),
261                    name: self.name.clone(),
262                    ..Default::default()
263                }],
264                ..Default::default()
265            });
266        }
267
268        let mut fm = FileMeta::new();
269        fm.unmarshal_msg(&self.metadata)?;
270        // `get_file_info_versions(..., false)` is the existing path that
271        // separates persisted tier free-version records into `free_versions`.
272        fm.get_file_info_versions(bucket, self.name.as_str(), false)
273    }
274
275    pub fn matches(&self, other: Option<&MetaCacheEntry>, strict: bool) -> (Option<MetaCacheEntry>, bool) {
276        if other.is_none() {
277            return (None, false);
278        }
279
280        let other = other.unwrap();
281        if self.name != other.name {
282            if self.name < other.name {
283                return (Some(self.clone()), false);
284            }
285            return (Some(other.clone()), false);
286        }
287
288        if other.is_dir() || self.is_dir() {
289            if self.is_dir() {
290                return (Some(self.clone()), other.is_dir() == self.is_dir());
291            }
292            return (Some(other.clone()), other.is_dir() == self.is_dir());
293        }
294
295        let self_vers = match &self.cached {
296            Some(file_meta) => file_meta.clone(),
297            None => match FileMeta::load(&self.metadata) {
298                Ok(meta) => meta,
299                Err(_) => return (None, false),
300            },
301        };
302
303        let other_vers = match &other.cached {
304            Some(file_meta) => file_meta.clone(),
305            None => match FileMeta::load(&other.metadata) {
306                Ok(meta) => meta,
307                Err(_) => return (None, false),
308            },
309        };
310
311        if self_vers.versions.len() != other_vers.versions.len() {
312            match self_vers.latest_mod_time().cmp(&other_vers.latest_mod_time()) {
313                Ordering::Greater => return (Some(self.clone()), false),
314                Ordering::Less => return (Some(other.clone()), false),
315                _ => {}
316            }
317
318            if self_vers.versions.len() > other_vers.versions.len() {
319                return (Some(self.clone()), false);
320            }
321            return (Some(other.clone()), false);
322        }
323
324        let mut prefer = None;
325        for (s_version, o_version) in self_vers.versions.iter().zip(other_vers.versions.iter()) {
326            if s_version.header != o_version.header {
327                if s_version.header.has_ec() != o_version.header.has_ec() {
328                    // One version has EC and the other doesn't - may have been written later.
329                    // Compare without considering EC.
330                    let (mut a, mut b) = (s_version.header.clone(), o_version.header.clone());
331                    (a.ec_n, a.ec_m, b.ec_n, b.ec_m) = (0, 0, 0, 0);
332                    if a == b {
333                        continue;
334                    }
335                }
336
337                if !strict && s_version.header.matches_not_strict(&o_version.header) {
338                    if prefer.is_none() {
339                        if s_version.header.sorts_before(&o_version.header) {
340                            prefer = Some(self.clone());
341                        } else {
342                            prefer = Some(other.clone());
343                        }
344                    }
345                    continue;
346                }
347
348                if prefer.is_some() {
349                    return (prefer, false);
350                }
351
352                if s_version.header.sorts_before(&o_version.header) {
353                    return (Some(self.clone()), false);
354                }
355
356                return (Some(other.clone()), false);
357            }
358        }
359
360        if prefer.is_none() {
361            prefer = Some(self.clone());
362        }
363
364        (prefer, true)
365    }
366
367    pub fn xl_meta(&mut self) -> Result<FileMeta> {
368        if self.is_dir() {
369            return Err(Error::FileNotFound);
370        }
371
372        if let Some(meta) = &self.cached {
373            Ok(meta.clone())
374        } else {
375            if self.metadata.is_empty() {
376                return Err(Error::FileNotFound);
377            }
378
379            let meta = FileMeta::load(&self.metadata)?;
380            self.cached = Some(meta.clone());
381            Ok(meta)
382        }
383    }
384}
385
386#[derive(Debug, Default)]
387pub struct MetaCacheEntries(pub Vec<Option<MetaCacheEntry>>);
388
389impl MetaCacheEntries {
390    #[allow(clippy::should_implement_trait)]
391    pub fn as_ref(&self) -> &[Option<MetaCacheEntry>] {
392        &self.0
393    }
394
395    pub fn resolve(&self, params: MetadataResolutionParams) -> Option<MetaCacheEntry> {
396        self.resolve_inner(params, false)
397    }
398
399    pub fn resolve_with_write_quorum(&self, params: MetadataResolutionParams) -> Option<MetaCacheEntry> {
400        self.resolve_inner(params, true)
401    }
402
403    /// Resolve the cross-disk UNION of every version present on ANY disk slot.
404    ///
405    /// This mirrors MinIO's global heal enumeration (cmd/global-heal.go
406    /// `healErasureSet` -> `listPathRaw` with `objQuorum = 1` feeding
407    /// `mergeEntries`/`mergeXLV2Versions`): at quorum 1 the merge is forced
408    /// strict and returns the union of all per-disk version streams, so a version
409    /// that survives on FEWER than read-quorum disks is still surfaced for
410    /// healing. Read-quorum resolution (`resolve` with `obj_quorum >= 2`) would
411    /// silently drop such a sub-quorum version, which is exactly the durability
412    /// gap this enumerator closes (backlog#920).
413    ///
414    /// `bucket` is only used to tag the merged entry; `strict:false` lets the
415    /// non-strict header reconciliation collapse equal-but-differently-signed
416    /// replicas of the SAME version id while still retaining genuinely distinct
417    /// version ids.
418    pub fn resolve_union(&self, bucket: &str) -> Option<MetaCacheEntry> {
419        self.resolve(MetadataResolutionParams {
420            dir_quorum: 1,
421            obj_quorum: 1,
422            requested_versions: 0,
423            bucket: bucket.to_string(),
424            strict: false,
425            write_quorum_slack: 0,
426            candidates: Vec::new(),
427        })
428    }
429
430    /// Discover validated object/delete-marker versions and safe unversioned
431    /// inspection candidates in the raw entries without applying read quorum.
432    /// This is intentionally separate from [`Self::resolve`]: a sub-quorum
433    /// version is a valid heal target even though it must not participate in
434    /// normal reads or writes.
435    ///
436    /// The validated list is bounded and deduplicated by object, version id,
437    /// and metadata kind; each candidate retains the number of raw disk
438    /// entries that carried it so callers can classify sub-quorum versions.
439    /// Entries whose xl.meta cannot be decoded are counted separately for
440    /// discovery accounting; they never become versionless destructive heal
441    /// requests and do not consume the validated quota. An
442    /// [`MetaCacheHealCandidateKind::UnversionedObject`] is always consumed by
443    /// a non-destructive scanner request.
444    pub fn discover_heal_candidates(&self, bucket: &str, max_candidates: usize) -> MetaCacheHealDiscovery {
445        let limit = max_candidates.min(MAX_META_CACHE_HEAL_CANDIDATES);
446        if limit == 0 || bucket.is_empty() {
447            return MetaCacheHealDiscovery::default();
448        }
449
450        let mut discovery = MetaCacheHealDiscovery {
451            candidates: Vec::<MetaCacheHealCandidate>::with_capacity(limit.min(self.0.len())),
452            unverified_count: 0,
453            truncated: false,
454            truncated_objects: Vec::with_capacity(MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS.min(limit)),
455            truncated_candidates: Vec::new(),
456        };
457        let mut seen: HashMap<(String, Option<Uuid>, MetaCacheHealCandidateKind), usize> =
458            HashMap::with_capacity(limit.min(self.0.len()));
459
460        for entry in self.0.iter().flatten() {
461            if !valid_heal_candidate_name(bucket, entry) {
462                continue;
463            }
464
465            let meta = match FileMeta::load(&entry.metadata) {
466                Ok(meta) => meta,
467                Err(_) => {
468                    discovery.unverified_count = discovery.unverified_count.saturating_add(1);
469                    continue;
470                }
471            };
472            let mut entry_seen = HashSet::new();
473
474            for shallow in meta.versions {
475                let version = match shallow.parse_version_meta() {
476                    Ok(version) if version.valid() => version,
477                    Ok(_) | Err(_) => {
478                        discovery.unverified_count = discovery.unverified_count.saturating_add(1);
479                        continue;
480                    }
481                };
482                if version.free_version() {
483                    continue;
484                }
485
486                let payload_header = version.header();
487                if normalize_version_id(shallow.header.version_id) != normalize_version_id(payload_header.version_id)
488                    || shallow.header.version_type != payload_header.version_type
489                {
490                    discovery.unverified_count = discovery.unverified_count.saturating_add(1);
491                    continue;
492                }
493
494                let (kind, version_id) = match version.version_type {
495                    VersionType::Object
496                        if version.object.is_some() && version.delete_marker.is_none() && version.legacy_object.is_none() =>
497                    {
498                        match version.object.as_ref().and_then(|object| object.version_id) {
499                            Some(id) if !id.is_nil() => (MetaCacheHealCandidateKind::Object, Some(id)),
500                            Some(_) | None => (MetaCacheHealCandidateKind::UnversionedObject, None),
501                        }
502                    }
503                    VersionType::Delete
504                        if version.delete_marker.is_some() && version.object.is_none() && version.legacy_object.is_none() =>
505                    {
506                        let Some(id) = version.delete_marker.as_ref().and_then(|marker| marker.version_id) else {
507                            discovery.unverified_count = discovery.unverified_count.saturating_add(1);
508                            continue;
509                        };
510                        if id.is_nil() {
511                            discovery.unverified_count = discovery.unverified_count.saturating_add(1);
512                            continue;
513                        }
514                        (MetaCacheHealCandidateKind::DeleteMarker, Some(id))
515                    }
516                    VersionType::Legacy
517                        if version.legacy_object.is_some() && version.object.is_none() && version.delete_marker.is_none() =>
518                    {
519                        let Some(legacy) = version.legacy_object.as_ref() else {
520                            continue;
521                        };
522                        if legacy.version_id.is_empty() {
523                            (MetaCacheHealCandidateKind::UnversionedObject, None)
524                        } else {
525                            let Ok(id) = Uuid::parse_str(&legacy.version_id) else {
526                                discovery.unverified_count = discovery.unverified_count.saturating_add(1);
527                                continue;
528                            };
529                            if id.is_nil() {
530                                discovery.unverified_count = discovery.unverified_count.saturating_add(1);
531                                continue;
532                            }
533                            (MetaCacheHealCandidateKind::Object, Some(id))
534                        }
535                    }
536                    _ => {
537                        discovery.unverified_count = discovery.unverified_count.saturating_add(1);
538                        continue;
539                    }
540                };
541
542                if normalize_version_id(payload_header.version_id) != version_id {
543                    discovery.unverified_count = discovery.unverified_count.saturating_add(1);
544                    continue;
545                }
546
547                // `all_parts=true` is the trust-boundary check for versioned
548                // candidates. A null/legacy object may still need the old
549                // non-destructive inspection fallback when its part arrays
550                // are parseable but incomplete; never use that fallback for
551                // a candidate carrying a real version id.
552                let file_info = match version.clone().into_fileinfo(bucket, &entry.name, true) {
553                    Ok(file_info) => file_info,
554                    Err(_) if version_id.is_none() && matches!(kind, MetaCacheHealCandidateKind::UnversionedObject) => {
555                        discovery.unverified_count = discovery.unverified_count.saturating_add(1);
556                        match version.into_fileinfo(bucket, &entry.name, false) {
557                            Ok(file_info) => file_info,
558                            Err(_) => continue,
559                        }
560                    }
561                    Err(_) => {
562                        discovery.unverified_count = discovery.unverified_count.saturating_add(1);
563                        continue;
564                    }
565                };
566                if file_info.volume != bucket || file_info.name != entry.name {
567                    discovery.unverified_count = discovery.unverified_count.saturating_add(1);
568                    continue;
569                }
570
571                let candidate = MetaCacheHealCandidate {
572                    object: entry.name.clone(),
573                    version_id,
574                    kind,
575                    replica_count: 1,
576                };
577                let key = (candidate.object.clone(), candidate.version_id, candidate.kind.clone());
578                if entry_seen.contains(&key) {
579                    continue;
580                }
581                if let Some(index) = seen.get(&key).copied() {
582                    entry_seen.insert(key);
583                    discovery.candidates[index].replica_count = discovery.candidates[index].replica_count.saturating_add(1);
584                } else if discovery.candidates.len() >= limit {
585                    // Keep the validated candidate list bounded, but retain a
586                    // bounded object-level continuation so the scanner cannot
587                    // silently lose every version of a busy object.
588                    discovery.truncated = true;
589                    if discovery.truncated_objects.len() < MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS
590                        && !discovery.truncated_objects.iter().any(|object| object == &candidate.object)
591                    {
592                        discovery.truncated_objects.push(candidate.object.clone());
593                    }
594                    if discovery.truncated_objects.iter().any(|object| object == &candidate.object) {
595                        discovery.truncated_candidates.push(candidate);
596                    }
597                    continue;
598                } else {
599                    entry_seen.insert(key.clone());
600                    seen.insert(key, discovery.candidates.len());
601                    discovery.candidates.push(candidate);
602                }
603            }
604        }
605
606        discovery
607    }
608
609    fn resolve_inner(&self, mut params: MetadataResolutionParams, enforce_write_quorum: bool) -> Option<MetaCacheEntry> {
610        if self.0.is_empty() {
611            debug!(
612                bucket = %params.bucket,
613                dir_quorum = params.dir_quorum,
614                obj_quorum = params.obj_quorum,
615                "metacache resolve skipped because there were no entries to reconcile"
616            );
617            return None;
618        }
619
620        let mut dir_exists = 0;
621        let mut selected = None;
622
623        params.candidates.clear();
624        let mut objs_agree = 0;
625        let mut objs_valid = 0;
626
627        for entry in self.0.iter().flatten() {
628            let mut entry = entry.clone();
629
630            debug!(entry = %entry.name, "metacache resolve examining candidate entry");
631            if entry.name.is_empty() {
632                continue;
633            }
634            if entry.is_dir() {
635                dir_exists += 1;
636                selected = Some(entry.clone());
637                debug!(entry = %entry.name, "metacache resolve observed directory candidate");
638                continue;
639            }
640
641            let xl = match entry.xl_meta() {
642                Ok(xl) => xl,
643                Err(e) => {
644                    debug!(entry = %entry.name, error = ?e, "metacache resolve skipped candidate with unreadable xl.meta");
645                    continue;
646                }
647            };
648
649            objs_valid += 1;
650            params.candidates.push(xl.versions.clone());
651
652            if selected.is_none() {
653                selected = Some(entry.clone());
654                objs_agree = 1;
655                debug!(entry = %entry.name, "metacache resolve selected initial candidate");
656                continue;
657            }
658
659            if let (prefer, true) = entry.matches(selected.as_ref(), params.strict) {
660                selected = prefer;
661                objs_agree += 1;
662                debug!(entry = %entry.name, "metacache resolve preferred candidate during reconciliation");
663                continue;
664            }
665        }
666
667        let Some(selected) = selected else {
668            debug!(
669                bucket = %params.bucket,
670                dir_quorum = params.dir_quorum,
671                obj_quorum = params.obj_quorum,
672                "metacache resolve could not select any candidate entry"
673            );
674            return None;
675        };
676
677        if selected.is_dir() && dir_exists >= params.dir_quorum {
678            debug!(
679                entry = %selected.name,
680                dir_exists,
681                dir_quorum = params.dir_quorum,
682                "metacache resolve selected directory candidate after quorum reconciliation"
683            );
684            return Some(selected);
685        }
686
687        // If we would never be able to reach the required object quorum.
688        if objs_valid < params.obj_quorum {
689            debug!(
690                objs_valid,
691                obj_quorum = params.obj_quorum,
692                "metacache resolve did not have enough valid object candidates to satisfy quorum"
693            );
694            return None;
695        }
696
697        if objs_agree == objs_valid {
698            let required_quorum = if enforce_write_quorum {
699                selected
700                    .cached
701                    .as_ref()
702                    .and_then(|cached| cached.versions.first())
703                    .map(|version| {
704                        version
705                            .write_quorum(params.obj_quorum)
706                            .saturating_sub(params.write_quorum_slack)
707                            .max(params.obj_quorum)
708                    })
709                    .unwrap_or(params.obj_quorum)
710            } else {
711                params.obj_quorum
712            };
713
714            if objs_agree >= required_quorum {
715                debug!(
716                    selected = %selected.name,
717                    objs_agree,
718                    objs_valid,
719                    "metacache resolve reused selected candidate because all valid object entries agreed"
720                );
721                return Some(selected);
722            }
723        }
724
725        let Some(cached) = selected.cached else {
726            debug!(selected = %selected.name, "metacache resolve could not merge because selected candidate had no cached metadata");
727            return None;
728        };
729
730        let versions = if enforce_write_quorum {
731            merge_file_meta_versions_with_write_quorum(
732                params.obj_quorum,
733                params.strict,
734                params.requested_versions,
735                params.write_quorum_slack,
736                &params.candidates,
737            )
738        } else {
739            merge_file_meta_versions(params.obj_quorum, params.strict, params.requested_versions, &params.candidates)
740        };
741        if versions.is_empty() {
742            debug!(
743                selected = %selected.name,
744                requested_versions = params.requested_versions,
745                "metacache resolve produced no merged versions after reconciliation"
746            );
747            return None;
748        }
749
750        let merged_cached = FileMeta {
751            meta_ver: cached.meta_ver,
752            versions,
753            ..Default::default()
754        };
755
756        let metadata = match merged_cached.marshal_msg() {
757            Ok(meta) => meta,
758            Err(e) => {
759                warn!(
760                    selected = %selected.name,
761                    error = ?e,
762                    "metacache resolve failed to marshal merged metadata entry"
763                );
764                return None;
765            }
766        };
767
768        // Merge if we have disagreement.
769        // Create a new merged result.
770        let new_selected = MetaCacheEntry {
771            name: selected.name.clone(),
772            cached: Some(merged_cached),
773            reusable: true,
774            metadata,
775        };
776
777        debug!(
778            selected = %new_selected.name,
779            candidate_count = params.candidates.len(),
780            obj_quorum = params.obj_quorum,
781            "metacache resolve produced merged metadata entry after reconciling disagreeing candidates"
782        );
783        Some(new_selected)
784    }
785
786    pub fn first_found(&self) -> (Option<MetaCacheEntry>, usize) {
787        (self.0.iter().find(|x| x.is_some()).cloned().unwrap_or_default(), self.0.len())
788    }
789}
790
791fn valid_heal_candidate_name(bucket: &str, entry: &MetaCacheEntry) -> bool {
792    if bucket.is_empty()
793        || entry.name.is_empty()
794        || entry.is_dir()
795        || (cfg!(windows) && entry.name.contains('\\'))
796        || entry.name.chars().any(char::is_control)
797    {
798        return false;
799    }
800
801    // Validate raw key components without normalizing them. The scanner maps
802    // accepted keys to filesystem paths later, so dot components and empty
803    // internal components must be rejected before that boundary. A final
804    // empty component is retained for valid keys ending in '/'.
805    let mut components = entry.name.split('/').peekable();
806    while let Some(component) = components.next() {
807        if component == "." || component == ".." || (component.is_empty() && components.peek().is_some()) {
808            return false;
809        }
810    }
811    true
812}
813
814fn normalize_version_id(version_id: Option<Uuid>) -> Option<Uuid> {
815    version_id.filter(|id| !id.is_nil())
816}
817
818#[derive(Debug, Default)]
819pub struct MetaCacheEntriesSortedResult {
820    pub entries: Option<MetaCacheEntriesSorted>,
821    pub err: Option<Error>,
822}
823
824#[derive(Debug, Default)]
825pub struct MetaCacheEntriesSorted {
826    pub o: MetaCacheEntries,
827    pub list_id: Option<String>,
828    pub reuse: bool,
829    pub last_skipped_entry: Option<String>,
830}
831
832impl MetaCacheEntriesSorted {
833    pub fn entries(&self) -> Vec<&MetaCacheEntry> {
834        let entries: Vec<&MetaCacheEntry> = self.o.0.iter().flatten().collect();
835        entries
836    }
837
838    pub fn forward_past(&mut self, marker: Option<String>) {
839        if let Some(val) = marker
840            && let Some(idx) = self.o.0.iter().flatten().position(|v| v.name > val)
841        {
842            self.o.0 = self.o.0.split_off(idx);
843        }
844    }
845}
846
847const METACACHE_STREAM_VERSION: u8 = 2;
848
849#[derive(Debug)]
850pub struct MetacacheWriter<W> {
851    wr: W,
852    created: bool,
853    buf: Vec<u8>,
854}
855
856impl<W: AsyncWrite + Unpin> MetacacheWriter<W> {
857    pub fn new(wr: W) -> Self {
858        Self {
859            wr,
860            created: false,
861            buf: Vec::new(),
862        }
863    }
864
865    pub async fn flush(&mut self) -> Result<()> {
866        self.wr.write_all(&self.buf).await?;
867        self.buf.clear();
868        Ok(())
869    }
870
871    pub async fn init(&mut self) -> Result<()> {
872        if !self.created {
873            rmp::encode::write_u8(&mut self.buf, METACACHE_STREAM_VERSION).map_err(|e| Error::other(format!("{e:?}")))?;
874            self.flush().await?;
875            self.created = true;
876        }
877        Ok(())
878    }
879
880    pub async fn write(&mut self, objs: &[MetaCacheEntry]) -> Result<()> {
881        if objs.is_empty() {
882            return Ok(());
883        }
884
885        self.init().await?;
886
887        for obj in objs.iter() {
888            if obj.name.is_empty() {
889                return Err(Error::other("metacacheWriter: no name"));
890            }
891
892            self.write_obj(obj).await?;
893        }
894
895        Ok(())
896    }
897
898    pub async fn write_obj(&mut self, obj: &MetaCacheEntry) -> Result<()> {
899        self.init().await?;
900
901        rmp::encode::write_bool(&mut self.buf, true).map_err(|e| Error::other(format!("{e:?}")))?;
902        rmp::encode::write_str(&mut self.buf, &obj.name).map_err(|e| Error::other(format!("{e:?}")))?;
903        rmp::encode::write_bin(&mut self.buf, &obj.metadata).map_err(|e| Error::other(format!("{e:?}")))?;
904        self.flush().await?;
905
906        Ok(())
907    }
908
909    pub async fn close(&mut self) -> Result<()> {
910        self.init().await?;
911        rmp::encode::write_bool(&mut self.buf, false).map_err(|e| Error::other(format!("{e:?}")))?;
912        self.flush().await?;
913        Ok(())
914    }
915}
916
917pub struct MetacacheReader<R> {
918    rd: R,
919    init: bool,
920    err: Option<Error>,
921    buf: Vec<u8>,
922    offset: usize,
923    current: Option<MetaCacheEntry>,
924}
925
926impl<R: AsyncRead + Unpin> MetacacheReader<R> {
927    pub fn new(rd: R) -> Self {
928        Self {
929            rd,
930            init: false,
931            err: None,
932            buf: Vec::new(),
933            offset: 0,
934            current: None,
935        }
936    }
937
938    pub async fn read_more(&mut self, read_size: usize) -> Result<&[u8]> {
939        // `read_size` is usually a length decoded from the stream itself, so
940        // it is untrusted: a corrupted length prefix must yield a decode
941        // error instead of a huge allocation that aborts the process
942        // (see rustfs/rustfs#2715).
943        if read_size > MAX_MSGP_ELEMENT_SIZE {
944            let err = Error::other(format!(
945                "metacache stream corrupt: element length {read_size} exceeds the {MAX_MSGP_ELEMENT_SIZE} byte limit"
946            ));
947            self.err = Some(err.clone());
948            return Err(err);
949        }
950
951        let pref = self.offset;
952        let ext_size = pref + read_size;
953
954        if self.buf.len() < ext_size {
955            let extra = ext_size - self.buf.len();
956            if let Err(e) = self.buf.try_reserve(extra) {
957                let err = Error::other(format!("metacache stream: buffer allocation of {extra} bytes failed: {e}"));
958                self.err = Some(err.clone());
959                return Err(err);
960            }
961            self.buf.resize(ext_size, 0);
962        }
963
964        self.rd.read_exact(&mut self.buf[pref..ext_size]).await?;
965
966        self.offset += read_size;
967
968        Ok(&self.buf[pref..ext_size])
969    }
970
971    fn reset(&mut self) {
972        self.buf.clear();
973        self.offset = 0;
974    }
975
976    async fn check_init(&mut self) -> Result<()> {
977        if !self.init {
978            let ver = match rmp::decode::read_u8(&mut self.read_more(2).await?) {
979                Ok(res) => res,
980                Err(err) => {
981                    self.err = Some(Error::other(format!("{err:?}")));
982                    0
983                }
984            };
985            match ver {
986                1 | 2 => (),
987                _ => {
988                    self.err = Some(Error::other("invalid version"));
989                }
990            }
991
992            self.init = true;
993        }
994        Ok(())
995    }
996
997    async fn read_str_len(&mut self) -> Result<u32> {
998        let mark = match rmp::decode::read_marker(&mut self.read_more(1).await?) {
999            Ok(res) => res,
1000            Err(err) => {
1001                let err: Error = err.into();
1002                self.err = Some(err.clone());
1003                return Err(err);
1004            }
1005        };
1006
1007        match mark {
1008            Marker::FixStr(size) => Ok(u32::from(size)),
1009            Marker::Str8 => Ok(u32::from(self.read_u8().await?)),
1010            Marker::Str16 => Ok(u32::from(self.read_u16().await?)),
1011            Marker::Str32 => Ok(self.read_u32().await?),
1012            _marker => Err(Error::other("str marker err")),
1013        }
1014    }
1015
1016    async fn read_bin_len(&mut self) -> Result<u32> {
1017        let mark = match rmp::decode::read_marker(&mut self.read_more(1).await?) {
1018            Ok(res) => res,
1019            Err(err) => {
1020                let err: Error = err.into();
1021                self.err = Some(err.clone());
1022                return Err(err);
1023            }
1024        };
1025
1026        match mark {
1027            Marker::Bin8 => Ok(u32::from(self.read_u8().await?)),
1028            Marker::Bin16 => Ok(u32::from(self.read_u16().await?)),
1029            Marker::Bin32 => Ok(self.read_u32().await?),
1030            _ => Err(Error::other("bin marker err")),
1031        }
1032    }
1033
1034    async fn read_u8(&mut self) -> Result<u8> {
1035        let buf = self.read_more(1).await?;
1036        Ok(u8::from_be_bytes(buf.try_into().expect("Slice with incorrect length")))
1037    }
1038
1039    async fn read_u16(&mut self) -> Result<u16> {
1040        let buf = self.read_more(2).await?;
1041        Ok(u16::from_be_bytes(buf.try_into().expect("Slice with incorrect length")))
1042    }
1043
1044    async fn read_u32(&mut self) -> Result<u32> {
1045        let buf = self.read_more(4).await?;
1046        Ok(u32::from_be_bytes(buf.try_into().expect("Slice with incorrect length")))
1047    }
1048
1049    pub async fn skip(&mut self, size: usize) -> Result<()> {
1050        self.check_init().await?;
1051
1052        if let Some(err) = &self.err {
1053            return Err(err.clone());
1054        }
1055
1056        let mut n = size;
1057
1058        if self.current.is_some() {
1059            n -= 1;
1060            self.current = None;
1061        }
1062
1063        while n > 0 {
1064            match rmp::decode::read_bool(&mut self.read_more(1).await?) {
1065                Ok(res) => {
1066                    if !res {
1067                        return Ok(());
1068                    }
1069                }
1070                Err(err) => {
1071                    let err: Error = err.into();
1072                    self.err = Some(err.clone());
1073                    return Err(err);
1074                }
1075            };
1076
1077            let l = self.read_str_len().await?;
1078            let _ = self.read_more(l as usize).await?;
1079            let l = self.read_bin_len().await?;
1080            let _ = self.read_more(l as usize).await?;
1081
1082            n -= 1;
1083        }
1084
1085        Ok(())
1086    }
1087
1088    pub async fn peek(&mut self) -> Result<Option<MetaCacheEntry>> {
1089        self.check_init().await?;
1090
1091        if let Some(err) = &self.err {
1092            return Err(err.clone());
1093        }
1094
1095        match rmp::decode::read_bool(&mut self.read_more(1).await?) {
1096            Ok(res) => {
1097                if !res {
1098                    return Ok(None);
1099                }
1100            }
1101            Err(err) => {
1102                let err: Error = err.into();
1103                self.err = Some(err.clone());
1104                return Err(err);
1105            }
1106        };
1107
1108        let l = self.read_str_len().await?;
1109
1110        let buf = self.read_more(l as usize).await?;
1111        let name_buf = buf.to_vec();
1112        let name = match from_utf8(&name_buf) {
1113            Ok(decoded) => decoded.to_owned(),
1114            Err(err) => {
1115                self.err = Some(Error::other(err.to_string()));
1116                return Err(Error::other(err.to_string()));
1117            }
1118        };
1119
1120        let l = self.read_bin_len().await?;
1121
1122        let buf = self.read_more(l as usize).await?;
1123
1124        let metadata = buf.to_vec();
1125
1126        self.reset();
1127
1128        let entry = Some(MetaCacheEntry {
1129            name,
1130            metadata,
1131            cached: None,
1132            reusable: false,
1133        });
1134        self.current = entry.clone();
1135
1136        Ok(entry)
1137    }
1138
1139    pub async fn read_all(&mut self) -> Result<Vec<MetaCacheEntry>> {
1140        let mut ret = Vec::new();
1141
1142        loop {
1143            if let Some(entry) = self.peek().await? {
1144                ret.push(entry);
1145                continue;
1146            }
1147            break;
1148        }
1149
1150        Ok(ret)
1151    }
1152}
1153
1154pub type UpdateFn<T> = Box<dyn Fn() -> Pin<Box<dyn Future<Output = std::io::Result<T>> + Send>> + Send + Sync + 'static>;
1155
1156#[derive(Clone, Debug, Default)]
1157pub struct Opts {
1158    pub return_last_good: bool,
1159    pub no_wait: bool,
1160}
1161
1162pub struct Cache<T: Clone + Debug + Send> {
1163    update_fn: UpdateFn<T>,
1164    ttl: Duration,
1165    opts: Opts,
1166    val: ArcSwapOption<T>,
1167    last_update_secs: AtomicU64,
1168    updating: Arc<Mutex<()>>,
1169}
1170
1171impl<T: Clone + Debug + Send + Sync + 'static> Cache<T> {
1172    pub fn new(update_fn: UpdateFn<T>, ttl: Duration, opts: Opts) -> Self {
1173        Self {
1174            update_fn,
1175            ttl,
1176            opts,
1177            val: ArcSwapOption::from(None),
1178            last_update_secs: AtomicU64::new(0),
1179            updating: Arc::new(Mutex::new(())),
1180        }
1181    }
1182
1183    pub async fn get(self: Arc<Self>) -> std::io::Result<T> {
1184        let value = self.get_shared().await?;
1185        Ok(value.as_ref().clone())
1186    }
1187
1188    pub async fn get_shared(self: Arc<Self>) -> std::io::Result<Arc<T>> {
1189        let now = Self::current_unix_secs();
1190        let current = self.cached_value();
1191        if self.age_since_last_update(now) < self.ttl.as_secs()
1192            && let Some(value) = current.clone()
1193        {
1194            return Ok(value);
1195        }
1196
1197        if self.opts.no_wait
1198            && self.age_since_last_update(now) < self.ttl.as_secs().saturating_mul(2)
1199            && let Some(value) = current
1200        {
1201            if let Ok(update_guard) = Arc::clone(&self.updating).try_lock_owned() {
1202                let this = Arc::clone(&self);
1203                spawn(async move {
1204                    let _guard = update_guard;
1205                    let _ = this.update().await;
1206                });
1207            }
1208            return Ok(value);
1209        }
1210
1211        let _guard = self.updating.lock().await;
1212
1213        let now = Self::current_unix_secs();
1214        if self.age_since_last_update(now) < self.ttl.as_secs()
1215            && let Some(value) = self.cached_value()
1216        {
1217            return Ok(value);
1218        }
1219
1220        self.update().await?;
1221        self.cached_value()
1222            .ok_or_else(|| std::io::Error::other("cache update completed without a value"))
1223    }
1224
1225    async fn update(&self) -> std::io::Result<()> {
1226        match (self.update_fn)().await {
1227            Ok(val) => {
1228                self.val.store(Some(Arc::new(val)));
1229                self.last_update_secs.store(Self::current_unix_secs(), AtomicOrdering::SeqCst);
1230                Ok(())
1231            }
1232            Err(err) => {
1233                if self.opts.return_last_good && self.cached_value().is_some() {
1234                    return Ok(());
1235                }
1236
1237                Err(err)
1238            }
1239        }
1240    }
1241
1242    fn current_unix_secs() -> u64 {
1243        SystemTime::now()
1244            .duration_since(UNIX_EPOCH)
1245            .expect("Time went backwards")
1246            .as_secs()
1247    }
1248
1249    fn age_since_last_update(&self, now_secs: u64) -> u64 {
1250        now_secs
1251            .checked_sub(self.last_update_secs.load(AtomicOrdering::SeqCst))
1252            .unwrap_or(u64::MAX)
1253    }
1254
1255    fn cached_value(&self) -> Option<Arc<T>> {
1256        self.val.load_full()
1257    }
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262    use super::*;
1263    use crate::test_data::create_real_xlmeta;
1264    use crate::{FileMetaVersion, MetaDeleteMarker, MetaObjectV1, MetaObjectV1Erasure, MetaObjectV1Stat, TRANSITION_COMPLETE};
1265    use std::collections::HashMap;
1266    use std::io::Cursor;
1267    use std::sync::{
1268        Arc, Mutex as StdMutex,
1269        atomic::{AtomicUsize, Ordering},
1270    };
1271    use tokio::sync::{Notify, oneshot};
1272    use uuid::Uuid;
1273
1274    #[tokio::test]
1275    async fn test_writer() {
1276        let mut f = Cursor::new(Vec::new());
1277        let mut w = MetacacheWriter::new(&mut f);
1278
1279        let mut objs = Vec::new();
1280        for i in 0..10 {
1281            let info = MetaCacheEntry {
1282                name: format!("item{i}"),
1283                metadata: vec![0u8, 10],
1284                cached: None,
1285                reusable: false,
1286            };
1287            objs.push(info);
1288        }
1289
1290        w.write(&objs).await.unwrap();
1291        w.close().await.unwrap();
1292
1293        let data = f.into_inner();
1294        let nf = Cursor::new(data);
1295
1296        let mut r = MetacacheReader::new(nf);
1297        let nobjs = r.read_all().await.unwrap();
1298
1299        assert_eq!(objs, nobjs);
1300    }
1301
1302    #[tokio::test]
1303    async fn empty_writer_emits_a_valid_stream() {
1304        let mut output = Cursor::new(Vec::new());
1305        let mut writer = MetacacheWriter::new(&mut output);
1306        writer.close().await.expect("empty stream should close");
1307
1308        let mut reader = MetacacheReader::new(Cursor::new(output.into_inner()));
1309        assert!(reader.read_all().await.expect("empty stream should decode").is_empty());
1310    }
1311
1312    fn corrupt_stream_with_metadata_len(len_marker: u8, len: u32) -> Vec<u8> {
1313        let mut data = Vec::new();
1314        rmp::encode::write_u8(&mut data, METACACHE_STREAM_VERSION).unwrap();
1315        rmp::encode::write_bool(&mut data, true).unwrap();
1316        rmp::encode::write_str(&mut data, "object").unwrap();
1317        // Hand-written bin/str length prefix claiming an absurd payload size.
1318        data.push(len_marker);
1319        data.extend_from_slice(&len.to_be_bytes());
1320        data
1321    }
1322
1323    /// Regression test for rustfs/rustfs#2715: a corrupted metadata length
1324    /// prefix must surface as a decode error instead of attempting a huge
1325    /// allocation that aborts the process.
1326    #[tokio::test]
1327    async fn test_reader_rejects_corrupt_bin_length_prefix() {
1328        // 0xc6 = bin32 marker.
1329        let data = corrupt_stream_with_metadata_len(0xc6, u32::MAX);
1330        let mut r = MetacacheReader::new(Cursor::new(data));
1331        let err = r.peek().await.expect_err("corrupt bin length prefix must fail to decode");
1332        assert!(err.to_string().contains("exceeds"), "error should mention the exceeded limit, got: {err}");
1333
1334        // The reader must stay in the error state instead of retrying.
1335        assert!(r.peek().await.is_err());
1336    }
1337
1338    #[tokio::test]
1339    async fn test_reader_rejects_corrupt_str_length_prefix() {
1340        let mut data = Vec::new();
1341        rmp::encode::write_u8(&mut data, METACACHE_STREAM_VERSION).unwrap();
1342        rmp::encode::write_bool(&mut data, true).unwrap();
1343        // 0xdb = str32 marker with an absurd object-name length.
1344        data.push(0xdb);
1345        data.extend_from_slice(&u32::MAX.to_be_bytes());
1346
1347        let mut r = MetacacheReader::new(Cursor::new(data));
1348        let err = r.peek().await.expect_err("corrupt str length prefix must fail to decode");
1349        assert!(err.to_string().contains("exceeds"), "error should mention the exceeded limit, got: {err}");
1350    }
1351
1352    #[tokio::test]
1353    async fn test_reader_skip_rejects_corrupt_length_prefix() {
1354        let data = corrupt_stream_with_metadata_len(0xc6, u32::MAX);
1355        let mut r = MetacacheReader::new(Cursor::new(data));
1356        assert!(r.skip(1).await.is_err(), "skip over a corrupt length prefix must fail");
1357    }
1358
1359    /// Large-but-legitimate records (well above typical sizes, below the
1360    /// corruption guard) must still round-trip.
1361    #[tokio::test]
1362    async fn test_reader_accepts_large_legitimate_metadata() {
1363        let entry = MetaCacheEntry {
1364            name: "big-object".to_string(),
1365            metadata: vec![0xab; 4 << 20],
1366            cached: None,
1367            reusable: false,
1368        };
1369
1370        let mut f = Cursor::new(Vec::new());
1371        let mut w = MetacacheWriter::new(&mut f);
1372        w.write(std::slice::from_ref(&entry)).await.unwrap();
1373        w.close().await.unwrap();
1374
1375        let mut r = MetacacheReader::new(Cursor::new(f.into_inner()));
1376        let decoded = r.read_all().await.unwrap();
1377        assert_eq!(decoded, vec![entry]);
1378    }
1379
1380    #[test]
1381    fn file_info_versions_with_free_versions_includes_persisted_tier_cleanup_records() {
1382        let version_id = Uuid::new_v4();
1383        let remote_version_id = Uuid::new_v4();
1384        let free_version_id = Uuid::new_v4();
1385        let mut fm = FileMeta::new();
1386        fm.add_version(FileInfo {
1387            volume: "bucket".to_string(),
1388            name: "object".to_string(),
1389            version_id: Some(version_id),
1390            transition_status: TRANSITION_COMPLETE.to_string(),
1391            transitioned_objname: "remote/object".to_string(),
1392            transition_version_id: Some(remote_version_id),
1393            transition_tier: "WARM".to_string(),
1394            mod_time: Some(OffsetDateTime::now_utc()),
1395            ..Default::default()
1396        })
1397        .expect("transitioned version should be added");
1398
1399        let mut delete_fi = FileInfo {
1400            volume: "bucket".to_string(),
1401            name: "object".to_string(),
1402            version_id: Some(version_id),
1403            mod_time: Some(OffsetDateTime::now_utc()),
1404            ..Default::default()
1405        };
1406        delete_fi.set_tier_free_version_id(&free_version_id.to_string());
1407        fm.delete_version(&delete_fi)
1408            .expect("transitioned delete should create free-version metadata");
1409
1410        let entry = MetaCacheEntry {
1411            name: "object".to_string(),
1412            metadata: fm.marshal_msg().expect("metadata should marshal"),
1413            ..Default::default()
1414        };
1415
1416        let normal = entry.file_info_versions("bucket").expect("normal versions should parse");
1417        assert!(normal.free_versions.is_empty());
1418
1419        let with_free = entry
1420            .file_info_versions_with_free_versions("bucket")
1421            .expect("versions with free versions should parse");
1422        assert_eq!(with_free.free_versions.len(), 1);
1423        assert!(with_free.free_versions[0].tier_free_version());
1424        assert_eq!(with_free.free_versions[0].transitioned_objname, "remote/object");
1425        assert_eq!(with_free.free_versions[0].transition_tier, "WARM");
1426        assert_eq!(with_free.free_versions[0].transition_version_id, Some(remote_version_id));
1427    }
1428
1429    #[test]
1430    fn file_info_versions_excludes_free_versions_from_visible_version_count() {
1431        let object_version_id = Uuid::new_v4();
1432        let remote_version_id = Uuid::new_v4();
1433        let free_version_id = Uuid::new_v4();
1434        let delete_marker_id = Uuid::new_v4();
1435        let base_time = OffsetDateTime::now_utc();
1436        let mut fm = FileMeta::new();
1437
1438        fm.add_version(FileInfo {
1439            volume: "bucket".to_string(),
1440            name: "object".to_string(),
1441            version_id: Some(object_version_id),
1442            transition_status: TRANSITION_COMPLETE.to_string(),
1443            transitioned_objname: "remote/object".to_string(),
1444            transition_version_id: Some(remote_version_id),
1445            transition_tier: "WARM".to_string(),
1446            mod_time: Some(base_time),
1447            ..Default::default()
1448        })
1449        .expect("transitioned object version should be added");
1450
1451        let mut delete_fi = FileInfo {
1452            volume: "bucket".to_string(),
1453            name: "object".to_string(),
1454            version_id: Some(object_version_id),
1455            mod_time: Some(base_time),
1456            ..Default::default()
1457        };
1458        delete_fi.set_tier_free_version_id(&free_version_id.to_string());
1459        fm.delete_version(&delete_fi)
1460            .expect("transitioned delete should create a free-version record");
1461
1462        fm.add_version(FileInfo {
1463            volume: "bucket".to_string(),
1464            name: "object".to_string(),
1465            version_id: Some(delete_marker_id),
1466            deleted: true,
1467            mod_time: Some(base_time + time::Duration::seconds(1)),
1468            ..Default::default()
1469        })
1470        .expect("delete marker should be added");
1471
1472        let entry = MetaCacheEntry {
1473            name: "object".to_string(),
1474            metadata: fm.marshal_msg().expect("metadata should marshal"),
1475            ..Default::default()
1476        };
1477
1478        let normal = entry.file_info_versions("bucket").expect("normal versions should parse");
1479        assert_eq!(normal.versions.len(), 1);
1480        assert!(normal.free_versions.is_empty());
1481        assert!(normal.versions[0].deleted);
1482        assert_eq!(normal.versions[0].num_versions, 1);
1483
1484        let with_free = entry
1485            .file_info_versions_with_free_versions("bucket")
1486            .expect("versions with free versions should parse");
1487        assert_eq!(with_free.versions.len(), 1);
1488        assert_eq!(with_free.free_versions.len(), 1);
1489        assert_eq!(with_free.versions[0].num_versions, 1);
1490        assert_eq!(with_free.free_versions[0].num_versions, 1);
1491    }
1492
1493    #[test]
1494    fn transitioned_delete_persists_recoverable_free_version_after_metadata_roundtrip() {
1495        let version_id = Uuid::new_v4();
1496        let remote_version_id = Uuid::new_v4();
1497        let free_version_id = Uuid::new_v4();
1498        let mut fm = FileMeta::new();
1499        fm.add_version(FileInfo {
1500            volume: "bucket".to_string(),
1501            name: "object".to_string(),
1502            version_id: Some(version_id),
1503            transition_status: TRANSITION_COMPLETE.to_string(),
1504            transitioned_objname: "remote/object".to_string(),
1505            transition_version_id: Some(remote_version_id),
1506            transition_tier: "WARM".to_string(),
1507            mod_time: Some(OffsetDateTime::now_utc()),
1508            ..Default::default()
1509        })
1510        .expect("transitioned version should be added");
1511
1512        let mut delete_fi = FileInfo {
1513            volume: "bucket".to_string(),
1514            name: "object".to_string(),
1515            version_id: Some(version_id),
1516            mod_time: Some(OffsetDateTime::now_utc()),
1517            ..Default::default()
1518        };
1519        delete_fi.set_tier_free_version_id(&free_version_id.to_string());
1520
1521        fm.delete_version(&delete_fi)
1522            .expect("transitioned delete should persist free-version metadata");
1523        let encoded = fm.marshal_msg().expect("metadata should marshal");
1524        let mut decoded = FileMeta::new();
1525        decoded
1526            .unmarshal_msg(&encoded)
1527            .expect("metadata should survive process restart roundtrip");
1528
1529        let versions = decoded
1530            .get_file_info_versions("bucket", "object", false)
1531            .expect("versions with free versions should parse");
1532
1533        assert!(versions.versions.is_empty());
1534        assert_eq!(versions.free_versions.len(), 1);
1535        let free_version = &versions.free_versions[0];
1536        assert_eq!(free_version.version_id, Some(free_version_id));
1537        assert!(free_version.tier_free_version());
1538        assert_eq!(free_version.transitioned_objname, "remote/object");
1539        assert_eq!(free_version.transition_version_id, Some(remote_version_id));
1540        assert_eq!(free_version.transition_tier, "WARM");
1541    }
1542
1543    #[test]
1544    fn skip_tier_free_version_does_not_persist_cleanup_record() {
1545        let version_id = Uuid::new_v4();
1546        let mut fm = FileMeta::new();
1547        fm.add_version(FileInfo {
1548            volume: "bucket".to_string(),
1549            name: "object".to_string(),
1550            version_id: Some(version_id),
1551            transition_status: TRANSITION_COMPLETE.to_string(),
1552            transitioned_objname: "remote/object".to_string(),
1553            transition_version_id: Some(Uuid::new_v4()),
1554            transition_tier: "WARM".to_string(),
1555            mod_time: Some(OffsetDateTime::now_utc()),
1556            ..Default::default()
1557        })
1558        .expect("transitioned version should be added");
1559
1560        let mut delete_fi = FileInfo {
1561            volume: "bucket".to_string(),
1562            name: "object".to_string(),
1563            version_id: Some(version_id),
1564            mod_time: Some(OffsetDateTime::now_utc()),
1565            ..Default::default()
1566        };
1567        delete_fi.set_tier_free_version_id(&Uuid::new_v4().to_string());
1568        delete_fi.set_skip_tier_free_version();
1569
1570        fm.delete_version(&delete_fi)
1571            .expect("transitioned delete with skip flag should remove the local version");
1572        let versions = fm
1573            .get_file_info_versions("bucket", "object", false)
1574            .expect("versions should parse after skipped cleanup");
1575
1576        assert!(versions.free_versions.is_empty());
1577        assert_eq!(versions.versions.len(), 1);
1578        assert!(versions.versions[0].deleted);
1579        assert!(!versions.versions[0].tier_free_version());
1580    }
1581
1582    #[test]
1583    fn worker_style_free_version_delete_removes_persisted_cleanup_record() {
1584        let version_id = Uuid::new_v4();
1585        let remote_version_id = Uuid::new_v4();
1586        let free_version_id = Uuid::new_v4();
1587        let mut fm = FileMeta::new();
1588        fm.add_version(FileInfo {
1589            volume: "bucket".to_string(),
1590            name: "object".to_string(),
1591            version_id: Some(version_id),
1592            transition_status: TRANSITION_COMPLETE.to_string(),
1593            transitioned_objname: "remote/object".to_string(),
1594            transition_version_id: Some(remote_version_id),
1595            transition_tier: "WARM".to_string(),
1596            mod_time: Some(OffsetDateTime::now_utc()),
1597            ..Default::default()
1598        })
1599        .expect("transitioned version should be added");
1600
1601        let mut delete_fi = FileInfo {
1602            volume: "bucket".to_string(),
1603            name: "object".to_string(),
1604            version_id: Some(version_id),
1605            mod_time: Some(OffsetDateTime::now_utc()),
1606            ..Default::default()
1607        };
1608        delete_fi.set_tier_free_version_id(&free_version_id.to_string());
1609        fm.delete_version(&delete_fi)
1610            .expect("transitioned delete should persist free-version metadata");
1611
1612        let mut free_delete_fi = FileInfo {
1613            volume: "bucket".to_string(),
1614            name: "object".to_string(),
1615            version_id: Some(free_version_id),
1616            deleted: true,
1617            ..Default::default()
1618        };
1619        free_delete_fi.set_tier_free_version();
1620
1621        fm.delete_version(&free_delete_fi)
1622            .expect("worker-style free-version delete should remove the cleanup record");
1623        let versions = fm
1624            .get_file_info_versions("bucket", "object", false)
1625            .expect("versions should parse after free-version cleanup");
1626
1627        assert!(versions.free_versions.is_empty());
1628        assert_eq!(versions.versions.len(), 1);
1629        assert!(versions.versions[0].deleted);
1630        assert!(!versions.versions[0].tier_free_version());
1631    }
1632
1633    #[test]
1634    fn test_resolve_rebuilds_metadata_from_merged_versions() {
1635        let base_metadata = create_real_xlmeta().expect("base xl.meta");
1636        let base = FileMeta::load(&base_metadata).expect("load base xl.meta");
1637
1638        let extra_version = FileMetaVersion {
1639            version_type: VersionType::Delete,
1640            object: None,
1641            delete_marker: Some(MetaDeleteMarker {
1642                version_id: Some(Uuid::from_u128(0x22222222333344445555666666666666)),
1643                mod_time: Some(OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp")),
1644                meta_sys: HashMap::new(),
1645            }),
1646            legacy_object: None,
1647            write_version: 99,
1648            uses_legacy_checksum: false,
1649        };
1650
1651        let extra_shallow = FileMetaShallowVersion::try_from(extra_version).expect("build shallow delete version");
1652
1653        let mut extended = base.clone();
1654        extended.versions.insert(0, extra_shallow);
1655
1656        let base_versions = base.versions.len();
1657        let extended_versions = extended.versions.len();
1658        let extended_metadata = extended.marshal_msg().expect("serialize extended xl.meta");
1659
1660        let resolved = MetaCacheEntries(vec![
1661            Some(MetaCacheEntry {
1662                name: "bucket/object".to_string(),
1663                metadata: extended_metadata,
1664                cached: Some(extended),
1665                reusable: false,
1666            }),
1667            Some(MetaCacheEntry {
1668                name: "bucket/object".to_string(),
1669                metadata: base_metadata,
1670                cached: Some(base),
1671                reusable: false,
1672            }),
1673        ])
1674        .resolve(MetadataResolutionParams {
1675            obj_quorum: 2,
1676            requested_versions: extended_versions,
1677            strict: true,
1678            ..Default::default()
1679        })
1680        .expect("merged entry should resolve");
1681
1682        let cached = resolved.cached.expect("resolved entry should keep merged cached metadata");
1683        let decoded = FileMeta::load(&resolved.metadata).expect("resolved metadata should decode");
1684
1685        assert_eq!(cached.versions.len(), base_versions);
1686        assert_eq!(decoded.versions.len(), base_versions);
1687        assert_eq!(decoded.versions, cached.versions);
1688        assert_ne!(extended_versions, cached.versions.len());
1689    }
1690
1691    fn metacache_entry_with_mod_time(mod_time: OffsetDateTime, etag: &str) -> MetaCacheEntry {
1692        let mut metadata = HashMap::new();
1693        metadata.insert("etag".to_string(), etag.to_string());
1694
1695        let mut meta = FileMeta::new();
1696        meta.add_version(FileInfo {
1697            volume: "bucket".to_string(),
1698            name: "object".to_string(),
1699            size: 1,
1700            mod_time: Some(mod_time),
1701            metadata,
1702            ..Default::default()
1703        })
1704        .expect("test file metadata should accept object version");
1705        let encoded = meta.marshal_msg().expect("test file metadata should marshal");
1706
1707        MetaCacheEntry {
1708            name: "object".to_string(),
1709            metadata: encoded,
1710            cached: Some(meta),
1711            reusable: false,
1712        }
1713    }
1714
1715    fn metacache_entry_with_erasure(
1716        mod_time: OffsetDateTime,
1717        etag: &str,
1718        data_blocks: usize,
1719        parity_blocks: usize,
1720    ) -> MetaCacheEntry {
1721        let mut metadata = HashMap::new();
1722        metadata.insert("etag".to_string(), etag.to_string());
1723
1724        let mut fi = FileInfo::new("object", data_blocks, parity_blocks);
1725        fi.volume = "bucket".to_string();
1726        fi.name = "object".to_string();
1727        fi.size = 1;
1728        fi.mod_time = Some(mod_time);
1729        fi.metadata = metadata;
1730
1731        let mut meta = FileMeta::new();
1732        meta.add_version(fi).expect("test file metadata should accept object version");
1733        let encoded = meta.marshal_msg().expect("test file metadata should marshal");
1734
1735        MetaCacheEntry {
1736            name: "object".to_string(),
1737            metadata: encoded,
1738            cached: Some(meta),
1739            reusable: false,
1740        }
1741    }
1742
1743    fn metacache_entry_with_erasure_versions(versions: &[(OffsetDateTime, &str, usize, usize)]) -> MetaCacheEntry {
1744        let mut meta = FileMeta::new();
1745        for (idx, (mod_time, etag, data_blocks, parity_blocks)) in versions.iter().enumerate() {
1746            let mut metadata = HashMap::new();
1747            metadata.insert("etag".to_string(), (*etag).to_string());
1748
1749            let mut fi = FileInfo::new("object", *data_blocks, *parity_blocks);
1750            fi.volume = "bucket".to_string();
1751            fi.name = "object".to_string();
1752            let version_idx = u128::try_from(idx + 1).expect("test version index should fit u128");
1753            fi.version_id = Some(Uuid::from_u128(version_idx));
1754            fi.versioned = true;
1755            fi.size = 1;
1756            fi.mod_time = Some(*mod_time);
1757            fi.metadata = metadata;
1758
1759            meta.add_version(fi).expect("test file metadata should accept object version");
1760        }
1761        let encoded = meta.marshal_msg().expect("test file metadata should marshal");
1762
1763        MetaCacheEntry {
1764            name: "object".to_string(),
1765            metadata: encoded,
1766            cached: Some(meta),
1767            reusable: false,
1768        }
1769    }
1770
1771    fn metacache_entry_without_header_ec(mut entry: MetaCacheEntry) -> MetaCacheEntry {
1772        let mut cached = entry.cached.take().expect("test entry should have cached metadata");
1773        for version in cached.versions.iter_mut() {
1774            version.header.ec_m = 0;
1775            version.header.ec_n = 0;
1776        }
1777        entry.metadata = cached.marshal_msg().expect("test file metadata should marshal");
1778        entry.cached = Some(cached);
1779        entry
1780    }
1781
1782    fn metacache_dir_entry(name: &str) -> MetaCacheEntry {
1783        MetaCacheEntry {
1784            name: name.to_string(),
1785            ..Default::default()
1786        }
1787    }
1788
1789    /// Build an entry holding a single object version with an explicit version id
1790    /// and mod_time, so a set of these can model DISJOINT per-disk version sets.
1791    fn metacache_entry_single_version(version_u128: u128, mod_time: OffsetDateTime, etag: &str) -> MetaCacheEntry {
1792        let mut metadata = HashMap::new();
1793        metadata.insert("etag".to_string(), etag.to_string());
1794
1795        let mut fi = FileInfo::new("object", 4, 2);
1796        fi.volume = "bucket".to_string();
1797        fi.name = "object".to_string();
1798        fi.version_id = Some(Uuid::from_u128(version_u128));
1799        fi.versioned = true;
1800        fi.size = 1;
1801        fi.mod_time = Some(mod_time);
1802        fi.metadata = metadata;
1803
1804        let mut meta = FileMeta::new();
1805        meta.add_version(fi).expect("test file metadata should accept object version");
1806        let encoded = meta.marshal_msg().expect("test file metadata should marshal");
1807
1808        MetaCacheEntry {
1809            name: "object".to_string(),
1810            metadata: encoded,
1811            cached: Some(meta),
1812            reusable: false,
1813        }
1814    }
1815
1816    /// backlog#920: `resolve_union` surfaces a version present on a SINGLE slot
1817    /// among four, while `resolve` at read-quorum (obj_quorum=2) drops it. This
1818    /// documents the exact sub-quorum durability gap the disk-walk closes.
1819    #[test]
1820    fn resolve_union_surfaces_single_disk_version() {
1821        let t0 = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
1822        let t1 = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
1823
1824        // Slot 0 carries a UNIQUE version (0xBEEF) present nowhere else; the other
1825        // three slots agree on a shared version (0xCAFE). Read-quorum sees only
1826        // the shared one; union must see BOTH.
1827        let unique = metacache_entry_single_version(0xBEEF, t1, "unique-etag");
1828        let shared_a = metacache_entry_single_version(0xCAFE, t0, "shared-etag");
1829        let shared_b = metacache_entry_single_version(0xCAFE, t0, "shared-etag");
1830        let shared_c = metacache_entry_single_version(0xCAFE, t0, "shared-etag");
1831
1832        let entries = || {
1833            MetaCacheEntries(vec![
1834                Some(unique.clone()),
1835                Some(shared_a.clone()),
1836                Some(shared_b.clone()),
1837                Some(shared_c.clone()),
1838            ])
1839        };
1840
1841        let union = entries().resolve_union("bucket").expect("union must resolve an entry");
1842        let union_meta = union.cached.expect("union entry keeps cached metadata");
1843        let union_ids: std::collections::HashSet<Option<Uuid>> =
1844            union_meta.versions.iter().map(|v| v.header.version_id).collect();
1845        assert!(
1846            union_ids.contains(&Some(Uuid::from_u128(0xBEEF))),
1847            "union must surface the single-slot version: {union_ids:?}"
1848        );
1849        assert!(
1850            union_ids.contains(&Some(Uuid::from_u128(0xCAFE))),
1851            "union must also keep the shared version: {union_ids:?}"
1852        );
1853
1854        // Read-quorum resolution (obj_quorum=2) drops the single-slot version.
1855        let read_quorum = entries()
1856            .resolve(MetadataResolutionParams {
1857                obj_quorum: 2,
1858                dir_quorum: 2,
1859                strict: false,
1860                ..Default::default()
1861            })
1862            .expect("read-quorum must still resolve the shared version");
1863        let rq_meta = read_quorum.cached.expect("read-quorum entry keeps cached metadata");
1864        let rq_ids: std::collections::HashSet<Option<Uuid>> = rq_meta.versions.iter().map(|v| v.header.version_id).collect();
1865        assert!(
1866            !rq_ids.contains(&Some(Uuid::from_u128(0xBEEF))),
1867            "read-quorum must DROP the single-slot version (the gap): {rq_ids:?}"
1868        );
1869        assert!(
1870            rq_ids.contains(&Some(Uuid::from_u128(0xCAFE))),
1871            "read-quorum must keep the shared version: {rq_ids:?}"
1872        );
1873    }
1874
1875    #[test]
1876    fn discover_heal_candidates_keeps_sub_quorum_versions_and_deduplicates() {
1877        let now = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
1878        let entries = MetaCacheEntries(vec![
1879            Some(metacache_entry_single_version(1, now, "one")),
1880            Some(metacache_entry_single_version(2, now, "two")),
1881            Some(metacache_entry_single_version(2, now, "two")),
1882            Some(metacache_entry_single_version(3, now, "three")),
1883        ]);
1884
1885        let discovery = entries.discover_heal_candidates("bucket", 16);
1886        let ids: std::collections::HashSet<Uuid> = discovery
1887            .candidates
1888            .iter()
1889            .filter_map(|candidate| candidate.version_id)
1890            .collect();
1891        assert_eq!(
1892            ids,
1893            [Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(3)]
1894                .into_iter()
1895                .collect()
1896        );
1897        assert_eq!(discovery.candidates.len(), 3, "duplicate tied versions must be emitted once");
1898        assert_eq!(
1899            discovery
1900                .candidates
1901                .iter()
1902                .find(|candidate| candidate.version_id == Some(Uuid::from_u128(2)))
1903                .expect("duplicate version should be discovered")
1904                .replica_count,
1905            2
1906        );
1907    }
1908
1909    #[test]
1910    fn discover_heal_candidates_does_not_count_duplicate_versions_within_one_entry() {
1911        let now = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
1912        let mut meta = FileMeta::load(&metacache_entry_single_version(1, now, "duplicate").metadata)
1913            .expect("duplicate fixture should decode");
1914        meta.versions.push(meta.versions[0].clone());
1915        let entry = MetaCacheEntry {
1916            name: "object".to_string(),
1917            metadata: meta.marshal_msg().expect("duplicate metadata should marshal"),
1918            cached: Some(meta),
1919            reusable: false,
1920        };
1921
1922        let discovery = MetaCacheEntries(vec![Some(entry)]).discover_heal_candidates("bucket", 16);
1923        let candidate = discovery
1924            .candidates
1925            .iter()
1926            .find(|candidate| candidate.version_id == Some(Uuid::from_u128(1)))
1927            .expect("duplicate fixture should be discovered");
1928        assert_eq!(candidate.replica_count, 1);
1929    }
1930
1931    #[test]
1932    fn discover_heal_candidates_covers_divergent_quorum_boundaries_n2_n4_n6() {
1933        let now = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
1934
1935        for (disk_count, quorum) in [(2usize, 1usize), (4, 2), (6, 3)] {
1936            let target_id = Uuid::from_u128(0x1000 + disk_count as u128);
1937            for target_replicas in [quorum.saturating_sub(1), quorum, quorum + 1] {
1938                let entries = (0..disk_count)
1939                    .map(|disk| {
1940                        let version_id = if disk < target_replicas {
1941                            target_id
1942                        } else {
1943                            Uuid::from_u128(0x2000 + disk as u128)
1944                        };
1945                        Some(metacache_entry_single_version(version_id.as_u128(), now, "divergent"))
1946                    })
1947                    .collect();
1948                let discovery = MetaCacheEntries(entries).discover_heal_candidates("bucket", 32);
1949                let target = discovery
1950                    .candidates
1951                    .iter()
1952                    .find(|candidate| candidate.version_id == Some(target_id));
1953                assert_eq!(target.is_some(), target_replicas > 0, "N={disk_count}, replicas={target_replicas}");
1954                if let Some(target) = target {
1955                    assert_eq!(target.replica_count, target_replicas);
1956                }
1957            }
1958        }
1959    }
1960
1961    #[test]
1962    fn discover_heal_candidates_separates_delete_markers_and_preserves_unversioned_objects() {
1963        let mut marker_meta = FileMeta::new();
1964        marker_meta
1965            .add_version(FileInfo {
1966                volume: "bucket".to_string(),
1967                name: "object".to_string(),
1968                version_id: Some(Uuid::from_u128(99)),
1969                deleted: true,
1970                mod_time: Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp")),
1971                ..Default::default()
1972            })
1973            .expect("delete marker should be added");
1974        let marker = MetaCacheEntry {
1975            name: "object".to_string(),
1976            metadata: marker_meta.marshal_msg().expect("delete marker metadata should marshal"),
1977            cached: Some(marker_meta),
1978            reusable: false,
1979        };
1980
1981        let unversioned_entry = metacache_entry_with_mod_time(
1982            OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"),
1983            "unversioned",
1984        );
1985        let discovery = MetaCacheEntries(vec![Some(marker), Some(unversioned_entry)]).discover_heal_candidates("bucket", 16);
1986        assert!(discovery.candidates.iter().any(|candidate| {
1987            candidate.kind == MetaCacheHealCandidateKind::DeleteMarker && candidate.version_id == Some(Uuid::from_u128(99))
1988        }));
1989        assert!(discovery.candidates.iter().any(|candidate| {
1990            candidate.kind == MetaCacheHealCandidateKind::UnversionedObject && candidate.version_id.is_none()
1991        }));
1992    }
1993
1994    #[test]
1995    fn discover_heal_candidates_rejects_delete_markers_without_ids() {
1996        let mut marker_meta = FileMeta::new();
1997        marker_meta
1998            .add_version(FileInfo {
1999                volume: "bucket".to_string(),
2000                name: "object".to_string(),
2001                deleted: true,
2002                mod_time: Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp")),
2003                ..Default::default()
2004            })
2005            .expect("nil delete marker should be added");
2006        let discovery = MetaCacheEntries(vec![Some(MetaCacheEntry {
2007            name: "object".to_string(),
2008            metadata: marker_meta.marshal_msg().expect("nil marker metadata should marshal"),
2009            cached: Some(marker_meta),
2010            reusable: false,
2011        })])
2012        .discover_heal_candidates("bucket", 16);
2013
2014        assert!(
2015            !discovery
2016                .candidates
2017                .iter()
2018                .any(|candidate| candidate.kind == MetaCacheHealCandidateKind::DeleteMarker)
2019        );
2020        assert!(discovery.unverified_count >= 1);
2021    }
2022
2023    #[test]
2024    fn discover_heal_candidates_skips_free_versions() {
2025        let object_id = Uuid::from_u128(100);
2026        let free_id = Uuid::from_u128(101);
2027        let mut meta = FileMeta::new();
2028        meta.add_version(FileInfo {
2029            volume: "bucket".to_string(),
2030            name: "object".to_string(),
2031            version_id: Some(object_id),
2032            transition_status: TRANSITION_COMPLETE.to_string(),
2033            transitioned_objname: "remote/object".to_string(),
2034            transition_version_id: Some(Uuid::from_u128(102)),
2035            transition_tier: "WARM".to_string(),
2036            mod_time: Some(OffsetDateTime::now_utc()),
2037            ..Default::default()
2038        })
2039        .expect("transitioned object should be added");
2040        let mut delete = FileInfo {
2041            volume: "bucket".to_string(),
2042            name: "object".to_string(),
2043            version_id: Some(object_id),
2044            mod_time: Some(OffsetDateTime::now_utc()),
2045            ..Default::default()
2046        };
2047        delete.set_tier_free_version_id(&free_id.to_string());
2048        meta.delete_version(&delete).expect("free version should be persisted");
2049
2050        let discovery = MetaCacheEntries(vec![Some(MetaCacheEntry {
2051            name: "object".to_string(),
2052            metadata: meta.marshal_msg().expect("free version metadata should marshal"),
2053            cached: Some(meta),
2054            reusable: false,
2055        })])
2056        .discover_heal_candidates("bucket", 16);
2057        assert!(discovery.candidates.is_empty());
2058    }
2059
2060    #[test]
2061    fn discover_heal_candidates_preserves_unversioned_legacy_object() {
2062        let legacy = MetaObjectV1 {
2063            version: "1.0.1".to_string(),
2064            format: "xl".to_string(),
2065            stat: MetaObjectV1Stat {
2066                size: 1,
2067                mod_time: Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp")),
2068                name: "object".to_string(),
2069                ..Default::default()
2070            },
2071            erasure: MetaObjectV1Erasure {
2072                data_blocks: 4,
2073                parity_blocks: 2,
2074                index: 1,
2075                distribution: vec![1, 2, 3, 4, 5, 6],
2076                ..Default::default()
2077            },
2078            ..Default::default()
2079        };
2080        let version = FileMetaVersion {
2081            version_type: VersionType::Legacy,
2082            legacy_object: Some(legacy),
2083            ..Default::default()
2084        };
2085        let mut meta = FileMeta::new();
2086        meta.versions
2087            .push(FileMetaShallowVersion::try_from(version).expect("legacy metadata should marshal"));
2088        let discovery = MetaCacheEntries(vec![Some(MetaCacheEntry {
2089            name: "object".to_string(),
2090            metadata: meta.marshal_msg().expect("legacy metadata should marshal"),
2091            cached: Some(meta),
2092            reusable: false,
2093        })])
2094        .discover_heal_candidates("bucket", 16);
2095        assert!(discovery.candidates.iter().any(|candidate| {
2096            candidate.kind == MetaCacheHealCandidateKind::UnversionedObject && candidate.version_id.is_none()
2097        }));
2098    }
2099
2100    #[test]
2101    fn discover_heal_candidates_rejects_nil_and_malformed_metadata_and_is_bounded() {
2102        let now = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
2103        let mut nil = metacache_entry_single_version(1, now, "nil");
2104        let mut nil_meta = FileMeta::load(&nil.metadata).expect("nil fixture should decode");
2105        let mut nil_version = nil_meta.versions[0]
2106            .parse_version_meta()
2107            .expect("nil fixture version should decode");
2108        nil_version.object.as_mut().expect("object fixture").version_id = Some(Uuid::nil());
2109        nil_meta.versions[0] = FileMetaShallowVersion::try_from(nil_version).expect("nil fixture should marshal");
2110        nil.metadata = nil_meta.marshal_msg().expect("nil fixture metadata should marshal");
2111
2112        let mut mismatched = metacache_entry_single_version(2, now, "mismatched");
2113        let mut mismatched_meta = FileMeta::load(&mismatched.metadata).expect("mismatched fixture should decode");
2114        mismatched_meta.versions[0].header.version_id = Some(Uuid::from_u128(200));
2115        mismatched.metadata = mismatched_meta.marshal_msg().expect("mismatched metadata should marshal");
2116
2117        let mut short_parts = metacache_entry_single_version(3, now, "short-parts");
2118        let mut short_parts_meta = FileMeta::load(&short_parts.metadata).expect("short-parts fixture should decode");
2119        let mut short_parts_version = short_parts_meta.versions[0]
2120            .parse_version_meta()
2121            .expect("short-parts fixture version should decode");
2122        let object = short_parts_version.object.as_mut().expect("object fixture");
2123        object.part_numbers = vec![1];
2124        object.part_actual_sizes = vec![1];
2125        object.part_sizes.clear();
2126        short_parts_meta.versions[0] = FileMetaShallowVersion::try_from(short_parts_version).expect("short-parts should marshal");
2127        short_parts.metadata = short_parts_meta.marshal_msg().expect("short-parts metadata should marshal");
2128
2129        let mut short_unversioned = metacache_entry_with_mod_time(now, "short-unversioned");
2130        let mut short_unversioned_meta =
2131            FileMeta::load(&short_unversioned.metadata).expect("short-unversioned fixture should decode");
2132        let mut short_unversioned_version = short_unversioned_meta.versions[0]
2133            .parse_version_meta()
2134            .expect("short-unversioned version should decode");
2135        let unversioned_object = short_unversioned_version.object.as_mut().expect("unversioned object fixture");
2136        unversioned_object.part_numbers = vec![1];
2137        unversioned_object.part_actual_sizes = vec![1];
2138        unversioned_object.part_sizes.clear();
2139        short_unversioned_meta.versions[0] =
2140            FileMetaShallowVersion::try_from(short_unversioned_version).expect("short-unversioned should marshal");
2141        short_unversioned.metadata = short_unversioned_meta
2142            .marshal_msg()
2143            .expect("short-unversioned metadata should marshal");
2144
2145        let mut malformed = nil.clone();
2146        malformed.name = "malformed".to_string();
2147        malformed.metadata = vec![1, 2, 3];
2148
2149        let entries = MetaCacheEntries(
2150            std::iter::once(Some(nil))
2151                .chain(std::iter::once(Some(mismatched)))
2152                .chain(std::iter::once(Some(short_parts)))
2153                .chain(std::iter::once(Some(short_unversioned)))
2154                .chain(std::iter::once(Some(malformed)))
2155                .chain((0..32).map(|id| Some(metacache_entry_single_version(id + 10, now, "bounded"))))
2156                .collect(),
2157        );
2158        let discovery = entries.discover_heal_candidates("bucket", 5);
2159        assert!(discovery.candidates.len() <= 5);
2160        assert!(discovery.truncated, "bounded discovery must expose dropped candidates");
2161        assert!(
2162            discovery
2163                .truncated_candidates
2164                .iter()
2165                .all(|candidate| candidate.version_id.is_some()),
2166            "overflow candidates must retain exact version identities"
2167        );
2168        assert!(
2169            discovery.truncated_objects.iter().any(|object| object == "object"),
2170            "bounded discovery must expose an object-level safe continuation"
2171        );
2172        assert!(
2173            !discovery
2174                .candidates
2175                .iter()
2176                .any(|candidate| candidate.version_id == Some(Uuid::nil()))
2177        );
2178        assert!(
2179            !discovery
2180                .candidates
2181                .iter()
2182                .any(|candidate| candidate.version_id == Some(Uuid::from_u128(2)))
2183        );
2184        assert!(
2185            !discovery
2186                .candidates
2187                .iter()
2188                .any(|candidate| candidate.version_id == Some(Uuid::from_u128(3)))
2189        );
2190        assert!(discovery.candidates.iter().any(|candidate| {
2191            candidate.kind == MetaCacheHealCandidateKind::UnversionedObject && candidate.version_id.is_none()
2192        }));
2193        assert!(
2194            discovery.unverified_count >= 1,
2195            "malformed and rejected metadata must remain observable during discovery"
2196        );
2197
2198        for invalid_name in [
2199            "../object",
2200            "./object",
2201            "object/../other",
2202            "object//name",
2203            "object\u{0001}name",
2204            "object\0name",
2205        ] {
2206            let mut entry = metacache_entry_single_version(400, now, invalid_name);
2207            entry.name = invalid_name.to_string();
2208            let discovery = MetaCacheEntries(vec![Some(entry)]).discover_heal_candidates("bucket", 5);
2209            assert!(
2210                discovery.candidates.is_empty(),
2211                "invalid key should not become a heal candidate: {invalid_name:?}"
2212            );
2213        }
2214
2215        #[cfg(windows)]
2216        {
2217            let mut entry = metacache_entry_single_version(400, now, "object\\name");
2218            entry.name = "object\\name".to_string();
2219            assert!(
2220                MetaCacheEntries(vec![Some(entry)])
2221                    .discover_heal_candidates("bucket", 5)
2222                    .candidates
2223                    .is_empty(),
2224                "backslash is a path separator on Windows"
2225            );
2226        }
2227
2228        #[cfg(not(windows))]
2229        {
2230            let mut entry = metacache_entry_single_version(400, now, "object\\name");
2231            entry.name = "object\\name".to_string();
2232            assert_eq!(
2233                MetaCacheEntries(vec![Some(entry)])
2234                    .discover_heal_candidates("bucket", 5)
2235                    .candidates
2236                    .len(),
2237                1,
2238                "backslash is object-key data on Unix"
2239            );
2240        }
2241
2242        for valid_name in ["trailing/", "prefix/object"] {
2243            let mut entry = metacache_entry_single_version(401, now, valid_name);
2244            entry.name = valid_name.to_string();
2245            let discovery = MetaCacheEntries(vec![Some(entry)]).discover_heal_candidates("bucket", 5);
2246            assert_eq!(discovery.candidates.len(), 1, "raw S3 key should remain opaque: {valid_name:?}");
2247        }
2248    }
2249
2250    #[test]
2251    fn resolve_rejects_partial_latest_and_returns_committed_previous_metadata() {
2252        let old_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
2253        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2254        let old_entry = metacache_entry_with_mod_time(old_mod_time, "old-etag");
2255        let new_entry = metacache_entry_with_mod_time(new_mod_time, "new-etag");
2256
2257        let resolved = MetaCacheEntries(vec![
2258            Some(new_entry.clone()),
2259            Some(new_entry),
2260            Some(old_entry.clone()),
2261            Some(old_entry.clone()),
2262            Some(old_entry.clone()),
2263            Some(old_entry.clone()),
2264            Some(old_entry),
2265        ])
2266        .resolve(MetadataResolutionParams {
2267            obj_quorum: 5,
2268            requested_versions: 1,
2269            bucket: "bucket".to_string(),
2270            strict: true,
2271            ..Default::default()
2272        })
2273        .expect("previous committed metadata should still satisfy write quorum");
2274
2275        let info = resolved
2276            .to_fileinfo("bucket")
2277            .expect("resolved committed metadata should decode as file info");
2278        assert_eq!(info.mod_time, Some(old_mod_time));
2279        assert_eq!(info.metadata.get("etag").map(String::as_str), Some("old-etag"));
2280    }
2281
2282    #[test]
2283    fn resolve_rejects_low_parity_partial_latest_below_required_object_quorum() {
2284        let old_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
2285        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2286        let old_entry = metacache_entry_with_erasure(old_mod_time, "old-etag", 7, 1);
2287        let new_entry = metacache_entry_with_erasure(new_mod_time, "new-etag", 7, 1);
2288
2289        let resolved = MetaCacheEntries(vec![
2290            Some(new_entry.clone()),
2291            Some(new_entry.clone()),
2292            Some(new_entry.clone()),
2293            Some(new_entry.clone()),
2294            Some(new_entry),
2295            Some(old_entry.clone()),
2296            Some(old_entry),
2297        ])
2298        .resolve_with_write_quorum(MetadataResolutionParams {
2299            obj_quorum: 5,
2300            requested_versions: 1,
2301            bucket: "bucket".to_string(),
2302            strict: true,
2303            ..Default::default()
2304        });
2305
2306        assert!(resolved.is_none());
2307    }
2308
2309    #[test]
2310    fn resolve_rejects_zero_parity_partial_latest_below_required_object_quorum() {
2311        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2312        let new_entry = metacache_entry_with_erasure(new_mod_time, "new-etag", 7, 0);
2313
2314        let resolved = MetaCacheEntries(vec![
2315            Some(new_entry.clone()),
2316            Some(new_entry.clone()),
2317            Some(new_entry.clone()),
2318            Some(new_entry.clone()),
2319            Some(new_entry.clone()),
2320            Some(new_entry),
2321        ])
2322        .resolve_with_write_quorum(MetadataResolutionParams {
2323            obj_quorum: 5,
2324            requested_versions: 1,
2325            bucket: "bucket".to_string(),
2326            strict: true,
2327            ..Default::default()
2328        });
2329
2330        assert!(resolved.is_none());
2331    }
2332
2333    #[test]
2334    fn resolve_accepts_low_parity_latest_at_required_object_quorum() {
2335        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2336        let new_entry = metacache_entry_with_erasure(new_mod_time, "new-etag", 7, 1);
2337
2338        let resolved = MetaCacheEntries(vec![
2339            Some(new_entry.clone()),
2340            Some(new_entry.clone()),
2341            Some(new_entry.clone()),
2342            Some(new_entry.clone()),
2343            Some(new_entry.clone()),
2344            Some(new_entry.clone()),
2345            Some(new_entry),
2346        ])
2347        .resolve_with_write_quorum(MetadataResolutionParams {
2348            obj_quorum: 5,
2349            requested_versions: 1,
2350            bucket: "bucket".to_string(),
2351            strict: true,
2352            ..Default::default()
2353        })
2354        .expect("latest metadata should resolve after satisfying its own write quorum");
2355
2356        let info = resolved
2357            .to_fileinfo("bucket")
2358            .expect("resolved committed metadata should decode as file info");
2359        assert_eq!(info.mod_time, Some(new_mod_time));
2360        assert_eq!(info.metadata.get("etag").map(String::as_str), Some("new-etag"));
2361    }
2362
2363    #[test]
2364    fn resolve_skips_low_parity_partial_latest_and_returns_committed_previous_version() {
2365        let old_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
2366        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2367        let old_entry = metacache_entry_with_erasure(old_mod_time, "old-etag", 4, 4);
2368        let new_and_old_entry =
2369            metacache_entry_with_erasure_versions(&[(old_mod_time, "old-etag", 4, 4), (new_mod_time, "new-etag", 7, 1)]);
2370
2371        let resolved = MetaCacheEntries(vec![
2372            Some(new_and_old_entry.clone()),
2373            Some(new_and_old_entry.clone()),
2374            Some(new_and_old_entry.clone()),
2375            Some(new_and_old_entry.clone()),
2376            Some(new_and_old_entry),
2377            Some(old_entry.clone()),
2378            Some(old_entry),
2379        ])
2380        .resolve_with_write_quorum(MetadataResolutionParams {
2381            obj_quorum: 5,
2382            requested_versions: 1,
2383            bucket: "bucket".to_string(),
2384            strict: true,
2385            ..Default::default()
2386        })
2387        .expect("previous committed metadata should resolve after rejecting partial latest");
2388
2389        let info = resolved
2390            .to_fileinfo("bucket")
2391            .expect("resolved committed metadata should decode as file info");
2392        assert_eq!(info.mod_time, Some(old_mod_time));
2393        assert_eq!(info.metadata.get("etag").map(String::as_str), Some("old-etag"));
2394    }
2395
2396    #[test]
2397    fn resolve_skips_legacy_header_low_parity_partial_latest_using_payload_quorum() {
2398        let old_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
2399        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2400        let old_entry = metacache_entry_with_erasure(old_mod_time, "old-etag", 4, 4);
2401        let new_and_old_entry = metacache_entry_without_header_ec(metacache_entry_with_erasure_versions(&[
2402            (old_mod_time, "old-etag", 4, 4),
2403            (new_mod_time, "new-etag", 7, 1),
2404        ]));
2405
2406        let resolved = MetaCacheEntries(vec![
2407            Some(new_and_old_entry.clone()),
2408            Some(new_and_old_entry.clone()),
2409            Some(new_and_old_entry.clone()),
2410            Some(new_and_old_entry.clone()),
2411            Some(new_and_old_entry),
2412            Some(old_entry.clone()),
2413            Some(old_entry),
2414        ])
2415        .resolve_with_write_quorum(MetadataResolutionParams {
2416            obj_quorum: 5,
2417            requested_versions: 1,
2418            bucket: "bucket".to_string(),
2419            strict: true,
2420            ..Default::default()
2421        })
2422        .expect("previous committed metadata should resolve after rejecting legacy-header partial latest");
2423
2424        let info = resolved
2425            .to_fileinfo("bucket")
2426            .expect("resolved committed metadata should decode as file info");
2427        assert_eq!(info.mod_time, Some(old_mod_time));
2428        assert_eq!(info.metadata.get("etag").map(String::as_str), Some("old-etag"));
2429    }
2430
2431    #[test]
2432    fn resolve_with_write_quorum_relaxes_requirement_by_unreachable_drives() {
2433        let mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2434        // EC 2+2 version, write quorum 3 of 4: committed with one holder drive
2435        // now offline, so only 2 of the 3 reachable drives carry the metadata.
2436        let entry = metacache_entry_with_erasure(mod_time, "etag", 2, 2);
2437        let entries = || MetaCacheEntries(vec![Some(entry.clone()), Some(entry.clone()), None]);
2438
2439        let resolved = entries()
2440            .resolve_with_write_quorum(MetadataResolutionParams {
2441                obj_quorum: 2,
2442                requested_versions: 1,
2443                bucket: "bucket".to_string(),
2444                strict: true,
2445                write_quorum_slack: 1,
2446                ..Default::default()
2447            })
2448            .expect("committed version should resolve when the missing copy sits on an unreachable drive");
2449        let info = resolved
2450            .to_fileinfo("bucket")
2451            .expect("resolved committed metadata should decode as file info");
2452        assert_eq!(info.mod_time, Some(mod_time));
2453
2454        // Without the slack the same sample is rejected outright.
2455        let rejected = entries().resolve_with_write_quorum(MetadataResolutionParams {
2456            obj_quorum: 3,
2457            requested_versions: 1,
2458            bucket: "bucket".to_string(),
2459            strict: true,
2460            ..Default::default()
2461        });
2462        assert!(rejected.is_none());
2463    }
2464
2465    #[test]
2466    fn resolve_with_write_quorum_slack_accepts_committed_latest_during_merge() {
2467        let old_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
2468        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2469        // The newer EC 2+2 version committed at write quorum 3 of 4; one holder
2470        // drive is offline, and a third reachable drive still carries only the
2471        // previous version. The candidates disagree, so the merge path (not the
2472        // all-agree branch) must honor the slack.
2473        let old_entry = metacache_entry_with_erasure(old_mod_time, "old-etag", 2, 2);
2474        let new_and_old_entry =
2475            metacache_entry_with_erasure_versions(&[(old_mod_time, "old-etag", 2, 2), (new_mod_time, "new-etag", 2, 2)]);
2476
2477        let resolved = MetaCacheEntries(vec![Some(new_and_old_entry.clone()), Some(new_and_old_entry), Some(old_entry)])
2478            .resolve_with_write_quorum(MetadataResolutionParams {
2479                obj_quorum: 2,
2480                requested_versions: 1,
2481                bucket: "bucket".to_string(),
2482                strict: true,
2483                write_quorum_slack: 1,
2484                ..Default::default()
2485            })
2486            .expect("committed latest should survive the merge when its missing copy is on an unreachable drive");
2487        let info = resolved
2488            .to_fileinfo("bucket")
2489            .expect("resolved committed metadata should decode as file info");
2490        assert_eq!(info.mod_time, Some(new_mod_time));
2491        assert_eq!(info.metadata.get("etag").map(String::as_str), Some("new-etag"));
2492    }
2493
2494    #[test]
2495    fn resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge() {
2496        let old_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
2497        let new_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
2498        // The newer EC 2+2 version (write quorum 3) reached only ONE drive: even
2499        // with one drive unreachable (slack 1) it cannot prove the relaxed
2500        // quorum of 2, so the committed previous version must win the merge.
2501        let old_entry = metacache_entry_with_erasure(old_mod_time, "old-etag", 2, 2);
2502        let new_and_old_entry =
2503            metacache_entry_with_erasure_versions(&[(old_mod_time, "old-etag", 2, 2), (new_mod_time, "new-etag", 2, 2)]);
2504
2505        let resolved = MetaCacheEntries(vec![Some(new_and_old_entry), Some(old_entry.clone()), Some(old_entry)])
2506            .resolve_with_write_quorum(MetadataResolutionParams {
2507                obj_quorum: 2,
2508                requested_versions: 1,
2509                bucket: "bucket".to_string(),
2510                strict: true,
2511                write_quorum_slack: 1,
2512                ..Default::default()
2513            })
2514            .expect("committed previous version should resolve after rejecting the partial latest");
2515        let info = resolved
2516            .to_fileinfo("bucket")
2517            .expect("resolved committed metadata should decode as file info");
2518        assert_eq!(info.mod_time, Some(old_mod_time));
2519        assert_eq!(info.metadata.get("etag").map(String::as_str), Some("old-etag"));
2520    }
2521
2522    #[test]
2523    fn resolve_rejects_partial_directory_below_dir_quorum() {
2524        let partial_dir = metacache_dir_entry("prefix/");
2525
2526        let resolved = MetaCacheEntries(vec![Some(partial_dir.clone()), Some(partial_dir)]).resolve(MetadataResolutionParams {
2527            dir_quorum: 5,
2528            obj_quorum: 5,
2529            bucket: "bucket".to_string(),
2530            strict: true,
2531            ..Default::default()
2532        });
2533
2534        assert!(resolved.is_none());
2535    }
2536
2537    fn build_hashmap_cache(update_size: usize) -> Arc<Cache<HashMap<usize, usize>>> {
2538        let generation = Arc::new(AtomicUsize::new(0));
2539        Arc::new(Cache::new(
2540            Box::new(move || {
2541                let generation = Arc::clone(&generation);
2542                Box::pin(async move {
2543                    let v = generation.fetch_add(1, Ordering::SeqCst);
2544                    let mut m = HashMap::with_capacity(update_size);
2545                    for i in 0..update_size {
2546                        m.insert(i, i ^ v);
2547                    }
2548                    Ok(m)
2549                })
2550            }),
2551            Duration::ZERO,
2552            Opts::default(),
2553        ))
2554    }
2555
2556    async fn run_cache_workload(cache: Arc<Cache<HashMap<usize, usize>>>, workers: usize, rounds: usize, probe_mod: usize) {
2557        let mut tasks = Vec::with_capacity(workers);
2558        for worker in 0..workers {
2559            let cache = Arc::clone(&cache);
2560            tasks.push(tokio::spawn(async move {
2561                for round in 0..rounds {
2562                    let m = Arc::clone(&cache).get().await.expect("cache get should succeed");
2563                    let key = (worker.wrapping_mul(17).wrapping_add(round)) % probe_mod;
2564                    assert!(m.contains_key(&key), "expected key {key} to exist");
2565                }
2566            }));
2567        }
2568
2569        for task in tasks {
2570            task.await.expect("worker task should not panic");
2571        }
2572    }
2573
2574    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
2575    async fn test_cache_concurrency_smoke() {
2576        let cache = build_hashmap_cache(2048);
2577        run_cache_workload(cache, 32, 120, 2048).await;
2578    }
2579
2580    #[tokio::test]
2581    async fn test_cache_get_shared_reuses_fresh_value() {
2582        let calls = Arc::new(AtomicUsize::new(0));
2583        let cache = Arc::new(Cache::new(
2584            Box::new({
2585                let calls = Arc::clone(&calls);
2586                move || {
2587                    let calls = Arc::clone(&calls);
2588                    Box::pin(async move { Ok(calls.fetch_add(1, Ordering::SeqCst)) })
2589                }
2590            }),
2591            Duration::from_secs(60),
2592            Opts::default(),
2593        ));
2594
2595        let first = Arc::clone(&cache).get_shared().await.expect("prime cache should succeed");
2596        let second = Arc::clone(&cache).get_shared().await.expect("fresh cache hit should succeed");
2597
2598        assert!(Arc::ptr_eq(&first, &second));
2599        assert_eq!(*first, 0);
2600        assert_eq!(calls.load(Ordering::SeqCst), 1);
2601    }
2602
2603    #[tokio::test]
2604    async fn test_cache_future_last_update_refreshes_instead_of_underflowing() {
2605        let calls = Arc::new(AtomicUsize::new(0));
2606        let cache = Arc::new(Cache::new(
2607            Box::new({
2608                let calls = Arc::clone(&calls);
2609                move || {
2610                    let calls = Arc::clone(&calls);
2611                    Box::pin(async move { Ok(calls.fetch_add(1, Ordering::SeqCst)) })
2612                }
2613            }),
2614            Duration::from_secs(60),
2615            Opts::default(),
2616        ));
2617
2618        let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
2619        assert_eq!(prime, 0);
2620
2621        let now = Cache::<usize>::current_unix_secs();
2622        cache.last_update_secs.store(now.saturating_add(60), AtomicOrdering::SeqCst);
2623
2624        let refreshed = Arc::clone(&cache)
2625            .get()
2626            .await
2627            .expect("future timestamp should force refresh instead of underflowing");
2628        assert_eq!(refreshed, 1);
2629        assert_eq!(calls.load(Ordering::SeqCst), 2);
2630    }
2631
2632    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2633    async fn test_cache_no_wait_returns_stale_and_refreshes_in_background() {
2634        let calls = Arc::new(AtomicUsize::new(0));
2635        let (bg_started_tx, bg_started_rx) = oneshot::channel::<()>();
2636        let (release_bg_tx, release_bg_rx) = oneshot::channel::<()>();
2637        let bg_started_tx = Arc::new(StdMutex::new(Some(bg_started_tx)));
2638        let release_bg_rx = Arc::new(StdMutex::new(Some(release_bg_rx)));
2639
2640        let cache = Arc::new(Cache::new(
2641            Box::new({
2642                let calls = Arc::clone(&calls);
2643                let bg_started_tx = Arc::clone(&bg_started_tx);
2644                let release_bg_rx = Arc::clone(&release_bg_rx);
2645                move || {
2646                    let calls = Arc::clone(&calls);
2647                    let bg_started_tx = Arc::clone(&bg_started_tx);
2648                    let release_bg_rx = Arc::clone(&release_bg_rx);
2649                    Box::pin(async move {
2650                        let call = calls.fetch_add(1, Ordering::SeqCst);
2651                        if call == 1 {
2652                            let tx = { bg_started_tx.lock().expect("start sender lock should not poison").take() };
2653                            if let Some(tx) = tx {
2654                                let _ = tx.send(());
2655                            }
2656                            let rx = { release_bg_rx.lock().expect("release receiver lock should not poison").take() };
2657                            if let Some(rx) = rx {
2658                                let _ = rx.await;
2659                            }
2660                        }
2661                        Ok(call)
2662                    })
2663                }
2664            }),
2665            Duration::from_secs(1),
2666            Opts {
2667                return_last_good: true,
2668                no_wait: true,
2669            },
2670        ));
2671
2672        let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
2673        assert_eq!(prime, 0);
2674
2675        let now = Cache::<usize>::current_unix_secs();
2676        cache.last_update_secs.store(now.saturating_sub(1), AtomicOrdering::SeqCst);
2677
2678        let stale = tokio::time::timeout(Duration::from_millis(200), Arc::clone(&cache).get())
2679            .await
2680            .expect("no_wait path should return without waiting for refresh")
2681            .expect("stale get should succeed");
2682        assert_eq!(stale, 0);
2683
2684        tokio::time::timeout(Duration::from_millis(200), bg_started_rx)
2685            .await
2686            .expect("background refresh should start")
2687            .expect("background start signal should be delivered");
2688
2689        release_bg_tx.send(()).expect("release signal should be delivered");
2690
2691        tokio::time::timeout(Duration::from_secs(1), async {
2692            loop {
2693                if cache.cached_value().as_deref() == Some(&1) {
2694                    break;
2695                }
2696                tokio::time::sleep(Duration::from_millis(10)).await;
2697            }
2698        })
2699        .await
2700        .expect("background refresh should complete");
2701    }
2702
2703    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2704    async fn test_cache_no_wait_coalesces_background_refreshes() {
2705        let calls = Arc::new(AtomicUsize::new(0));
2706        let release_refresh = Arc::new(Notify::new());
2707        let ttl = Duration::from_secs(60);
2708        let cache = Arc::new(Cache::new(
2709            Box::new({
2710                let calls = Arc::clone(&calls);
2711                let release_refresh = Arc::clone(&release_refresh);
2712                move || {
2713                    let calls = Arc::clone(&calls);
2714                    let release_refresh = Arc::clone(&release_refresh);
2715                    Box::pin(async move {
2716                        let call = calls.fetch_add(1, Ordering::SeqCst);
2717                        if call > 0 {
2718                            release_refresh.notified().await;
2719                        }
2720                        Ok(call)
2721                    })
2722                }
2723            }),
2724            ttl,
2725            Opts {
2726                return_last_good: true,
2727                no_wait: true,
2728            },
2729        ));
2730
2731        let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
2732        assert_eq!(prime, 0);
2733
2734        let now = Cache::<usize>::current_unix_secs();
2735        cache
2736            .last_update_secs
2737            .store(now.saturating_sub(ttl.as_secs()), AtomicOrdering::SeqCst);
2738
2739        let stale = Arc::clone(&cache).get().await.expect("stale get should succeed");
2740        assert_eq!(stale, 0);
2741
2742        tokio::time::timeout(Duration::from_secs(1), async {
2743            loop {
2744                if calls.load(Ordering::SeqCst) == 2 {
2745                    break;
2746                }
2747                tokio::time::sleep(Duration::from_millis(10)).await;
2748            }
2749        })
2750        .await
2751        .expect("background refresh should start");
2752
2753        let mut readers = Vec::new();
2754        for _ in 0..8 {
2755            let cache = Arc::clone(&cache);
2756            readers.push(tokio::spawn(
2757                async move { Arc::clone(&cache).get().await.expect("stale get should succeed") },
2758            ));
2759        }
2760
2761        for reader in readers {
2762            assert_eq!(reader.await.expect("reader task should not panic"), 0);
2763        }
2764
2765        tokio::time::sleep(Duration::from_millis(50)).await;
2766        assert_eq!(calls.load(Ordering::SeqCst), 2);
2767
2768        release_refresh.notify_waiters();
2769    }
2770
2771    #[tokio::test]
2772    async fn test_cache_return_last_good_on_refresh_error() {
2773        let calls = Arc::new(AtomicUsize::new(0));
2774        let cache = Arc::new(Cache::new(
2775            Box::new({
2776                let calls = Arc::clone(&calls);
2777                move || {
2778                    let calls = Arc::clone(&calls);
2779                    Box::pin(async move {
2780                        let call = calls.fetch_add(1, Ordering::SeqCst);
2781                        if call == 0 {
2782                            Ok(42usize)
2783                        } else {
2784                            Err(std::io::Error::other("refresh failed"))
2785                        }
2786                    })
2787                }
2788            }),
2789            Duration::from_secs(1),
2790            Opts {
2791                return_last_good: true,
2792                no_wait: false,
2793            },
2794        ));
2795
2796        let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
2797        assert_eq!(prime, 42);
2798
2799        let now = Cache::<usize>::current_unix_secs();
2800        cache.last_update_secs.store(now.saturating_sub(2), AtomicOrdering::SeqCst);
2801
2802        let stale = Arc::clone(&cache)
2803            .get()
2804            .await
2805            .expect("return_last_good should keep stale value");
2806        assert_eq!(stale, 42);
2807        assert_eq!(calls.load(Ordering::SeqCst), 2);
2808    }
2809
2810    #[tokio::test]
2811    async fn test_cache_refresh_error_without_return_last_good() {
2812        let calls = Arc::new(AtomicUsize::new(0));
2813        let cache = Arc::new(Cache::new(
2814            Box::new({
2815                let calls = Arc::clone(&calls);
2816                move || {
2817                    let calls = Arc::clone(&calls);
2818                    Box::pin(async move {
2819                        let call = calls.fetch_add(1, Ordering::SeqCst);
2820                        if call == 0 {
2821                            Ok(7usize)
2822                        } else {
2823                            Err(std::io::Error::other("refresh failed"))
2824                        }
2825                    })
2826                }
2827            }),
2828            Duration::from_secs(1),
2829            Opts {
2830                return_last_good: false,
2831                no_wait: false,
2832            },
2833        ));
2834
2835        let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
2836        assert_eq!(prime, 7);
2837
2838        let now = Cache::<usize>::current_unix_secs();
2839        cache.last_update_secs.store(now.saturating_sub(2), AtomicOrdering::SeqCst);
2840
2841        let err = Arc::clone(&cache)
2842            .get()
2843            .await
2844            .expect_err("refresh error should be propagated when return_last_good is false");
2845        assert_eq!(err.kind(), std::io::ErrorKind::Other);
2846        assert_eq!(calls.load(Ordering::SeqCst), 2);
2847    }
2848
2849    // ------------------------------------------------------------------
2850    // backlog#900: metacache boundaries live outside the HTTP CatchPanicLayer
2851    // (background / listing chains). A corrupt-part xl.meta must yield Err,
2852    // not panic, so it cannot poison a worker.
2853    // ------------------------------------------------------------------
2854
2855    fn corrupt_parts_filemeta() -> FileMeta {
2856        use crate::{ChecksumAlgo, ErasureAlgo, MetaObject, VersionType};
2857        let mut fm = FileMeta::new();
2858        fm.add_version_filemata(FileMetaVersion {
2859            version_type: VersionType::Object,
2860            object: Some(MetaObject {
2861                version_id: Some(Uuid::new_v4()),
2862                erasure_algorithm: ErasureAlgo::ReedSolomon,
2863                erasure_m: 2,
2864                erasure_n: 2,
2865                erasure_block_size: 1 << 20,
2866                bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
2867                part_numbers: vec![1, 2],
2868                part_sizes: vec![10], // corrupt: shorter than part_numbers
2869                part_actual_sizes: vec![10, 20],
2870                mod_time: Some(time::OffsetDateTime::now_utc()),
2871                ..Default::default()
2872            }),
2873            ..Default::default()
2874        })
2875        .expect("add version");
2876        // Round-trip once so the corrupt array survives exactly like on disk.
2877        FileMeta::load(&fm.marshal_msg().expect("marshal")).expect("load")
2878    }
2879
2880    #[test]
2881    fn metacache_to_fileinfo_returns_err_not_panic_on_corrupt_parts() {
2882        let entry = MetaCacheEntry {
2883            name: "obj".to_string(),
2884            metadata: Vec::new(),
2885            cached: Some(corrupt_parts_filemeta()),
2886            reusable: false,
2887        };
2888        let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| entry.to_fileinfo("bucket")));
2889        let inner = caught.expect("to_fileinfo must not panic");
2890        assert!(matches!(inner, Err(Error::FileCorrupt)), "expected FileCorrupt from metacache boundary");
2891    }
2892
2893    #[test]
2894    fn metacache_file_info_versions_returns_err_not_panic_on_corrupt_parts() {
2895        // file_info_versions reads self.metadata (not cached), so fill marshaled bytes.
2896        let bytes = corrupt_parts_filemeta().marshal_msg().expect("marshal");
2897        let entry = MetaCacheEntry {
2898            name: "obj".to_string(),
2899            metadata: bytes,
2900            cached: None,
2901            reusable: false,
2902        };
2903        let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| entry.file_info_versions("bucket")));
2904        let inner = caught.expect("file_info_versions must not panic");
2905        assert!(matches!(inner, Err(Error::FileCorrupt)), "expected FileCorrupt");
2906    }
2907
2908    #[test]
2909    fn metacache_to_fileinfo_ignores_part_checksum_sidecar_regardless_of_cache_state() {
2910        let mut meta = FileMeta::load(&create_real_xlmeta().expect("create real xl.meta")).expect("load real xl.meta");
2911        let version_id = Uuid::parse_str("01234567-89ab-cdef-0123-456789abcdef").expect("valid fixture version id");
2912        let (index, mut version) = meta.find_version(Some(version_id)).expect("find fixture object version");
2913        rustfs_utils::http::insert_bytes(
2914            &mut version.object.as_mut().expect("fixture object").meta_sys,
2915            rustfs_utils::http::SUFFIX_PART_CHECKSUMS,
2916            b"not-json".to_vec(),
2917        );
2918        meta.versions[index] = FileMetaShallowVersion::try_from(version).expect("replace fixture object version");
2919        let encoded = meta.marshal_msg().expect("marshal object metadata");
2920
2921        let uncached = MetaCacheEntry {
2922            name: "object".to_string(),
2923            metadata: encoded.clone(),
2924            cached: None,
2925            reusable: false,
2926        }
2927        .to_fileinfo("bucket")
2928        .expect("uncached metacache conversion must stay lazy");
2929        let cached = MetaCacheEntry {
2930            name: "object".to_string(),
2931            metadata: encoded,
2932            cached: Some(meta),
2933            reusable: false,
2934        }
2935        .to_fileinfo("bucket")
2936        .expect("cached metacache conversion must stay lazy");
2937
2938        assert_eq!(cached, uncached);
2939        assert!(cached.parts.iter().all(|part| part.checksums.is_none()));
2940    }
2941}