ifc_lite_server/routes/parse/parquet_optimized_replay.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-replay plumbing for `POST /api/v1/parse/parquet/optimized`, split out
6//! of `parquet_optimized.rs` (issue #5128) so the route stays under the
7//! module-size ratchet once it gains a `?sha256=` probe of its own — the same
8//! reason the flat route's replay lives in `cached_replay.rs` rather than in
9//! `parquet_stream.rs`. [`replay_optimized_by_client_hash`] IS that probe.
10//!
11//! Everything here is `pub(super)`: only `parquet_optimized.rs` and its
12//! siblings under `routes::parse` need it.
13
14use super::cache_keys::{
15 cache_key_from_parts, data_model_cache_key, has_cached_symbolic, has_current_data_model,
16 has_optimized_metadata, is_file_digest, not_a_file_digest, parquet_optimized_cache_key,
17 parquet_optimized_metadata_cache_key,
18};
19use super::parquet::DataModelStats;
20use super::replay_header::mark_header_from_cache;
21use super::ParseQuery;
22use crate::error::ApiError;
23use crate::services::{DataModelEntities, OptimizedStats};
24use crate::types::{ModelMetadata, ProcessingStats};
25use crate::AppState;
26use axum::{
27 body::Body,
28 http::{header, StatusCode},
29 response::Response,
30};
31use ifc_lite_processing::{MeshCoordinateSpace, TessellationQuality};
32use serde::Serialize;
33
34/// Response header containing metadata for optimized Parquet response.
35#[derive(Debug, Clone, Serialize)]
36pub(super) struct OptimizedParquetMetadataHeader {
37 pub cache_key: String,
38 pub metadata: ModelMetadata,
39 pub stats: ProcessingStats,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub mesh_coordinate_space: Option<MeshCoordinateSpace>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub site_transform: Option<Vec<f64>>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub building_transform: Option<Vec<f64>>,
46 pub optimization_stats: OptimizedStats,
47 /// Vertex multiplier for dequantization (10,000 = 0.1mm precision)
48 pub vertex_multiplier: f32,
49 /// Data model statistics (issue #5129: this route now produces the data
50 /// model too, so its own header can report the same stats
51 /// `ParquetMetadataHeader` does).
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub data_model_stats: Option<DataModelStats>,
54}
55
56/// Read a cache entry, treating an unreadable one as absent.
57///
58/// A corrupt entry must look like a miss to every caller on the replay path,
59/// or the route answers 500 forever for that file.
60pub(super) async fn readable_entry(state: &AppState, key: &str) -> Option<Vec<u8>> {
61 match state.cache.get_bytes(key).await {
62 Ok(bytes) => bytes,
63 Err(e) => {
64 tracing::warn!(error = %e, cache_key = %key, "Unreadable cache entry; re-parsing");
65 None
66 }
67 }
68}
69
70/// The optimized route's wire response, built in ONE place.
71///
72/// The live parse and the cache replay must be indistinguishable to a client,
73/// and two hand-copied builders are indistinguishable only for as long as
74/// nobody edits one of them.
75pub(super) fn optimized_parquet_response(
76 metadata_json: String,
77 body: bytes::Bytes,
78) -> Result<Response, ApiError> {
79 Response::builder()
80 .status(StatusCode::OK)
81 .header(
82 header::CONTENT_TYPE,
83 "application/x-parquet-geometry-optimized",
84 )
85 .header("X-IFC-Metadata", metadata_json)
86 .header(header::CONTENT_LENGTH, body.len())
87 .body(Body::from(body))
88 .map_err(|e| ApiError::Internal(e.to_string()))
89}
90
91/// Try to serve this request from the cache (issue #3889). `Ok(Some(_))` is a
92/// usable hit the caller returns as-is without parsing; anything else falls
93/// through to the live parse, which rewrites the entries.
94///
95/// Ordered cheapest-gate-first: the body is the only large read, so it comes
96/// last, once the hit is otherwise known good.
97///
98/// The stored metadata is the response header VERBATIM, so nothing here
99/// deserializes `OptimizedParquetMetadataHeader` and a replay reports the same
100/// `optimization_stats` the live parse did.
101///
102/// EVERY way an entry can be unusable is a miss, never an error: a read that
103/// fails, and a header that is not valid UTF-8. cacache verifies content on
104/// read, so a truncated or orphaned blob (interrupted write, partial GC) comes
105/// back as an error -- and propagating it would 500 this file's every future
106/// request, because the parse that would overwrite the bad entry is the thing
107/// the error skips. A miss re-parses and rewrites it.
108///
109/// The symbolic gate is not optional: this route's parse also writes the
110/// symbolic sidecar, so replaying past a missing one leaves the client's
111/// `GET /api/v1/parse/symbolic/{cache_key}` polling a key nobody writes.
112///
113/// The data-model gate (#5129) is the same #3869 rule the flat route already
114/// applies: this route now writes a data model beside the geometry, so a hit
115/// warmed BEFORE that change (or one whose data-model entry has since been
116/// bumped) must re-parse, or nothing ever writes a current one and
117/// `get_data_model` polls a key nobody writes forever.
118///
119/// That gate asks about the entities VARIANT the request selected (#6034): a
120/// body warmed by a default request has no rooted data model behind it, and
121/// replaying it would leave a `?data_model_entities=rooted` client with a
122/// data-model key nobody writes. A miss re-parses; the body and header it
123/// rewrites are the same bytes, because the variant touches only the data
124/// model.
125pub(super) async fn try_cached_optimized_parquet(
126 state: &AppState,
127 cache_key: &str,
128 data_model_entities: DataModelEntities,
129) -> Result<Option<Response>, ApiError> {
130 if !has_cached_symbolic(&state.cache, cache_key).await {
131 return Ok(None);
132 }
133
134 if !has_current_data_model(&state.cache, cache_key, data_model_entities).await {
135 return Ok(None);
136 }
137
138 let Some(cached_metadata_json) =
139 readable_entry(state, &parquet_optimized_metadata_cache_key(cache_key)).await
140 else {
141 return Ok(None);
142 };
143
144 let Ok(metadata_json) = String::from_utf8(cached_metadata_json) else {
145 tracing::warn!(
146 cache_key = %cache_key,
147 "Cached optimized metadata is not valid UTF-8; ignoring cache and re-parsing"
148 );
149 return Ok(None);
150 };
151
152 let Some(cached_body) = readable_entry(state, &parquet_optimized_cache_key(cache_key)).await
153 else {
154 return Ok(None);
155 };
156
157 tracing::info!(
158 cache_key = %cache_key,
159 payload_size = cached_body.len(),
160 "Optimized Parquet cache HIT - returning cached response"
161 );
162
163 // Stored as the live parse wrote it, `from_cache: false` included (#5542).
164 optimized_parquet_response(mark_header_from_cache(metadata_json), cached_body.into()).map(Some)
165}
166
167/// Cache the data model produced alongside this route's geometry (#5129).
168///
169/// Log-and-continue like `parse_parquet`'s data-model write: a write failure
170/// here must not fail the response, which already has a valid geometry
171/// payload in hand. The caller writes this BEFORE the geometry body and
172/// metadata, so a data model is never missing behind an entry the replay gate
173/// above would otherwise treat as current.
174pub(super) async fn cache_data_model(
175 state: &AppState,
176 cache_key: &str,
177 data_model_entities: DataModelEntities,
178 bytes: &[u8],
179) {
180 let key = data_model_cache_key(cache_key, data_model_entities);
181 if let Err(e) = state.cache.set_bytes(&key, bytes).await {
182 tracing::error!(error = %e, cache_key = %key, "Failed to cache data model from optimized route");
183 } else {
184 tracing::info!(cache_key = %key, size = bytes.len(), "Data model cached from optimized route");
185 }
186}
187
188/// Serve `POST /api/v1/parse/parquet/optimized` from a client-supplied file
189/// hash, with no request body at all (issue #5128), the same contract the
190/// flat route's `?sha256=` probe has (issue #3901):
191/// [`cached_replay::replay_by_client_hash`].
192///
193/// The optimized key ignores `parquet_layout` -- unlike the flat route, this
194/// route has only ever emitted one payload shape, so there is no second
195/// namespace to select between.
196///
197/// The hash SELECTS; it never asserts. A miss answers `404` and runs no
198/// parse, so a probe cannot become a way to make the server do work for a
199/// body it never received.
200///
201/// A MISS costs no admission slot: the gates below are three small reads
202/// (symbolic sidecar, optimized metadata, and the data model, #5129), cheaper
203/// than queueing for a slot only to fail the actual lookup. Without the
204/// data-model gate here, a probe for an entry warmed before #5129 (geometry
205/// and symbolic present, no data model) would still take an admission slot,
206/// only to be refused anyway by the same gate inside
207/// `try_cached_optimized_parquet` -- charging a slot for a lookup already
208/// known to fail. A HIT takes a slot around the cache read
209/// (`try_cached_optimized_parquet` reads the whole body into memory), dropped
210/// before the response goes out.
211///
212/// [`cached_replay::replay_by_client_hash`]: super::cached_replay::replay_by_client_hash
213pub(super) async fn replay_optimized_by_client_hash(
214 state: &AppState,
215 query: &ParseQuery,
216 quality: TessellationQuality,
217 sha256: &str,
218) -> Result<Response, ApiError> {
219 if !is_file_digest(sha256) {
220 return Err(not_a_file_digest(sha256));
221 }
222 let cache_key = cache_key_from_parts(sha256, query.opening_filter, quality);
223
224 let hit = if has_optimized_metadata(&state.cache, &cache_key).await
225 && has_cached_symbolic(&state.cache, &cache_key).await
226 && has_current_data_model(&state.cache, &cache_key, query.data_model_entities).await
227 {
228 let admission_guard = state
229 .admission
230 .acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
231 .await?;
232 let hit = try_cached_optimized_parquet(state, &cache_key, query.data_model_entities).await;
233 drop(admission_guard);
234 hit?
235 } else {
236 None
237 };
238
239 if let Some(response) = hit {
240 tracing::info!(
241 cache_key = %cache_key,
242 "Optimized Parquet cache HIT by client-supplied hash - no upload"
243 );
244 return Ok(response);
245 }
246
247 tracing::debug!(
248 cache_key = %cache_key,
249 "Hash-only optimized-Parquet request has nothing cached; asking the client to upload"
250 );
251 Err(ApiError::NotFound(format!(
252 "Nothing cached for sha256 {sha256} under this opening_filter / tessellation_quality. Resend the request with the multipart file body."
253 )))
254}
255
256/// Write the optimized body and then its metadata, stopping at the first
257/// failure so metadata never outlives a body that was never stored.
258pub(super) async fn cache_optimized_response(
259 state: &AppState,
260 cache_key: &str,
261 body: &[u8],
262 metadata_json: &str,
263) -> Result<(), ApiError> {
264 state
265 .cache
266 .set_bytes(&parquet_optimized_cache_key(cache_key), body)
267 .await?;
268 state
269 .cache
270 .set_bytes(
271 &parquet_optimized_metadata_cache_key(cache_key),
272 metadata_json.as_bytes(),
273 )
274 .await
275}