ifc_lite_server/routes/parse/
parquet_optimized.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//! `POST /api/v1/parse/parquet/optimized`: the ara3d BOS-optimized Parquet
6//! parse endpoint, split out of `parquet.rs` when it gained a cache of its own
7//! (issue #3889) so neither module crosses the 400-line ratchet. The cache
8//! replay itself (metadata header, read-through-cache, response builder) now
9//! lives in `parquet_optimized_replay.rs` (issue #5128) for the same reason.
10
11use super::cache_keys::request_cache_key;
12use super::parquet::DataModelStats;
13use super::parquet_optimized_replay::{
14    cache_data_model, cache_optimized_response, optimized_parquet_response,
15    replay_optimized_by_client_hash, try_cached_optimized_parquet, OptimizedParquetMetadataHeader,
16};
17use super::{cache_symbolic_data_off_runtime, extract_file, ParseQuery};
18use crate::error::ApiError;
19use crate::services::{
20    baked_basis_zup, extract_data_model, serialize_data_model_to_parquet,
21    serialize_to_parquet_optimized_with_stats, VERTEX_MULTIPLIER,
22};
23use crate::types::finish_meshes;
24use crate::AppState;
25use ifc_lite_processing::style::ModelFinishes;
26use axum::{
27    extract::{Multipart, Query, State},
28    response::Response,
29};
30use ifc_lite_processing::{
31    extract_symbolic_data_with_provenance_in_frame, process_geometry_filtered_with_quality,
32};
33
34/// POST /api/v1/parse/parquet/optimized - Full parse with ara3d BOS-optimized Parquet format.
35///
36/// Returns highly optimized binary Parquet data with:
37/// - Integer quantized vertices (0.1mm precision)
38/// - Mesh deduplication (instancing)
39/// - Byte colors instead of floats
40/// - Optional normals
41///
42/// Query params:
43/// - `normals=true` - Include normals (default: false, compute on client)
44///
45/// Typical compression: 3-5x smaller than basic Parquet, 50-75x smaller than JSON.
46///
47/// ## Two ways to ask
48///
49/// With a multipart `file` body: the normal path. The cache key is the
50/// SHA-256 of the RECEIVED bytes, so a `sha256` query parameter sent alongside
51/// a body is IGNORED, same as the flat route.
52///
53/// With `?sha256={hex}` and no body: a probe (issue #5128), the same contract
54/// the flat route's `?sha256=` probe has (issue #3901). It replays the cached
55/// response if, and only if, every entry the replay needs already exists
56/// under that key, and otherwise answers `404` meaning "upload it". See
57/// [`replay_optimized_by_client_hash`].
58pub async fn parse_parquet_optimized(
59    State(state): State<AppState>,
60    Query(query): Query<ParseQuery>,
61    multipart: Option<Multipart>,
62) -> Result<Response, ApiError> {
63    let tessellation_quality = query.resolved_tessellation_quality()?;
64
65    // Hash-only probe: no body was sent, so there is nothing to extract and
66    // nothing to parse. Checked before the admission gate below only because
67    // that gate reserves an upload that does not exist -- the probe takes
68    // admission itself, on the hit path where it has real work to bound.
69    let Some(mut multipart) = multipart else {
70        let Some(sha256) = query.sha256.as_deref() else {
71            // No body and no hash: there is nothing to identify a file with.
72            return Err(ApiError::MissingFile);
73        };
74        return replay_optimized_by_client_hash(&state, &query, tessellation_quality, sha256).await;
75    };
76
77    // Extract file from multipart
78    // Admission gate (bounded concurrency + byte budget): acquired BEFORE the
79    // upload is buffered, reserving the max upload size since multipart rarely
80    // declares a length up front. Held for the request's whole lifetime so a
81    // disconnected-but-still-running job keeps its memory slot.
82    let admission_guard = state
83        .admission
84        .acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
85        .await?;
86    let data = extract_file(&mut multipart, state.config.max_file_size_mb).await?;
87
88    // Generate cache key (include opening filter so different modes get different cache entries)
89    let cache_key = request_cache_key(&data, &query, tessellation_quality);
90
91    // Cache first, before any processing (issue #3889). The optimized route is
92    // the SMALL payload and the one a viewer opens repeatedly, so re-parsing it
93    // on every request was the worst of both shapes.
94    let data_model_entities = query.data_model_entities;
95    if let Some(response) =
96        try_cached_optimized_parquet(&state, &cache_key, data_model_entities).await?
97    {
98        return Ok(response);
99    }
100
101    tracing::info!(
102        cache_key = %cache_key,
103        size = data.len(),
104        "Optimized Parquet cache MISS - processing file (ara3d BOS format)"
105    );
106
107    // Parse content
108    let content = data;
109    let opening_filter = query.opening_filter;
110
111    // The geometry parse, the data model extraction (#5129: this route now
112    // produces one the way the flat route does), the 2D symbol stream
113    // (IfcAnnotation + IfcGrid, endpoint parity, issue #900) and both
114    // serializations all run in this one blocking task, so none of it
115    // occupies an async worker. Guard rides the blocking task (see
116    // parse_full).
117    let cache_key_for_log = cache_key.clone();
118    let (result, dm_stats, symbolic_data, parquet_data, dm_parquet, opt_stats, _admission) =
119        tokio::task::spawn_blocking(move || -> Result<_, ApiError> {
120            // First: geometry and the data model in parallel, mirroring
121            // `parse_parquet` -- independent extractions over the same bytes,
122            // each on its own rayon thread.
123            // #5984: the finish index is a third independent read of the bytes.
124            let (mut result, (mut data_model, mut finishes)) = rayon::join(
125                || process_geometry_filtered_with_quality(&content, opening_filter, tessellation_quality),
126                || rayon::join(|| extract_data_model(&content), || ModelFinishes::from_content(&content)),
127            );
128            let meshes = finish_meshes(&mut finishes, std::mem::take(&mut result.meshes));
129            drop(finishes);
130            let dm_stats = DataModelStats {
131                entity_count: data_model.entities.len(),
132                property_set_count: data_model.property_sets.len(),
133                relationship_count: data_model.relationships.len(),
134                spatial_node_count: data_model.spatial_hierarchy.nodes.len(),
135            };
136            // After the stats (#6034): they describe the MODEL, so the header
137            // they go into is the same for both entities variants, and the
138            // one metadata entry both share can never disagree with either.
139            data_model_entities.apply(&mut data_model);
140            // Don't include normals by default - client can compute them
141            // The frame `result`'s vertices were baked in (#4118): the
142            // collator's emitted `rel` is consumed directly by this route, so
143            // without it a site-rotated model's repeated shapes fail the
144            // residual check and fall back to content-hash dedup.
145            let basis = baked_basis_zup(
146                Some(result.mesh_coordinate_space),
147                result.site_transform.as_deref(),
148                result.metadata.coordinate_info.origin_shift,
149            );
150            // The symbol stream used to run in a `rayon::join` BESIDE the
151            // parse. It cannot: it needs the frame the parse selected, or a
152            // site-local model's symbols keep the site translation and
153            // rotation its meshes dropped (#4706). Joined with both
154            // serializations instead, so it still overlaps other work. The
155            // upload therefore stays resident until the join ends rather than
156            // being freed before the serialization; admission reserves the
157            // upload size for the request's whole lifetime either way.
158            let (symbolic_data, (serialized, dm_parquet)) = rayon::join(
159                || extract_symbolic_data_with_provenance_in_frame(&content, result.frame),
160                || {
161                    rayon::join(
162                        || serialize_to_parquet_optimized_with_stats(&meshes, false, Some(&basis)),
163                        || serialize_data_model_to_parquet(&data_model),
164                    )
165                },
166            );
167            drop(content);
168            let (parquet_data, opt_stats) = serialized?;
169            let dm_parquet = dm_parquet?;
170            // Nothing after this reads the meshes; free them here rather than
171            // hold the model across the cache writes below.
172            drop(meshes);
173            tracing::info!(
174                cache_key = %cache_key_for_log,
175                input_meshes = opt_stats.input_meshes,
176                unique_meshes = opt_stats.unique_meshes,
177                unique_materials = opt_stats.unique_materials,
178                mesh_reuse_ratio = opt_stats.mesh_reuse_ratio,
179                payload_size = parquet_data.len(),
180                "Optimized Parquet serialization complete"
181            );
182            Ok((
183                result,
184                dm_stats,
185                symbolic_data,
186                parquet_data,
187                dm_parquet,
188                opt_stats,
189                admission_guard,
190            ))
191        })
192        .await??;
193
194    // Cache the data model FIRST (#5129), so it can never sit missing behind
195    // a geometry/metadata pair the replay gate would treat as current -- the
196    // same ordering `parse_parquet` uses and the same #3869 reasoning: written
197    // synchronously, before the response, not in a background task, because a
198    // background write races the client's very next request.
199    cache_data_model(&state, &cache_key, data_model_entities, &dm_parquet).await;
200
201    // Cache the symbolic stream so the client can fetch it via
202    // `GET /api/v1/parse/symbolic/{cache_key}`.
203    cache_symbolic_data_off_runtime(state.cache.clone(), cache_key.clone(), symbolic_data).await;
204
205    // Create metadata header
206    let metadata_header = OptimizedParquetMetadataHeader {
207        cache_key: cache_key.clone(),
208        metadata: result.metadata,
209        stats: result.stats,
210        mesh_coordinate_space: Some(result.mesh_coordinate_space),
211        site_transform: result.site_transform,
212        building_transform: result.building_transform,
213        optimization_stats: opt_stats,
214        vertex_multiplier: VERTEX_MULTIPLIER,
215        data_model_stats: Some(dm_stats),
216    };
217
218    let metadata_json = serde_json::to_string(&metadata_header)?;
219
220    // Store body and metadata BEFORE responding, not in a background task like
221    // the flat route (issue #3889): a background write races the client's very
222    // next request, which is the request the cache exists to serve, and this
223    // payload is the small one so the write is cheap. Metadata is written
224    // LAST -- after the body AND the data model land -- so it can never sit
225    // under a key with either one missing behind it: the replay gate reads
226    // the metadata presence as "this cache_key is fully replayable" (#3869,
227    // #5129). A write failure is logged and the response still goes out: the
228    // parse succeeded, only the replay is lost.
229    if let Err(e) = cache_optimized_response(&state, &cache_key, &parquet_data, &metadata_json).await
230    {
231        tracing::error!(error = %e, cache_key = %cache_key, "Failed to cache optimized Parquet response");
232    } else {
233        tracing::info!(
234            cache_key = %cache_key,
235            payload_size = parquet_data.len(),
236            "Cached optimized Parquet response"
237        );
238    }
239
240    optimized_parquet_response(metadata_json, parquet_data)
241}