Skip to main content

oxirs_core/parser/
async_parser.rs

1//! Async RDF streaming parser for high-performance large file processing
2//!
3//! **Honest streaming status**: only [`RdfFormat::NTriples`] and
4//! [`RdfFormat::NQuads`] are truly streamed here -- they are line-based
5//! formats, so each chunk read from the source is processed line-by-line as
6//! it arrives and the accumulation buffer is cleared after every batch of
7//! complete lines (bounded memory, independent of document size).
8//!
9//! Turtle, TriG, RDF/XML, JSON-LD and N3 are **not** incrementally streamed:
10//! their grammars allow constructs (multi-line statements, blank-node
11//! property lists, forward references inside a single document) that the
12//! underlying oxttl/oxrdfxml/oxjsonld parsers only expose through a
13//! synchronous [`std::io::Read`]-based API, which cannot be safely driven
14//! from an arbitrary [`tokio::io::AsyncRead`] without either spawning a
15//! blocking bridge thread or fully materializing the document first. This
16//! parser takes the second option: it accumulates chunks into memory and
17//! parses the complete document once the source is exhausted. To keep that
18//! bounded and fail loudly instead of exhausting memory on an unexpectedly
19//! large source, accumulation is capped at [`AsyncStreamingParser::max_buffer_size`]
20//! (configurable via [`AsyncStreamingParser::with_max_buffer_size`]); once
21//! the cap is exceeded, [`AsyncStreamingParser::parse_stream`] returns an
22//! explicit [`OxirsError::Parse`] rather than continuing to buffer.
23
24use super::{Parser, ParserConfig, RdfFormat};
25use crate::model::Quad;
26use crate::{OxirsError, Result};
27use std::future::Future;
28use std::pin::Pin;
29
30/// Default cap (256 MiB) on the in-memory accumulation buffer used for
31/// formats that are not truly streamed (see module docs).
32#[cfg(feature = "async")]
33const DEFAULT_MAX_BUFFER_SIZE: usize = 256 * 1024 * 1024;
34
35/// Async RDF streaming parser for high-performance large file processing
36///
37/// See the module-level docs for which formats are genuinely streamed
38/// (N-Triples/N-Quads) versus buffered-then-parsed (everything else).
39#[cfg(feature = "async")]
40pub struct AsyncStreamingParser {
41    format: RdfFormat,
42    config: ParserConfig,
43    progress_callback: Option<Box<dyn Fn(usize) + Send + Sync>>,
44    chunk_size: usize,
45    max_buffer_size: usize,
46}
47
48#[cfg(feature = "async")]
49impl AsyncStreamingParser {
50    /// Create a new async streaming parser
51    pub fn new(format: RdfFormat) -> Self {
52        AsyncStreamingParser {
53            format,
54            config: ParserConfig::default(),
55            progress_callback: None,
56            chunk_size: 8192, // 8KB default chunk size
57            max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
58        }
59    }
60
61    /// Set a progress callback that reports the number of bytes processed
62    pub fn with_progress_callback<F>(mut self, callback: F) -> Self
63    where
64        F: Fn(usize) + Send + Sync + 'static,
65    {
66        self.progress_callback = Some(Box::new(callback));
67        self
68    }
69
70    /// Set the chunk size for streaming processing
71    pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
72        self.chunk_size = chunk_size;
73        self
74    }
75
76    /// Set the maximum number of bytes this parser will accumulate in
77    /// memory before returning an error, for formats that are not
78    /// incrementally streamed (anything other than N-Triples/N-Quads; see
79    /// module docs). Has no effect on N-Triples/N-Quads, which always
80    /// stream with bounded per-chunk memory regardless of document size.
81    pub fn with_max_buffer_size(mut self, max_buffer_size: usize) -> Self {
82        self.max_buffer_size = max_buffer_size;
83        self
84    }
85
86    /// Configure error tolerance
87    pub fn with_error_tolerance(mut self, ignore_errors: bool) -> Self {
88        self.config.ignore_errors = ignore_errors;
89        self
90    }
91
92    /// Parse from an async readable stream
93    pub async fn parse_stream<R, F, Fut>(&self, mut reader: R, mut handler: F) -> Result<()>
94    where
95        R: tokio::io::AsyncRead + Unpin,
96        F: FnMut(Quad) -> Fut,
97        Fut: Future<Output = Result<()>>,
98    {
99        use tokio::io::AsyncReadExt;
100
101        let mut buffer = Vec::with_capacity(self.chunk_size);
102        let mut accumulated_data = String::new();
103        let mut bytes_processed = 0usize;
104        let mut line_buffer = String::new();
105
106        loop {
107            buffer.clear();
108            buffer.resize(self.chunk_size, 0);
109
110            let bytes_read = reader.read(&mut buffer).await?;
111
112            if bytes_read == 0 {
113                break; // End of stream
114            }
115
116            buffer.truncate(bytes_read);
117            bytes_processed += bytes_read;
118
119            // Convert bytes to string and append to accumulated data
120            let chunk_str = String::from_utf8_lossy(&buffer);
121            accumulated_data.push_str(&chunk_str);
122
123            // Process complete lines for line-based formats (N-Triples, N-Quads):
124            // these are truly streamed and accumulated_data is drained below.
125            if matches!(self.format, RdfFormat::NTriples | RdfFormat::NQuads) {
126                self.process_lines_async(&mut accumulated_data, &mut line_buffer, &mut handler)
127                    .await?;
128            } else if accumulated_data.len() > self.max_buffer_size {
129                // Every other format is not incrementally streamed (see
130                // module docs): fail loudly instead of silently buffering
131                // an unbounded amount of the source into memory.
132                return Err(OxirsError::Parse(format!(
133                    "AsyncStreamingParser: {:?} is not incrementally streamed and the \
134                     source exceeded the configured max_buffer_size of {} bytes \
135                     (buffered {} bytes so far). Increase the limit via \
136                     with_max_buffer_size(), split the document, or use \
137                     RdfFormat::NTriples/NQuads for true streaming.",
138                    self.format,
139                    self.max_buffer_size,
140                    accumulated_data.len()
141                )));
142            }
143
144            // Report progress if callback is set
145            if let Some(ref callback) = self.progress_callback {
146                callback(bytes_processed);
147            }
148        }
149
150        // Process any remaining data
151        if !accumulated_data.is_empty() {
152            match self.format {
153                RdfFormat::NTriples | RdfFormat::NQuads => {
154                    // Process final lines
155                    accumulated_data.push_str(&line_buffer);
156                    self.process_lines_async(
157                        &mut accumulated_data,
158                        &mut String::new(),
159                        &mut handler,
160                    )
161                    .await?;
162                }
163                _ => {
164                    // For other formats (see module docs), the document is
165                    // fully buffered (bounded by max_buffer_size above) and
166                    // parsed once via the synchronous Parser API. Results
167                    // are then handed to the async handler directly -- no
168                    // nested runtime/block_on bridge is needed since we are
169                    // not calling an async fn from inside a sync callback.
170                    let parser = Parser::with_config(self.format, self.config.clone());
171                    let quads = parser.parse_str_to_quads(&accumulated_data)?;
172                    for quad in quads {
173                        handler(quad).await?;
174                    }
175                }
176            }
177        }
178
179        Ok(())
180    }
181
182    /// Process lines asynchronously for line-based formats
183    async fn process_lines_async<F, Fut>(
184        &self,
185        accumulated_data: &mut String,
186        line_buffer: &mut String,
187        handler: &mut F,
188    ) -> Result<()>
189    where
190        F: FnMut(Quad) -> Fut,
191        Fut: Future<Output = Result<()>>,
192    {
193        // Combine line buffer with new data
194        let mut full_data = line_buffer.clone();
195        full_data.push_str(accumulated_data);
196
197        let mut last_newline_pos = 0;
198
199        // Find complete lines
200        for (pos, _) in full_data.match_indices('\n') {
201            let line = &full_data[last_newline_pos..pos];
202            last_newline_pos = pos + 1;
203
204            // Parse the line
205            if let Some(quad) = self.parse_line(line)? {
206                handler(quad).await?;
207            }
208        }
209
210        // Keep incomplete line for next iteration
211        line_buffer.clear();
212        if last_newline_pos < full_data.len() {
213            line_buffer.push_str(&full_data[last_newline_pos..]);
214        }
215
216        accumulated_data.clear();
217        Ok(())
218    }
219
220    /// Parse a single line (for N-Triples/N-Quads)
221    fn parse_line(&self, line: &str) -> Result<Option<Quad>> {
222        let parser = Parser::with_config(self.format, self.config.clone());
223
224        match self.format {
225            RdfFormat::NTriples => parser.parse_ntriples_line(line),
226            RdfFormat::NQuads => {
227                // For N-Quads, we need a more sophisticated parser
228                // This is a simplified implementation
229                parser.parse_ntriples_line(line)
230            }
231            _ => Err(OxirsError::Parse(
232                "Unsupported format for line parsing".to_string(),
233            )),
234        }
235    }
236
237    /// Parse from bytes asynchronously
238    pub async fn parse_bytes<F, Fut>(&self, data: &[u8], handler: F) -> Result<()>
239    where
240        F: FnMut(Quad) -> Fut,
241        Fut: Future<Output = Result<()>>,
242    {
243        use std::io::Cursor;
244        let cursor = Cursor::new(data);
245        self.parse_stream(cursor, handler).await
246    }
247
248    /// Parse from string asynchronously
249    pub async fn parse_str_async<F, Fut>(&self, data: &str, handler: F) -> Result<()>
250    where
251        F: FnMut(Quad) -> Fut,
252        Fut: Future<Output = Result<()>>,
253    {
254        let bytes = data.as_bytes();
255        self.parse_bytes(bytes, handler).await
256    }
257
258    /// Convenience method to parse to a vector asynchronously
259    pub async fn parse_str_to_quads_async(&self, data: &str) -> Result<Vec<Quad>> {
260        use std::sync::Arc;
261        use tokio::sync::Mutex;
262
263        let quads = Arc::new(Mutex::new(Vec::new()));
264        let quads_clone = Arc::clone(&quads);
265
266        self.parse_str_async(data, move |quad| {
267            let quads = Arc::clone(&quads_clone);
268            async move {
269                quads.lock().await.push(quad);
270                Ok(())
271            }
272        })
273        .await?;
274
275        let result = quads.lock().await;
276        Ok(result.clone())
277    }
278}
279
280/// Progress information for async parsing
281#[cfg(feature = "async")]
282#[derive(Debug, Clone)]
283pub struct ParseProgress {
284    pub bytes_processed: usize,
285    pub quads_parsed: usize,
286    pub errors_encountered: usize,
287    pub estimated_total_bytes: Option<usize>,
288}
289
290#[cfg(feature = "async")]
291impl ParseProgress {
292    /// Calculate completion percentage if total size is known
293    pub fn completion_percentage(&self) -> Option<f64> {
294        self.estimated_total_bytes.map(|total| {
295            if total == 0 {
296                100.0
297            } else {
298                (self.bytes_processed as f64 / total as f64) * 100.0
299            }
300        })
301    }
302}
303
304/// Async streaming sink for writing parsed RDF data
305#[cfg(feature = "async")]
306pub trait AsyncRdfSink: Send + Sync {
307    /// Process a parsed quad asynchronously
308    fn process_quad(&mut self, quad: Quad)
309        -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
310
311    /// Finalize processing (called when parsing is complete)
312    fn finalize(&mut self) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
313}
314
315/// Memory-based async sink that collects quads
316#[cfg(feature = "async")]
317pub struct MemoryAsyncSink {
318    quads: Vec<Quad>,
319}
320
321#[cfg(feature = "async")]
322impl MemoryAsyncSink {
323    pub fn new() -> Self {
324        MemoryAsyncSink { quads: Vec::new() }
325    }
326
327    pub fn into_quads(self) -> Vec<Quad> {
328        self.quads
329    }
330}
331
332#[cfg(feature = "async")]
333impl Default for MemoryAsyncSink {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339#[cfg(feature = "async")]
340impl AsyncRdfSink for MemoryAsyncSink {
341    fn process_quad(
342        &mut self,
343        quad: Quad,
344    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
345        Box::pin(async move {
346            self.quads.push(quad);
347            Ok(())
348        })
349    }
350
351    fn finalize(&mut self) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
352        Box::pin(async move { Ok(()) })
353    }
354}
355
356#[cfg(all(test, feature = "async"))]
357mod tests {
358    use super::*;
359    use std::io::Cursor;
360    use std::sync::{Arc, Mutex};
361
362    /// N-Triples must remain truly line-streamed: this is a baseline
363    /// correctness check for the fast path that `process_lines_async`
364    /// already handles (untouched by the buffered-format fix below).
365    #[tokio::test]
366    async fn test_ntriples_streams_correctly() {
367        let data = "<http://example.org/s1> <http://example.org/p> \"o1\" .\n\
368                     <http://example.org/s2> <http://example.org/p> \"o2\" .\n";
369        let reader = Cursor::new(data.as_bytes());
370        let parser = AsyncStreamingParser::new(RdfFormat::NTriples);
371
372        let quads = Arc::new(Mutex::new(Vec::new()));
373        let quads_clone = Arc::clone(&quads);
374        parser
375            .parse_stream(reader, move |quad| {
376                let quads = Arc::clone(&quads_clone);
377                async move {
378                    quads
379                        .lock()
380                        .map_err(|_| OxirsError::Parse("poisoned".into()))?
381                        .push(quad);
382                    Ok(())
383                }
384            })
385            .await
386            .expect("N-Triples streaming should succeed");
387
388        assert_eq!(quads.lock().expect("lock").len(), 2);
389    }
390
391    /// Regression test for the P0 finding: parsing a buffered (non
392    /// line-based) format must no longer nest a `block_in_place` +
393    /// `block_on` call inside the already-async `parse_stream` future (this
394    /// used to panic/deadlock risk if called from certain runtime flavors,
395    /// and is the P2 concurrency finding at async_parser.rs:112). We assert
396    /// the happy path still produces correct quads through the async
397    /// handler without any nested runtime call.
398    #[tokio::test]
399    async fn test_turtle_buffered_parsing_produces_correct_quads() {
400        let data = r#"@prefix ex: <http://example.org/> .
401ex:alice ex:knows ex:bob ."#;
402        let reader = Cursor::new(data.as_bytes());
403        let parser = AsyncStreamingParser::new(RdfFormat::Turtle);
404
405        let quads = Arc::new(Mutex::new(Vec::new()));
406        let quads_clone = Arc::clone(&quads);
407        parser
408            .parse_stream(reader, move |quad| {
409                let quads = Arc::clone(&quads_clone);
410                async move {
411                    quads
412                        .lock()
413                        .map_err(|_| OxirsError::Parse("poisoned".into()))?
414                        .push(quad);
415                    Ok(())
416                }
417            })
418            .await
419            .expect("buffered Turtle parsing should succeed");
420
421        assert_eq!(quads.lock().expect("lock").len(), 1);
422    }
423
424    /// Regression test for the P0 finding: non-line-based formats used to
425    /// buffer the entire document with no bound. Now, exceeding the
426    /// configured `max_buffer_size` must fail loudly with a clear error
427    /// instead of continuing to grow memory unboundedly.
428    #[tokio::test]
429    async fn test_non_streaming_format_exceeding_max_buffer_errors_loudly() {
430        let data = r#"@prefix ex: <http://example.org/> .
431ex:alice ex:knows ex:bob, ex:carol, ex:dave, ex:eve, ex:frank ."#;
432        let reader = Cursor::new(data.as_bytes());
433        // Deliberately tiny cap so the (~100+ byte) document overflows it.
434        let parser = AsyncStreamingParser::new(RdfFormat::Turtle)
435            .with_chunk_size(16)
436            .with_max_buffer_size(8);
437
438        let result = parser.parse_stream(reader, |_quad| async { Ok(()) }).await;
439
440        assert!(
441            result.is_err(),
442            "exceeding max_buffer_size must be a loud error, not silent unbounded buffering"
443        );
444        let message = result.unwrap_err().to_string();
445        assert!(
446            message.contains("max_buffer_size") || message.contains("not incrementally streamed"),
447            "error should clearly explain the buffering limitation: {message}"
448        );
449    }
450
451    /// N-Triples must be exempt from the buffer cap: it is genuinely
452    /// streamed line-by-line and `accumulated_data` never grows unbounded,
453    /// so an arbitrarily large N-Triples document must succeed even with a
454    /// tiny `max_buffer_size`.
455    #[tokio::test]
456    async fn test_ntriples_ignores_max_buffer_size_cap() {
457        let mut data = String::new();
458        for i in 0..50 {
459            data.push_str(&format!(
460                "<http://example.org/s{i}> <http://example.org/p> \"o{i}\" .\n"
461            ));
462        }
463        let reader = Cursor::new(data.as_bytes());
464        let parser = AsyncStreamingParser::new(RdfFormat::NTriples)
465            .with_chunk_size(16)
466            .with_max_buffer_size(8);
467
468        let quads = Arc::new(Mutex::new(Vec::new()));
469        let quads_clone = Arc::clone(&quads);
470        parser
471            .parse_stream(reader, move |quad| {
472                let quads = Arc::clone(&quads_clone);
473                async move {
474                    quads
475                        .lock()
476                        .map_err(|_| OxirsError::Parse("poisoned".into()))?
477                        .push(quad);
478                    Ok(())
479                }
480            })
481            .await
482            .expect("true streaming formats must ignore the buffered-format cap");
483
484        assert_eq!(quads.lock().expect("lock").len(), 50);
485    }
486}