ifc_lite_server/routes/cache.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//! Cache retrieval and deletion endpoints.
6//!
7//! `GET` and `DELETE /api/v1/cache/{key}` take ONE key space: the request
8//! `cache_key` a parse route returns (`result.cache_key` on `POST /parse`,
9//! the `cache_key` in every Parquet response's `X-IFC-Metadata` header). Both
10//! resolve it through [`resolve_request_cache_key`], so a key one of them
11//! accepts the other accepts too, and a key one of them refuses the other
12//! refuses with the same `400` (#5750).
13
14use crate::error::ApiError;
15use crate::routes::parse::cache_keys::{cache_key_from_parts, is_file_digest, json_response_cache_key};
16use crate::services::OpeningFilterMode;
17use crate::types::SymbolicParseResponse;
18use crate::AppState;
19use axum::{
20 body::Body,
21 extract::{Path, State},
22 http::header,
23 response::{IntoResponse, Response},
24 Json,
25};
26use ifc_lite_processing::TessellationQuality;
27use serde::Serialize;
28
29/// A request `cache_key` checked against the writer's own key builder: the
30/// file's sha256 plus the opening-filter and tessellation-quality suffixes,
31/// exactly as [`cache_key_from_parts`] spells them. Borrowed from the path
32/// segment it was resolved from.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub(crate) struct RequestCacheKey<'a> {
35 key: &'a str,
36 digest: &'a str,
37}
38
39impl<'a> RequestCacheKey<'a> {
40 /// The entry `POST /api/v1/parse` stored its response under.
41 pub(crate) fn json_response_key(&self) -> String {
42 json_response_cache_key(self.key)
43 }
44
45 /// The source file this key was derived from. Every cache entry for that
46 /// file, whatever its variant or transport, starts with it.
47 pub(crate) fn file_digest(&self) -> &'a str {
48 self.digest
49 }
50}
51
52/// Every opening filter a request key can carry. The `match` makes a new
53/// variant a compile error here rather than a key both cache routes refuse.
54fn opening_filters() -> [OpeningFilterMode; 3] {
55 let every = |mode: OpeningFilterMode| match mode {
56 OpeningFilterMode::Default | OpeningFilterMode::IgnoreAll | OpeningFilterMode::IgnoreOpaque => mode,
57 };
58 [
59 every(OpeningFilterMode::Default),
60 every(OpeningFilterMode::IgnoreAll),
61 every(OpeningFilterMode::IgnoreOpaque),
62 ]
63}
64
65/// Every tessellation level a request key can carry, for the same reason.
66fn tessellation_qualities() -> [TessellationQuality; 5] {
67 let every = |quality: TessellationQuality| match quality {
68 TessellationQuality::Lowest
69 | TessellationQuality::Low
70 | TessellationQuality::Medium
71 | TessellationQuality::High
72 | TessellationQuality::Highest => quality,
73 };
74 [
75 every(TessellationQuality::Lowest),
76 every(TessellationQuality::Low),
77 every(TessellationQuality::Medium),
78 every(TessellationQuality::High),
79 every(TessellationQuality::Highest),
80 ]
81}
82
83/// THE resolver for the `{key}` of `GET` and `DELETE /api/v1/cache/{key}`.
84///
85/// Accepts exactly the strings [`cache_key_from_parts`] can produce, by
86/// rebuilding each candidate with it rather than parsing the suffixes a second
87/// way: the writer and this reader cannot disagree about what a key looks
88/// like. Anything else -- a bare digest, an internal storage key with its
89/// `-json-v6` / `-parquet-v8` suffix, arbitrary text -- is a `400`, which
90/// also keeps `DELETE`'s index walk out of reach of a caller-shaped string
91/// (#4582).
92pub(crate) fn resolve_request_cache_key(key: &str) -> Result<RequestCacheKey<'_>, ApiError> {
93 let digest = key.get(..64).filter(|digest| is_file_digest(digest));
94 let resolved = digest.filter(|digest| {
95 opening_filters().into_iter().any(|filter| {
96 tessellation_qualities()
97 .into_iter()
98 .any(|quality| cache_key_from_parts(digest, filter, quality) == key)
99 })
100 });
101 match resolved {
102 Some(digest) => Ok(RequestCacheKey { key, digest }),
103 None => Err(not_a_request_cache_key(key)),
104 }
105}
106
107/// The rejection for a `{key}` that is not a request `cache_key`. Like
108/// `not_a_file_digest`, the value is caller-controlled text and is not echoed.
109fn not_a_request_cache_key(value: &str) -> ApiError {
110 ApiError::BadRequest(format!(
111 "expected the `cache_key` a parse response returned ({{sha256}}-{{opening_filter}}, plus -q{{quality}} for a non-default tessellation quality); got {} character(s)",
112 value.chars().count()
113 ))
114}
115
116/// GET /api/v1/cache/:key - Retrieve a cached `POST /api/v1/parse` result.
117///
118/// `key` is the `cache_key` that route returned, which is the value the
119/// client's `getCached(result.cache_key)` passes, resolved by
120/// [`resolve_request_cache_key`]. The response itself is stored under
121/// [`json_response_cache_key`] (`{cache_key}-json-v6`), versioned separately
122/// so the JSON wire format can move without retiring the Parquet entries that
123/// share the same seed (#5542).
124///
125/// Only JSON response entries are reachable here: an entry that exists but
126/// does not decode answers `404`, not `500` (#5128). Reading the bytes
127/// directly (rather than through `DiskCache::get`, whose `?` cannot tell that
128/// failure apart from a real I/O error) keeps an actual cache-store error a
129/// `500`.
130///
131/// Decoded as [`SymbolicParseResponse`], the type the writer stored. The entry
132/// is the whole model, so the read, decode and re-encode hold a parse
133/// admission slot, reserved exactly as the parse route reserves one, and run
134/// on the blocking pool (#4696, #5750): without the slot, concurrent GETs of
135/// a large model were an unbounded number of whole-model working sets. The
136/// slot travels with the blocking decode, not with this handler, so a client
137/// that hangs up mid-decode does not free it while the decode runs on. A miss
138/// is answered from the index before any slot is taken, so a key nobody wrote
139/// costs one metadata lookup, like the hash-only probes (#3901).
140pub async fn get_cached(
141 State(state): State<AppState>,
142 Path(key): Path<String>,
143) -> Result<Response, ApiError> {
144 let resolved = resolve_request_cache_key(&key)?;
145 let response_key = resolved.json_response_key();
146 tracing::debug!(key = %key, response_key = %response_key, "Cache lookup");
147
148 let not_found = || ApiError::NotFound(format!("Cache key not found: {}", key));
149 if !state.cache.has(&response_key).await? {
150 tracing::debug!(key = %key, "Cache MISS");
151 return Err(not_found());
152 }
153
154 let admission_guard = state
155 .admission
156 .acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
157 .await?;
158 let bytes = match state.cache.get_bytes(&response_key).await? {
159 Some(bytes) => bytes,
160 None => {
161 tracing::debug!(key = %key, "Cache MISS");
162 return Err(not_found());
163 }
164 };
165
166 // The guard moves INTO the blocking task and comes back with the result,
167 // as in `parse_full`: if a disconnect or the TimeoutLayer cancels this
168 // handler future, the detached decode keeps running and keeps its slot
169 // until it actually exits, so a replacement is not admitted on top of it.
170 let gate_key = response_key.clone();
171 let (encoded, _admission) = tokio::task::spawn_blocking(move || {
172 decode_gate(&gate_key);
173 let encoded = serde_json::from_slice::<SymbolicParseResponse>(&bytes).and_then(|mut response| {
174 response.mark_from_cache();
175 serde_json::to_vec(&response)
176 });
177 (encoded, admission_guard)
178 })
179 .await
180 .map_err(|e| ApiError::Internal(e.to_string()))?;
181
182 match encoded {
183 Ok(body) => {
184 tracing::info!(key = %key, "Cache HIT");
185 Ok(([(header::CONTENT_TYPE, "application/json")], Body::from(body)).into_response())
186 }
187 Err(e) => {
188 tracing::warn!(error = %e, key = %key, "Cache entry is not a ParseResponse; answering 404");
189 Err(not_found())
190 }
191 }
192}
193
194/// Test seam: lets a test hold one entry's decode open to observe what the
195/// handler does while it runs. Compiled out of every non-test build.
196#[cfg(test)]
197fn decode_gate(response_key: &str) {
198 cache_get_tests::hold_decode_if_gated(response_key);
199}
200
201#[cfg(not(test))]
202fn decode_gate(_response_key: &str) {}
203
204/// Response body for `DELETE /api/v1/cache/:key`.
205#[derive(Debug, Serialize, PartialEq, Eq)]
206pub struct CacheDeleteResponse {
207 /// The request `cache_key` the call named, echoed back once it resolved.
208 pub key: String,
209 /// Number of index entries removed. `0` for a file nothing was cached
210 /// under, or whose entries were already gone -- see `remove_by_key_prefix`.
211 pub deleted: usize,
212}
213
214/// DELETE /api/v1/cache/:key - Invalidate every cache entry for the source
215/// file a request `cache_key` names (issues #3636, #5750).
216///
217/// `key` is the same `cache_key` [`get_cached`] takes, resolved by the same
218/// [`resolve_request_cache_key`]. One source file fans out into several cache
219/// entries (request, JSON, Parquet geometry, Parquet metadata, symbolic
220/// sidecar, crossed with opening-filter and tessellation-quality variants);
221/// this removes all of them, not only the variant the key names, because they
222/// are all derived from the one file and a client invalidating it cannot know
223/// which variants other callers warmed. Any content blob none of them (or any
224/// unrelated entry) references any more is reclaimed.
225///
226/// Idempotent: deleting a key with no matching entries is a `200` with
227/// `deleted: 0`, not a `404`, so a client can call this unconditionally and
228/// retry safely without checking existence first.
229///
230/// Two bounds stand in front of that work, because it is expensive and, in
231/// the shipped default configuration (`config.api_token` unset, so
232/// `middleware::auth` is a pass-through), reachable by anyone:
233///
234/// - the path segment must resolve. `remove_by_key_prefix` walks the whole
235/// cache index twice whatever it is handed, and a miss costs exactly as
236/// much as a hit, so without this an arbitrary string buys a full index
237/// walk (#4582).
238/// - one such walk runs at a time, enforced by `DiskCache` itself so the
239/// permit travels with the blocking work rather than with this request
240/// future (a client that hangs up mid-walk must not release the bound
241/// while the walk runs on). A concurrent one is shed with 503 +
242/// `Retry-After` rather than queued; this route is retry-safe by
243/// construction.
244pub async fn delete_cached(
245 State(state): State<AppState>,
246 Path(key): Path<String>,
247) -> Result<Json<CacheDeleteResponse>, ApiError> {
248 let resolved = resolve_request_cache_key(&key)?;
249 let deleted = state.cache.remove_by_key_prefix(resolved.file_digest()).await?;
250 tracing::info!(key = %key, deleted, "Cache invalidation");
251 Ok(Json(CacheDeleteResponse { key, deleted }))
252}
253
254#[cfg(test)]
255#[path = "cache_delete_tests.rs"]
256mod cache_delete_tests;
257
258#[cfg(test)]
259#[path = "cache_get_tests.rs"]
260mod cache_get_tests;
261
262#[cfg(test)]
263#[path = "cache_key_tests.rs"]
264mod cache_key_tests;