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;