Skip to main content

oxirs_ttl/
parallel.rs

1//! Parallel processing support for RDF parsing using rayon
2//!
3//! This module provides parallel parsing capabilities for processing large RDF files
4//! by splitting them into chunks and parsing them concurrently.
5
6#[cfg(feature = "parallel")]
7use rayon::prelude::*;
8
9use crate::error::TurtleResult;
10use oxirs_core::model::Triple;
11use std::io::{BufRead, BufReader, Read};
12use std::sync::{Arc, Mutex};
13
14/// Configuration for parallel parsing
15#[derive(Debug, Clone)]
16pub struct ParallelConfig {
17    /// Number of threads to use (0 = use rayon's default thread pool)
18    pub num_threads: usize,
19    /// Target number of complete statements to group into each parallel
20    /// chunk. Chunks are always cut on complete-statement boundaries (a
21    /// statement is never split across two chunks), so the actual statement
22    /// count in a given chunk may occasionally exceed this when a single
23    /// statement is unusually large.
24    pub chunk_size: usize,
25    /// Whether to continue parsing after errors
26    pub lenient: bool,
27}
28
29impl Default for ParallelConfig {
30    fn default() -> Self {
31        Self {
32            num_threads: 0, // Use rayon's default
33            chunk_size: 10_000,
34            lenient: false,
35        }
36    }
37}
38
39impl ParallelConfig {
40    /// Create a new parallel configuration
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Set the number of threads
46    pub fn with_num_threads(mut self, num_threads: usize) -> Self {
47        self.num_threads = num_threads;
48        self
49    }
50
51    /// Set the chunk size
52    pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
53        self.chunk_size = chunk_size;
54        self
55    }
56
57    /// Enable lenient mode
58    pub fn lenient(mut self, lenient: bool) -> Self {
59        self.lenient = lenient;
60        self
61    }
62}
63
64/// Outcome of a parallel parse.
65///
66/// In strict mode a per-chunk parse error aborts the whole parse (returned as
67/// `Err` from [`ParallelParser::parse_all`]) exactly as before; `errors` is
68/// therefore always empty in that mode. In lenient mode, per-chunk parse
69/// errors are collected here instead of being silently printed to stderr, so
70/// the caller can inspect (or count) how much data failed to parse.
71#[derive(Debug, Default)]
72pub struct ParallelParseOutcome {
73    /// Triples successfully parsed from every chunk.
74    pub triples: Vec<Triple>,
75    /// Errors collected from chunks that failed to parse (lenient mode only).
76    pub errors: Vec<crate::error::TurtleParseError>,
77}
78
79/// Parallel parser for processing RDF files using multiple threads
80#[cfg(feature = "parallel")]
81pub struct ParallelParser<R: Read> {
82    reader: BufReader<R>,
83    config: ParallelConfig,
84}
85
86#[cfg(feature = "parallel")]
87impl<R: Read + Send + Sync> ParallelParser<R> {
88    /// Create a new parallel parser
89    pub fn new(reader: R) -> Self {
90        Self::with_config(reader, ParallelConfig::default())
91    }
92
93    /// Create a parallel parser with custom configuration
94    pub fn with_config(reader: R, config: ParallelConfig) -> Self {
95        Self {
96            reader: BufReader::new(reader),
97            config,
98        }
99    }
100
101    /// Parse the entire document in parallel
102    ///
103    /// This splits the document into chunks and parses them concurrently.
104    /// Chunks are cut on complete-statement boundaries (see
105    /// `statement_boundary`) rather than raw line boundaries, so a
106    /// statement that spans multiple lines (a pretty-printed predicate/object
107    /// list, or a triple-quoted string containing embedded newlines) is
108    /// always kept whole in a single chunk instead of being corrupted or
109    /// silently dropped at a chunk boundary.
110    ///
111    /// `@prefix`/`@base`/`PREFIX`/`BASE` declarations are extracted (as
112    /// complete statements, so a rare multi-line prefix declaration is still
113    /// captured whole) and prepended to every data chunk so prefixed names
114    /// resolve correctly no matter which chunk the declaration appeared in.
115    ///
116    /// In lenient mode, per-chunk parse errors are collected into the
117    /// returned [`ParallelParseOutcome::errors`] instead of being silently
118    /// printed to stderr.
119    ///
120    /// Note: This loads the entire document into memory for splitting, since
121    /// finding a statement boundary may require looking arbitrarily far ahead
122    /// past a string literal.
123    pub fn parse_all(&mut self) -> TurtleResult<ParallelParseOutcome> {
124        use std::io::Read;
125
126        // Read entire document
127        let mut content = String::new();
128        self.reader
129            .read_to_string(&mut content)
130            .map_err(crate::error::TurtleParseError::io)?;
131
132        // Split into complete statements first (rather than by raw line), then
133        // separate prefix/base declarations from data statements.
134        let boundaries = crate::statement_boundary::statement_boundaries(&content);
135        let mut prefix_header = String::new();
136        let mut data_statements = String::new();
137        let mut start = 0usize;
138        for &end in &boundaries {
139            let statement = &content[start..end];
140            let trimmed = statement.trim_start();
141            if trimmed.starts_with("@prefix")
142                || trimmed.starts_with("@base")
143                || trimmed.starts_with("PREFIX")
144                || trimmed.starts_with("prefix")
145                || trimmed.starts_with("BASE")
146                || trimmed.starts_with("base")
147            {
148                prefix_header.push_str(statement);
149            } else {
150                data_statements.push_str(statement);
151            }
152            start = end;
153        }
154        // Any trailing bytes after the last complete statement (a truncated
155        // or malformed final statement, or trailing whitespace/comments) are
156        // still handed to the parser so they can surface a proper syntax
157        // error instead of being silently dropped.
158        if start < content.len() {
159            data_statements.push_str(&content[start..]);
160        }
161
162        // Split data statements into chunks, each ending on a complete
163        // statement boundary.
164        let chunks = crate::statement_boundary::split_into_statement_chunks(
165            &data_statements,
166            self.config.chunk_size,
167        );
168
169        // Parse chunks in parallel, each with the collected prefixes prepended
170        let prefix_arc = std::sync::Arc::new(prefix_header);
171        let results: Vec<TurtleResult<Vec<Triple>>> = chunks
172            .par_iter()
173            .map(|chunk| {
174                let chunk_text = format!("{prefix_arc}{chunk}");
175                self.parse_chunk(&chunk_text)
176            })
177            .collect();
178
179        // Collect results
180        let mut all_triples = Vec::new();
181        let mut errors = Vec::new();
182        for result in results {
183            match result {
184                Ok(triples) => all_triples.extend(triples),
185                Err(e) if self.config.lenient => {
186                    errors.push(e);
187                }
188                Err(e) => return Err(e),
189            }
190        }
191
192        Ok(ParallelParseOutcome {
193            triples: all_triples,
194            errors,
195        })
196    }
197
198    /// Parse a chunk of text
199    fn parse_chunk(&self, chunk: &str) -> TurtleResult<Vec<Triple>> {
200        use crate::turtle::TurtleParser;
201        let parser = TurtleParser::new();
202        parser.parse_document(chunk)
203    }
204}
205
206/// Parallel streaming parser for processing large files without loading entirely into memory
207#[cfg(feature = "parallel")]
208pub struct ParallelStreamingParser<R: Read + Send + Sync> {
209    reader: Arc<Mutex<BufReader<R>>>,
210    config: ParallelConfig,
211}
212
213#[cfg(feature = "parallel")]
214impl<R: Read + Send + Sync + 'static> ParallelStreamingParser<R> {
215    /// Create a new parallel streaming parser
216    pub fn new(reader: R) -> Self {
217        Self::with_config(reader, ParallelConfig::default())
218    }
219
220    /// Create a parallel streaming parser with custom configuration
221    pub fn with_config(reader: R, config: ParallelConfig) -> Self {
222        Self {
223            reader: Arc::new(Mutex::new(BufReader::new(reader))),
224            config,
225        }
226    }
227
228    /// Process the file in parallel batches
229    ///
230    /// This reads batches from the file and processes them in parallel.
231    pub fn process_batches<F>(&mut self, mut processor: F) -> TurtleResult<usize>
232    where
233        F: FnMut(Vec<Triple>) + Send,
234    {
235        let batch_size = self.config.chunk_size;
236        let mut total_triples = 0;
237        let mut batches = Vec::new();
238        let mut prefixes = String::new();
239
240        // Read all batches first and extract prefixes
241        loop {
242            let mut reader_guard = self.reader.lock().expect("lock should not be poisoned");
243            let mut batch_content = String::new();
244            let mut lines_read = 0;
245
246            while lines_read < batch_size {
247                let mut line = String::new();
248                match reader_guard.read_line(&mut line) {
249                    Ok(0) => break, // EOF
250                    Ok(_) => {
251                        // Extract prefix declarations
252                        let trimmed = line.trim();
253                        if (trimmed.starts_with("@prefix")
254                            || trimmed.starts_with("@base")
255                            || trimmed.starts_with("PREFIX")
256                            || trimmed.starts_with("BASE"))
257                            && !prefixes.contains(trimmed)
258                        {
259                            prefixes.push_str(&line);
260                        }
261                        batch_content.push_str(&line);
262                        lines_read += 1;
263                    }
264                    Err(e) => return Err(crate::error::TurtleParseError::io(e)),
265                }
266            }
267
268            if batch_content.is_empty() {
269                break;
270            }
271
272            batches.push(batch_content);
273        }
274
275        // Process batches in parallel with prefixes prepended
276        let prefix_arc = Arc::new(prefixes);
277        let results: Vec<TurtleResult<Vec<Triple>>> = batches
278            .par_iter()
279            .map(|batch| {
280                use crate::turtle::TurtleParser;
281                let parser = TurtleParser::new();
282                let doc_with_prefixes = format!("{}{}", prefix_arc, batch);
283                parser.parse_document(&doc_with_prefixes)
284            })
285            .collect();
286
287        // Collect and process results
288        for result in results {
289            match result {
290                Ok(triples) => {
291                    total_triples += triples.len();
292                    processor(triples);
293                }
294                Err(e) if self.config.lenient => {
295                    eprintln!("Warning: Parse error in batch: {}", e);
296                }
297                Err(e) => return Err(e),
298            }
299        }
300
301        Ok(total_triples)
302    }
303}
304
305#[cfg(not(feature = "parallel"))]
306compile_error!("Parallel processing requires the 'parallel' feature to be enabled");
307
308#[cfg(all(test, feature = "parallel"))]
309mod tests {
310    use super::*;
311    use std::io::Cursor;
312
313    #[test]
314    fn test_parallel_parser_basic() {
315        let turtle = r#"
316            @prefix ex: <http://example.org/> .
317            ex:alice ex:name "Alice" .
318            ex:bob ex:name "Bob" .
319            ex:charlie ex:name "Charlie" .
320        "#;
321
322        let mut parser = ParallelParser::new(Cursor::new(turtle));
323        let result = parser.parse_all();
324
325        assert!(result.is_ok());
326        let outcome = result.expect("result should be Ok");
327        assert_eq!(outcome.triples.len(), 3);
328        assert!(outcome.errors.is_empty());
329    }
330
331    #[test]
332    fn test_parallel_parser_large_document() {
333        let mut turtle = String::from("@prefix ex: <http://example.org/> .\n");
334        for i in 0..1000 {
335            turtle.push_str(&format!("ex:subject{} ex:predicate \"object{}\" .\n", i, i));
336        }
337
338        let config = ParallelConfig::default().with_chunk_size(100);
339        let mut parser = ParallelParser::with_config(Cursor::new(turtle), config);
340        let result = parser.parse_all();
341
342        match &result {
343            Ok(outcome) => {
344                assert_eq!(outcome.triples.len(), 1000);
345                assert!(outcome.errors.is_empty());
346            }
347            Err(e) => {
348                panic!("Parse failed: {:?}", e);
349            }
350        }
351    }
352
353    #[test]
354    fn test_parallel_streaming_parser() {
355        let mut turtle = String::from("@prefix ex: <http://example.org/> .\n");
356        for i in 0..500 {
357            turtle.push_str(&format!("ex:subject{} ex:predicate \"object{}\" .\n", i, i));
358        }
359
360        let config = ParallelConfig::default().with_chunk_size(100);
361        let mut parser = ParallelStreamingParser::with_config(Cursor::new(turtle), config);
362
363        let mut total_processed = 0;
364        let result = parser.process_batches(|triples| {
365            total_processed += triples.len();
366        });
367
368        match &result {
369            Ok(count) => {
370                assert_eq!(*count, 500);
371                assert_eq!(total_processed, 500);
372            }
373            Err(e) => {
374                panic!("Parse failed: {:?}", e);
375            }
376        }
377    }
378
379    #[test]
380    fn test_parallel_parser_lenient_mode() {
381        let turtle = r#"
382            @prefix ex: <http://example.org/> .
383            ex:alice ex:name "Alice" .
384            invalid syntax here
385            ex:bob ex:name "Bob" .
386        "#;
387
388        let config = ParallelConfig::default().lenient(true);
389        let mut parser = ParallelParser::with_config(Cursor::new(turtle), config);
390        let result = parser.parse_all();
391
392        // Should succeed in lenient mode, and the chunk-level error must be
393        // surfaced to the caller rather than only printed to stderr.
394        assert!(result.is_ok());
395        let outcome = result.expect("result should be Ok");
396        assert!(
397            !outcome.errors.is_empty(),
398            "lenient mode should report the chunk parse error instead of silently discarding it"
399        );
400    }
401
402    #[test]
403    fn test_parallel_parser_does_not_split_multiline_statement() {
404        // Regression test: a statement pretty-printed across multiple lines
405        // (semicolon-separated predicate/object list) must survive parallel
406        // chunking intact even with a chunk size of 1 statement per chunk,
407        // instead of being corrupted or silently dropped because it landed on
408        // a raw line-based chunk boundary.
409        let turtle = concat!(
410            "@prefix ex: <http://example.org/> .\n",
411            "ex:alice\n",
412            "  ex:name \"Alice\" ;\n",
413            "  ex:age \"30\" ;\n",
414            "  ex:email \"alice@example.org\" .\n",
415            "ex:bob ex:name \"Bob\" .\n",
416        );
417
418        let config = ParallelConfig::default().with_chunk_size(1);
419        let mut parser = ParallelParser::with_config(Cursor::new(turtle), config);
420        let result = parser.parse_all();
421
422        let outcome = result.expect("parsing should succeed");
423        assert!(outcome.errors.is_empty());
424        // 3 triples for ex:alice (name/age/email) + 1 for ex:bob
425        assert_eq!(outcome.triples.len(), 4);
426    }
427}