Skip to main content

noyalib/
parallel.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Parallel multi-document YAML parsing — the "MapReduce" path.
5//!
6//! For massive multi-document streams (telemetry logs, audit
7//! exports, Kubernetes-resource snapshots, anything emitting `---`-
8//! separated documents at scale), even the fastest single-threaded
9//! parser is bounded by one CPU core. This module pre-scans the
10//! input on demand and dispatches per-document slices to Rayon
11//! workers without materialising the complete boundary list.
12//!
13//! Gated behind the `parallel` Cargo feature.
14//!
15//! # Linear scaling
16//!
17//! Boundary discovery runs in `O(input_len)` without allocating a
18//! marker or slice vector. Rayon requests slices on demand, so the
19//! worker count bounds in-flight parse work. Parse-per-document work
20//! is the dominant cost and parallelises naturally across cores.
21//! Expect near-linear speedup with the number of cores up to the
22//! point where document size starts to dominate (very large single
23//! documents see less benefit because one document still parses on
24//! one thread).
25//!
26//! # Document-boundary contract
27//!
28//! The pre-scanner recognises `---` document-start markers that
29//! begin at column 0 and are followed by `\n`, `\r`, ` `, `\t`, or
30//! end-of-input. This matches the YAML 1.2.2 §9.1.2 grammar for
31//! `c-directives-end`. The scanner does **not** recognise:
32//!
33//! - `---` inside a literal (`|`) or folded (`>`) block scalar
34//!   that is column-0-aligned (extremely rare in practice; the
35//!   YAML spec does not actually permit such a literal because
36//!   block scalars must indent past the parent).
37//! - `...` document-end markers — they are advisory in YAML 1.2,
38//!   and the document-start scan picks up the next document
39//!   anyway.
40//!
41//! Inputs that violate the column-0 rule fall back to the
42//! conservative single-document slice (everything before the next
43//! valid `---` is treated as one document).
44//!
45//! # Examples
46//!
47//! ```
48//! # #[cfg(feature = "parallel")] {
49//! let yaml = "---\nid: 1\n---\nid: 2\n---\nid: 3\n";
50//! #[derive(serde::Deserialize, Debug)]
51//! struct Record { id: u32 }
52//! let records: Vec<Record> = noyalib::parallel::parse(yaml).unwrap();
53//! assert_eq!(records.len(), 3);
54//! # }
55//! ```
56//!
57//! # API shape
58//!
59//! - [`parse`](crate::parallel::parse) — typed deserialise into `Vec<T>`.
60//! - [`parse_with_config`](crate::parallel::parse_with_config) — the same
61//!   operation with caller-supplied limits and semantic policy.
62//! - [`parse_with_config_in_pool`](crate::parallel::parse_with_config_in_pool) —
63//!   run the configured parse in a caller-owned Rayon pool.
64//! - [`values`](crate::parallel::values) — dynamic-tree variant returning `Vec<Value>`.
65//! - [`split`](crate::parallel::split) — standalone document-boundary
66//!   pre-scanner for callers driving their own concurrency primitives.
67//!
68//! Names are kept short on purpose — the `parallel` namespace
69//! already encodes the concurrency contract, so the function
70//! verb stays single-word: `parallel::parse` reads as one
71//! sentence.
72
73use crate::ParserConfig;
74use crate::error::Result;
75use rayon::prelude::*;
76
77pub use rayon::{ThreadPool, ThreadPoolBuilder};
78
79/// Deserialise every YAML document in `input` into `T`, parsing
80/// in parallel via Rayon's global thread pool.
81///
82/// `T` must be `Send` because the per-document parses run on
83/// arbitrary worker threads. The result is collected back into a
84/// `Vec<T>` in document order.
85///
86/// Reads as `noyalib::parallel::parse::<MyType>(input)` —
87/// concurrency lives in the namespace, the verb is one word.
88///
89/// # Errors
90///
91/// - Any document fails to parse — the first error is returned;
92///   sibling documents may still complete in parallel but their
93///   results are discarded.
94///
95/// # Examples
96///
97/// ```
98/// let yaml = "---\nport: 80\n---\nport: 443\n";
99/// #[derive(serde::Deserialize, Debug, PartialEq)]
100/// struct Service { port: u16 }
101/// let v: Vec<Service> = noyalib::parallel::parse(yaml).unwrap();
102/// assert_eq!(v[0].port, 80);
103/// assert_eq!(v[1].port, 443);
104/// ```
105pub fn parse<T>(input: &str) -> Result<Vec<T>>
106where
107    T: serde_core::de::DeserializeOwned + Send + 'static,
108{
109    let config = ParserConfig::default();
110    parse_with_config(input, &config)
111}
112
113/// Deserialise every document with a caller-supplied parser policy.
114/// Document-count and per-document limits are enforced before and during
115/// parallel work. Streams with fewer than four documents stay sequential to
116/// avoid Rayon scheduling overhead.
117///
118/// # Errors
119///
120/// Returns the first document or budget error in source order.
121pub fn parse_with_config<T>(input: &str, config: &ParserConfig) -> Result<Vec<T>>
122where
123    T: serde_core::de::DeserializeOwned + Send + 'static,
124{
125    const SEQUENTIAL_DOCUMENTS: usize = 4;
126
127    if input.len() > config.max_stream_bytes {
128        return Err(crate::Error::Parse(format!(
129            "stream exceeds max_stream_bytes of {} bytes",
130            config.max_stream_bytes
131        )));
132    }
133    crate::doc_boundary::validate_document_budget(input, config.max_documents)?;
134    // Every document is parsed separately; one shared tally keeps the
135    // stream-wide budgets (events, nodes, scalar bytes, merge keys,
136    // documents) charged across all of them, as a single loader would.
137    let config = &crate::parser::meter::StreamTally::share(config);
138    let mut chunks = crate::doc_boundary::DocumentStream::new(input, config.max_documents);
139    let mut prefix = Vec::with_capacity(SEQUENTIAL_DOCUMENTS);
140    while prefix.len() < SEQUENTIAL_DOCUMENTS {
141        match chunks.next() {
142            Some(Ok(chunk)) => prefix.push(chunk),
143            Some(Err(error)) => return Err(error),
144            None => break,
145        }
146    }
147
148    // `parallel::split` is a byte-partitioning API: unlike recovery,
149    // a whitespace-only non-empty input must remain represented by its
150    // original slice so concatenating the output reproduces the input.
151    if prefix.is_empty() && !input.is_empty() {
152        if config.max_documents == 0 {
153            return Err(crate::Error::Budget(crate::BudgetBreach::MaxDocuments {
154                limit: 0,
155                observed: 1,
156            }));
157        }
158        prefix.push(input);
159    }
160
161    if prefix.len() < SEQUENTIAL_DOCUMENTS {
162        return prefix
163            .iter()
164            .map(|chunk| crate::from_str_with_config::<T>(chunk, config))
165            .collect();
166    }
167
168    let mut parsed = prefix
169        .into_iter()
170        .map(Ok)
171        .chain(chunks)
172        .enumerate()
173        .par_bridge()
174        .map(|(index, chunk)| {
175            (
176                index,
177                chunk.and_then(|chunk| crate::from_str_with_config::<T>(chunk, config)),
178            )
179        })
180        .collect::<Vec<_>>();
181    parsed.sort_unstable_by_key(|(index, _)| *index);
182    parsed.into_iter().map(|(_, result)| result).collect()
183}
184
185/// Deserialise every document using a caller-owned Rayon thread pool.
186///
187/// This is the bounded-concurrency entry point for services that must not use
188/// Rayon's global pool. The caller controls the worker count, thread names,
189/// stack size, and lifecycle through [`ThreadPoolBuilder`]. Small
190/// streams still use the same sequential fast path as [`parse_with_config`].
191///
192/// # Errors
193///
194/// Returns the first document or budget error in source order.
195///
196/// # Examples
197///
198/// ```
199/// let pool = noyalib::parallel::ThreadPoolBuilder::new()
200///     .num_threads(2)
201///     .build()
202///     .unwrap();
203/// let yaml = "---\nid: 1\n---\nid: 2\n---\nid: 3\n---\nid: 4\n";
204/// let docs = noyalib::parallel::parse_with_config_in_pool::<noyalib::Value>(
205///     yaml,
206///     &noyalib::ParserConfig::default(),
207///     &pool,
208/// )
209/// .unwrap();
210/// assert_eq!(docs.len(), 4);
211/// ```
212pub fn parse_with_config_in_pool<T>(
213    input: &str,
214    config: &ParserConfig,
215    pool: &ThreadPool,
216) -> Result<Vec<T>>
217where
218    T: serde_core::de::DeserializeOwned + Send + 'static,
219{
220    pool.install(|| parse_with_config(input, config))
221}
222
223/// Dynamic-tree variant of [`parse`]: returns a
224/// [`Vec<crate::Value>`]. Use when the caller wants to route
225/// documents to different typed handlers post-parse.
226///
227/// # Examples
228///
229/// ```
230/// use noyalib::Value;
231/// let yaml = "---\na: 1\n---\nb: 2\n";
232/// let docs: Vec<Value> = noyalib::parallel::values(yaml).unwrap();
233/// assert_eq!(docs.len(), 2);
234/// assert_eq!(docs[0]["a"].as_i64(), Some(1));
235/// assert_eq!(docs[1]["b"].as_i64(), Some(2));
236/// ```
237pub fn values(input: &str) -> Result<Vec<crate::Value>> {
238    parse::<crate::Value>(input)
239}
240
241/// Dynamic-tree variant of [`parse_with_config`].
242///
243/// # Errors
244///
245/// Returns the first document or budget error in source order.
246pub fn values_with_config(input: &str, config: &ParserConfig) -> Result<Vec<crate::Value>> {
247    parse_with_config::<crate::Value>(input, config)
248}
249
250/// Dynamic-tree variant of [`parse_with_config_in_pool`].
251///
252/// # Errors
253///
254/// Returns the first document or budget error in source order.
255pub fn values_with_config_in_pool(
256    input: &str,
257    config: &ParserConfig,
258    pool: &ThreadPool,
259) -> Result<Vec<crate::Value>> {
260    parse_with_config_in_pool::<crate::Value>(input, config, pool)
261}
262
263/// Split `input` into per-document byte slices on YAML 1.2 `---`
264/// markers. Single-pass `O(input.len())`. Public so callers that
265/// drive their own concurrency primitives (async tasks, custom
266/// thread pools) can reuse the same boundary scan.
267///
268/// # Examples
269///
270/// ```
271/// let docs = noyalib::parallel::split("---\na: 1\n---\nb: 2\n");
272/// assert_eq!(docs.len(), 2);
273/// ```
274#[must_use]
275pub fn split(input: &str) -> Vec<&str> {
276    let mut docs = crate::doc_boundary::split_documents(input);
277    if docs.is_empty() && !input.is_empty() {
278        docs.push(input);
279    }
280    docs
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn split_separates_three_records() {
289        let yaml = "---\nid: 1\n---\nid: 2\n---\nid: 3\n";
290        let docs = split(yaml);
291        assert_eq!(docs.len(), 3);
292        assert!(docs[0].contains("id: 1"));
293        assert!(docs[1].contains("id: 2"));
294        assert!(docs[2].contains("id: 3"));
295    }
296
297    #[test]
298    fn split_handles_no_separators() {
299        let yaml = "single: doc\n";
300        let docs = split(yaml);
301        assert_eq!(docs.len(), 1);
302        assert_eq!(docs[0], yaml);
303    }
304
305    #[test]
306    fn split_handles_empty_input() {
307        assert!(split("").is_empty(), "expected empty, got {:?}", split(""));
308    }
309
310    #[test]
311    fn split_handles_implicit_first_doc() {
312        // Input starts with content, then `---`, then another doc.
313        // The preamble is the implicit first document.
314        let yaml = "name: a\n---\nname: b\n";
315        let docs = split(yaml);
316        assert_eq!(docs.len(), 2);
317        assert!(docs[0].contains("name: a"));
318        assert!(docs[1].contains("name: b"));
319    }
320
321    #[test]
322    fn split_ignores_dashes_mid_line() {
323        // `---` not at column 0 is part of a scalar, not a marker.
324        let yaml = "key: value---suffix\n";
325        let docs = split(yaml);
326        assert_eq!(docs.len(), 1);
327        assert!(docs[0].contains("value---suffix"));
328    }
329
330    #[test]
331    fn split_requires_post_marker_whitespace() {
332        // `---foo` is *not* a document start — the marker must be
333        // followed by whitespace or end-of-input. (This is a
334        // simplification of the spec but matches every real YAML
335        // emitter we've encountered.)
336        let yaml = "key: a\n---foo\nkey: b\n";
337        let docs = split(yaml);
338        assert_eq!(docs.len(), 1, "got: {docs:?}");
339    }
340
341    // Rayon's global thread pool pulls in `crossbeam-epoch`, whose
342    // epoch-based reclamation performs integer-to-pointer casts that
343    // Miri rejects under `-Zmiri-strict-provenance` (see the weekly
344    // `soak-miri` job). The provenance issue is entirely inside the
345    // third-party dep — noyalib's own call sites stay
346    // `#![forbid(unsafe_code)]` — so the rayon-invoking tests are
347    // skipped under Miri while every other test still runs. Mirrors
348    // `scripts/miri.sh`, which excludes rayon from the focused sweep
349    // for the same reason.
350    #[cfg_attr(
351        miri,
352        ignore = "rayon/crossbeam-epoch uses int-to-ptr casts unsupported under -Zmiri-strict-provenance"
353    )]
354    #[test]
355    fn parse_round_trips_typed_records() {
356        #[derive(Debug, serde::Deserialize, PartialEq)]
357        struct Record {
358            id: u32,
359        }
360        let yaml = "---\nid: 1\n---\nid: 2\n---\nid: 3\n";
361        let records: Vec<Record> = parse(yaml).unwrap();
362        assert_eq!(
363            records,
364            vec![Record { id: 1 }, Record { id: 2 }, Record { id: 3 }]
365        );
366    }
367
368    #[cfg_attr(
369        miri,
370        ignore = "rayon/crossbeam-epoch uses int-to-ptr casts unsupported under -Zmiri-strict-provenance"
371    )]
372    #[test]
373    fn values_yields_value_per_document() {
374        let yaml = "---\na: 1\n---\nb: 2\n";
375        let docs = values(yaml).unwrap();
376        assert_eq!(docs.len(), 2);
377        assert_eq!(docs[0]["a"].as_i64(), Some(1));
378        assert_eq!(docs[1]["b"].as_i64(), Some(2));
379    }
380
381    #[test]
382    fn parse_with_config_enforces_document_count_before_scheduling() {
383        let config = ParserConfig::default().max_documents(1);
384        let error =
385            parse_with_config::<crate::Value>("---\na: 1\n---\nb: 2\n", &config).unwrap_err();
386        assert!(matches!(
387            error,
388            crate::Error::Budget(crate::BudgetBreach::MaxDocuments { limit: 1, .. })
389        ));
390    }
391
392    #[test]
393    fn document_overflow_is_rejected_before_deserialization() {
394        use core::sync::atomic::{AtomicUsize, Ordering};
395
396        static DESERIALIZATIONS: AtomicUsize = AtomicUsize::new(0);
397
398        #[derive(Debug)]
399        struct CountingValue;
400
401        impl<'de> serde_core::Deserialize<'de> for CountingValue {
402            fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
403            where
404                D: serde_core::Deserializer<'de>,
405            {
406                let _ = <crate::Value as serde_core::Deserialize>::deserialize(deserializer)?;
407                let _ = DESERIALIZATIONS.fetch_add(1, Ordering::Relaxed);
408                Ok(Self)
409            }
410        }
411
412        DESERIALIZATIONS.store(0, Ordering::Relaxed);
413        let config = ParserConfig::default().max_documents(4);
414        let yaml = "---\na: 1\n---\na: 2\n---\na: 3\n---\na: 4\n---\na: 5\n";
415        let error = parse_with_config::<CountingValue>(yaml, &config).unwrap_err();
416        assert!(matches!(
417            error,
418            crate::Error::Budget(crate::BudgetBreach::MaxDocuments {
419                limit: 4,
420                observed: 5
421            })
422        ));
423        assert_eq!(DESERIALIZATIONS.load(Ordering::Relaxed), 0);
424    }
425
426    #[test]
427    fn parse_with_config_preserves_semantic_policy() {
428        let config = ParserConfig::default().duplicate_key_policy(crate::DuplicateKeyPolicy::Error);
429        let error = parse_with_config::<crate::Value>("a: 1\na: 2\n", &config).unwrap_err();
430        assert_eq!(error.kind(), crate::ErrorKind::DuplicateKey);
431    }
432
433    #[cfg_attr(
434        miri,
435        ignore = "rayon/crossbeam-epoch uses int-to-ptr casts unsupported under -Zmiri-strict-provenance"
436    )]
437    #[test]
438    fn parallel_errors_remain_in_source_order() {
439        let config = ParserConfig::default().duplicate_key_policy(crate::DuplicateKeyPolicy::Error);
440        let yaml = concat!(
441            "---\nid: 1\n",
442            "---\nid: 2\nid: 3\n",
443            "---\nid: [\n",
444            "---\nid: 4\n",
445        );
446        let error = parse_with_config::<crate::Value>(yaml, &config).unwrap_err();
447        assert_eq!(error.kind(), crate::ErrorKind::DuplicateKey);
448    }
449
450    #[cfg_attr(
451        miri,
452        ignore = "rayon/crossbeam-epoch uses int-to-ptr casts unsupported under -Zmiri-strict-provenance"
453    )]
454    #[test]
455    fn parse_propagates_first_error() {
456        #[derive(Debug, serde::Deserialize)]
457        #[allow(dead_code)]
458        struct Record {
459            id: u32,
460        }
461        // Second doc has unparseable content.
462        let yaml = "---\nid: 1\n---\nid: [\n";
463        let res: Result<Vec<Record>> = parse(yaml);
464        assert!(res.is_err());
465    }
466
467    #[cfg_attr(
468        miri,
469        ignore = "rayon/crossbeam-epoch uses int-to-ptr casts unsupported under -Zmiri-strict-provenance"
470    )]
471    #[test]
472    fn parse_matches_sequential_for_correctness() {
473        // Stress test: 50 small documents. The parallel and
474        // sequential implementations must produce bit-for-bit
475        // identical results.
476        let mut yaml = String::new();
477        for i in 0..50 {
478            yaml.push_str(&format!("---\nid: {i}\nname: record-{i}\n"));
479        }
480
481        #[derive(Debug, serde::Deserialize, PartialEq)]
482        struct Record {
483            id: u32,
484            name: String,
485        }
486        let parallel: Vec<Record> = parse(&yaml).unwrap();
487        let sequential: Vec<Record> = crate::load_all_as(&yaml).unwrap();
488        assert_eq!(parallel, sequential);
489    }
490
491    #[cfg_attr(
492        miri,
493        ignore = "rayon/crossbeam-epoch uses int-to-ptr casts unsupported under -Zmiri-strict-provenance"
494    )]
495    #[test]
496    fn configured_parse_uses_the_caller_owned_pool() {
497        use core::sync::atomic::{AtomicUsize, Ordering};
498
499        static OBSERVED_POOL_WIDTH: AtomicUsize = AtomicUsize::new(0);
500
501        #[derive(Debug)]
502        struct ObservedValue;
503
504        impl<'de> serde_core::Deserialize<'de> for ObservedValue {
505            fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
506            where
507                D: serde_core::Deserializer<'de>,
508            {
509                let _ =
510                    OBSERVED_POOL_WIDTH.fetch_max(rayon::current_num_threads(), Ordering::Relaxed);
511                let _ = <crate::Value as serde_core::Deserialize>::deserialize(deserializer)?;
512                Ok(Self)
513            }
514        }
515
516        OBSERVED_POOL_WIDTH.store(0, Ordering::Relaxed);
517        let pool = ThreadPoolBuilder::new().num_threads(2).build().unwrap();
518        let yaml = "---\nid: 1\n---\nid: 2\n---\nid: 3\n---\nid: 4\n";
519        let docs =
520            parse_with_config_in_pool::<ObservedValue>(yaml, &ParserConfig::default(), &pool)
521                .unwrap();
522
523        assert_eq!(docs.len(), 4);
524        assert_eq!(OBSERVED_POOL_WIDTH.load(Ordering::Relaxed), 2);
525    }
526}