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}