use std::sync::Arc;
use crate::Result;
use crate::core::config::{ExtractInput, ExtractionConfig, ExtractionResult};
#[cfg(all(feature = "url-ingestion", feature = "tokio-runtime", not(target_arch = "wasm32")))]
mod crawl_handle;
mod extract_impl;
pub mod seams;
#[cfg(feature = "heuristics")]
pub mod structured;
#[cfg(all(feature = "heuristics", feature = "pdf"))]
pub mod parsed;
use seams::{CacheBackend, NoopCache, NoopProgressSink, ProgressSink};
struct EngineInner {
#[cfg(all(feature = "url-ingestion", feature = "tokio-runtime", not(target_arch = "wasm32")))]
crawl: parking_lot::Mutex<Option<crawl_handle::CrawlHandleMemo>>,
cache: Arc<dyn CacheBackend>,
progress: Arc<dyn ProgressSink>,
}
#[derive(Clone)]
pub struct Engine {
inner: Arc<EngineInner>,
}
impl Engine {
pub fn builder() -> EngineBuilder {
EngineBuilder::default()
}
pub fn new_default() -> Self {
EngineBuilder::default().build()
}
pub async fn extract(&self, input: ExtractInput, config: &ExtractionConfig) -> Result<ExtractionResult> {
extract_impl::extract(&self.inner, input, config).await
}
pub async fn extract_batch(
&self,
inputs: Vec<ExtractInput>,
config: &ExtractionConfig,
) -> Result<ExtractionResult> {
extract_impl::extract_batch(&self.inner, inputs, config).await
}
pub fn cache_backend(&self) -> &Arc<dyn CacheBackend> {
&self.inner.cache
}
pub fn progress_sink(&self) -> &Arc<dyn ProgressSink> {
&self.inner.progress
}
}
#[derive(Default)]
pub struct EngineBuilder {
cache: Option<Arc<dyn CacheBackend>>,
progress: Option<Arc<dyn ProgressSink>>,
}
impl EngineBuilder {
pub fn with_cache_backend(mut self, cache: Arc<dyn CacheBackend>) -> Self {
self.cache = Some(cache);
self
}
pub fn with_progress_sink(mut self, progress: Arc<dyn ProgressSink>) -> Self {
self.progress = Some(progress);
self
}
pub fn build(self) -> Engine {
let inner = EngineInner {
#[cfg(all(feature = "url-ingestion", feature = "tokio-runtime", not(target_arch = "wasm32")))]
crawl: parking_lot::Mutex::new(None),
cache: self.cache.unwrap_or_else(|| Arc::new(NoopCache)),
progress: self.progress.unwrap_or_else(|| Arc::new(NoopProgressSink)),
};
Engine { inner: Arc::new(inner) }
}
}
#[cfg(all(test, feature = "tokio-runtime"))]
mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use super::*;
use crate::types::ExtractedDocument;
use seams::ProgressEvent;
#[derive(Default)]
struct RecordingProgressSink {
stages: Mutex<Vec<String>>,
}
impl ProgressSink for RecordingProgressSink {
fn emit(&self, event: ProgressEvent) {
self.stages
.lock()
.expect("recording sink mutex poisoned")
.push(event.stage);
}
}
#[tokio::test]
async fn should_emit_start_then_complete_progress_events_for_a_successful_bytes_extraction() {
let sink = Arc::new(RecordingProgressSink::default());
let engine = Engine::builder().with_progress_sink(sink.clone()).build();
let output = engine
.extract(
ExtractInput::from_bytes(b"hello progress".to_vec(), "text/plain", None),
&ExtractionConfig::default(),
)
.await
.unwrap();
assert_eq!(output.results.len(), 1);
assert_eq!(
*sink.stages.lock().expect("recording sink mutex poisoned"),
vec!["extract_start".to_string(), "extract_complete".to_string()],
"expected exactly a start event followed by a complete event, in that order"
);
}
#[tokio::test]
async fn should_emit_start_then_error_progress_events_for_a_failed_extraction() {
let sink = Arc::new(RecordingProgressSink::default());
let engine = Engine::builder().with_progress_sink(sink.clone()).build();
let error = engine
.extract(
ExtractInput::from_uri("s3://bucket/file.txt"),
&ExtractionConfig::default(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("unsupported URI scheme"));
assert_eq!(
*sink.stages.lock().expect("recording sink mutex poisoned"),
vec!["extract_start".to_string(), "extract_error".to_string()],
"expected exactly a start event followed by an error event, in that order"
);
}
struct StubCacheBackend {
cached_payload: Vec<u8>,
gets: AtomicUsize,
puts: AtomicUsize,
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl CacheBackend for StubCacheBackend {
async fn get(&self, _key: &str) -> Option<Vec<u8>> {
self.gets.fetch_add(1, Ordering::SeqCst);
Some(self.cached_payload.clone())
}
async fn put(&self, _key: &str, _value: Vec<u8>, _ttl: Option<Duration>) {
self.puts.fetch_add(1, Ordering::SeqCst);
}
}
#[tokio::test]
async fn should_return_cached_result_and_skip_extraction_on_cache_hit() {
let cached_result = ExtractionResult::single(ExtractedDocument {
content: "CACHED-RESULT-NOT-REEXTRACTED".to_string(),
..Default::default()
});
let cache = Arc::new(StubCacheBackend {
cached_payload: serde_json::to_vec(&cached_result).unwrap(),
gets: AtomicUsize::new(0),
puts: AtomicUsize::new(0),
});
let engine = Engine::builder().with_cache_backend(cache.clone()).build();
let output = engine
.extract(
ExtractInput::from_bytes(b"this is not the cached content".to_vec(), "text/plain", None),
&ExtractionConfig::default(),
)
.await
.unwrap();
assert_eq!(output.results.len(), 1);
assert_eq!(output.results[0].content, "CACHED-RESULT-NOT-REEXTRACTED");
assert_eq!(
cache.gets.load(Ordering::SeqCst),
1,
"the cache backend must be consulted exactly once"
);
assert_eq!(
cache.puts.load(Ordering::SeqCst),
0,
"a hit must not also write back to the cache"
);
}
#[derive(Default)]
struct InMemoryCacheBackend {
store: Mutex<std::collections::HashMap<String, Vec<u8>>>,
gets: AtomicUsize,
puts: AtomicUsize,
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl CacheBackend for InMemoryCacheBackend {
async fn get(&self, key: &str) -> Option<Vec<u8>> {
self.gets.fetch_add(1, Ordering::SeqCst);
self.store
.lock()
.expect("in-memory cache mutex poisoned")
.get(key)
.cloned()
}
async fn put(&self, key: &str, value: Vec<u8>, _ttl: Option<Duration>) {
self.puts.fetch_add(1, Ordering::SeqCst);
self.store
.lock()
.expect("in-memory cache mutex poisoned")
.insert(key.to_string(), value);
}
}
#[tokio::test]
async fn should_populate_cache_on_miss_and_skip_reextraction_on_identical_second_call() {
let cache = Arc::new(InMemoryCacheBackend::default());
let engine = Engine::builder().with_cache_backend(cache.clone()).build();
let config = ExtractionConfig::default();
let bytes = b"identical bytes for cache".to_vec();
let first = engine
.extract(ExtractInput::from_bytes(bytes.clone(), "text/plain", None), &config)
.await
.unwrap();
assert_eq!(
cache.gets.load(Ordering::SeqCst),
1,
"the first extract must consult the cache exactly once (a miss)"
);
assert_eq!(
cache.puts.load(Ordering::SeqCst),
1,
"a successful cache-miss extraction must populate the cache exactly once"
);
let second = engine
.extract(ExtractInput::from_bytes(bytes, "text/plain", None), &config)
.await
.unwrap();
assert_eq!(
cache.gets.load(Ordering::SeqCst),
2,
"the second identical extract must also consult the cache"
);
assert_eq!(
cache.puts.load(Ordering::SeqCst),
1,
"a cache hit must short-circuit extraction and must not write back to the cache again"
);
assert_eq!(
first.results[0].content, second.results[0].content,
"the cache-hit result must equal the originally-extracted content"
);
}
#[tokio::test]
async fn should_miss_cache_when_extraction_config_changes_for_identical_bytes() {
let cache = Arc::new(InMemoryCacheBackend::default());
let engine = Engine::builder().with_cache_backend(cache.clone()).build();
let bytes = b"same bytes different config".to_vec();
let config_a = ExtractionConfig::default();
engine
.extract(ExtractInput::from_bytes(bytes.clone(), "text/plain", None), &config_a)
.await
.unwrap();
let config_b = ExtractionConfig {
enable_quality_processing: !config_a.enable_quality_processing,
..ExtractionConfig::default()
};
engine
.extract(ExtractInput::from_bytes(bytes, "text/plain", None), &config_b)
.await
.unwrap();
assert_eq!(
cache.gets.load(Ordering::SeqCst),
2,
"both extracts must consult the cache once each"
);
assert_eq!(
cache.puts.load(Ordering::SeqCst),
2,
"a config change must derive a different cache key, forcing a second miss and a second write"
);
}
#[tokio::test]
async fn should_emit_start_then_cache_hit_progress_events_on_a_cache_hit() {
let sink = Arc::new(RecordingProgressSink::default());
let cached_result = ExtractionResult::single(ExtractedDocument {
content: "CACHED".to_string(),
..Default::default()
});
let cache = Arc::new(StubCacheBackend {
cached_payload: serde_json::to_vec(&cached_result).unwrap(),
gets: AtomicUsize::new(0),
puts: AtomicUsize::new(0),
});
let engine = Engine::builder()
.with_progress_sink(sink.clone())
.with_cache_backend(cache)
.build();
engine
.extract(
ExtractInput::from_bytes(b"progress on cache hit".to_vec(), "text/plain", None),
&ExtractionConfig::default(),
)
.await
.unwrap();
assert_eq!(
*sink.stages.lock().expect("recording sink mutex poisoned"),
vec!["extract_start".to_string(), "extract_cache_hit".to_string()],
"a cache hit must emit start then cache_hit, not complete"
);
}
#[tokio::test]
async fn should_cache_an_identical_bytes_batch_and_emit_batch_progress() {
let sink = Arc::new(RecordingProgressSink::default());
let cache = Arc::new(InMemoryCacheBackend::default());
let engine = Engine::builder()
.with_progress_sink(sink.clone())
.with_cache_backend(cache.clone())
.build();
let config = ExtractionConfig::default();
let inputs = vec![
ExtractInput::from_bytes(b"first batch item".to_vec(), "text/plain", None),
ExtractInput::from_bytes(b"second batch item".to_vec(), "text/plain", None),
];
let first = engine.extract_batch(inputs.clone(), &config).await.unwrap();
let second = engine.extract_batch(inputs, &config).await.unwrap();
assert_eq!(first.results.len(), 2);
assert_eq!(second.results.len(), 2);
assert_eq!(cache.gets.load(Ordering::SeqCst), 2);
assert_eq!(cache.puts.load(Ordering::SeqCst), 1);
assert_eq!(
*sink.stages.lock().expect("recording sink mutex poisoned"),
vec![
"extract_batch_start".to_string(),
"extract_batch_complete".to_string(),
"extract_batch_start".to_string(),
"extract_batch_cache_hit".to_string(),
]
);
}
#[tokio::test]
async fn should_not_cache_a_batch_with_per_input_errors() {
let cache = Arc::new(InMemoryCacheBackend::default());
let engine = Engine::builder().with_cache_backend(cache.clone()).build();
let inputs = vec![
ExtractInput::from_bytes(b"valid batch item".to_vec(), "text/plain", None),
ExtractInput::from_bytes(b"invalid batch item".to_vec(), "", None),
];
let output = engine
.extract_batch(inputs, &ExtractionConfig::default())
.await
.unwrap();
assert_eq!(output.results.len(), 1);
assert_eq!(output.errors.len(), 1);
assert_eq!(cache.gets.load(Ordering::SeqCst), 1);
assert_eq!(cache.puts.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn should_validate_config_before_single_and_batch_extraction() {
let sink = Arc::new(RecordingProgressSink::default());
let cache = Arc::new(InMemoryCacheBackend::default());
let engine = Engine::builder()
.with_progress_sink(sink.clone())
.with_cache_backend(cache.clone())
.build();
let config = ExtractionConfig {
csv: Some(crate::core::config::CsvConfig {
delimiter: Some(String::new()),
..Default::default()
}),
..Default::default()
};
let input = ExtractInput::from_bytes(b"invalid config".to_vec(), "text/plain", None);
let single_error = engine.extract(input.clone(), &config).await.unwrap_err();
let batch_error = engine.extract_batch(vec![input], &config).await.unwrap_err();
assert!(matches!(single_error, crate::XbergError::Validation { .. }));
assert!(matches!(batch_error, crate::XbergError::Validation { .. }));
assert_eq!(cache.gets.load(Ordering::SeqCst), 0);
assert_eq!(cache.puts.load(Ordering::SeqCst), 0);
assert_eq!(
*sink.stages.lock().expect("recording sink mutex poisoned"),
vec![
"extract_start".to_string(),
"extract_error".to_string(),
"extract_batch_start".to_string(),
"extract_batch_error".to_string(),
]
);
}
}