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;