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;