ifc_lite_server/services/
streaming.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//! Streaming geometry processing with Server-Sent Events.
6//!
7//! Thin bridge over the canonical `ifc_lite_processing` pipeline: the
8//! blocking task runs `process_geometry_streaming_filtered_with_baked_basis`
9//! (`..._with_options` plus the frame published before the first batch,
10//! #5407; the same code path as `POST /api/v1/parse` and the wasm
11//! `processGeometryBatch` boundary) and forwards its batch callbacks through
12//! a bounded channel as [`StreamEvent`]s (see [`EVENT_BUFFER_EVENTS`]).
13//!
14//! This file used to host a third, bespoke geometry pipeline with its own
15//! scan, style index (SurfaceColour-only, no material chain, no indexed
16//! colour maps), no aggregate void propagation, no submeshes and no type
17//! geometry — meshes streamed from `/parse/stream` could differ from every
18//! other surface (alignment audit). Supersede means delete: it is gone, and
19//! the streaming endpoints inherit every pipeline feature (and bug fix)
20//! automatically, including `opening_filter` support which the bespoke
21//! pipeline never had.
22
23use crate::services::cache::DiskCache;
24use crate::types::{finish_meshes, StreamEvent};
25use ifc_lite_processing::style::ModelFinishes;
26use async_stream::stream;
27use futures::Stream;
28use ifc_lite_processing::{
29    extract_symbolic_data_with_provenance_in_frame, process_geometry_streaming_filtered_with_baked_basis, OpeningFilterMode,
30    StreamingOptions, TessellationQuality,
31};
32use std::pin::Pin;
33use tokio::sync::mpsc;
34
35/// How many events the blocking producer may run ahead of the response
36/// stream before it waits for the client.
37///
38/// The channel between the two is the only place the parse's emit rate meets
39/// the client's read rate. A client that stops reading keeps its connection
40/// until the write-idle timeout closes it (`IFC_STREAM_IDLE_TIMEOUT_SECS`,
41/// see `write_timeout.rs`); until then only this bound stops the producer
42/// from queuing every batch of the model on top of the parse's working set,
43/// which admission does not account for (it charges the UPLOAD size, not the
44/// tessellated output). A full queue parks the producer in `blocking_send`
45/// (legal there, it runs on a `spawn_blocking` thread), so a stalled reader
46/// stalls its own parse instead of growing the process.
47///
48/// Four events is two batches: the pipeline emits each batch as a `Batch`
49/// frame followed by its `Progress` frame, so the consumer always has one
50/// batch to serialise and one queued behind it while the producer meshes the
51/// next. Nothing is dropped: the producer waits for capacity. A receiver that
52/// goes away makes a parked send return at once, and the next callback sees
53/// `tx.is_closed()` and cancels.
54pub(crate) const EVENT_BUFFER_EVENTS: usize = 4;
55
56fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
57    haystack
58        .windows(needle.len())
59        .position(|window| window == needle)
60}
61
62/// Detect the declared IFC schema from the STEP header.
63///
64/// Schema-like text in DATA values or comments must not influence metadata.
65pub(crate) fn detect_schema_version(content: &[u8]) -> &'static str {
66    let header_end = find_bytes(content, b"ENDSEC;").unwrap_or(content.len());
67    let header = &content[..header_end];
68    let Some(schema_start) = find_bytes(header, b"FILE_SCHEMA") else {
69        return "IFC2X3";
70    };
71    let declaration = &header[schema_start..];
72    let declaration_end = declaration
73        .iter()
74        .position(|byte| *byte == b';')
75        .unwrap_or(declaration.len());
76    let declaration = &declaration[..declaration_end];
77
78    if find_bytes(declaration, b"IFC4X3").is_some() {
79        "IFC4X3"
80    } else if find_bytes(declaration, b"IFC4").is_some() {
81        "IFC4"
82    } else {
83        "IFC2X3"
84    }
85}
86
87/// Generate streaming geometry events backed by the canonical pipeline.
88///
89/// Takes the raw IFC bytes (issue #1023): localized non-UTF-8 byte sequences
90/// in the HEADER must not block otherwise valid models, so no `String`
91/// conversion happens anywhere on this path.
92pub fn process_streaming(
93    content: bytes::Bytes,
94    initial_batch_size: usize,
95    max_batch_size: usize,
96    opening_filter: OpeningFilterMode,
97    tessellation_quality: TessellationQuality,
98    admission: Option<crate::admission::AdmissionGuard>,
99) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
100    // Zero is a caller bug, not a reason to stall the stream.
101    let initial_batch_size = initial_batch_size.max(1);
102    let max_batch_size = max_batch_size.max(1);
103
104    let (tx, mut rx) = mpsc::channel::<StreamEvent>(EVENT_BUFFER_EVENTS);
105
106    // Disconnect-aware cancellation: when the SSE client hangs up, the
107    // receiver drops, the batch callback notices via `tx.is_closed()`, and the
108    // streaming core stops between chunks instead of meshing the rest of the
109    // model for nobody (a full-size parse used to keep burning a core and its
110    // memory slot to completion).
111    let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
112    let cancel_for_task = std::sync::Arc::clone(&cancel);
113
114    // The admission permit must be held until BOTH sides are done: the
115    // blocking producer (on disconnect the stream drops first, but the task
116    // keeps its memory/CPU until the cooperative cancel takes effect) AND the
117    // response stream (the channel can still hold `EVENT_BUFFER_EVENTS`
118    // emitted events after the producer exits, so dropping the permit at task
119    // exit would let a replacement parse be admitted on top of the undrained
120    // buffers). An Arc'd guard held by both releases on whichever finishes
121    // last.
122    let admission = admission.map(std::sync::Arc::new);
123    let admission_for_task = admission.clone();
124
125    let handle = tokio::task::spawn_blocking(move || {
126        let _admission = admission_for_task;
127        let cache_key = DiskCache::generate_key(&content);
128
129        let mut started = false;
130        let mut batch_number = 0usize;
131        let mut last_type = String::new();
132        // Filled by the pipeline once it has chosen the frame, before the
133        // first batch (#5407).
134        let baked_basis = std::sync::OnceLock::new();
135        // #5984: every batch's meshes carry their IFC-authored finish. One
136        // styled-item scan up front; a file that authors none joins nothing.
137        let mut finishes = ModelFinishes::from_content(&content);
138
139        let result = process_geometry_streaming_filtered_with_baked_basis(
140            &content,
141            opening_filter,
142            StreamingOptions {
143                initial_batch_size,
144                throughput_batch_size: max_batch_size,
145                tessellation_quality,
146                // Batches are forwarded as they are emitted — retaining them
147                // in the ProcessingResult would double peak memory.
148                retain_emitted_meshes: false,
149                cancel: Some(std::sync::Arc::clone(&cancel_for_task)),
150                ..StreamingOptions::default()
151            },
152            &baked_basis,
153            |meshes, processed, total| {
154                if tx.is_closed() {
155                    cancel_for_task.store(true, std::sync::atomic::Ordering::Relaxed);
156                    return;
157                }
158                if !started {
159                    started = true;
160                    let _ = tx.blocking_send(StreamEvent::Start {
161                        total_estimate: total,
162                    });
163                    let _ = tx.blocking_send(StreamEvent::Progress {
164                        processed: 0,
165                        total,
166                        current_type: "indexing".into(),
167                    });
168                }
169                if let Some(mesh) = meshes.last() {
170                    last_type = mesh.ifc_type.clone();
171                }
172                if !meshes.is_empty() {
173                    batch_number += 1;
174                    let _ = tx.blocking_send(StreamEvent::Batch {
175                        meshes: finish_meshes(&mut finishes, meshes.to_vec()),
176                        batch_number,
177                        baked_basis: baked_basis.get().copied(),
178                    });
179                }
180                let _ = tx.blocking_send(StreamEvent::Progress {
181                    processed,
182                    total,
183                    current_type: last_type.clone(),
184                });
185            },
186            // Styling is eager on this path (`fast_first_batch` defaults to
187            // false), so colour updates never fire.
188            |_| {},
189            |_| {},
190        );
191
192        if cancel_for_task.load(std::sync::atomic::Ordering::Relaxed) || tx.is_closed() {
193            // Client gone: the partial result must not be presented as a
194            // completed parse - skip Complete AND the symbolic extraction
195            // (which re-scans the file). The is_closed check also covers a
196            // disconnect after the LAST batch, where the callback can no
197            // longer observe it.
198            tracing::info!("SSE client disconnected; streaming parse stopped early");
199            return;
200        }
201
202        if !started {
203            // Zero-geometry model: the batch callback never ran. Emit Start
204            // so consumers still observe the Start → Complete contract.
205            let _ = tx.blocking_send(StreamEvent::Start { total_estimate: 0 });
206        }
207
208        // 2D symbolic stream (IfcAnnotation + IfcGrid) on the same blocking
209        // thread — parity with the synchronous endpoints (issue #900).
210        // Georeferencing already rides in `result.metadata`.
211        //
212        // In the frame the batches above were meshed in: the `Complete` event
213        // carries the symbols and `mesh_coordinate_space` together, so they
214        // must agree (#4706).
215        let symbolic_data =
216            extract_symbolic_data_with_provenance_in_frame(&content, result.frame);
217
218        let _ = tx.blocking_send(StreamEvent::Complete {
219            stats: result.stats,
220            metadata: result.metadata,
221            cache_key,
222            mesh_coordinate_space: Some(result.mesh_coordinate_space),
223            site_transform: result.site_transform,
224            building_transform: result.building_transform,
225            symbolic_data,
226        });
227        // `tx` drops here, closing the channel and ending the stream below.
228    });
229
230    Box::pin(stream! {
231        let _admission = admission;
232        while let Some(event) = rx.recv().await {
233            yield event;
234        }
235        // Surface a panicked/cancelled blocking task as a stream error
236        // instead of silently truncating the SSE stream.
237        if let Err(e) = handle.await {
238            yield StreamEvent::Error {
239                message: format!("Streaming geometry task failed: {e}"),
240            };
241        }
242    })
243}
244
245#[cfg(test)]
246#[path = "streaming_tests.rs"]
247mod streaming_tests;