ifc_lite_server/routes/parse/parquet_stream.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//! SSE Parquet-batch streaming parse endpoint.
6
7use super::cache_keys::{
8 data_model_cache_key, parquet_geometry_key, parquet_metadata_key,
9 request_cache_key,
10};
11use super::parquet::ParquetMetadataHeader;
12use super::stream_event::ParquetStreamEvent;
13use super::stream_progress::{cache_stream_progress, StreamProgressRecorder};
14use super::{cache_symbolic_data_off_runtime, extract_file, ParseQuery};
15use crate::error::ApiError;
16use crate::services::{extract_data_model, process_streaming, serialize_data_model_to_parquet};
17use crate::types::StreamEvent;
18use crate::AppState;
19use axum::{
20 extract::{Multipart, Query, State},
21 response::sse::{Event, KeepAlive, Sse},
22};
23use std::convert::Infallible;
24
25/// POST /api/v1/parse/parquet-stream - Streaming parse with Parquet batches.
26///
27/// Returns SSE events with Parquet-encoded geometry batches for progressive rendering.
28/// Each batch can be decoded and rendered immediately without waiting for the full response.
29///
30/// Events:
31/// - `start`: Initial event with `total_estimate` and `cache_key`
32/// - `progress`: Progress updates with `processed` and `total` counts
33/// - `batch`: Geometry batch with base64-encoded Parquet `data`, `mesh_count`, `batch_number`
34/// - `complete`: Final event with `stats` and `metadata`
35/// - `error`: Error event with `message`
36///
37/// After `complete`, client should fetch data model via `/api/v1/data-model/{cache_key}`.
38///
39/// ## Two ways to ask
40///
41/// With a multipart `file` body: the normal path. The cache key is the SHA-256
42/// of the RECEIVED bytes, so the upload always completes first, and a `sha256`
43/// query parameter sent alongside a body is IGNORED - the bytes on the wire
44/// decide which entry is read and written, never the client's claim about them.
45///
46/// With `?sha256={hex}` and no body: a probe (issue #3901). It replays the
47/// cached stream if, and only if, every entry the replay needs already exists
48/// under that key, and otherwise answers `404` meaning "upload it". See
49/// [`cached_replay::replay_by_client_hash`]. This is what lets a 40 MB cache
50/// hit cost no upload.
51pub async fn parse_parquet_stream(
52 State(state): State<AppState>,
53 Query(query): Query<ParseQuery>,
54 multipart: Option<Multipart>,
55) -> Result<axum::response::Response, ApiError> {
56 use crate::services::parquet_stream_shapes::StreamShapePlanner;
57 use crate::services::{StreamShapes, StreamingParquetCacheWriter};
58 use axum::response::IntoResponse;
59 use base64::{engine::general_purpose::STANDARD, Engine};
60 use futures::StreamExt;
61 use std::sync::{Arc, Mutex};
62
63 let tessellation_quality = query.resolved_tessellation_quality()?;
64 let stream_shapes = query.resolved_stream_shapes()?;
65
66 // Hash-only probe: no body was sent, so there is nothing to extract and
67 // nothing to parse. It is checked before the gate below only because that
68 // gate reserves an upload that does not exist. The probe takes admission
69 // itself, on the hit path where it has real work to bound -- see
70 // `replay_by_client_hash`.
71 let Some(mut multipart) = multipart else {
72 let Some(sha256) = query.sha256.as_deref() else {
73 // No body and no hash: there is nothing to identify a file with.
74 // `MissingFile` (400) is what a body with no `file` field already
75 // answers, and it says the same thing here.
76 return Err(ApiError::MissingFile);
77 };
78 return super::cached_replay::replay_by_client_hash(
79 &state,
80 &query,
81 tessellation_quality,
82 stream_shapes,
83 sha256,
84 )
85 .await;
86 };
87
88 // Extract file
89 // Admission gate (bounded concurrency + byte budget): acquired BEFORE the
90 // upload is buffered, reserving the max upload size since multipart rarely
91 // declares a length up front. Held for the request's whole lifetime so a
92 // disconnected-but-still-running job keeps its memory slot.
93 let admission_guard = state
94 .admission
95 .acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
96 .await?;
97 let data = extract_file(&mut multipart, state.config.max_file_size_mb).await?;
98
99 // Generate cache key before processing (include opening filter + quality).
100 // From the RECEIVED BYTES, always: a `sha256` parameter that arrived
101 // alongside a body has no say here, so a client whose claimed hash does not
102 // describe what it uploaded still reads and writes the entry its bytes name.
103 let cache_key = request_cache_key(&data, &query, tessellation_quality);
104 let cache_key_clone = cache_key.clone();
105 // Without `stream_shapes=cross-batch` this route SHARES nothing -- each
106 // batch must decode on its own -- so the layout only decides whether the
107 // mesh table carries identity `rot0..rot8`, and which cache namespace the
108 // result lands in. A default request therefore still produces
109 // byte-identical v5 output. With it (#5407), `planner` below carries the
110 // shapes already emitted from one batch to the next.
111 let layout = query.parquet_layout;
112
113 // OPTIMIZATION: Check cache first and fast-path return if available
114 // This avoids re-processing files that are already cached (see
115 // `cached_replay.rs`; a short/corrupt blob falls through as a miss).
116 if let Some(response) =
117 super::cached_replay::try_cached_replay(&state, &cache_key, layout, stream_shapes, query.data_model_entities).await?
118 {
119 // Cached replay: no parse work runs, so holding the admission
120 // guard (and its CPU slot) while a slow client drains the SSE
121 // would starve real parses for nothing. The replay blob is
122 // already materialized and bounded by cache content, far below
123 // a parse working set.
124 drop(admission_guard);
125 return Ok(response);
126 }
127
128 tracing::info!(
129 cache_key = %cache_key,
130 size = data.len(),
131 "Streaming cache MISS - processing file"
132 );
133
134 let content = data;
135 let initial_batch_size = state.config.initial_batch_size;
136 let max_batch_size = state.config.max_batch_size;
137 let cache = state.cache.clone();
138
139 // Incremental cache writer: each batch's columns are appended as Parquet
140 // row groups (GLOBAL offsets) and the meshes dropped, replacing the old
141 // Arc<Mutex<Vec<MeshData>>> accumulator that held a FULL second copy of
142 // the model's geometry until Complete. `None` after a writer error (the
143 // cache fill is skipped; the client stream is unaffected).
144 let cache_writer: Arc<Mutex<Option<StreamingParquetCacheWriter>>> =
145 Arc::new(Mutex::new(match StreamingParquetCacheWriter::new(layout) {
146 Ok(w) => Some(w),
147 Err(e) => {
148 tracing::error!(error = %e, "Failed to create streaming cache writer");
149 None
150 }
151 }));
152 let cache_writer_for_stream = cache_writer.clone();
153 // Cross-batch streams only: the whole-stream offsets and the shapes
154 // emitted so far (#5407). Owned by the stream, NOT by the cache writer,
155 // because the client's batches depend on it and must keep flowing when a
156 // cache-writer error has dropped the cache fill.
157 let mut planner = (stream_shapes == StreamShapes::CrossBatch)
158 .then(|| StreamShapePlanner::new(layout, stream_shapes));
159 // Job-unit progress checkpoints for the cache-hit replay: `progress`
160 // events report the pipeline's `processed_jobs` / `total_jobs`, which the
161 // geometry blob does not record (issue #3897).
162 let mut progress_recorder = StreamProgressRecorder::default();
163 let cache_for_geometry = cache.clone();
164 let cache_key_for_geometry = cache_key.clone();
165
166 // Create streaming response that yields Parquet batches
167 let stream = process_streaming(
168 content.clone(),
169 initial_batch_size,
170 max_batch_size,
171 query.opening_filter,
172 tessellation_quality,
173 Some(admission_guard),
174 )
175 .map(move |event: StreamEvent| {
176 let sse_event = match event {
177 StreamEvent::Start { total_estimate } => {
178 ParquetStreamEvent::Start {
179 total_estimate,
180 cache_key: cache_key_clone.clone(),
181 }
182 }
183 StreamEvent::Progress { processed, total, .. } => {
184 progress_recorder.on_progress(processed, total);
185 ParquetStreamEvent::Progress { processed, total }
186 }
187 StreamEvent::Batch { meshes, batch_number, baked_basis } => {
188 progress_recorder.on_batch();
189 // Per-batch CPU work (client-blob serialization + cache-writer
190 // append) runs inside this stream map, i.e. on an async worker,
191 // so it steps off the async pool for it.
192 let serialized = super::stream_batch::off_the_async_worker(|| {
193 super::stream_batch::encode_batch(
194 &meshes,
195 layout,
196 baked_basis.as_ref(),
197 planner.as_mut(),
198 &cache_writer_for_stream,
199 )
200 });
201
202 match serialized {
203 Ok((parquet_bytes, bases)) => {
204 let base64_data = STANDARD.encode(&parquet_bytes);
205 ParquetStreamEvent::Batch {
206 data: base64_data,
207 mesh_count: meshes.len(),
208 batch_number,
209 vertex_base: bases.map(|(v, _)| v),
210 index_base: bases.map(|(_, i)| i),
211 }
212 }
213 Err(e) => {
214 ParquetStreamEvent::Error {
215 message: format!("Failed to serialize batch: {}", e),
216 }
217 }
218 }
219 }
220 StreamEvent::Complete { stats, metadata, mesh_coordinate_space, site_transform, building_transform, symbolic_data, .. } => {
221 // Cache the symbolic stream so the cached-geometry fast-path and
222 // `GET /api/v1/parse/symbolic/{cache_key}` reach parity (issue #900).
223 // Reuses the value already computed inside `process_streaming` —
224 // no re-extraction.
225 tokio::spawn(cache_symbolic_data_off_runtime(
226 cache_for_geometry.clone(),
227 cache_key_for_geometry.clone(),
228 symbolic_data.clone(),
229 ));
230
231 // Finish the incremental writer: the cache blob was built row
232 // group by row group as batches streamed, so nothing is
233 // re-serialized and no second copy of the geometry exists.
234 let cache = cache_for_geometry.clone();
235 let key = cache_key_for_geometry.clone();
236 let stats_clone = stats.clone();
237 let metadata_clone = metadata.clone();
238 let writer_for_cache = cache_writer.clone();
239 let recorded_progress = progress_recorder.take();
240 let coord_space = mesh_coordinate_space;
241 let site_tf = site_transform.clone();
242 let building_tf = building_transform.clone();
243
244 tokio::spawn(async move {
245 // Take the writer (moves out of Arc<Mutex>).
246 let writer = {
247 match writer_for_cache.lock() {
248 Ok(mut guard) => guard.take(),
249 Err(_) => {
250 tracing::error!("Failed to lock streaming cache writer");
251 return;
252 }
253 }
254 };
255 let Some(writer) = writer else {
256 tracing::warn!("Streaming cache writer unavailable; skipping cache fill");
257 return;
258 };
259 if writer.mesh_count() == 0 {
260 tracing::warn!("No meshes streamed; skipping cache fill");
261 return;
262 }
263
264 tracing::info!(
265 mesh_count = writer.mesh_count(),
266 "Caching streamed geometry (incremental writer, no re-serialization)"
267 );
268
269 // `finish_combined` writes the outer `[geo_len][geo_bytes]
270 // [dm_len=0]` framing (same as non-streaming endpoint,
271 // format: [geometry_len: u32][geometry_data][data_model_len: u32])
272 // directly, instead of framing the inner geometry blob and
273 // then copying it a second time into an outer buffer.
274 let finish_result =
275 tokio::task::spawn_blocking(move || writer.finish_combined()).await;
276
277 if let Ok(Ok(combined_parquet)) = finish_result {
278 // Cache geometry (same format as non-streaming)
279 let parquet_cache_key = parquet_geometry_key(&key, layout);
280 if let Err(e) = cache.set_bytes(&parquet_cache_key, &combined_parquet).await {
281 tracing::error!(error = %e, "Failed to cache geometry from stream");
282 } else {
283 tracing::info!(
284 cache_key = %parquet_cache_key,
285 size = combined_parquet.len(),
286 "Geometry cached from stream (optimized - no re-processing)"
287 );
288 }
289
290 // Cache metadata
291 let metadata_header = ParquetMetadataHeader {
292 cache_key: key.clone(),
293 metadata: metadata_clone,
294 stats: stats_clone,
295 mesh_coordinate_space: coord_space,
296 site_transform: site_tf,
297 building_transform: building_tf,
298 data_model_stats: None, // Data model cached separately via data model endpoint
299 };
300 if let Ok(metadata_json) = serde_json::to_vec(&metadata_header) {
301 let metadata_cache_key = parquet_metadata_key(&key);
302 if let Err(e) = cache.set_bytes(&metadata_cache_key, &metadata_json).await {
303 tracing::error!(error = %e, "Failed to cache metadata from stream");
304 } else {
305 tracing::debug!(cache_key = %metadata_cache_key, "Metadata cached from stream");
306 }
307 }
308
309 // The job-unit progress a replay must reproduce.
310 cache_stream_progress(&cache, &key, &recorded_progress).await;
311 } else {
312 tracing::error!("Failed to serialize accumulated meshes for caching");
313 }
314 });
315
316 ParquetStreamEvent::Complete { stats, metadata, symbolic_data }
317 }
318 StreamEvent::Error { message } => {
319 ParquetStreamEvent::Error { message }
320 }
321 };
322
323 let json = serde_json::to_string(&sse_event).unwrap_or_else(|e| {
324 serde_json::to_string(&ParquetStreamEvent::Error {
325 message: e.to_string(),
326 })
327 .unwrap()
328 });
329 Ok(Event::default().data(json))
330 });
331
332 // Spawn background task to extract and cache data model. Marked
333 // in-flight for the task's whole lifetime (#5129): `get_data_model`
334 // answers 202 only for a key in this set, so the marker must cover every
335 // way the task can end -- completion, a `set_bytes` failure, and the
336 // admission-saturated early return alike -- or a client polling during
337 // exactly that window sees a 404 for a fill that is, in fact, still
338 // possible on retry. `InFlightGuard::drop` handles all three uniformly.
339 let content_for_cache = content.clone();
340 // Keyed by the data-model ENTRY this fill writes, as `get_data_model`
341 // asks (#6034): a fill of one variant says nothing about the other.
342 let data_model_entities = query.data_model_entities;
343 let dm_key = data_model_cache_key(&cache_key, data_model_entities);
344 let cache_for_dm = cache.clone();
345 let admission_for_dm = state.admission.clone();
346 let in_flight = state.data_model_in_flight.begin(dm_key.clone());
347 tokio::spawn(async move {
348 let _in_flight = in_flight;
349 // The data-model extraction re-parses the whole upload, so it must
350 // pass admission like any parse job. It is a cache-fill optimization:
351 // when the server is saturated, skipping it (the next request rebuilds
352 // it inline) beats bypassing the gate.
353 let _dm_admission = match admission_for_dm
354 .acquire(content_for_cache.len() as u64)
355 .await
356 {
357 Ok(guard) => guard,
358 Err(_) => {
359 tracing::debug!("Skipping data-model cache fill: admission saturated");
360 return;
361 }
362 };
363 // Run data model extraction in blocking task
364 let dm_result =
365 tokio::task::spawn_blocking(move || extract_data_model(&content_for_cache)).await;
366
367 if let Ok(mut data_model) = dm_result {
368 // Serialize and cache
369 let serialize_result = tokio::task::spawn_blocking(move || {
370 data_model_entities.apply(&mut data_model);
371 serialize_data_model_to_parquet(&data_model)
372 }).await;
373
374 if let Ok(Ok(parquet_data)) = serialize_result {
375 if let Err(e) = cache_for_dm.set_bytes(&dm_key, &parquet_data).await {
376 tracing::error!(error = %e, "Failed to cache data model from stream");
377 } else {
378 tracing::info!(cache_key = %dm_key, size = parquet_data.len(), "Data model cached from stream");
379 }
380 }
381 }
382 // `_in_flight` drops here, clearing the marker only after the cache
383 // write (success or failure) is fully resolved.
384 });
385
386 let boxed_stream: std::pin::Pin<
387 Box<dyn futures::Stream<Item = Result<Event, Infallible>> + Send>,
388 > = Box::pin(stream);
389 Ok(Sse::new(boxed_stream)
390 .keep_alive(KeepAlive::default())
391 .into_response())
392}
393
394#[cfg(test)]
395#[path = "parquet_stream_tests.rs"]
396mod parquet_stream_tests;