ifc_lite_server/services/
parquet_replay_batches.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//! Splits a cached combined-geometry Parquet blob back into the per-batch
6//! chunks a live parse would have streamed, so a cache hit on
7//! `POST /api/v1/parse/parquet-stream` can replay the same `Start` / `Batch`+
8//! `Progress` / `Complete` event shape as a miss (issue #3895) instead of one
9//! oversized `Batch`.
10//!
11//! `StreamingParquetCacheWriter` (`parquet.rs`) appends one Parquet row group
12//! per live stream batch to each of the three section tables (mesh / vertex /
13//! index) — an invariant that writer pins by disabling the row-count-driven
14//! row-group split. This module reads those boundaries back out: the
15//! vertex/index tables are already batch-local (no offset is baked into their
16//! columns), and the mesh table's `vertex_start` / `index_start` columns are
17//! re-based from whole-model globals back to batch-local zero, so the
18//! re-encoded per-batch blob matches what the live per-batch serializer
19//! (`serialize_to_parquet`, base offsets 0/0) would have produced for that
20//! same batch.
21//!
22//! A cross-batch client (`stream_shapes=cross-batch`, #5407) gets each batch
23//! as written instead, whole-stream offsets and all, with its bases stated
24//! beside it: [`split_into_stream_batches`]. A blob a cross-batch
25//! stream wrote cannot be re-based batch-locally at all, since its rows point
26//! into earlier row groups, so [`split_into_batches`] refuses it and a
27//! batch-local client is replayed the whole blob as one batch, which decodes
28//! correctly, only not progressively.
29
30use super::parquet::{frame_sections, write_parquet_buffer};
31use arrow::array::{Array, ArrayRef, UInt32Array};
32use arrow::record_batch::RecordBatch;
33use bytes::Bytes;
34use parquet::arrow::arrow_reader::{
35    ArrowReaderMetadata, ArrowReaderOptions, ParquetRecordBatchReaderBuilder,
36};
37use std::sync::Arc;
38
39/// One reconstructed batch, ready to base64-encode into a `Batch` SSE event.
40pub struct ReplayBatch {
41    /// A complete `[mesh_len][mesh][vertex_len][vertex][index_len][index]`
42    /// section blob for just this batch, in the same framing a live
43    /// per-batch `Batch` event carries.
44    pub data: Bytes,
45    pub mesh_count: usize,
46    /// `(vertex_base, index_base)` for a cross-batch replay; `None` when the
47    /// batch is batch-local.
48    pub bases: Option<(u32, u32)>,
49}
50
51/// Split a cached geometry blob (the inner section framing
52/// `cached_geometry_slice` returns) back into per-original-batch chunks.
53///
54/// Returns `None` when the blob carries no recoverable batch boundary — a
55/// short/corrupt/foreign blob, or one written as a single row group (a
56/// one-shot `serialize_to_parquet` cache entry, or a stream that emitted one
57/// batch). In the single-row-group case re-encoding would only reproduce the
58/// bytes the caller already holds, so falling back is both cheaper and
59/// byte-equivalent.
60pub fn split_into_batches(geometry: &[u8]) -> Option<Vec<ReplayBatch>> {
61    let (mesh_bytes, vertex_bytes, index_bytes) = split_geometry_sections(geometry)?;
62    let mesh = Section::open(mesh_bytes)?;
63    let vertex = Section::open(vertex_bytes)?;
64    let index = Section::open(index_bytes)?;
65
66    let row_groups = mesh.row_groups();
67    if row_groups <= 1 || vertex.row_groups() != row_groups || index.row_groups() != row_groups {
68        return None;
69    }
70
71    let mut batches = Vec::with_capacity(row_groups);
72    for i in 0..row_groups {
73        let mesh_rb = mesh.read_row_group(i)?;
74        let vertex_rb = vertex.read_row_group(i)?;
75        let index_rb = index.read_row_group(i)?;
76
77        // Equal row-group counts are only a proxy for alignment. Prove it
78        // instead: this mesh row group's own `vertex_count` / `index_count`
79        // columns must account for exactly the rows in the vertex / index
80        // row groups beside it. (The index table is one row per TRIANGLE
81        // while `index_count` counts indices, hence the /3.) A group with a
82        // row pointing into an EARLIER one (a cross-batch blob, #5407) names
83        // more rows than it holds, and fails here.
84        let vertex_rows = column_sum(&mesh_rb, "vertex_count", 1)?;
85        let index_rows = column_sum(&mesh_rb, "index_count", 3)?;
86        if vertex_rows != vertex_rb.num_rows() as u64 || index_rows != index_rb.num_rows() as u64 {
87            return None;
88        }
89
90        let localized_mesh = localize_mesh_batch(&mesh_rb)?;
91        batches.push(encode_batch(&localized_mesh, &vertex_rb, &index_rb, None)?);
92    }
93    Some(batches)
94}
95
96/// Split a cached geometry blob into its batches for a CROSS-BATCH client
97/// (#5407): each batch keeps its whole-stream offsets and states the bases its
98/// vertex and index tables start at, exactly as the live cross-batch stream
99/// does. Works on a blob either kind of stream wrote, because a batch-local
100/// stream's cache rows are whole-stream too and simply never point backwards.
101///
102/// The batches are the MESH table's row groups; the vertex and index tables
103/// are not split by theirs. A batch whose every row points back emits no
104/// vertices, and an empty `write` adds no row group, so those two tables can
105/// hold fewer groups than there were batches. What locates a batch's slice
106/// instead is the planner's own invariant: a batch appends its new shapes
107/// contiguously at the running end, and every shape it emits is referenced by
108/// one of its rows, so the batch ends at the furthest range its rows name.
109/// The spans must tile both tables exactly, or the blob is refused (`None`).
110pub fn split_into_stream_batches(geometry: &[u8]) -> Option<Vec<ReplayBatch>> {
111    let (mesh_bytes, vertex_bytes, index_bytes) = split_geometry_sections(geometry)?;
112    let mesh = Section::open(mesh_bytes)?;
113    let vertex = Section::open(vertex_bytes)?.read_all()?;
114    let index = Section::open(index_bytes)?.read_all()?;
115    if mesh.row_groups() <= 1 {
116        return None;
117    }
118
119    let (mut vertex_base, mut index_base) = (0u64, 0u64);
120    let mut batches = Vec::with_capacity(mesh.row_groups());
121    for i in 0..mesh.row_groups() {
122        let mesh_rb = mesh.read_row_group(i)?;
123        let vertex_end = furthest_range_end(&mesh_rb, "vertex_start", "vertex_count")?.max(vertex_base);
124        let index_end = furthest_range_end(&mesh_rb, "index_start", "index_count")?.max(index_base);
125        if index_end % 3 != 0 || vertex_end > vertex.num_rows() as u64 || index_end / 3 > index.num_rows() as u64 {
126            return None;
127        }
128        let bases = (u32::try_from(vertex_base).ok()?, u32::try_from(index_base).ok()?);
129        let vertex_rb = vertex.slice(vertex_base as usize, (vertex_end - vertex_base) as usize);
130        let index_rb = index.slice((index_base / 3) as usize, ((index_end - index_base) / 3) as usize);
131        batches.push(encode_batch(&mesh_rb, &vertex_rb, &index_rb, Some(bases))?);
132        vertex_base = vertex_end;
133        index_base = index_end;
134    }
135    // Every vertex and triangle belongs to exactly one batch.
136    if vertex_base != vertex.num_rows() as u64 || index_base != 3 * index.num_rows() as u64 {
137        return None;
138    }
139    Some(batches)
140}
141
142fn encode_batch(
143    mesh: &RecordBatch,
144    vertex: &RecordBatch,
145    index: &RecordBatch,
146    bases: Option<(u32, u32)>,
147) -> Option<ReplayBatch> {
148    let mesh_buf = write_parquet_buffer(mesh).ok()?;
149    let vertex_buf = write_parquet_buffer(vertex).ok()?;
150    let index_buf = write_parquet_buffer(index).ok()?;
151    Some(ReplayBatch {
152        data: frame_sections(&mesh_buf, &vertex_buf, &index_buf).ok()?,
153        mesh_count: mesh.num_rows(),
154        bases,
155    })
156}
157
158/// The furthest `start + count` any row of `rb` names (0 for no rows).
159/// `None` when either column is missing or not `UInt32`.
160fn furthest_range_end(rb: &RecordBatch, start: &str, count: &str) -> Option<u64> {
161    let column = |name: &str| -> Option<UInt32Array> {
162        let idx = rb.schema().index_of(name).ok()?;
163        rb.column(idx).as_any().downcast_ref::<UInt32Array>().cloned()
164    };
165    let (starts, counts) = (column(start)?, column(count)?);
166    Some(
167        starts
168            .values()
169            .iter()
170            .zip(counts.values().iter())
171            .map(|(&s, &c)| u64::from(s) + u64::from(c))
172            .max()
173            .unwrap_or(0),
174    )
175}
176
177/// One Parquet section of the cached blob, with its footer parsed ONCE.
178/// Re-opening a `ParquetRecordBatchReaderBuilder` per row group re-parses
179/// the whole footer each time, which is quadratic in the row-group count —
180/// hundreds of milliseconds on a model that streamed a few hundred batches.
181struct Section {
182    bytes: Bytes,
183    metadata: ArrowReaderMetadata,
184}
185
186impl Section {
187    fn open(bytes: Bytes) -> Option<Self> {
188        let metadata = ArrowReaderMetadata::load(&bytes, ArrowReaderOptions::new()).ok()?;
189        Some(Self { bytes, metadata })
190    }
191
192    /// The whole table as one `RecordBatch`, however many row groups it has
193    /// (none included).
194    fn read_all(&self) -> Option<RecordBatch> {
195        let schema = self.metadata.schema().clone();
196        let reader =
197            ParquetRecordBatchReaderBuilder::new_with_metadata(self.bytes.clone(), self.metadata.clone())
198                .build()
199                .ok()?;
200        let parts: Vec<RecordBatch> = reader.collect::<Result<_, _>>().ok()?;
201        arrow::compute::concat_batches(&schema, &parts).ok()
202    }
203
204    fn row_groups(&self) -> usize {
205        self.metadata.metadata().num_row_groups()
206    }
207
208    /// Read row group `index` as one `RecordBatch`. The reader's batch size is
209    /// pinned to the group's own row count so it yields a single batch instead
210    /// of fragments that would then have to be copied back together.
211    fn read_row_group(&self, index: usize) -> Option<RecordBatch> {
212        let rows = self.metadata.metadata().row_group(index).num_rows();
213        let rows = usize::try_from(rows).ok()?;
214        let schema = self.metadata.schema().clone();
215        let reader =
216            ParquetRecordBatchReaderBuilder::new_with_metadata(self.bytes.clone(), self.metadata.clone())
217                .with_row_groups(vec![index])
218                .with_batch_size(rows.max(1))
219                .build()
220                .ok()?;
221        let parts: Vec<RecordBatch> = reader.collect::<Result<_, _>>().ok()?;
222        match parts.len() {
223            1 => parts.into_iter().next(),
224            _ => arrow::compute::concat_batches(&schema, &parts).ok(),
225        }
226    }
227}
228
229/// Split the triple-framed `[mesh_len][mesh][vertex_len][vertex][index_len]
230/// [index]` geometry blob into its three sections. `None` on any short or
231/// mismatched-length framing, or trailing bytes past the third section.
232fn split_geometry_sections(geometry: &[u8]) -> Option<(Bytes, Bytes, Bytes)> {
233    let mut off = 0usize;
234    let mesh = take_length_prefixed(geometry, &mut off)?;
235    let vertex = take_length_prefixed(geometry, &mut off)?;
236    let index = take_length_prefixed(geometry, &mut off)?;
237    if off != geometry.len() {
238        return None;
239    }
240    Some((mesh, vertex, index))
241}
242
243fn take_length_prefixed(blob: &[u8], off: &mut usize) -> Option<Bytes> {
244    let header = blob.get(*off..off.checked_add(4)?)?;
245    let len = u32::from_le_bytes(header.try_into().ok()?) as usize;
246    let start = *off + 4;
247    let end = start.checked_add(len)?;
248    let section = blob.get(start..end)?;
249    *off = end;
250    Some(Bytes::copy_from_slice(section))
251}
252
253/// Sum a non-nullable `UInt32` mesh column, dividing each value by
254/// `per_row` first. The division is PER ROW, not on the total, because
255/// `build_mesh_tables` counts index-table rows the same way — one row per
256/// `indices.len() / 3` per mesh, so a mesh whose index count is not a
257/// multiple of 3 rounds down on its own, not against its neighbours.
258fn column_sum(rb: &RecordBatch, name: &str, per_row: u32) -> Option<u64> {
259    let idx = rb.schema().index_of(name).ok()?;
260    let col = rb.column(idx).as_any().downcast_ref::<UInt32Array>()?;
261    Some(col.values().iter().map(|v| u64::from(v / per_row)).sum())
262}
263
264/// Re-base a mesh-table row group's `vertex_start` / `index_start` columns
265/// from whole-model globals back to batch-local zero, by subtracting the
266/// group's own first-row values — the exact base offset
267/// `StreamingParquetCacheWriter::append` fed into `build_mesh_tables` for that
268/// batch. Every other column is untouched. Columns are resolved BY NAME, so a
269/// schema reorder can never silently re-base the wrong one.
270fn localize_mesh_batch(rb: &RecordBatch) -> Option<RecordBatch> {
271    if rb.num_rows() == 0 {
272        // `StreamingParquetCacheWriter::append` skips empty batches, so this
273        // shouldn't happen; there is no first row to read a base offset from.
274        return None;
275    }
276    let vertex_idx = rb.schema().index_of("vertex_start").ok()?;
277    let index_idx = rb.schema().index_of("index_start").ok()?;
278    let localized_v = rebase_to_zero(rb.column(vertex_idx))?;
279    let localized_i = rebase_to_zero(rb.column(index_idx))?;
280
281    let mut columns = rb.columns().to_vec();
282    columns[vertex_idx] = localized_v;
283    columns[index_idx] = localized_i;
284    RecordBatch::try_new(rb.schema(), columns).ok()
285}
286
287/// Subtract a `UInt32` column's first value from every value. `checked_sub`
288/// rather than `-`: on a foreign blob whose offsets are not non-decreasing
289/// this would panic in debug and, with the release profile's
290/// `overflow-checks = false`, WRAP to a ~4-billion offset that decodes as
291/// valid geometry. `None` instead sends the caller to its fallback.
292fn rebase_to_zero(column: &ArrayRef) -> Option<ArrayRef> {
293    let values = column.as_any().downcast_ref::<UInt32Array>()?;
294    let base = values.value(0);
295    let rebased: Option<Vec<u32>> = values.values().iter().map(|v| v.checked_sub(base)).collect();
296    Some(Arc::new(UInt32Array::from(rebased?)) as ArrayRef)
297}
298
299#[cfg(test)]
300#[path = "parquet_replay_batches_tests.rs"]
301mod parquet_replay_batches_tests;