ifc_lite_server/services/
cache_remove.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `DiskCache::remove_by_key_prefix` and the index-walk helpers it alone
6//! uses. A sibling of `cache.rs` because the removal is the one operation
7//! that walks the whole cacache index, twice, under two gates, and its
8//! argument is long enough that `cache.rs` crossed the module-size ratchet
9//! carrying it.
10
11use super::cache::DiskCache;
12use crate::error::ApiError;
13use std::path::PathBuf;
14use std::sync::Arc;
15use tokio::sync::OwnedSemaphorePermit;
16
17/// `Retry-After` handed to a caller whose removal was shed because another
18/// walk was in flight. Removal is idempotent and retry-safe, so shedding
19/// costs a legitimate client one retry, where queueing would let a caller
20/// park unbounded requests on work that starves cache writes.
21const INDEX_WALK_RETRY_AFTER_SECS: u64 = 5;
22
23/// cacache's on-disk index root inside a cache directory.
24///
25/// `cacache::list_sync` reports a cache nothing has ever been written to as a
26/// `NotFound` walk error rather than as an empty iterator (its own
27/// `list_sync` test asserts exactly that), so telling "empty" apart from
28/// "unreadable" means knowing whether this directory exists. cacache does not
29/// expose the path, so the layout is mirrored here -- `INDEX_VERSION` is `5`
30/// in cacache 13.1.0, the same way `cache_tests::corrupt_stored_content`
31/// mirrors the content layout. `index_root_matches_the_cacache_layout` fails
32/// if a cacache bump moves it.
33pub(super) fn index_root(cache_dir: &std::path::Path) -> PathBuf {
34    cache_dir.join("index-v5")
35}
36
37/// Classify an error yielded by `cacache::list_sync` on a walk that may be
38/// starting from a cache that was never written to.
39///
40/// The ONE benign case takes BOTH halves of a conjunction, and each half
41/// closes a different way of reading a broken cache as an empty one:
42///
43///  - the index root must be ABSENT, so the walk demonstrably never started.
44///    A `NotFound` raised part-way through a walk (a bucket or subdirectory
45///    disappearing under it) is a real failure; treating it as "the cache is
46///    empty" is how a truncated walk gets to unlink live content.
47///  - the cache directory itself must still be PRESENT. If it is gone too --
48///    an unmounted volume, a wiped `CACHE_DIR` -- the same `NotFound` means
49///    the store is unreadable, and answering "0 entries" would make a broken
50///    cache indistinguishable from a healthy empty one.
51///
52/// Note what cacache does NOT report: `bucket_entries` drops unreadable and
53/// unparseable index LINES silently (`map_while(Result::ok)` then a
54/// `filter_map` that yields `None` for a bad line), and turns a `NotFound`
55/// on opening a bucket into an empty vec. Only a failure to open a bucket
56/// for some other reason (a permission error, say) and the directory walk
57/// itself can produce the errors classified here.
58///
59/// `Some(err)` means "propagate this"; `None` means "the cache is empty, stop
60/// walking".
61pub(super) fn classify_index_walk_error(cache_dir: &std::path::Path, err: cacache::Error) -> Option<ApiError> {
62    if let cacache::Error::IoError(ref io, _) = err {
63        if io.kind() != std::io::ErrorKind::NotFound {
64            return Some(ApiError::Cache(err.to_string()));
65        }
66        // `Path::exists`/`is_dir` answer FALSE for a stat that failed, which
67        // is the wrong answer in the direction that matters: a permission
68        // error on the index root would read as "absent" and license the
69        // benign verdict. `try_exists` separates "it is not there" from "I
70        // could not tell", and only the former may be called empty.
71        let index_root_absent = match index_root(cache_dir).try_exists() {
72            Ok(present) => !present,
73            // Could not tell. Never benign -- report the walk error, and say
74            // why the classification could not be made.
75            Err(stat_err) => {
76                return Some(ApiError::Cache(format!(
77                    "{err} (could not stat the cache index root: {stat_err})"
78                )))
79            }
80        };
81        // Same rule for the cache directory: a stat that fails is not
82        // evidence that it is there, and only a directory we positively
83        // found may be called a healthy empty cache.
84        let cache_dir_present = match cache_dir.try_exists() {
85            Ok(present) => present,
86            Err(stat_err) => {
87                return Some(ApiError::Cache(format!(
88                    "{err} (could not stat the cache directory: {stat_err})"
89                )))
90            }
91        };
92        if cache_dir_present && index_root_absent {
93            return None;
94        }
95    }
96    Some(ApiError::Cache(err.to_string()))
97}
98
99impl DiskCache {
100    /// Remove every index entry whose key is `key_prefix` itself or starts with
101    /// `"{key_prefix}-"` (issue #3636). One source file fans out into several
102    /// entries under the same hash prefix -- the plain request key, the
103    /// `-json-v2`, `-parquet-vN`, `-parquet-metadata-v4` and `-symbolic-v1`
104    /// variants, each combination of opening-filter and quality suffix -- and
105    /// `DELETE /api/v1/cache/{key}` (handed the file hash its `cache_key`
106    /// resolves to) is meant to drop all of them for that
107    /// file in one call.
108    ///
109    /// The underlying store is content-addressable: two different source
110    /// files can produce byte-identical output (the issue's example is two
111    /// different IFCs that both emit an empty symbolic-data payload) and then
112    /// share one content blob across two index entries. So this removes
113    /// INDEX ENTRIES first -- an index-only removal (`cacache::remove`) is
114    /// exactly the operation the issue's own hand-pruning experiment found
115    /// safe: leaving a blob temporarily orphaned makes it unreachable but
116    /// never makes a *surviving* entry read as corrupt, whereas removing a
117    /// blob a live index entry still points at does (their write-up
118    /// reproduced the resulting 500s). Only once every matching index entry
119    /// is gone does this walk the remaining index and drop content blobs
120    /// that nothing references any more; a blob still referenced by an
121    /// unrelated entry is left alone.
122    ///
123    /// Returns the number of index entries removed. Zero is a normal,
124    /// successful result for a prefix nothing was ever cached under, or that
125    /// already had its entries removed -- deleting an absent entry is a
126    /// no-op, not an error, so a retried or duplicate `DELETE` stays safe.
127    pub async fn remove_by_key_prefix(&self, key_prefix: &str) -> Result<usize, ApiError> {
128        // Deliberately non-blocking (see `index_walk`): a concurrent removal
129        // is shed, never queued.
130        let walk_permit = Arc::clone(&self.index_walk).try_acquire_owned().map_err(|_| {
131            tracing::warn!("Cache index walk already in flight; shedding this one");
132            ApiError::Overloaded { retry_after_secs: INDEX_WALK_RETRY_AFTER_SECS }
133        })?;
134        let cache_dir = self.cache_dir.clone();
135        let prefix = format!("{key_prefix}-");
136        let exact = key_prefix.to_string();
137        let (prefix_phase2, exact_phase2) = (prefix.clone(), exact.clone());
138
139        // PHASE 1 -- index-entry removals, deliberately OUTSIDE the exclusion
140        // lock. Removing an index entry can only orphan a blob (wasted disk,
141        // never a corrupt read); it cannot unlink content a concurrent writer
142        // is committing. Only phase 2 below races `cacache::write`'s two-step
143        // commit, so only phase 2 excludes writers.
144        //
145        // `cacache` only exposes a synchronous index iterator (it walks the
146        // index directory on disk), so this runs on a blocking thread rather
147        // than stalling the async runtime. Integrities are tracked by their
148        // string form (`Integrity` itself carries no `Hash`/`Eq` impl) and
149        // re-parsed just before the hash-addressed removal call, which is the
150        // only place that needs the typed value.
151        // The walk permit rides in the closure and comes back out with the
152        // result, so it covers exactly the blocking work: dropped request
153        // future or not, phase 1 holds it until phase 1 ends, and a dropped
154        // `JoinHandle` between the phases releases it without starting phase 2.
155        let (removed_integrities, walk_permit): (Vec<String>, OwnedSemaphorePermit) =
156            tokio::task::spawn_blocking(move || {
157            let mut removed = Vec::new();
158            for entry in cacache::list_sync(&cache_dir) {
159                // A walk that cannot complete is a real failure, not an
160                // empty cache: swallowing it here would under-count
161                // `deleted` and report a partial invalidation as a complete
162                // `200`. (An individual index LINE that fails to read or
163                // parse never reaches us -- cacache drops those silently --
164                // so this catches walk failures and non-`NotFound` bucket
165                // open failures, not corruption within a bucket.)
166                let meta = match entry {
167                    Ok(meta) => meta,
168                    Err(e) => match classify_index_walk_error(&cache_dir, e) {
169                        Some(err) => return Err(err),
170                        None => break,
171                    },
172                };
173                if (meta.key == exact || meta.key.starts_with(&prefix))
174                    && cacache::remove_sync(&cache_dir, &meta.key).is_ok()
175                {
176                    removed.push(meta.integrity.to_string());
177                }
178            }
179            Ok::<_, ApiError>((removed, walk_permit))
180        })
181        .await??;
182
183        // PHASE 2 -- blob reclaim, under exclusion. The guard is OWNED and
184        // moved into the blocking closure rather than held by this async fn:
185        // a request future can be dropped mid-flight (`TimeoutLayer` firing,
186        // a client disconnecting) and Tokio DETACHES a started
187        // `spawn_blocking` task when its `JoinHandle` is dropped -- it
188        // neither aborts nor waits. A guard held here would be released by
189        // that drop while the blocking pass was still unlinking blobs, which
190        // is exactly the window `write_gc_lock` exists to close. Owned by the
191        // closure, it is released only when the blocking work itself ends.
192        let guard = Arc::clone(&self.write_gc_lock).write_owned().await;
193        let cache_dir = self.cache_dir.clone();
194        let mut removed_integrities = removed_integrities;
195        tokio::task::spawn_blocking(move || {
196            let _guard = guard;
197            let _walk_permit = walk_permit;
198
199            // A SECOND walk, not the one phase 1 made. Phase 1 ran before the
200            // lock was taken, so this walk does two jobs the first cannot:
201            //
202            //  - it sweeps up any MATCHING entry that a writer committed
203            //    while phase 1 was scanning past it. Without this, narrowing
204            //    the lock would let a concurrent cache fill survive the
205            //    `DELETE` that was supposed to remove it; with it, every
206            //    entry committed before the lock was acquired is removed,
207            //    which is the guarantee holding the lock over both phases
208            //    used to give.
209            //  - it rebuilds "what is still referenced" under exclusion. Its
210            //    phase-1 equivalent would be stale: an entry committed
211            //    between that walk and this acquisition would be missing from
212            //    the set, and its blob unlinked out from under a live entry.
213            let mut still_referenced = std::collections::HashSet::new();
214            // Deliberately NOT `classify_index_walk_error`: this walk decides
215            // which blobs nothing references any more, so a walk that stops
216            // early reads as "nothing references these" and licenses an
217            // unlink that corrupts a surviving entry. Every error here
218            // propagates, and the only "empty" this accepts is an index root
219            // that does not exist at all -- checked once, before walking,
220            // rather than inferred from an error mid-walk.
221            // `try_exists`, never `exists`: the latter answers false for a
222            // stat that FAILED, so a permission error on the index root
223            // would skip this walk, leave `still_referenced` empty, and send
224            // every integrity in `removed_integrities` to the unlink loop
225            // below with no reference check at all. "I could not tell" must
226            // abort the pass, not silently authorise it.
227            if index_root(&cache_dir)
228                .try_exists()
229                .map_err(|e| ApiError::Cache(format!("could not stat the cache index root: {e}")))?
230            {
231                for entry in cacache::list_sync(&cache_dir) {
232                    let meta = entry.map_err(|e| ApiError::Cache(e.to_string()))?;
233                    if (meta.key == exact_phase2 || meta.key.starts_with(&prefix_phase2))
234                        && cacache::remove_sync(&cache_dir, &meta.key).is_ok()
235                    {
236                        // Removed just now, so it must NOT count as a live
237                        // reference; its blob is a reclaim candidate like the
238                        // rest. A failed removal falls through instead, which
239                        // keeps the entry AND its content.
240                        removed_integrities.push(meta.integrity.to_string());
241                        continue;
242                    }
243                    still_referenced.insert(meta.integrity.to_string());
244                }
245            }
246            for integrity_str in &removed_integrities {
247                if !still_referenced.contains(integrity_str) {
248                    // Best-effort: an orphaned blob left behind on a rare
249                    // removal failure just wastes disk space, it never makes
250                    // a surviving entry read as corrupt.
251                    if let Ok(integrity) = integrity_str.parse::<cacache::Integrity>() {
252                        let _ = cacache::remove_hash_sync(&cache_dir, &integrity);
253                    }
254                }
255            }
256            Ok::<usize, ApiError>(removed_integrities.len())
257        })
258        .await?
259    }
260}