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}