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}