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;