1use std::collections::BinaryHeap;
2use std::fs::File;
3use std::io;
4use std::num::NonZeroU64;
5use std::path::PathBuf;
6use std::time::{Duration, SystemTime};
7
8use crate::build_id::{MAX_BUILD_ID_LEN, hex_nibble};
9use crate::error::io_error;
10use crate::manage::{FailureRecord, is_corrupt, remove_file_if_exists, try_lock_file};
11use crate::{BuildId, Cache, Result, layout};
12
13const STALE_TEMPORARY_AGE: Duration = Duration::from_secs(24 * 60 * 60);
14const MIN_PRUNE_BATCH_SIZE: usize = 256;
15const MAX_PRUNE_BATCH_SIZE: usize = 65_536;
16
17#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
20pub struct ByteLimit(NonZeroU64);
21
22impl ByteLimit {
23 #[must_use]
25 pub const fn new(bytes: u64) -> Option<Self> {
26 match NonZeroU64::new(bytes) {
27 Some(bytes) => Some(Self(bytes)),
28 None => None,
29 }
30 }
31
32 #[must_use]
34 pub const fn get(self) -> u64 {
35 self.0.get()
36 }
37}
38
39#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
41#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
42pub struct EntryLimit(NonZeroU64);
43
44impl EntryLimit {
45 #[must_use]
47 pub const fn new(entries: u64) -> Option<Self> {
48 match NonZeroU64::new(entries) {
49 Some(entries) => Some(Self(entries)),
50 None => None,
51 }
52 }
53
54 #[must_use]
56 pub const fn get(self) -> u64 {
57 self.0.get()
58 }
59}
60
61macro_rules! impl_limit_conversions {
62 ($limit:ty) => {
63 impl From<NonZeroU64> for $limit {
64 fn from(value: NonZeroU64) -> Self {
65 Self(value)
66 }
67 }
68
69 impl From<$limit> for NonZeroU64 {
70 fn from(limit: $limit) -> Self {
71 limit.0
72 }
73 }
74
75 impl From<$limit> for u64 {
76 fn from(limit: $limit) -> Self {
77 limit.get()
78 }
79 }
80
81 impl TryFrom<u64> for $limit {
82 type Error = std::num::TryFromIntError;
83
84 fn try_from(value: u64) -> std::result::Result<Self, Self::Error> {
85 NonZeroU64::try_from(value).map(Self)
86 }
87 }
88 };
89}
90
91impl_limit_conversions!(ByteLimit);
92impl_limit_conversions!(EntryLimit);
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
97pub struct PrunePolicy {
98 bytes: ByteLimit,
99 entries: Option<EntryLimit>,
100 unused_age: Option<Duration>,
101}
102
103impl PrunePolicy {
104 #[must_use]
106 pub const fn new(max_bytes: ByteLimit) -> Self {
107 Self {
108 bytes: max_bytes,
109 entries: None,
110 unused_age: None,
111 }
112 }
113
114 #[must_use]
116 pub const fn max_entries(mut self, limit: EntryLimit) -> Self {
117 self.entries = Some(limit);
118 self
119 }
120
121 #[must_use]
123 pub const fn max_unused_age(mut self, age: Duration) -> Self {
124 self.unused_age = Some(age);
125 self
126 }
127
128 #[must_use]
130 pub const fn byte_limit(self) -> ByteLimit {
131 self.bytes
132 }
133
134 #[must_use]
136 pub const fn entry_limit(self) -> Option<EntryLimit> {
137 self.entries
138 }
139
140 #[must_use]
142 pub const fn unused_age(self) -> Option<Duration> {
143 self.unused_age
144 }
145}
146
147#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
148impl Cache {
149 pub fn stats(&self) -> Result<CacheStats> {
156 object_totals(self)
157 }
158
159 pub fn prune(&self, policy: PrunePolicy) -> Result<PruneOutcome> {
173 self.prepare()?;
174 let Some(_maintenance_lock) = try_maintenance_lock(self)? else {
175 return Ok(PruneOutcome::Busy);
176 };
177
178 let now = SystemTime::now();
179 let mut progress = match policy.unused_age {
180 Some(age) => prune_by_age(self, age, now)?,
181 None => PruneProgress::new(object_totals(self)?),
182 };
183 let byte_triggered = progress.before.bytes > policy.bytes.get();
184 let entry_triggered = policy
185 .entries
186 .is_some_and(|limit| progress.before.entries > limit.get());
187 if !byte_triggered && !entry_triggered {
188 return Ok(PruneOutcome::Completed(progress.report()));
189 }
190 let target_bytes = low_watermark(policy.bytes.get());
191 let target_entries = policy.entries.map(|limit| low_watermark(limit.get()));
192 let over_target = |stats: CacheStats| {
193 (byte_triggered && stats.bytes > target_bytes)
194 || (entry_triggered && target_entries.is_some_and(|target| stats.entries > target))
195 };
196 let batch_size = prune_batch_size(
197 progress.after,
198 byte_triggered.then_some(target_bytes),
199 target_entries.filter(|_| entry_triggered),
200 );
201 let mut candidate_storage = Vec::with_capacity(batch_size);
202 let mut retried_changed_batch = false;
203
204 'prune: loop {
205 if !over_target(progress.after) {
206 break;
207 }
208
209 let mut candidates = BinaryHeap::from(std::mem::take(&mut candidate_storage));
210 visit_object_entries(self, |prefix, suffix, decoded_len, metadata| {
211 let Some(build_id) = decode_build_id_bytes(prefix, suffix, decoded_len) else {
212 return Ok(());
213 };
214 if progress
215 .busy_slots
216 .contains(layout::lock_slot(build_id.as_bytes()))
217 {
218 return Ok(());
219 }
220 let last_used = access_time_bytes(self, build_id.as_bytes(), now)
221 .or_else(|| metadata.modified().ok())
222 .unwrap_or(SystemTime::UNIX_EPOCH);
223 retain_oldest(
224 &mut candidates,
225 batch_size,
226 build_id,
227 metadata.len(),
228 last_used,
229 );
230 Ok(())
231 })?;
232 if candidates.is_empty() {
233 break;
234 }
235
236 let mut advanced = false;
237 let mut changed = false;
238 candidate_storage = candidates.into_sorted_vec();
239 #[expect(
240 clippy::iter_with_drain,
241 reason = "draining retains the bounded candidate allocation for the next scan"
242 )]
243 for candidate in candidate_storage.drain(..) {
244 if !over_target(progress.after) {
245 break 'prune;
246 }
247 let slot = layout::lock_slot(candidate.build_id.as_bytes());
248 if progress.busy_slots.contains(slot) {
249 continue;
250 }
251 match try_remove_prune_candidate(self, &candidate, now)? {
252 PruneCandidateOutcome::Removed => {
253 progress.record_removed(candidate.len);
254 advanced = true;
255 }
256 PruneCandidateOutcome::Busy => {
257 if progress.record_busy(slot) {
258 advanced = true;
259 }
260 }
261 PruneCandidateOutcome::Changed => changed = true,
262 }
263 }
264
265 if advanced {
266 retried_changed_batch = false;
267 } else if changed && !retried_changed_batch {
268 retried_changed_batch = true;
269 } else {
270 break;
271 }
272 }
273
274 Ok(PruneOutcome::Completed(progress.report()))
275 }
276
277 pub fn scrub(&self) -> Result<ScrubOutcome> {
292 self.prepare()?;
293 let Some(_maintenance_lock) = try_maintenance_lock(self)? else {
294 return Ok(ScrubOutcome::Busy);
295 };
296
297 let mut report = ScrubReport::default();
298 visit_object_build_ids(self, |build_id, object| {
299 let Some(_entry_lock) = try_entry_lock(self, &build_id)? else {
300 report.skipped_busy = report.skipped_busy.saturating_add(1);
301 return Ok(());
302 };
303 let path = object.path();
304 let Some(entry) = self.lookup_path(&path)? else {
305 return Ok(());
306 };
307 report.checked = report.checked.saturating_add(1);
308 match crate::manage::verify_file(entry.file(), &build_id, &path) {
309 Ok(()) => {}
310 Err(error) if is_corrupt(&error) => {
311 drop(entry);
312 if remove_object(self, &build_id, &path)? {
313 report.removed_corrupt = report.removed_corrupt.saturating_add(1);
314 }
315 }
316 Err(error) => return Err(error),
317 }
318 Ok(())
319 })?;
320 let (removed_temporary, skipped_temporary) =
321 scrub_temporary_files(self, SystemTime::now())?;
322 report.removed_negative = scrub_negative_records(self)?;
323 report.removed_access = scrub_orphan_access_markers(self)?;
324 report.removed_temporary = removed_temporary;
325 report.skipped_busy = report.skipped_busy.saturating_add(skipped_temporary);
326 Ok(ScrubOutcome::Completed(report))
327 }
328}
329
330fn prune_by_age(cache: &Cache, age: Duration, now: SystemTime) -> Result<PruneProgress> {
331 let mut progress = PruneProgress::default();
332 visit_object_entries(cache, |prefix, suffix, decoded_len, metadata| {
333 let Some(build_id) = decode_build_id_bytes(prefix, suffix, decoded_len) else {
334 return Ok(());
335 };
336 let len = metadata.len();
337 progress.before.entries = progress.before.entries.saturating_add(1);
338 progress.before.bytes = progress.before.bytes.saturating_add(len);
339 progress.after.entries = progress.after.entries.saturating_add(1);
340 progress.after.bytes = progress.after.bytes.saturating_add(len);
341 let slot = layout::lock_slot(build_id.as_bytes());
342 if progress.busy_slots.contains(slot) {
343 return Ok(());
344 }
345 let last_used = access_time_bytes(cache, build_id.as_bytes(), now)
346 .or_else(|| metadata.modified().ok())
347 .unwrap_or(SystemTime::UNIX_EPOCH);
348 if now.duration_since(last_used).unwrap_or_default() < age {
349 return Ok(());
350 }
351 let Some(build_id) = CandidateBuildId::from_decoded(build_id) else {
352 return Ok(());
353 };
354 let candidate = Candidate {
355 last_used,
356 build_id,
357 len,
358 };
359 match try_remove_prune_candidate(cache, &candidate, now)? {
360 PruneCandidateOutcome::Removed => {
361 progress.record_removed(len);
362 }
363 PruneCandidateOutcome::Busy => {
364 let _ = progress.record_busy(slot);
365 }
366 PruneCandidateOutcome::Changed => {}
367 }
368 Ok(())
369 })?;
370 Ok(progress)
371}
372
373#[derive(Default)]
374struct PruneProgress {
375 before: CacheStats,
376 after: CacheStats,
377 removed: u64,
378 skipped_busy: u64,
379 busy_slots: BusySlots,
380}
381
382impl PruneProgress {
383 fn new(stats: CacheStats) -> Self {
384 Self {
385 before: stats,
386 after: stats,
387 ..Self::default()
388 }
389 }
390
391 const fn report(self) -> PruneReport {
392 PruneReport {
393 before: self.before,
394 after: self.after,
395 removed: self.removed,
396 skipped_busy: self.skipped_busy,
397 }
398 }
399
400 const fn record_removed(&mut self, len: u64) {
401 self.after.bytes = self.after.bytes.saturating_sub(len);
402 self.after.entries = self.after.entries.saturating_sub(1);
403 self.removed = self.removed.saturating_add(1);
404 }
405
406 fn record_busy(&mut self, slot: u16) -> bool {
407 let inserted = self.busy_slots.insert(slot);
408 if inserted {
409 self.skipped_busy = self.skipped_busy.saturating_add(1);
410 }
411 inserted
412 }
413}
414
415#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
417#[non_exhaustive]
418#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
419pub struct CacheStats {
420 pub entries: u64,
422 pub bytes: u64,
424}
425
426#[derive(Clone, Copy, Debug, Eq, PartialEq)]
428#[must_use]
429#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
430pub enum PruneOutcome {
431 Completed(PruneReport),
433 Busy,
435}
436
437impl PruneOutcome {
438 #[must_use]
440 pub const fn is_busy(&self) -> bool {
441 matches!(self, Self::Busy)
442 }
443
444 #[must_use]
446 pub const fn into_report(self) -> Option<PruneReport> {
447 match self {
448 Self::Completed(report) => Some(report),
449 Self::Busy => None,
450 }
451 }
452}
453
454#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
456#[non_exhaustive]
457#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
458pub struct PruneReport {
459 pub before: CacheStats,
461 pub after: CacheStats,
463 pub removed: u64,
465 pub skipped_busy: u64,
467}
468
469#[derive(Clone, Copy, Debug, Eq, PartialEq)]
471#[must_use]
472#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
473pub enum ScrubOutcome {
474 Completed(ScrubReport),
476 Busy,
478}
479
480impl ScrubOutcome {
481 #[must_use]
483 pub const fn is_busy(&self) -> bool {
484 matches!(self, Self::Busy)
485 }
486
487 #[must_use]
489 pub const fn into_report(self) -> Option<ScrubReport> {
490 match self {
491 Self::Completed(report) => Some(report),
492 Self::Busy => None,
493 }
494 }
495}
496
497#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
499#[non_exhaustive]
500#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
501pub struct ScrubReport {
502 pub checked: u64,
504 pub removed_corrupt: u64,
506 pub removed_temporary: u64,
508 pub removed_negative: u64,
510 pub removed_access: u64,
512 pub skipped_busy: u64,
514}
515
516fn scrub_negative_records(cache: &Cache) -> Result<u64> {
517 let mut removed = 0_u64;
518 visit_keyed_entries(
519 cache,
520 layout::NEGATIVE,
521 layout::NEGATIVE_EXTENSION,
522 |prefix, suffix, entry| {
523 let Some(build_id) = decode_build_id(prefix, suffix) else {
524 return Ok(());
525 };
526 let Some(_entry_lock) = try_entry_lock(cache, &build_id)? else {
527 return Ok(());
528 };
529 let path = entry.path();
530 if !matches!(cache.failure_record(&build_id)?, FailureRecord::Cached(_))
531 && remove_file_if_exists("remove negative-cache record", &path)?
532 {
533 removed = removed.saturating_add(1);
534 }
535 Ok(())
536 },
537 )?;
538 Ok(removed)
539}
540
541fn scrub_orphan_access_markers(cache: &Cache) -> Result<u64> {
542 let mut removed = 0_u64;
543 visit_keyed_entries(
544 cache,
545 layout::ACCESS,
546 layout::ACCESS_EXTENSION,
547 |prefix, suffix, entry| {
548 let Some(build_id) = decode_build_id(prefix, suffix) else {
549 return Ok(());
550 };
551 let path = entry.path();
552 let object = layout::object(cache.base(), &build_id);
553 match std::fs::symlink_metadata(&object) {
554 Ok(metadata) if metadata.is_file() => return Ok(()),
555 Ok(_) => {}
556 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
557 Err(source) => return Err(io_error("inspect cache object", object, source)),
558 }
559 let Some(_entry_lock) = try_entry_lock(cache, &build_id)? else {
560 return Ok(());
561 };
562 match std::fs::symlink_metadata(&object) {
563 Ok(metadata) if metadata.is_file() => return Ok(()),
564 Ok(_) => {}
565 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
566 Err(source) => return Err(io_error("recheck cache object", object, source)),
567 }
568 if remove_file_if_exists("remove orphan access marker", &path)? {
569 removed = removed.saturating_add(1);
570 }
571 Ok(())
572 },
573 )?;
574 Ok(removed)
575}
576
577fn visit_keyed_entries(
578 cache: &Cache,
579 kind: &str,
580 extension: &str,
581 mut visitor: impl FnMut(&str, &str, std::fs::DirEntry) -> Result<()>,
582) -> Result<()> {
583 let root = cache.base().join(kind).join(layout::BUILD_ID);
584 let shards = match std::fs::read_dir(&root) {
585 Ok(shards) => shards,
586 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
587 Err(source) => return Err(io_error("scan cache metadata shards", root, source)),
588 };
589 for shard in shards {
590 let shard = shard.map_err(|source| io_error("read cache metadata shard", &root, source))?;
591 let prefix_os = shard.file_name();
592 let Some(prefix) = prefix_os.to_str() else {
593 continue;
594 };
595 if prefix.len() != 2 || !is_lower_hex(prefix) {
596 continue;
597 }
598 let shard_path = shard.path();
599 let metadata = std::fs::symlink_metadata(&shard_path)
600 .map_err(|source| io_error("inspect cache metadata shard", &shard_path, source))?;
601 if !metadata.is_dir() {
602 continue;
603 }
604 for entry in std::fs::read_dir(&shard_path)
605 .map_err(|source| io_error("scan cache metadata shard", &shard_path, source))?
606 {
607 let entry = entry
608 .map_err(|source| io_error("read cache metadata shard", &shard_path, source))?;
609 let name_os = entry.file_name();
610 let Some(name) = name_os.to_str() else {
611 continue;
612 };
613 let Some(suffix) = name.strip_suffix(extension) else {
614 continue;
615 };
616 if !is_lower_hex(suffix) {
617 continue;
618 }
619 visitor(prefix, suffix, entry)?;
620 }
621 }
622 Ok(())
623}
624
625#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)]
626struct Candidate {
627 last_used: SystemTime,
628 build_id: CandidateBuildId,
629 len: u64,
630}
631
632const INLINE_CANDIDATE_BUILD_ID_LEN: usize = 32;
635
636#[derive(Debug)]
637enum CandidateBuildId {
638 Inline {
639 bytes: [u8; INLINE_CANDIDATE_BUILD_ID_LEN],
640 len: u8,
641 },
642 Heap(BuildId),
643}
644
645impl CandidateBuildId {
646 fn from_decoded(decoded: BuildIdBytes) -> Option<Self> {
647 let source = decoded.as_bytes();
648 if source.len() > INLINE_CANDIDATE_BUILD_ID_LEN {
649 return decoded.into_owned().map(Self::Heap);
650 }
651 let mut bytes = [0; INLINE_CANDIDATE_BUILD_ID_LEN];
652 bytes.get_mut(..source.len())?.copy_from_slice(source);
653 Some(Self::Inline {
654 bytes,
655 len: u8::try_from(source.len()).ok()?,
656 })
657 }
658
659 fn as_bytes(&self) -> &[u8] {
660 match self {
661 Self::Inline { bytes, len } => bytes.get(..usize::from(*len)).unwrap_or_default(),
662 Self::Heap(build_id) => build_id.as_bytes(),
663 }
664 }
665}
666
667impl PartialEq for CandidateBuildId {
668 fn eq(&self, other: &Self) -> bool {
669 self.as_bytes() == other.as_bytes()
670 }
671}
672
673impl Eq for CandidateBuildId {}
674
675impl PartialOrd for CandidateBuildId {
676 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
677 Some(self.cmp(other))
678 }
679}
680
681impl Ord for CandidateBuildId {
682 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
683 self.as_bytes().cmp(other.as_bytes())
684 }
685}
686
687fn retain_oldest(
688 candidates: &mut BinaryHeap<Candidate>,
689 capacity: usize,
690 build_id: BuildIdBytes,
691 len: u64,
692 last_used: SystemTime,
693) {
694 let retain = candidates.len() < capacity
695 || candidates.peek().is_some_and(|newest| {
696 last_used
697 .cmp(&newest.last_used)
698 .then_with(|| build_id.as_bytes().cmp(newest.build_id.as_bytes()))
699 .then_with(|| len.cmp(&newest.len))
700 .is_lt()
701 });
702 if !retain {
703 return;
704 }
705 let Some(build_id) = CandidateBuildId::from_decoded(build_id) else {
706 return;
707 };
708 let candidate = Candidate {
709 last_used,
710 build_id,
711 len,
712 };
713 if candidates.len() == capacity {
714 drop(candidates.pop());
715 }
716 candidates.push(candidate);
717}
718
719fn object_totals(cache: &Cache) -> Result<CacheStats> {
720 let mut stats = CacheStats::default();
721 visit_object_entries(cache, |_prefix, _suffix, _decoded_len, metadata| {
722 stats.entries = stats.entries.saturating_add(1);
723 stats.bytes = stats.bytes.saturating_add(metadata.len());
724 Ok(())
725 })?;
726 Ok(stats)
727}
728
729fn visit_object_entries(
730 cache: &Cache,
731 mut visitor: impl FnMut(&str, &str, usize, std::fs::Metadata) -> Result<()>,
732) -> Result<()> {
733 visit_object_names(cache, |prefix, suffix, decoded_len, file| {
734 let metadata = file
735 .metadata()
736 .map_err(|source| io_error("inspect cache object", file.path(), source))?;
737 if metadata.is_file() {
738 visitor(prefix, suffix, decoded_len, metadata)?;
739 }
740 Ok(())
741 })
742}
743
744fn visit_object_build_ids(
745 cache: &Cache,
746 mut visitor: impl FnMut(BuildId, std::fs::DirEntry) -> Result<()>,
747) -> Result<()> {
748 visit_object_names(cache, |prefix, suffix, decoded_len, file| {
749 let file_type = file
750 .file_type()
751 .map_err(|source| io_error("inspect cache object type", file.path(), source))?;
752 #[expect(
753 clippy::filetype_is_file,
754 reason = "cache objects must be regular files, not merely non-directories"
755 )]
756 if file_type.is_file()
757 && let Some(build_id) = decode_build_id_with_len(prefix, suffix, decoded_len)
758 {
759 visitor(build_id, file)?;
760 }
761 Ok(())
762 })
763}
764
765fn visit_object_names(
766 cache: &Cache,
767 mut visitor: impl FnMut(&str, &str, usize, std::fs::DirEntry) -> Result<()>,
768) -> Result<()> {
769 visit_keyed_entries(
770 cache,
771 layout::OBJECTS,
772 layout::OBJECT_EXTENSION,
773 |prefix, suffix, file| {
774 if let Some(decoded_len) = decoded_build_id_len(prefix, suffix) {
775 visitor(prefix, suffix, decoded_len, file)?;
776 }
777 Ok(())
778 },
779 )
780}
781
782fn decode_build_id(prefix: &str, suffix: &str) -> Option<BuildId> {
783 decode_build_id_with_len(prefix, suffix, decoded_build_id_len(prefix, suffix)?)
784}
785
786fn decode_build_id_with_len(prefix: &str, suffix: &str, decoded_len: usize) -> Option<BuildId> {
787 let mut bytes = vec![0; decoded_len];
788 decode_build_id_into(prefix, suffix, &mut bytes)?;
789 BuildId::try_from(bytes).ok()
790}
791
792struct BuildIdBytes {
793 bytes: [u8; MAX_BUILD_ID_LEN],
794 len: u8,
795}
796
797impl BuildIdBytes {
798 fn as_bytes(&self) -> &[u8] {
799 self.bytes.get(..usize::from(self.len)).unwrap_or_default()
800 }
801
802 fn into_owned(self) -> Option<BuildId> {
803 BuildId::try_from(self.as_bytes()).ok()
804 }
805}
806
807fn decode_build_id_bytes(prefix: &str, suffix: &str, decoded_len: usize) -> Option<BuildIdBytes> {
808 let mut bytes = [0_u8; MAX_BUILD_ID_LEN];
809 let decoded = bytes.get_mut(..decoded_len)?;
810 decode_build_id_into(prefix, suffix, decoded)?;
811 Some(BuildIdBytes {
812 bytes,
813 len: u8::try_from(decoded_len).ok()?,
814 })
815}
816
817fn decoded_build_id_len(prefix: &str, suffix: &str) -> Option<usize> {
818 let encoded_len = prefix.len().checked_add(suffix.len())?;
819 let len = encoded_len.checked_div(2)?;
820 (encoded_len % 2 == 0 && len <= MAX_BUILD_ID_LEN).then_some(len)
821}
822
823fn decode_build_id_into(prefix: &str, suffix: &str, decoded: &mut [u8]) -> Option<()> {
824 let mut encoded = prefix.bytes().chain(suffix.bytes());
825 for byte in decoded {
826 let high = hex_nibble(encoded.next()?)?;
827 let low = hex_nibble(encoded.next()?)?;
828 *byte = high << 4 | low;
829 }
830 encoded.next().is_none().then_some(())
831}
832
833fn is_lower_hex(value: &str) -> bool {
834 value
835 .bytes()
836 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
837}
838
839fn access_time(cache: &Cache, build_id: &BuildId, now: SystemTime) -> Option<SystemTime> {
840 access_time_bytes(cache, build_id.as_bytes(), now)
841}
842
843fn access_time_bytes(cache: &Cache, build_id: &[u8], now: SystemTime) -> Option<SystemTime> {
844 let directory = cache.access_directory.get()?;
845 let key = layout::access_key_bytes(build_id);
846 let key = key.as_c_str()?;
847 let metadata =
848 rustix::fs::statat(directory, key, rustix::fs::AtFlags::SYMLINK_NOFOLLOW).ok()?;
849 if rustix::fs::FileType::from_raw_mode(metadata.st_mode) != rustix::fs::FileType::RegularFile {
850 return None;
851 }
852 let modified = crate::access::system_time_from_unix(metadata.st_mtime, metadata.st_mtime_nsec)?;
853 Some(modified.min(now))
854}
855
856struct BusySlots([u64; 64]);
857
858impl Default for BusySlots {
859 fn default() -> Self {
860 Self([0; 64])
861 }
862}
863
864impl BusySlots {
865 fn contains(&self, slot: u16) -> bool {
866 let index = usize::from(slot / 64);
867 let mask = 1_u64 << u32::from(slot % 64);
868 self.0.get(index).is_some_and(|word| word & mask != 0)
869 }
870
871 fn insert(&mut self, slot: u16) -> bool {
872 let index = usize::from(slot / 64);
873 let mask = 1_u64 << u32::from(slot % 64);
874 let Some(word) = self.0.get_mut(index) else {
875 return false;
876 };
877 let new = *word & mask == 0;
878 *word |= mask;
879 new
880 }
881}
882
883fn current_candidate_path(
884 cache: &Cache,
885 build_id: &BuildId,
886 candidate: &Candidate,
887 now: SystemTime,
888) -> Result<Option<PathBuf>> {
889 let path = layout::object(cache.base(), build_id);
890 let metadata = match std::fs::symlink_metadata(&path) {
891 Ok(metadata) => metadata,
892 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
893 Err(source) => {
894 return Err(io_error("recheck cache entry before pruning", path, source));
895 }
896 };
897 let last_used = access_time(cache, build_id, now)
898 .or_else(|| metadata.modified().ok())
899 .unwrap_or(SystemTime::UNIX_EPOCH);
900 if !metadata.is_file() || metadata.len() != candidate.len || last_used != candidate.last_used {
901 return Ok(None);
902 }
903 Ok(Some(path))
904}
905
906enum PruneCandidateOutcome {
907 Removed,
908 Busy,
909 Changed,
910}
911
912fn try_remove_prune_candidate(
913 cache: &Cache,
914 candidate: &Candidate,
915 now: SystemTime,
916) -> Result<PruneCandidateOutcome> {
917 match &candidate.build_id {
918 CandidateBuildId::Inline { .. } => {
919 let Ok(build_id) = BuildId::new(candidate.build_id.as_bytes()) else {
920 return Ok(PruneCandidateOutcome::Changed);
921 };
922 try_remove_prune_candidate_with_id(cache, &build_id, candidate, now)
923 }
924 CandidateBuildId::Heap(build_id) => {
925 try_remove_prune_candidate_with_id(cache, build_id, candidate, now)
926 }
927 }
928}
929
930fn try_remove_prune_candidate_with_id(
931 cache: &Cache,
932 build_id: &BuildId,
933 candidate: &Candidate,
934 now: SystemTime,
935) -> Result<PruneCandidateOutcome> {
936 let Some(_entry_lock) = try_entry_lock(cache, build_id)? else {
937 return Ok(PruneCandidateOutcome::Busy);
938 };
939 let Some(path) = current_candidate_path(cache, build_id, candidate, now)? else {
940 return Ok(PruneCandidateOutcome::Changed);
941 };
942 if remove_object(cache, build_id, &path)? {
943 Ok(PruneCandidateOutcome::Removed)
944 } else {
945 Ok(PruneCandidateOutcome::Changed)
946 }
947}
948
949fn remove_object(cache: &Cache, build_id: &BuildId, path: &std::path::Path) -> Result<bool> {
950 if !remove_file_if_exists("remove cache entry", path)? {
951 return Ok(false);
952 }
953 cache.clear_auxiliary(build_id);
954 Ok(true)
955}
956
957fn scrub_temporary_files(cache: &Cache, now: SystemTime) -> Result<(u64, u64)> {
958 let mut removed = 0_u64;
959 let mut skipped_busy = 0_u64;
960 visit_keyed_entries(
961 cache,
962 layout::TEMPORARY,
963 layout::TEMPORARY_EXTENSION,
964 |prefix, suffix, identifier| {
965 let Some(build_id) = decode_build_id(prefix, suffix) else {
966 return Ok(());
967 };
968 let identifier_path = identifier.path();
969 let metadata = std::fs::symlink_metadata(&identifier_path).map_err(|source| {
970 io_error(
971 "inspect staged GSYM identifier directory",
972 &identifier_path,
973 source,
974 )
975 })?;
976 if !metadata.is_dir() {
977 return Ok(());
978 }
979 let Some(_entry_lock) = try_entry_lock(cache, &build_id)? else {
980 skipped_busy = skipped_busy.saturating_add(1);
981 return Ok(());
982 };
983 let files = std::fs::read_dir(&identifier_path).map_err(|source| {
984 io_error("scan staged GSYM identifier", &identifier_path, source)
985 })?;
986 for file in files {
987 let file = file.map_err(|source| {
988 io_error("read staged GSYM identifier", &identifier_path, source)
989 })?;
990 let path = file.path();
991 let metadata = std::fs::symlink_metadata(&path)
992 .map_err(|source| io_error("inspect staged GSYM file", &path, source))?;
993 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
994 let stale = now
995 .duration_since(modified)
996 .map_or(true, |age| age >= STALE_TEMPORARY_AGE);
997 if !metadata.is_file() || !stale {
998 continue;
999 }
1000 if remove_file_if_exists("remove staged GSYM file", &path)? {
1001 removed = removed.saturating_add(1);
1002 }
1003 }
1004 drop(std::fs::remove_dir(&identifier_path));
1005 Ok(())
1006 },
1007 )?;
1008 Ok((removed, skipped_busy))
1009}
1010
1011fn try_maintenance_lock(cache: &Cache) -> Result<Option<File>> {
1012 let path = cache.base().join("gc.lock");
1013 try_lock_file(cache, &path, "cache maintenance")
1014}
1015
1016fn try_entry_lock(cache: &Cache, build_id: &BuildId) -> Result<Option<File>> {
1017 let path = layout::lock(cache.base(), build_id);
1018 try_lock_file(cache, &path, "cache entry")
1019}
1020
1021fn prune_batch_size(
1022 before: CacheStats,
1023 target_bytes: Option<u64>,
1024 target_entries: Option<u64>,
1025) -> usize {
1026 let byte_estimate = target_bytes.map_or(0, |target| {
1027 let excess = before.bytes.saturating_sub(target);
1028 if excess == 0 || before.bytes == 0 {
1029 return 0;
1030 }
1031 let scaled = u128::from(excess).saturating_mul(u128::from(before.entries));
1032 let total = u128::from(before.bytes);
1033 let quotient = scaled.checked_div(total).unwrap_or_default();
1034 let has_remainder = scaled.checked_rem(total).is_some_and(|value| value != 0);
1035 let estimate = quotient.saturating_add(u128::from(has_remainder));
1036 u64::try_from(estimate).unwrap_or(before.entries)
1037 });
1038 let entry_estimate = target_entries.map_or(0, |target| before.entries.saturating_sub(target));
1039 let desired = byte_estimate
1040 .max(entry_estimate)
1041 .max(MIN_PRUNE_BATCH_SIZE as u64);
1042 usize::try_from(desired.min(MAX_PRUNE_BATCH_SIZE as u64)).unwrap_or(MAX_PRUNE_BATCH_SIZE)
1043}
1044
1045const fn low_watermark(high: u64) -> u64 {
1046 let quotient = high / 5;
1047 let remainder = high % 5;
1048 quotient
1049 .saturating_mul(4)
1050 .saturating_add(remainder.saturating_mul(4) / 5)
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::low_watermark;
1056
1057 #[test]
1058 fn low_watermark_does_not_saturate_before_division() {
1059 for high in [1, 4, 5, 6, u64::MAX] {
1060 let expected =
1061 u64::try_from(u128::from(high) * 4 / 5).expect("80% of a u64 fits in a u64");
1062 assert_eq!(low_watermark(high), expected);
1063 }
1064 }
1065}