ifc_lite_server/routes/parse/
fetch.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//! GET cache fetch / check endpoints.
6
7use super::cache_keys::{
8    cache_key_from_parts, data_model_cache_key, has_current_data_model, has_cached_symbolic, has_parquet_metadata,
9    parquet_cache_key, parquet_metadata_cache_key, symbolic_cache_key,
10};
11use super::replay_header::mark_header_from_cache;
12use super::ParseQuery;
13use crate::error::ApiError;
14use crate::services::DataModelEntities;
15use crate::AppState;
16use axum::{
17    body::Body,
18    extract::{Query, State},
19    http::{header, StatusCode},
20    response::Response,
21};
22
23/// Query of `GET /api/v1/parse/data-model/{cache_key}`.
24///
25/// Its own struct rather than [`ParseQuery`]: this route takes one parameter,
26/// and it must be the SAME value the parse request sent, because it selects
27/// which of the two data-model entries is read (#6034).
28#[derive(serde::Deserialize, Default)]
29pub struct DataModelQuery {
30    /// "all" (default) or "rooted"; see [`DataModelEntities`].
31    #[serde(default)]
32    pub data_model_entities: DataModelEntities,
33}
34
35/// GET /api/v1/parse/data-model/:cache_key
36///
37/// Fetch the data model for a previously parsed file.
38///
39/// `?data_model_entities=rooted` (#6034) fetches the rooted-only variant,
40/// which only a parse sent with the same parameter writes. Without it, the
41/// full table. Neither ever answers for the other.
42///
43/// Response:
44/// - 200: Data model Parquet binary
45/// - 202: A fill IS running for this key right now (client should retry)
46/// - 404: Nothing cached for this key and nothing filling it (issue #5129:
47///   `/parse/parquet/optimized` never triggers a data-model write, so before
48///   this a client that called only that route polled 202 forever -- the
49///   response read as "still processing" for a key nothing was processing)
50pub async fn get_data_model(
51    State(state): State<AppState>,
52    Query(query): Query<DataModelQuery>,
53    axum::extract::Path(cache_key): axum::extract::Path<String>,
54) -> Result<Response, ApiError> {
55    // Checked on BOTH sides of the (awaited, disk-I/O) cache read, not once,
56    // and answering 202 if EITHER saw it (issue #5134 review): a single
57    // check before the read leaves the read's whole await window open for a
58    // fill to begin and go unnoticed, and a single check after leaves the
59    // window before the read open the same way in the other direction --
60    // either alone can land on a 404 for a fill that is (or was, moments
61    // ago) genuinely in progress. `fetchDataModel` treats 404 as terminal, so
62    // that false negative does not just cost a retry, it stops the client
63    // from ever asking again. A real background fill's begin-to-drop span is
64    // milliseconds to seconds of real parse/serialize/write work, so the
65    // residual window -- a fill starting AND finishing its own drop entirely
66    // between these two checks -- is negligible; closing it fully would need
67    // a lock spanning both the in-memory marker and the disk read, which is
68    // disproportionate here.
69    //
70    // In-flight markers are keyed by the data-model ENTRY, not the request
71    // key (#6034): a fill of the full table says nothing about whether the
72    // rooted one will ever be written.
73    let data_model_cache_key = data_model_cache_key(&cache_key, query.data_model_entities);
74    let in_flight_before = state.data_model_in_flight.contains(&data_model_cache_key);
75    let cached = state.cache.get_bytes(&data_model_cache_key).await?;
76    let in_flight =
77        in_flight_before || state.data_model_in_flight.contains(&data_model_cache_key);
78
79    match cached {
80        Some(data_model_parquet) => {
81            tracing::info!(
82                cache_key = %cache_key,
83                size = data_model_parquet.len(),
84                "Data model cache HIT"
85            );
86
87            let response = Response::builder()
88                .status(StatusCode::OK)
89                .header(header::CONTENT_TYPE, "application/x-parquet-datamodel")
90                .header(header::CONTENT_LENGTH, data_model_parquet.len())
91                .body(Body::from(data_model_parquet))
92                .map_err(|e| ApiError::Internal(e.to_string()))?;
93
94            Ok(response)
95        }
96        None if in_flight => {
97            tracing::debug!(cache_key = %cache_key, "Data model fill in flight");
98
99            let response = Response::builder()
100                .status(StatusCode::ACCEPTED)
101                .header(header::CONTENT_TYPE, "application/json")
102                .body(Body::from(r#"{"status":"processing","message":"Data model is still being processed. Retry in a moment."}"#))
103                .map_err(|e| ApiError::Internal(e.to_string()))?;
104
105            Ok(response)
106        }
107        None => {
108            tracing::debug!(cache_key = %cache_key, "No data model cached and none in flight for this key");
109            Err(ApiError::NotFound(format!(
110                "No data model cached for key: {cache_key}"
111            )))
112        }
113    }
114}
115
116/// GET /api/v1/parse/symbolic/:cache_key
117///
118/// Fetch the 2D symbol stream (`IfcAnnotation` + `IfcGrid`) for a previously
119/// parsed file as JSON. This brings the binary-transport endpoints (Parquet,
120/// optimized Parquet, cached geometry) to parity with the inline `symbolic_data`
121/// field on `POST /api/v1/parse` (issue #900). Symbol data is cached separately
122/// from geometry — exactly like the data model — so it's fetched the same way,
123/// keyed by the `cache_key` carried in each response's metadata header.
124///
125/// `cache_key` is the full `{hash}-{opening_filter}` value (e.g. `<hash>-default`).
126///
127/// Response:
128/// - 200: `SymbolicData` JSON (may have empty arrays when the model has no 2D symbols)
129/// - 202: Not yet available — streaming caches symbolic data in the background; retry
130pub async fn get_symbolic(
131    State(state): State<AppState>,
132    axum::extract::Path(cache_key): axum::extract::Path<String>,
133) -> Result<Response, ApiError> {
134    let key = symbolic_cache_key(&cache_key);
135
136    match state.cache.get_bytes(&key).await? {
137        Some(symbolic_json) => {
138            tracing::info!(
139                cache_key = %cache_key,
140                size = symbolic_json.len(),
141                "Symbolic data cache HIT"
142            );
143
144            let response = Response::builder()
145                .status(StatusCode::OK)
146                .header(header::CONTENT_TYPE, "application/json")
147                .header(header::CONTENT_LENGTH, symbolic_json.len())
148                .body(Body::from(symbolic_json))
149                .map_err(|e| ApiError::Internal(e.to_string()))?;
150
151            Ok(response)
152        }
153        None => {
154            tracing::debug!(cache_key = %cache_key, "Symbolic data not yet available");
155
156            // Return 202 Accepted to indicate processing (mirrors get_data_model);
157            // the streaming endpoints cache symbolic data in a background task.
158            let response = Response::builder()
159                .status(StatusCode::ACCEPTED)
160                .header(header::CONTENT_TYPE, "application/json")
161                .body(Body::from(r#"{"status":"processing","message":"Symbolic data is still being processed. Retry in a moment."}"#))
162                .map_err(|e| ApiError::Internal(e.to_string()))?;
163
164            Ok(response)
165        }
166    }
167}
168
169/// GET /api/v1/cache/check/:hash
170///
171/// Check if a file hash is already cached.
172/// Allows client to skip upload if file is already processed.
173///
174/// The optional `opening_filter` query parameter must match the value used when
175/// the file was uploaded — different filter modes produce distinct cache entries.
176///
177/// Response:
178/// - 200: File is cached (geometry available)
179/// - 404: File not cached (needs upload), in the shared error envelope
180///   (#5750; the body was empty before)
181pub async fn check_cache(
182    State(state): State<AppState>,
183    Query(query): Query<ParseQuery>,
184    axum::extract::Path(hash): axum::extract::Path<String>,
185) -> Result<Response, ApiError> {
186    let quality = query.resolved_tessellation_quality()?;
187    // Answers for the LAYOUT the client asked about (#3888). A client that
188    // does not send `parquet_layout` is asking about v5, and a v6 entry sitting
189    // beside it must NOT report a hit: the client would skip the upload and
190    // then fetch a blob it draws wrong.
191    let parquet_cache_key =
192        parquet_cache_key(&hash, query.opening_filter, quality, query.parquet_layout);
193    // A geometry entry alone is not enough: the client skips the upload on a
194    // hit, so a data model at the current payload version has to exist too, or
195    // nothing will ever write one (issue #3869). The same holds for the
196    // metadata header: a hit sends the client to `get_cached_geometry`, which
197    // needs it at the current version, and a header version bump leaves the
198    // geometry entry behind (#4675).
199    let seed_cache_key = cache_key_from_parts(&hash, query.opening_filter, quality);
200    let sidecars_are_current =
201        has_current_data_model(&state.cache, &seed_cache_key, query.data_model_entities).await
202        && has_cached_symbolic(&state.cache, &seed_cache_key).await
203        && has_parquet_metadata(&state.cache, &seed_cache_key).await;
204
205    match state.cache.get_bytes(&parquet_cache_key).await? {
206        Some(_) if sidecars_are_current => {
207            tracing::debug!(hash = %hash, cache_key = %parquet_cache_key, "Cache check HIT");
208            let response = Response::builder()
209                .status(StatusCode::OK)
210                .body(Body::empty())
211                .map_err(|e| ApiError::Internal(e.to_string()))?;
212            Ok(response)
213        }
214        cached => {
215            tracing::debug!(
216                hash = %hash,
217                cache_key = %parquet_cache_key,
218                geometry_cached = cached.is_some(),
219                "Cache check MISS"
220            );
221            // The hash is caller-supplied and unvalidated here, so it is not
222            // echoed back (as `not_a_file_digest` does not echo it either).
223            Err(ApiError::NotFound(
224                "Nothing cached for this hash under this opening_filter / tessellation_quality / parquet_layout"
225                    .to_owned(),
226            ))
227        }
228    }
229}
230
231/// GET /api/v1/cache/geometry/:hash
232///
233/// Fetch cached Parquet geometry directly without uploading the file.
234/// Used when client-side hash check confirms file is already cached.
235///
236/// The optional `opening_filter` query parameter must match the value used when
237/// the file was uploaded — different filter modes produce distinct cache entries.
238///
239/// Response:
240/// - 200: Cached Parquet geometry with metadata header
241/// - 404: Cache entry not found
242pub async fn get_cached_geometry(
243    State(state): State<AppState>,
244    Query(query): Query<ParseQuery>,
245    axum::extract::Path(hash): axum::extract::Path<String>,
246) -> Result<Response, ApiError> {
247    // Same layout signal as the cache check and the parse routes, so this can
248    // only ever hand back the layout the caller declared it understands.
249    let parquet_cache_key = parquet_cache_key(
250        &hash,
251        query.opening_filter,
252        query.resolved_tessellation_quality()?,
253        query.parquet_layout,
254    );
255    let metadata_cache_key = parquet_metadata_cache_key(
256        &hash,
257        query.opening_filter,
258        query.resolved_tessellation_quality()?,
259    );
260
261    match (
262        state.cache.get_bytes(&parquet_cache_key).await?,
263        state.cache.get_bytes(&metadata_cache_key).await?,
264    ) {
265        (Some(parquet), Some(metadata)) => {
266            tracing::info!(
267                hash = %hash,
268                parquet_size = parquet.len(),
269                "Returning cached geometry (no upload needed)"
270            );
271
272            let response = Response::builder()
273                .status(StatusCode::OK)
274                .header(header::CONTENT_TYPE, "application/x-parquet-geometry")
275                // Stored as the live parse wrote it, `from_cache: false`
276                // included; this is the warm path of `parseParquet()` (#5542).
277                .header(
278                    "X-IFC-Metadata",
279                    mark_header_from_cache(
280                        String::from_utf8(metadata)
281                            .map_err(|error| ApiError::Internal(error.to_string()))?,
282                    ),
283                )
284                .header(header::CONTENT_LENGTH, parquet.len())
285                .body(Body::from(parquet))
286                .map_err(|e| ApiError::Internal(e.to_string()))?;
287
288            Ok(response)
289        }
290        _ => {
291            tracing::debug!(hash = %hash, "Cached geometry not found");
292            // Caller-supplied and unvalidated: logged, not echoed (as in
293            // `check_cache` above).
294            Err(ApiError::NotFound(
295                "No cached geometry for this hash".to_owned(),
296            ))
297        }
298    }
299}