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}