1use 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;
45pub 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 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 pub name: String,
69 pub metadata: Vec<u8>,
72
73 #[serde(skip)]
75 pub cached: Option<FileMeta>,
76
77 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 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 pub truncated_objects: Vec<String>,
120 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 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 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 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 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 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 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 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 ¶ms.candidates,
737 )
738 } else {
739 merge_file_meta_versions(params.obj_quorum, params.strict, params.requested_versions, ¶ms.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 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 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 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 data.push(len_marker);
1319 data.extend_from_slice(&len.to_be_bytes());
1320 data
1321 }
1322
1323 #[tokio::test]
1327 async fn test_reader_rejects_corrupt_bin_length_prefix() {
1328 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 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 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 #[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 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 #[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 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 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 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 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 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 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 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], 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 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 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}