ifc_lite_server/services/
parquet.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//! Parquet serialization for efficient mesh data transfer.
6//!
7//! Uses columnar format (ara3d BOS-compatible) for dramatically better compression
8//! compared to JSON serialization. Typical compression ratios:
9//! - JSON: ~30KB per mesh with ~500 vertices
10//! - Parquet: ~2KB per mesh (15x smaller)
11
12use crate::services::parquet_layout::{ParquetLayout, StreamShapes};
13use crate::services::parquet_mesh_tables::build_mesh_tables;
14use crate::services::parquet_schema::{index_schema, mesh_schema, vertex_schema};
15use crate::services::parquet_shape_plan::ShapePlan;
16use crate::services::parquet_stream_shapes::{PlannedBatch, StreamShapePlanner};
17use crate::types::MeshData;
18use arrow::datatypes::{DataType, Schema};
19use arrow::record_batch::RecordBatch;
20use bytes::Bytes;
21use parquet::arrow::ArrowWriter;
22use parquet::basic::Compression;
23use parquet::file::properties::WriterProperties;
24use parquet::schema::types::ColumnPath;
25use std::io::Cursor;
26use std::sync::Arc;
27use thiserror::Error;
28
29/// Errors during Parquet serialization.
30#[derive(Debug, Error)]
31pub enum ParquetError {
32    #[error("Format overflow: {0}")]
33    Overflow(String),
34    #[error("Arrow error: {0}")]
35    Arrow(#[from] arrow::error::ArrowError),
36    #[error("Parquet error: {0}")]
37    Parquet(#[from] parquet::errors::ParquetError),
38    #[error("IO error: {0}")]
39    Io(#[from] std::io::Error),
40}
41
42/// Serialize mesh data to Parquet format.
43///
44/// Creates a single Parquet file with multiple row groups:
45/// 1. Mesh metadata (ExpressId, IfcType, offsets, colors)
46/// 2. Vertex data (X, Y, Z, NormalX, NormalY, NormalZ) - columnar
47/// 3. Index data (I0, I1, I2) - columnar triangles
48///
49/// This format is compatible with ara3d BOS and provides excellent compression
50/// for geometry data through columnar storage and dictionary encoding.
51///
52/// Test-only (`#[cfg(test)]`), like `StreamingParquetCacheWriter::finish`:
53/// the parse route takes the outer-framed body from
54/// [`serialize_combined_for_layout`], so no production caller needs the bare
55/// inner blob. The tests use it as the reference inner blob.
56#[cfg(test)]
57pub fn serialize_to_parquet(meshes: &[MeshData]) -> Result<Bytes, ParquetError> {
58    serialize_with_plan(meshes, &ShapePlan::Identity, ParquetLayout::Flat)
59}
60
61/// Serialize one batch under the layout the client asked for, sharing nothing.
62///
63/// The batch-local streaming route's per-batch blobs: `SharedShapes` here
64/// means only that the mesh table carries identity `rot0..rot8`, since each
65/// blob must decode on its own. Sharing across batches is the cross-batch
66/// stream's, planned by `parquet_stream_shapes::StreamShapePlanner` (#5407).
67pub fn serialize_batch_with_layout(
68    meshes: &[MeshData],
69    layout: ParquetLayout,
70) -> Result<Bytes, ParquetError> {
71    serialize_with_plan(meshes, &ShapePlan::Identity, layout)
72}
73
74/// Serialize mesh data with rotation-aware shape sharing (issue #3888). Same
75/// tables and framing as [`serialize_to_parquet`]; see `mesh_schema()` in
76/// `services::parquet_schema` for what the layout means on the wire.
77///
78/// Test-only for the same reason as [`serialize_to_parquet`].
79#[cfg(test)]
80pub fn serialize_to_parquet_shared_shapes(meshes: &[MeshData]) -> Result<Bytes, ParquetError> {
81    serialize_with_plan(
82        meshes,
83        &ShapePlan::shared_shapes(meshes, None),
84        ParquetLayout::SharedShapes,
85    )
86}
87
88/// The whole-model body of `POST /api/v1/parse/parquet` under the layout the
89/// client asked for, already in the outer `[geo_len][geo_bytes][data_model_len=0]`
90/// framing. Only `SharedShapes` runs the shape-sharing planner; a default
91/// request gets the pre-#3888 bytes (#3888). Framed by
92/// `frame_combined_sections`, which guards the outer length (it can pass
93/// `u32::MAX` while every section passes) and writes both frames in one
94/// allocation.
95pub(crate) fn serialize_combined_for_layout(
96    meshes: &[MeshData],
97    layout: ParquetLayout,
98    baked_basis: Option<&ifc_lite_geometry::Matrix4<f64>>,
99) -> Result<Bytes, ParquetError> {
100    let plan = match layout {
101        ParquetLayout::SharedShapes => ShapePlan::shared_shapes(meshes, baked_basis),
102        ParquetLayout::Flat => ShapePlan::Identity,
103    };
104    let (mesh, vertex, index) = write_sections(meshes, &plan, layout)?;
105    frame_combined_sections(&mesh, &vertex, &index)
106}
107
108fn serialize_with_plan(
109    meshes: &[MeshData],
110    plan: &ShapePlan,
111    layout: ParquetLayout,
112) -> Result<Bytes, ParquetError> {
113    // Format: [mesh_parquet_len:u32][mesh_parquet][vertex_parquet_len:u32][vertex_parquet][index_parquet_len:u32][index_parquet]
114    let (mesh, vertex, index) = write_sections(meshes, plan, layout)?;
115    frame_sections(&mesh, &vertex, &index)
116}
117
118/// The mesh, vertex and index Parquet buffers, in wire order.
119type Sections = (Vec<u8>, Vec<u8>, Vec<u8>);
120
121/// The mesh, vertex and index tables, each written as its own Parquet buffer.
122fn write_sections(meshes: &[MeshData], plan: &ShapePlan, layout: ParquetLayout) -> Result<Sections, ParquetError> {
123    let (mesh_batch, vertex_batch, index_batch) = build_mesh_tables(meshes, plan, layout, 0, 0)?;
124    Ok((
125        write_parquet_buffer(&mesh_batch)?,
126        write_parquet_buffer(&vertex_batch)?,
127        write_parquet_buffer(&index_batch)?,
128    ))
129}
130
131/// Fail loud instead of silently truncating a wire-format `u32` length
132/// prefix when a section exceeds 4 GiB.
133///
134/// `pub(crate)` so `parquet_optimized`'s length-prefixed sections (the same
135/// `[len:u32][bytes]` wire shape) can share this guard instead of growing an
136/// unguarded sibling copy of the cast.
137pub(crate) fn check_u32_len(name: &str, len: usize) -> Result<(), ParquetError> {
138    if u32::try_from(len).is_err() {
139        return Err(ParquetError::Overflow(format!(
140            "{name} section is {len} bytes, over the u32 wire-format limit"
141        )));
142    }
143    Ok(())
144}
145
146/// Assemble the three Parquet buffers into the length-prefixed section layout
147/// shared by the whole-model serializer and the incremental cache writer.
148/// Section lengths are u32 on the wire; fail loud instead of truncating a
149/// section over 4 GiB into a silently corrupt blob.
150pub(super) fn frame_sections(mesh: &[u8], vertex: &[u8], index: &[u8]) -> Result<Bytes, ParquetError> {
151    check_u32_len("mesh", mesh.len())?;
152    check_u32_len("vertex", vertex.len())?;
153    check_u32_len("index", index.len())?;
154    let mut output = Vec::with_capacity(12 + mesh.len() + vertex.len() + index.len());
155    output.extend_from_slice(&(mesh.len() as u32).to_le_bytes());
156    output.extend_from_slice(mesh);
157    output.extend_from_slice(&(vertex.len() as u32).to_le_bytes());
158    output.extend_from_slice(vertex);
159    output.extend_from_slice(&(index.len() as u32).to_le_bytes());
160    output.extend_from_slice(index);
161    Ok(Bytes::from(output))
162}
163
164/// Assemble the three Parquet buffers directly into the OUTER combined
165/// framing the parse endpoints wrap the geometry blob in:
166/// `[geo_len:u32][geo_bytes][data_model_len=0:u32]`, where `geo_bytes` is
167/// exactly `frame_sections`'s `[mesh_len][mesh][vertex_len][vertex][index_len][index]`
168/// layout. Endpoints that don't attach a data model inline (the streamed
169/// cache fill) previously called `frame_sections` for the inner blob and then
170/// copied that whole blob a second time into an outer `Vec` to add the
171/// `[geo_len]...[dm_len=0]` wrapper. Writing both frames into one
172/// pre-sized allocation skips that second copy; the resulting bytes are
173/// identical to the old two-copy path.
174fn frame_combined_sections(mesh: &[u8], vertex: &[u8], index: &[u8]) -> Result<Bytes, ParquetError> {
175    check_u32_len("mesh", mesh.len())?;
176    check_u32_len("vertex", vertex.len())?;
177    check_u32_len("index", index.len())?;
178    let inner_len = 12 + mesh.len() + vertex.len() + index.len();
179    check_u32_len("geometry", inner_len)?;
180    let mut output = Vec::with_capacity(4 + inner_len + 4);
181    output.extend_from_slice(&(inner_len as u32).to_le_bytes());
182    output.extend_from_slice(&(mesh.len() as u32).to_le_bytes());
183    output.extend_from_slice(mesh);
184    output.extend_from_slice(&(vertex.len() as u32).to_le_bytes());
185    output.extend_from_slice(vertex);
186    output.extend_from_slice(&(index.len() as u32).to_le_bytes());
187    output.extend_from_slice(index);
188    output.extend_from_slice(&0u32.to_le_bytes());
189    Ok(Bytes::from(output))
190}
191
192/// Incremental whole-model cache writer for the streaming endpoint: each
193/// batch's columns are appended as one Parquet row group per table, so no
194/// `MeshData` has to be retained past the batch that produced it (previously
195/// the endpoint kept a FULL second copy of the model's meshes just to
196/// re-serialize them at Complete). The mesh-table `vertex_start`/`index_start`
197/// columns carry GLOBAL offsets (whole-model), matching what the one-shot
198/// `serialize_to_parquet` emits for the cached fast-path replay.
199pub struct StreamingParquetCacheWriter {
200    /// Whole-stream offsets for [`Self::append`]'s unshared batches. A
201    /// cross-batch stream plans with its own planner and hands the result to
202    /// [`Self::append_planned`], so this one is then never advanced.
203    planner: StreamShapePlanner,
204    mesh_w: ArrowWriter<Vec<u8>>,
205    vert_w: ArrowWriter<Vec<u8>>,
206    idx_w: ArrowWriter<Vec<u8>>,
207    mesh_count: usize,
208}
209
210impl StreamingParquetCacheWriter {
211    pub fn new(layout: ParquetLayout) -> Result<Self, ParquetError> {
212        fn writer(schema: Arc<Schema>) -> Result<ArrowWriter<Vec<u8>>, ParquetError> {
213            // One `append` must produce exactly ONE row group per table, or
214            // the cached blob loses the batch boundaries
215            // `parquet_replay_batches` recovers on a cache hit (#3895):
216            // arrow-rs otherwise splits a `write` at 1,048,576 rows, which the
217            // vertex table crosses on large models. `append` flushes per batch.
218            let props = writer_props(&schema)
219                .into_builder()
220                .set_max_row_group_row_count(None)
221                .build();
222            Ok(ArrowWriter::try_new(Vec::new(), schema, Some(props))?)
223        }
224        Ok(Self {
225            planner: StreamShapePlanner::new(layout, StreamShapes::BatchLocal),
226            mesh_w: writer(mesh_schema(layout.has_rotation()))?,
227            vert_w: writer(vertex_schema())?,
228            idx_w: writer(index_schema())?,
229            mesh_count: 0,
230        })
231    }
232
233    /// Append one batch, every mesh with its own geometry, as one row group
234    /// per table at whole-stream offsets. The meshes can be dropped by the
235    /// caller afterwards.
236    pub fn append(&mut self, meshes: &[MeshData]) -> Result<(), ParquetError> {
237        if meshes.is_empty() {
238            return Ok(());
239        }
240        let batch = self.planner.plan(meshes, None)?;
241        self.append_planned(&batch)
242    }
243
244    /// Append a batch some other [`StreamShapePlanner`] already planned (the
245    /// cross-batch stream, #5407), as one row group per table. The caller's
246    /// planner owns the offsets; the tables are written exactly as planned, so
247    /// the cached blob and the client's batch agree on every range.
248    pub fn append_planned(&mut self, batch: &PlannedBatch) -> Result<(), ParquetError> {
249        if batch.mesh_count() == 0 {
250            return Ok(());
251        }
252        self.mesh_w.write(&batch.mesh)?;
253        self.mesh_w.flush()?;
254        self.vert_w.write(&batch.vertex)?;
255        self.vert_w.flush()?;
256        self.idx_w.write(&batch.index)?;
257        self.idx_w.flush()?;
258        self.mesh_count += batch.mesh_count();
259        Ok(())
260    }
261
262    /// Total meshes appended so far.
263    pub fn mesh_count(&self) -> usize {
264        self.mesh_count
265    }
266
267    /// Close all three writers and assemble the `[len][mesh][len][vert][len][idx]`
268    /// section blob, identical in framing to `serialize_to_parquet`.
269    ///
270    /// Test-only (`#[cfg(test)]`): no production caller needs the bare inner
271    /// blob anymore (the parquet-stream route uses `finish_combined()`), so it
272    /// stays out of the production binary. It survives as the direct
273    /// counterpart to `serialize_to_parquet` for
274    /// `incremental_writer_matches_one_shot_serializer`, which pins the
275    /// incremental writer's decode-equivalence independent of the outer frame.
276    #[cfg(test)]
277    pub fn finish(self) -> Result<Bytes, ParquetError> {
278        let mesh = self.mesh_w.into_inner()?;
279        let vertex = self.vert_w.into_inner()?;
280        let index = self.idx_w.into_inner()?;
281        frame_sections(&mesh, &vertex, &index)
282    }
283
284    /// Close all three writers and assemble the OUTER combined
285    /// `[geo_len][geo_bytes][data_model_len=0]` blob the parquet-stream route
286    /// caches, in one allocation. Equivalent to wrapping `finish()`'s output
287    /// with the route's `[geo_len]...[dm_len=0]` framing, but without
288    /// copying the inner geometry blob a second time to do it.
289    pub fn finish_combined(self) -> Result<Bytes, ParquetError> {
290        let mesh = self.mesh_w.into_inner()?;
291        let vertex = self.vert_w.into_inner()?;
292        let index = self.idx_w.into_inner()?;
293        frame_combined_sections(&mesh, &vertex, &index)
294    }
295}
296
297/// Write a RecordBatch to a Parquet buffer with LZ4 compression.
298/// Dictionary encoding is disabled for numeric columns (floats, integers) as they
299/// have high entropy and dictionary encoding provides no benefit while adding significant overhead.
300pub(super) fn write_parquet_buffer(batch: &RecordBatch) -> Result<Vec<u8>, ParquetError> {
301    let mut buffer = Vec::new();
302    let cursor = Cursor::new(&mut buffer);
303    let props = writer_props(&batch.schema());
304    let mut writer = ArrowWriter::try_new(cursor, batch.schema(), Some(props))?;
305    writer.write(batch)?;
306    writer.close()?;
307
308    Ok(buffer)
309}
310
311/// Writer properties shared by the one-shot and incremental writers: LZ4, and
312/// dictionary encoding disabled for numeric columns (high-entropy vertex data
313/// gains nothing from a dictionary while paying significant overhead).
314///
315/// `rot0..rot8` are the exception, and the reason is the justification above
316/// read backwards. They are the LOWEST-entropy columns in the schema: identity
317/// on every row of an identity-plan payload (every streamed batch, and every
318/// v6 model with nothing to share), and one of a handful of distinct values on
319/// a shared one. Dictionary plus RLE is exactly what that shape is for, where
320/// the numeric opt-out would write nine plain f32 per row -- 3.6 MB per 100k
321/// rows handed to the compressor for a column with one value in it.
322fn writer_props(schema: &Schema) -> WriterProperties {
323    let mut props_builder = WriterProperties::builder()
324        .set_compression(Compression::LZ4_RAW)
325        .set_dictionary_enabled(true); // Default: enabled for strings
326
327    for field in schema.fields() {
328        let is_numeric = matches!(
329            field.data_type(),
330            DataType::Float32
331                | DataType::Float64
332                | DataType::UInt32
333                | DataType::UInt64
334                | DataType::Int32
335                | DataType::Int64
336        ) && !field.name().starts_with("rot");
337
338        if is_numeric {
339            props_builder = props_builder
340                .set_column_dictionary_enabled(ColumnPath::from(field.name().as_str()), false);
341        }
342    }
343
344    props_builder.build()
345}
346
347// The unit tests live in the ratchet-exempt sibling file `parquet_tests.rs`
348// (kept out of this module to stay under the module-size budget). `#[path]`
349// points at the sibling while it remains a child module, so `use super::*`
350// still reaches this file's private helpers.
351#[cfg(test)]
352#[path = "parquet_tests.rs"]
353mod parquet_tests;