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}