ifc_lite_processing/parallel_scan.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//! Parallel entity-index construction.
6//!
7//! [`build_entity_index_parallel`] returns a **byte-identical**
8//! [`EntityIndex`](ifc_lite_core::EntityIndex) to the serial
9//! [`ifc_lite_core::build_entity_index`], but scans the STEP DATA section on all
10//! cores. The STEP scan (entity offsets) is otherwise 100% single-threaded and
11//! is a large fraction of load on big models.
12//!
13//! ## Why byte-identical is achievable despite splitting mid-record
14//!
15//! The serial builder walks `EntityScanner::next_entity()` from the header-skip
16//! to EOF and does `index.insert(id, (start, end))` per entity, so the contract
17//! we must reproduce is: **the same key set, the same spans, and last-wins on a
18//! duplicate id in file order.**
19//!
20//! We split the file into N byte ranges and scan them concurrently. Only chunk 0
21//! starts at a known-good boundary (`EntityScanner::new`, header-aware); every
22//! other chunk starts at an arbitrary byte via `EntityScanner::new_at`, which may
23//! land inside a quoted string or a `/* … */` comment. A speculative scan from
24//! there can emit garbage "records" until it re-synchronises to the real STEP
25//! record grid (STEP is self-synchronising: after the next real `;` terminator
26//! the misaligned scanner produces exactly the records an aligned scanner would).
27//!
28//! The **handoff-stitch** makes this exact, not heuristic:
29//! * Each chunk `i` scans until the first entity whose `start >= range_end_i`,
30//! recording that offset as its `handoff` (the first real entity the *next*
31//! chunk owns), and keeps every earlier record.
32//! * A serial O(N) stitch replays the chunks in order. Chunk 0 is authoritative.
33//! For chunk `i>0` the previous chunk's validated handoff is a **real** entity
34//! start; we binary-search chunk `i`'s records for it. Records before it are
35//! speculative false-starts and are dropped; from it onward the scan is
36//! provably aligned (a record can only begin exactly at that offset if the
37//! `#`-hunt landed on the real `#`, and `find_entity_end` re-parses the record
38//! from its `#`, so the span is computed identically).
39//! * If the handoff is **not** present (the speculative scan overshot it, or a
40//! single record spans the whole chunk), we fall back to a serial rescan of
41//! that one range from the known-real handoff — identical output to the serial
42//! builder for those bytes. This never triggers on real files; it is the
43//! correctness net that keeps the merge byte-identical on adversarial input.
44//!
45//! Concatenating the validated slices in chunk order reproduces the serial
46//! file-order entity stream with no gap and no overlap, so inserting them in that
47//! order preserves last-wins exactly.
48//!
49//! ## Targets
50//!
51//! Native only. On wasm32 rayon runs inline (no worker threads are wired), so a
52//! parallel driver buys nothing and only adds merge overhead — the wasm build
53//! delegates straight to the serial scanner and is unchanged.
54
55use ifc_lite_core::{EntityIndex, EntityScanner};
56
57/// One shard's records: `(id, start, end)` per entity, strictly increasing in `start`.
58pub type ShardRecords = Vec<(u32, usize, usize)>;
59
60/// One shard's refusals: the `start` byte of each record the scan dropped for
61/// an instance name above `u32::MAX` (#3395), strictly increasing.
62///
63/// Offsets, not a count, because a shard cannot tell on its own which of its
64/// refusals are real — see [`scan_shard_with_diagnostics`], which is where
65/// that "cannot report from inside a shard" reasoning actually lives.
66pub type ShardRefusals = Vec<usize>;
67
68/// [`scan_shard_with_refusals`] without the refusal offsets.
69///
70/// A shard's refusal list is only meaningful next to the stitch that decides
71/// which of the shard's bytes were kept, so this convenience wrapper is for
72/// callers that build no index from the result (the parity tests) — a caller
73/// that DOES must take the offsets and attribute them, or it reports refusals
74/// that no retained record produced.
75pub fn scan_shard(
76 content: &[u8],
77 range_start: usize,
78 range_end: usize,
79) -> (ShardRecords, Option<usize>) {
80 let (records, handoff, _refusals) = scan_shard_with_refusals(content, range_start, range_end);
81 (records, handoff)
82}
83
84/// [`scan_shard_with_diagnostics`] without the malformed-record offset.
85///
86/// Kept as its own 3-tuple-returning function — not folded into
87/// [`scan_shard_with_diagnostics`] in place — because it is public on a
88/// published crate and adding a return value in place would be a breaking
89/// change (the same reasoning [`crate::scan_shard_classified`] documents for
90/// staying a 3-tuple after #3395 added refusal offsets).
91pub fn scan_shard_with_refusals(
92 content: &[u8],
93 range_start: usize,
94 range_end: usize,
95) -> (ShardRecords, Option<usize>, ShardRefusals) {
96 let (records, handoff, refusals, _malformed_starts) =
97 scan_shard_with_diagnostics(content, range_start, range_end);
98 (records, handoff, refusals)
99}
100
101/// One shard's speculative scan over `[range_start, range_end)`, plus the byte
102/// offset of every record this shard refused and, if any, the byte offset of
103/// the record that made the scan stop early (#3695's malformed-record stop).
104///
105/// This is the exact per-chunk primitive [`build_entity_index_parallel`] fans
106/// across cores, and the sibling of the wasm **sharded pre-pass**'s
107/// `scan_shard_classified_with_refusals`: each browser geometry worker calls
108/// that one on a byte range and the main thread stitches the columns
109/// (binary-searching each shard for the previous shard's handoff — see the
110/// [`native::stitch`] doc). Compiled on all targets (the `native` merge is
111/// wasm-gated, but the shard primitive itself is target-independent).
112///
113/// Chunk 0 (`range_start == 0`) uses the header-aware [`EntityScanner::new`];
114/// every other shard starts *speculatively* at `range_start` via
115/// [`EntityScanner::new_at`] (which may land mid-record — the handoff stitch
116/// makes that exact, not heuristic). Returns every record with
117/// `start < range_end` (strictly increasing in `start`), the `handoff` (the
118/// `start` of the first record at/after `range_end`, i.e. the next shard's
119/// first real entity, or `None` at EOF), the refusal offsets, and the
120/// malformed-record stop offset (see below).
121///
122/// **It does not report either diagnostic, and it must not.** A shard with
123/// `range_start > 0` starts at an arbitrary byte, so it can begin inside a
124/// quoted value; a string literal containing `#4294967297=IFCWALL(` satisfies
125/// the scanner's `#<digits>[ws]*=` shape check (which has no quote context),
126/// so the speculative prefix can refuse arbitrarily many records that the file
127/// never declared. Reporting from inside the shard therefore turns a file with
128/// NOTHING oversized in it into a "skipped N records" warning — a false alarm
129/// on valid input, which is worse than the inflated count the first version of
130/// this was thought to produce (#3395, retracted reasoning on #3430).
131///
132/// Bounding by ownership alone (`start < range_end`) does not fix it either:
133/// a false refusal parsed out of a quoted value INSIDE the owned range still
134/// counts. Only the stitch knows which bytes of a shard were kept, so only the
135/// stitch can attribute a refusal — see [`native::stitch`].
136///
137/// The malformed-record offsets are the `line_start` of every record
138/// [`ifc_lite_core::EntityScanner::find_entity_end`] refused. Most are
139/// RECOVERABLE: a record missing its `;` is dropped and the scan carries on
140/// (#4179), so `records`/`handoff` above continue past it. Only a record with
141/// nothing to resume from stops the scan, and that shows in `handoff` being
142/// `None` before `range_end`. All of them, not just the first: a shard can
143/// hold a speculative drop inside a quoted value AND a real one after it, and
144/// [`native::stitch`] must be able to find the real one. Same
145/// speculative-prefix caveat as a refusal, so it is not reported here either.
146pub fn scan_shard_with_diagnostics(
147 content: &[u8],
148 range_start: usize,
149 range_end: usize,
150) -> (ShardRecords, Option<usize>, ShardRefusals, Vec<usize>) {
151 // Deliberately NOT delegating to `scan_shard_classified`: index-only
152 // callers (native exporters / georeferencing via
153 // `build_entity_index_parallel`) would pay a per-entity keyword
154 // classification — string matches + the `has_geometry_by_name` cache —
155 // across every record for a column they never read.
156 let mut scanner = if range_start == 0 {
157 EntityScanner::new(content)
158 } else {
159 EntityScanner::new_at(content, range_start)
160 };
161 let mut records = Vec::new();
162 let mut handoff = None;
163 while let Some((id, _type_name, start, entity_end)) = scanner.next_entity() {
164 if start >= range_end {
165 handoff = Some(start);
166 break;
167 }
168 records.push((id, start, entity_end));
169 }
170 (
171 records,
172 handoff,
173 scanner.skipped_oversized_id_starts().to_vec(),
174 scanner.malformed_record_starts().to_vec(),
175 )
176}
177
178/// Build the entity index (expressId -> byte span) across all available cores.
179///
180/// Byte-identical to [`ifc_lite_core::build_entity_index`] over the same
181/// `content`; a drop-in replacement wherever the index is built as a standalone
182/// scan on native. On wasm32 it *is* the serial builder.
183///
184/// Safe to nest under an outer rayon task (it is a pure map-reduce with no locks
185/// or channels); rayon work-steals rather than deadlocking. In practice every
186/// caller invokes it at the top level, before the per-element geometry
187/// `par_iter`, so no nesting occurs.
188pub fn build_entity_index_parallel<T>(content: &T) -> EntityIndex
189where
190 T: AsRef<[u8]> + ?Sized,
191{
192 let content = content.as_ref();
193 #[cfg(target_arch = "wasm32")]
194 {
195 ifc_lite_core::build_entity_index(content)
196 }
197 #[cfg(not(target_arch = "wasm32"))]
198 {
199 native::build(content)
200 }
201}
202
203// Split into its own file (module-size ratchet: this is the bulk of the
204// merge logic, not test code) — see `native.rs`'s own doc comment.
205#[cfg(not(target_arch = "wasm32"))]
206#[path = "parallel_scan/native.rs"]
207mod native;
208
209#[cfg(all(test, not(target_arch = "wasm32")))]
210#[path = "parallel_scan_tests.rs"]
211mod tests;