Skip to main content

gsym_cache/
maintenance.rs

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/// Nonzero cache-size limit in bytes.
18#[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    /// Creates a byte limit, returning `None` for zero.
24    #[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    /// Returns the limit in bytes.
33    #[must_use]
34    pub const fn get(self) -> u64 {
35        self.0.get()
36    }
37}
38
39/// Nonzero cache-entry limit.
40#[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    /// Creates an entry limit, returning `None` for zero.
46    #[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    /// Returns the entry limit.
55    #[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/// Size, count, and age policy for cache pruning.
95#[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    /// Creates a byte-bounded policy.
105    #[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    /// Adds a maximum entry count.
115    #[must_use]
116    pub const fn max_entries(mut self, limit: EntryLimit) -> Self {
117        self.entries = Some(limit);
118        self
119    }
120
121    /// Removes entries unused for at least `age`, even below capacity.
122    #[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    /// Returns the high byte watermark.
129    #[must_use]
130    pub const fn byte_limit(self) -> ByteLimit {
131        self.bytes
132    }
133
134    /// Returns the optional high entry watermark.
135    #[must_use]
136    pub const fn entry_limit(self) -> Option<EntryLimit> {
137        self.entries
138    }
139
140    /// Returns the optional maximum unused age.
141    #[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    /// Counts recognized GSYM objects in this converter epoch without writing
150    /// to the cache.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error when the object tree cannot be scanned.
155    pub fn stats(&self) -> Result<CacheStats> {
156        object_totals(self)
157    }
158
159    /// Prunes this converter epoch's least-recently-used objects under a
160    /// nonblocking maintenance lock.
161    ///
162    /// Capacity pruning starts only above a high watermark and continues to
163    /// 80% of it. This hysteresis prevents a deletion on every subsequent
164    /// publication. Objects whose population lock is held are skipped.
165    ///
166    /// See [`docs::operations`](crate::docs::operations) for scheduling and
167    /// recovery guidance.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error when the cache cannot be scanned, locked, or modified.
172    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    /// Verifies every recognized object and removes corrupt entries, expired
278    /// negative records, and stale crash-leftover staging files.
279    ///
280    /// Temporary files younger than one day are retained. Older files are
281    /// removed only after acquiring their build identifier's population lock,
282    /// so a long-running conversion cannot lose its staging path.
283    ///
284    /// See [`docs::operations`](crate::docs::operations) for the full repair
285    /// contract and crash behavior.
286    ///
287    /// # Errors
288    ///
289    /// Returns an error when the cache cannot be scanned, locked, mapped, or
290    /// modified. Transient mapping failures do not cause object deletion.
291    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/// Current size of the recognized object tree.
416#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
417#[non_exhaustive]
418#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
419pub struct CacheStats {
420    /// Number of immutable GSYM objects.
421    pub entries: u64,
422    /// Sum of their logical file lengths.
423    pub bytes: u64,
424}
425
426/// Outcome of nonblocking cache pruning.
427#[derive(Clone, Copy, Debug, Eq, PartialEq)]
428#[must_use]
429#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
430pub enum PruneOutcome {
431    /// This process completed a prune pass.
432    Completed(PruneReport),
433    /// Another process currently owns cache maintenance.
434    Busy,
435}
436
437impl PruneOutcome {
438    /// Returns whether another process currently owns cache maintenance.
439    #[must_use]
440    pub const fn is_busy(&self) -> bool {
441        matches!(self, Self::Busy)
442    }
443
444    /// Consumes the outcome and returns the report when pruning completed.
445    #[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/// Measurements from a completed prune pass.
455#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
456#[non_exhaustive]
457#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
458pub struct PruneReport {
459    /// Object-tree measurements before pruning.
460    pub before: CacheStats,
461    /// Object-tree measurements after pruning.
462    pub after: CacheStats,
463    /// Number of objects removed.
464    pub removed: u64,
465    /// Number of busy population-lock slots encountered.
466    pub skipped_busy: u64,
467}
468
469/// Outcome of nonblocking cache scrubbing.
470#[derive(Clone, Copy, Debug, Eq, PartialEq)]
471#[must_use]
472#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
473pub enum ScrubOutcome {
474    /// This process completed a scrub pass.
475    Completed(ScrubReport),
476    /// Another process currently owns cache maintenance.
477    Busy,
478}
479
480impl ScrubOutcome {
481    /// Returns whether another process currently owns cache maintenance.
482    #[must_use]
483    pub const fn is_busy(&self) -> bool {
484        matches!(self, Self::Busy)
485    }
486
487    /// Consumes the outcome and returns the report when scrubbing completed.
488    #[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/// Measurements from a completed scrub pass.
498#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
499#[non_exhaustive]
500#[cfg_attr(docsrs, doc(cfg(feature = "manage")))]
501pub struct ScrubReport {
502    /// Number of immutable GSYM objects fully verified.
503    pub checked: u64,
504    /// Number of malformed or build-ID-mismatched objects removed.
505    pub removed_corrupt: u64,
506    /// Number of old crash-leftover staging files removed.
507    pub removed_temporary: u64,
508    /// Number of expired or malformed negative records removed.
509    pub removed_negative: u64,
510    /// Number of access markers without a corresponding object removed.
511    pub removed_access: u64,
512    /// Number of objects or staging files skipped because population was active.
513    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
632// Covers SHA-1 and SHA-256 identifiers without inflating the bounded heap for
633// the format's uncommon 126-byte maximum.
634const 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}