1use crate::{CacheManager, EmbeddingModel};
8use anyhow::{anyhow, Result};
9use chrono::{DateTime, Utc};
10use rayon::prelude::*;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Instant;
16use tokio::fs;
17use tokio::sync::{RwLock, Semaphore};
18use tokio::task::JoinHandle;
19use tracing::{debug, info, warn};
20use uuid::Uuid;
21
22pub struct MemoryOptimizedBatchIterator<T> {
24 data: Vec<T>,
26 position: usize,
28 batch_size: usize,
30 memory_usage: usize,
32 max_memory_bytes: usize,
34}
35
36impl<T> MemoryOptimizedBatchIterator<T> {
37 pub fn new(data: Vec<T>, batch_size: usize, max_memory_mb: usize) -> Self {
39 Self {
40 data,
41 position: 0,
42 batch_size,
43 memory_usage: 0,
44 max_memory_bytes: max_memory_mb * 1024 * 1024,
45 }
46 }
47
48 pub fn next_batch(&mut self) -> Option<Vec<T>>
50 where
51 T: Clone,
52 {
53 if self.position >= self.data.len() {
54 return None;
55 }
56
57 let mut batch = Vec::new();
58 let mut current_memory = 0;
59 let item_size = std::mem::size_of::<T>();
60
61 while self.position < self.data.len()
63 && batch.len() < self.batch_size
64 && current_memory + item_size <= self.max_memory_bytes
65 {
66 batch.push(self.data[self.position].clone());
67 self.position += 1;
68 current_memory += item_size;
69 }
70
71 self.memory_usage = current_memory;
72
73 if batch.is_empty() {
74 None
75 } else {
76 Some(batch)
77 }
78 }
79
80 pub fn get_memory_usage(&self) -> usize {
82 self.memory_usage
83 }
84
85 pub fn get_progress(&self) -> f64 {
87 if self.data.is_empty() {
88 1.0
89 } else {
90 self.position as f64 / self.data.len() as f64
91 }
92 }
93
94 pub fn is_finished(&self) -> bool {
96 self.position >= self.data.len()
97 }
98}
99
100pub struct BatchProcessingManager {
102 active_jobs: Arc<RwLock<HashMap<Uuid, BatchJob>>>,
104 config: BatchProcessingConfig,
106 cache_manager: Arc<CacheManager>,
108 semaphore: Arc<Semaphore>,
110 persistence_dir: PathBuf,
112}
113
114#[derive(Debug, Clone)]
116pub struct BatchProcessingConfig {
117 pub max_workers: usize,
119 pub chunk_size: usize,
121 pub enable_incremental: bool,
123 pub checkpoint_frequency: usize,
125 pub enable_resume: bool,
127 pub max_memory_per_worker_mb: usize,
129 pub enable_notifications: bool,
131 pub retry_config: RetryConfig,
133 pub output_config: OutputConfig,
135}
136
137impl Default for BatchProcessingConfig {
138 fn default() -> Self {
139 Self {
140 max_workers: std::thread::available_parallelism()
141 .map(|n| n.get())
142 .unwrap_or(1),
143 chunk_size: 1000,
144 enable_incremental: true,
145 checkpoint_frequency: 10,
146 enable_resume: true,
147 max_memory_per_worker_mb: 512,
148 enable_notifications: true,
149 retry_config: RetryConfig::default(),
150 output_config: OutputConfig::default(),
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct RetryConfig {
158 pub max_retries: usize,
160 pub initial_backoff_ms: u64,
162 pub max_backoff_ms: u64,
164 pub backoff_multiplier: f64,
166}
167
168impl Default for RetryConfig {
169 fn default() -> Self {
170 Self {
171 max_retries: 3,
172 initial_backoff_ms: 1000,
173 max_backoff_ms: 30000,
174 backoff_multiplier: 2.0,
175 }
176 }
177}
178
179#[derive(Debug, Clone)]
181pub struct OutputConfig {
182 pub format: OutputFormat,
184 pub compression_level: u32,
186 pub include_metadata: bool,
188 pub batch_output: bool,
190 pub max_entities_per_file: usize,
192}
193
194impl Default for OutputConfig {
195 fn default() -> Self {
196 Self {
197 format: OutputFormat::Parquet,
198 compression_level: 6,
199 include_metadata: true,
200 batch_output: true,
201 max_entities_per_file: 100_000,
202 }
203 }
204}
205
206#[derive(Debug, Clone)]
208pub enum OutputFormat {
209 Parquet,
211 JsonLines,
213 Binary,
215 HDF5,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct BatchJob {
222 pub job_id: Uuid,
224 pub name: String,
226 pub status: JobStatus,
228 pub input: BatchInput,
230 pub output: BatchOutput,
232 pub config: BatchJobConfig,
234 pub model_id: Uuid,
236 pub created_at: DateTime<Utc>,
238 pub started_at: Option<DateTime<Utc>>,
240 pub completed_at: Option<DateTime<Utc>>,
242 pub progress: JobProgress,
244 pub error: Option<String>,
246 pub checkpoint: Option<JobCheckpoint>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
252pub enum JobStatus {
253 Pending,
254 Running,
255 Completed,
256 Failed,
257 Cancelled,
258 Paused,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct BatchInput {
264 pub input_type: InputType,
266 pub source: String,
268 pub filters: Option<HashMap<String, String>>,
270 pub incremental: Option<IncrementalConfig>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub enum InputType {
277 EntityList,
279 EntityFile,
281 SparqlQuery,
283 DatabaseQuery,
285 StreamSource,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct IncrementalConfig {
292 pub enabled: bool,
294 pub last_processed: Option<DateTime<Utc>>,
296 pub timestamp_field: String,
298 pub check_deletions: bool,
300 pub existing_embeddings_path: Option<String>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct BatchOutput {
307 pub path: String,
309 pub format: String,
311 pub compression: Option<String>,
313 pub partitioning: Option<PartitioningStrategy>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub enum PartitioningStrategy {
320 None,
322 ByEntityType,
324 ByDate,
326 ByHash { num_partitions: usize },
328 Custom { field: String },
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct BatchJobConfig {
335 pub chunk_size: usize,
337 pub num_workers: usize,
339 pub max_retries: usize,
341 pub use_cache: bool,
343 pub custom_params: HashMap<String, String>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct JobProgress {
350 pub total_entities: usize,
352 pub processed_entities: usize,
354 pub failed_entities: usize,
356 pub current_chunk: usize,
358 pub total_chunks: usize,
360 pub processing_rate: f64,
362 pub eta_seconds: Option<u64>,
364 pub memory_usage_mb: f64,
366}
367
368impl Default for JobProgress {
369 fn default() -> Self {
370 Self {
371 total_entities: 0,
372 processed_entities: 0,
373 failed_entities: 0,
374 current_chunk: 0,
375 total_chunks: 0,
376 processing_rate: 0.0,
377 eta_seconds: None,
378 memory_usage_mb: 0.0,
379 }
380 }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct JobCheckpoint {
386 pub timestamp: DateTime<Utc>,
388 pub last_processed_index: usize,
390 pub processed_entities: HashSet<String>,
392 pub failed_entities: HashMap<String, String>,
394 pub intermediate_results_path: String,
396 pub model_state_hash: String,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct BatchProcessingResult {
403 pub job_id: Uuid,
405 pub stats: BatchProcessingStats,
407 pub output_info: OutputInfo,
409 pub quality_metrics: Option<QualityMetrics>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct BatchProcessingStats {
416 pub total_time_seconds: f64,
418 pub total_entities: usize,
420 pub successful_embeddings: usize,
422 pub failed_embeddings: usize,
424 pub cache_hits: usize,
426 pub cache_misses: usize,
428 pub avg_time_per_entity_ms: f64,
430 pub peak_memory_mb: f64,
432 pub cpu_utilization: f64,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct OutputInfo {
439 pub output_files: Vec<String>,
441 pub total_size_bytes: u64,
443 pub compression_ratio: f64,
445 pub num_partitions: usize,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct QualityMetrics {
452 pub avg_embedding_norm: f64,
454 pub embedding_norm_std: f64,
456 pub avg_cosine_similarity: f64,
458 pub embedding_dimension: usize,
460 pub zero_embeddings: usize,
462 pub nan_embeddings: usize,
464}
465
466impl BatchProcessingManager {
467 pub fn new(
469 config: BatchProcessingConfig,
470 cache_manager: Arc<CacheManager>,
471 persistence_dir: PathBuf,
472 ) -> Self {
473 Self {
474 active_jobs: Arc::new(RwLock::new(HashMap::new())),
475 semaphore: Arc::new(Semaphore::new(config.max_workers)),
476 config,
477 cache_manager,
478 persistence_dir,
479 }
480 }
481
482 pub async fn submit_job(&self, job: BatchJob) -> Result<Uuid> {
484 let job_id = job.job_id;
485
486 self.validate_job(&job).await?;
488
489 {
491 let mut jobs = self.active_jobs.write().await;
492 jobs.insert(job_id, job.clone());
493 }
494
495 self.persist_job(&job).await?;
497
498 info!("Submitted batch job: {} ({})", job.name, job_id);
499 Ok(job_id)
500 }
501
502 pub async fn start_job(
504 &self,
505 job_id: Uuid,
506 model: Arc<dyn EmbeddingModel + Send + Sync>,
507 ) -> Result<JoinHandle<Result<BatchProcessingResult>>> {
508 let job = {
509 let mut jobs = self.active_jobs.write().await;
510 let job = jobs
511 .get_mut(&job_id)
512 .ok_or_else(|| anyhow!("Job not found: {}", job_id))?;
513
514 if !matches!(job.status, JobStatus::Pending | JobStatus::Paused) {
515 return Err(anyhow!("Job {} is not in a startable state", job_id));
516 }
517
518 job.status = JobStatus::Running;
519 job.started_at = Some(Utc::now());
520 job.clone()
521 };
522
523 let manager = self.clone();
524 let handle = tokio::spawn(async move { manager.process_job(job, model).await });
525
526 Ok(handle)
527 }
528
529 async fn process_job(
531 &self,
532 job: BatchJob,
533 model: Arc<dyn EmbeddingModel + Send + Sync>,
534 ) -> Result<BatchProcessingResult> {
535 let start_time = Instant::now();
536 info!(
537 "Starting batch job processing: {} ({})",
538 job.name, job.job_id
539 );
540
541 let entities = self.load_entities(&job).await?;
543
544 let entities_to_process = if job
546 .input
547 .incremental
548 .as_ref()
549 .map(|inc| inc.enabled)
550 .unwrap_or(false)
551 {
552 self.filter_incremental_entities(&job, entities).await?
553 } else {
554 entities
555 };
556
557 {
559 let mut jobs = self.active_jobs.write().await;
560 if let Some(active_job) = jobs.get_mut(&job.job_id) {
561 active_job.progress.total_entities = entities_to_process.len();
562 active_job.progress.total_chunks =
563 (entities_to_process.len() + job.config.chunk_size - 1) / job.config.chunk_size;
564 }
565 }
566
567 let chunks: Vec<_> = entities_to_process
569 .chunks(job.config.chunk_size)
570 .map(|chunk| chunk.to_vec())
571 .collect();
572
573 let mut successful_embeddings = 0;
574 let mut failed_embeddings = 0;
575 let mut cache_hits = 0;
576 let mut cache_misses = 0;
577 let mut processed_entities = HashSet::new();
578 let mut failed_entities = HashMap::new();
579
580 for (chunk_idx, chunk) in chunks.iter().enumerate() {
581 {
583 let jobs = self.active_jobs.read().await;
584 if let Some(active_job) = jobs.get(&job.job_id) {
585 if matches!(active_job.status, JobStatus::Cancelled) {
586 info!("Job {} was cancelled", job.job_id);
587 return Err(anyhow!("Job was cancelled"));
588 }
589 }
590 }
591
592 let chunk_result = self
594 .process_chunk(&job, chunk, chunk_idx, model.clone())
595 .await?;
596
597 successful_embeddings += chunk_result.successful;
599 failed_embeddings += chunk_result.failed;
600 cache_hits += chunk_result.cache_hits;
601 cache_misses += chunk_result.cache_misses;
602
603 for entity in chunk {
605 processed_entities.insert(entity.clone());
606 }
607 for (entity, error) in chunk_result.failures {
608 failed_entities.insert(entity, error);
609 }
610
611 self.update_job_progress(
613 &job.job_id,
614 chunk_idx + 1,
615 successful_embeddings + failed_embeddings,
616 )
617 .await?;
618
619 if chunk_idx % self.config.checkpoint_frequency == 0 {
621 self.create_checkpoint(&job.job_id, &processed_entities, &failed_entities)
622 .await?;
623 }
624
625 info!(
626 "Processed chunk {}/{} for job {}",
627 chunk_idx + 1,
628 chunks.len(),
629 job.job_id
630 );
631 }
632
633 let processing_time = start_time.elapsed().as_secs_f64();
635 let result = self
636 .finalize_job_processing(
637 &job,
638 processing_time,
639 successful_embeddings,
640 failed_embeddings,
641 cache_hits,
642 cache_misses,
643 )
644 .await?;
645
646 {
648 let mut jobs = self.active_jobs.write().await;
649 if let Some(active_job) = jobs.get_mut(&job.job_id) {
650 active_job.status = JobStatus::Completed;
651 active_job.completed_at = Some(Utc::now());
652 }
653 }
654
655 info!(
656 "Completed batch job: {} in {:.2}s",
657 job.job_id, processing_time
658 );
659 Ok(result)
660 }
661
662 async fn process_chunk(
664 &self,
665 job: &BatchJob,
666 entities: &[String],
667 chunk_idx: usize,
668 model: Arc<dyn EmbeddingModel + Send + Sync>,
669 ) -> Result<ChunkResult> {
670 let _permit = self.semaphore.acquire().await?;
671
672 let mut successful = 0;
673 let mut failed = 0;
674 let mut cache_hits = 0;
675 let mut cache_misses = 0;
676 let mut failures = HashMap::new();
677
678 for entity in entities {
679 match self
680 .process_single_entity(entity, model.clone(), job.config.use_cache)
681 .await
682 {
683 Ok(from_cache) => {
684 successful += 1;
685 if from_cache {
686 cache_hits += 1;
687 } else {
688 cache_misses += 1;
689 }
690 }
691 Err(e) => {
692 failed += 1;
693 failures.insert(entity.clone(), e.to_string());
694 warn!("Failed to process entity {}: {}", entity, e);
695 }
696 }
697 }
698
699 Ok(ChunkResult {
700 chunk_idx,
701 successful,
702 failed,
703 cache_hits,
704 cache_misses,
705 failures,
706 })
707 }
708
709 async fn process_single_entity(
711 &self,
712 entity: &str,
713 model: Arc<dyn EmbeddingModel + Send + Sync>,
714 use_cache: bool,
715 ) -> Result<bool> {
716 if use_cache {
717 if let Some(_embedding) = self.cache_manager.get_embedding(entity) {
719 return Ok(true);
720 }
721 }
722
723 let embedding = model.get_entity_embedding(entity)?;
725
726 if use_cache {
728 self.cache_manager
729 .put_embedding(entity.to_string(), embedding);
730 }
731
732 Ok(false)
733 }
734
735 async fn load_entities(&self, job: &BatchJob) -> Result<Vec<String>> {
737 match &job.input.input_type {
738 InputType::EntityList => {
739 let entities: Vec<String> = serde_json::from_str(&job.input.source)?;
741 Ok(entities)
742 }
743 InputType::EntityFile => {
744 let content = fs::read_to_string(&job.input.source).await?;
746 let entities: Vec<String> = content
747 .lines()
748 .map(|line| line.trim().to_string())
749 .filter(|line| !line.is_empty())
750 .collect();
751 Ok(entities)
752 }
753 InputType::SparqlQuery => {
754 Err(anyhow::anyhow!(
760 "BatchJob input type SparqlQuery is not yet implemented: no SPARQL engine \
761 handle is wired into BatchProcessingManager"
762 ))
763 }
764 InputType::DatabaseQuery => Err(anyhow::anyhow!(
765 "BatchJob input type DatabaseQuery is not yet implemented: no database \
766 connection is wired into BatchProcessingManager"
767 )),
768 InputType::StreamSource => Err(anyhow::anyhow!(
769 "BatchJob input type StreamSource is not yet implemented: no streaming \
770 source reader is wired into BatchProcessingManager"
771 )),
772 }
773 }
774
775 async fn filter_incremental_entities(
777 &self,
778 job: &BatchJob,
779 entities: Vec<String>,
780 ) -> Result<Vec<String>> {
781 if let Some(incremental) = &job.input.incremental {
782 if !incremental.enabled {
783 return Ok(entities);
784 }
785
786 let existing_entities =
788 if let Some(existing_path) = &incremental.existing_embeddings_path {
789 self.load_existing_entities(existing_path).await?
790 } else {
791 HashSet::new()
792 };
793
794 let filtered: Vec<String> = entities
796 .into_iter()
797 .filter(|entity| !existing_entities.contains(entity))
798 .collect();
799
800 info!(
801 "Incremental filtering: {} entities remaining after filtering",
802 filtered.len()
803 );
804 Ok(filtered)
805 } else {
806 Ok(entities)
807 }
808 }
809
810 async fn load_existing_entities(&self, path: &str) -> Result<HashSet<String>> {
812 if Path::new(path).exists() {
815 let content = fs::read_to_string(path).await?;
816 let entities: HashSet<String> = content
817 .lines()
818 .map(|line| line.trim().to_string())
819 .filter(|line| !line.is_empty())
820 .collect();
821 Ok(entities)
822 } else {
823 Ok(HashSet::new())
824 }
825 }
826
827 async fn update_job_progress(
829 &self,
830 job_id: &Uuid,
831 current_chunk: usize,
832 processed_entities: usize,
833 ) -> Result<()> {
834 let mut jobs = self.active_jobs.write().await;
835 if let Some(job) = jobs.get_mut(job_id) {
836 job.progress.current_chunk = current_chunk;
837 job.progress.processed_entities = processed_entities;
838
839 if let Some(started_at) = job.started_at {
841 let elapsed = Utc::now().signed_duration_since(started_at);
842 let elapsed_seconds = elapsed.num_seconds() as f64;
843 if elapsed_seconds > 0.0 {
844 job.progress.processing_rate = processed_entities as f64 / elapsed_seconds;
845
846 let remaining_entities = job.progress.total_entities - processed_entities;
848 if job.progress.processing_rate > 0.0 {
849 let eta = remaining_entities as f64 / job.progress.processing_rate;
850 job.progress.eta_seconds = Some(eta as u64);
851 }
852 }
853 }
854 }
855 Ok(())
856 }
857
858 async fn create_checkpoint(
860 &self,
861 job_id: &Uuid,
862 processed_entities: &HashSet<String>,
863 failed_entities: &HashMap<String, String>,
864 ) -> Result<()> {
865 let checkpoint = JobCheckpoint {
866 timestamp: Utc::now(),
867 last_processed_index: processed_entities.len(),
868 processed_entities: processed_entities.clone(),
869 failed_entities: failed_entities.clone(),
870 intermediate_results_path: format!(
871 "{}/checkpoint_{}.json",
872 self.persistence_dir.display(),
873 job_id
874 ),
875 model_state_hash: "placeholder".to_string(), };
877
878 let checkpoint_path = self
880 .persistence_dir
881 .join(format!("checkpoint_{job_id}.json"));
882 let checkpoint_json = serde_json::to_string_pretty(&checkpoint)?;
883 fs::write(checkpoint_path, checkpoint_json).await?;
884
885 let mut jobs = self.active_jobs.write().await;
887 if let Some(job) = jobs.get_mut(job_id) {
888 job.checkpoint = Some(checkpoint);
889 }
890
891 debug!("Created checkpoint for job {}", job_id);
892 Ok(())
893 }
894
895 async fn finalize_job_processing(
897 &self,
898 job: &BatchJob,
899 processing_time: f64,
900 successful_embeddings: usize,
901 failed_embeddings: usize,
902 cache_hits: usize,
903 cache_misses: usize,
904 ) -> Result<BatchProcessingResult> {
905 let total_entities = successful_embeddings + failed_embeddings;
906 let avg_time_per_entity_ms = if total_entities > 0 {
907 (processing_time * 1000.0) / total_entities as f64
908 } else {
909 0.0
910 };
911
912 let stats = BatchProcessingStats {
913 total_time_seconds: processing_time,
914 total_entities,
915 successful_embeddings,
916 failed_embeddings,
917 cache_hits,
918 cache_misses,
919 avg_time_per_entity_ms,
920 peak_memory_mb: 0.0, cpu_utilization: 0.0, };
923
924 let output_info = OutputInfo {
925 output_files: vec![job.output.path.clone()],
926 total_size_bytes: 0, compression_ratio: 1.0,
928 num_partitions: 1,
929 };
930
931 Ok(BatchProcessingResult {
932 job_id: job.job_id,
933 stats,
934 output_info,
935 quality_metrics: None, })
937 }
938
939 async fn validate_job(&self, job: &BatchJob) -> Result<()> {
941 if let InputType::EntityFile = &job.input.input_type {
943 if !Path::new(&job.input.source).exists() {
944 return Err(anyhow!("Input file does not exist: {}", job.input.source));
945 }
946 } if let Some(parent) = Path::new(&job.output.path).parent() {
950 if !parent.exists() {
951 fs::create_dir_all(parent).await?;
952 }
953 }
954
955 Ok(())
956 }
957
958 async fn persist_job(&self, job: &BatchJob) -> Result<()> {
960 let job_path = self
961 .persistence_dir
962 .join(format!("job_{}.json", job.job_id));
963 let job_json = serde_json::to_string_pretty(job)?;
964 fs::write(job_path, job_json).await?;
965 Ok(())
966 }
967
968 pub async fn get_job_status(&self, job_id: &Uuid) -> Option<JobStatus> {
970 let jobs = self.active_jobs.read().await;
971 jobs.get(job_id).map(|job| job.status.clone())
972 }
973
974 pub async fn get_job_progress(&self, job_id: &Uuid) -> Option<JobProgress> {
976 let jobs = self.active_jobs.read().await;
977 jobs.get(job_id).map(|job| job.progress.clone())
978 }
979
980 pub async fn cancel_job(&self, job_id: &Uuid) -> Result<()> {
982 let mut jobs = self.active_jobs.write().await;
983 if let Some(job) = jobs.get_mut(job_id) {
984 job.status = JobStatus::Cancelled;
985 info!("Cancelled job: {}", job_id);
986 Ok(())
987 } else {
988 Err(anyhow!("Job not found: {}", job_id))
989 }
990 }
991
992 pub async fn list_jobs(&self) -> Vec<BatchJob> {
994 let jobs = self.active_jobs.read().await;
995 jobs.values().cloned().collect()
996 }
997}
998
999impl Clone for BatchProcessingManager {
1000 fn clone(&self) -> Self {
1001 Self {
1002 active_jobs: Arc::clone(&self.active_jobs),
1003 config: self.config.clone(),
1004 cache_manager: Arc::clone(&self.cache_manager),
1005 semaphore: Arc::clone(&self.semaphore),
1006 persistence_dir: self.persistence_dir.clone(),
1007 }
1008 }
1009}
1010
1011#[derive(Debug)]
1013#[allow(dead_code)]
1014struct ChunkResult {
1015 chunk_idx: usize,
1016 successful: usize,
1017 failed: usize,
1018 cache_hits: usize,
1019 cache_misses: usize,
1020 failures: HashMap<String, String>,
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026 use tempfile::tempdir;
1027
1028 #[test]
1029 fn test_batch_job_creation() {
1030 let job = BatchJob {
1031 job_id: Uuid::new_v4(),
1032 name: "test_job".to_string(),
1033 status: JobStatus::Pending,
1034 input: BatchInput {
1035 input_type: InputType::EntityList,
1036 source: r#"["entity1", "entity2", "entity3"]"#.to_string(),
1037 filters: None,
1038 incremental: None,
1039 },
1040 output: BatchOutput {
1041 path: std::env::temp_dir()
1042 .join(format!("oxirs_batch_out_{}", std::process::id()))
1043 .display()
1044 .to_string(),
1045 format: "parquet".to_string(),
1046 compression: Some("gzip".to_string()),
1047 partitioning: Some(PartitioningStrategy::None),
1048 },
1049 config: BatchJobConfig {
1050 chunk_size: 100,
1051 num_workers: 4,
1052 max_retries: 3,
1053 use_cache: true,
1054 custom_params: HashMap::new(),
1055 },
1056 model_id: Uuid::new_v4(),
1057 created_at: Utc::now(),
1058 started_at: None,
1059 completed_at: None,
1060 progress: JobProgress::default(),
1061 error: None,
1062 checkpoint: None,
1063 };
1064
1065 assert_eq!(job.status, JobStatus::Pending);
1066 assert_eq!(job.name, "test_job");
1067 }
1068
1069 #[tokio::test]
1070 async fn test_batch_processing_manager_creation() {
1071 let config = BatchProcessingConfig::default();
1072 let cache_config = crate::CacheConfig::default();
1073 let cache_manager = Arc::new(CacheManager::new(cache_config));
1074 let temp_dir = tempdir().expect("should succeed");
1075
1076 let manager =
1077 BatchProcessingManager::new(config, cache_manager, temp_dir.path().to_path_buf());
1078
1079 assert_eq!(
1080 manager.config.max_workers,
1081 std::thread::available_parallelism()
1082 .map(|n| n.get())
1083 .unwrap_or(1)
1084 );
1085 assert_eq!(manager.config.chunk_size, 1000);
1086 }
1087
1088 fn make_test_job(input_type: InputType, source: &str) -> BatchJob {
1089 BatchJob {
1090 job_id: Uuid::new_v4(),
1091 name: "test_job".to_string(),
1092 status: JobStatus::Pending,
1093 input: BatchInput {
1094 input_type,
1095 source: source.to_string(),
1096 filters: None,
1097 incremental: None,
1098 },
1099 output: BatchOutput {
1100 path: std::env::temp_dir()
1101 .join(format!("oxirs_batch_out_{}", Uuid::new_v4()))
1102 .display()
1103 .to_string(),
1104 format: "parquet".to_string(),
1105 compression: None,
1106 partitioning: Some(PartitioningStrategy::None),
1107 },
1108 config: BatchJobConfig {
1109 chunk_size: 100,
1110 num_workers: 1,
1111 max_retries: 1,
1112 use_cache: false,
1113 custom_params: HashMap::new(),
1114 },
1115 model_id: Uuid::new_v4(),
1116 created_at: Utc::now(),
1117 started_at: None,
1118 completed_at: None,
1119 progress: JobProgress::default(),
1120 error: None,
1121 checkpoint: None,
1122 }
1123 }
1124
1125 #[tokio::test]
1128 async fn test_load_entities_unimplemented_input_types_error() {
1129 let config = BatchProcessingConfig::default();
1130 let cache_manager = Arc::new(CacheManager::new(crate::CacheConfig::default()));
1131 let temp_dir = tempdir().expect("should succeed");
1132 let manager =
1133 BatchProcessingManager::new(config, cache_manager, temp_dir.path().to_path_buf());
1134
1135 for input_type in [
1136 InputType::SparqlQuery,
1137 InputType::DatabaseQuery,
1138 InputType::StreamSource,
1139 ] {
1140 let job = make_test_job(input_type, "irrelevant source");
1141 let result = manager.load_entities(&job).await;
1142 assert!(
1143 result.is_err(),
1144 "expected an error for unimplemented input type"
1145 );
1146 }
1147 }
1148
1149 #[tokio::test]
1152 async fn test_load_entities_entity_list_still_works() {
1153 let config = BatchProcessingConfig::default();
1154 let cache_manager = Arc::new(CacheManager::new(crate::CacheConfig::default()));
1155 let temp_dir = tempdir().expect("should succeed");
1156 let manager =
1157 BatchProcessingManager::new(config, cache_manager, temp_dir.path().to_path_buf());
1158
1159 let job = make_test_job(InputType::EntityList, r#"["e1", "e2"]"#);
1160 let entities = manager.load_entities(&job).await.expect("should succeed");
1161 assert_eq!(entities, vec!["e1".to_string(), "e2".to_string()]);
1162 }
1163
1164 #[test]
1165 fn test_incremental_config() {
1166 let incremental = IncrementalConfig {
1167 enabled: true,
1168 last_processed: Some(Utc::now()),
1169 timestamp_field: "updated_at".to_string(),
1170 check_deletions: true,
1171 existing_embeddings_path: Some("/path/to/existing".to_string()),
1172 };
1173
1174 assert!(incremental.enabled);
1175 assert!(incremental.last_processed.is_some());
1176 assert_eq!(incremental.timestamp_field, "updated_at");
1177 }
1178
1179 #[test]
1180 fn test_memory_optimized_batch_iterator() {
1181 let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
1182 let mut iterator = MemoryOptimizedBatchIterator::new(data.clone(), 3, 1); let batch1 = iterator.next_batch().expect("should succeed");
1186 assert_eq!(batch1.len(), 3);
1187 assert_eq!(batch1, vec![1, 2, 3]);
1188 assert_eq!(iterator.get_progress(), 0.3);
1189 assert!(!iterator.is_finished());
1190
1191 let batch2 = iterator.next_batch().expect("should succeed");
1193 assert_eq!(batch2.len(), 3);
1194 assert_eq!(batch2, vec![4, 5, 6]);
1195 assert_eq!(iterator.get_progress(), 0.6);
1196
1197 let batch3 = iterator.next_batch().expect("should succeed");
1199 assert_eq!(batch3.len(), 3);
1200 assert_eq!(batch3, vec![7, 8, 9]);
1201 assert_eq!(iterator.get_progress(), 0.9);
1202
1203 let batch4 = iterator.next_batch().expect("should succeed");
1205 assert_eq!(batch4.len(), 1);
1206 assert_eq!(batch4, vec![10]);
1207 assert_eq!(iterator.get_progress(), 1.0);
1208 assert!(iterator.is_finished());
1209
1210 let batch5 = iterator.next_batch();
1212 assert!(batch5.is_none());
1213 }
1214
1215 #[test]
1216 fn test_memory_optimized_batch_iterator_empty() {
1217 let data: Vec<i32> = vec![];
1218 let mut iterator = MemoryOptimizedBatchIterator::new(data, 3, 1);
1219
1220 assert_eq!(iterator.get_progress(), 1.0);
1221 assert!(iterator.is_finished());
1222 assert!(iterator.next_batch().is_none());
1223 }
1224
1225 #[test]
1226 fn test_memory_optimized_batch_iterator_single_item() {
1227 let data = vec![42];
1228 let mut iterator = MemoryOptimizedBatchIterator::new(data, 5, 1);
1229
1230 let batch = iterator.next_batch().expect("should succeed");
1231 assert_eq!(batch.len(), 1);
1232 assert_eq!(batch[0], 42);
1233 assert_eq!(iterator.get_progress(), 1.0);
1234 assert!(iterator.is_finished());
1235 }
1236
1237 #[test]
1238 fn test_memory_optimized_batch_iterator_memory_tracking() {
1239 let data = vec![1, 2, 3, 4, 5];
1240 let mut iterator = MemoryOptimizedBatchIterator::new(data, 3, 1);
1241
1242 let _batch = iterator.next_batch().expect("should succeed");
1244 let memory_usage = iterator.get_memory_usage();
1245 assert!(memory_usage > 0);
1246
1247 let expected_memory = 3 * std::mem::size_of::<i32>();
1249 assert_eq!(memory_usage, expected_memory);
1250 }
1251
1252 #[test]
1253 fn test_parallel_batch_processor() {
1254 let processor =
1256 ParallelBatchProcessor::new(ParallelBatchConfig::default()).expect("should succeed");
1257 assert!(processor.num_workers() > 0);
1259 assert!(
1260 processor.num_workers()
1261 <= std::thread::available_parallelism()
1262 .map(|n| n.get())
1263 .unwrap_or(1)
1264 );
1265 }
1266}
1267
1268pub struct ParallelBatchProcessor {
1276 config: ParallelBatchConfig,
1277}
1278
1279#[derive(Debug, Clone, Serialize, Deserialize)]
1281pub struct ParallelBatchConfig {
1282 pub num_workers: usize,
1284 pub chunk_size: usize,
1286 pub adaptive_balancing: bool,
1288 pub memory_threshold_mb: usize,
1290 pub numa_aware: bool,
1292 pub work_stealing: bool,
1294}
1295
1296impl Default for ParallelBatchConfig {
1297 fn default() -> Self {
1298 Self {
1299 num_workers: std::thread::available_parallelism()
1300 .map(|n| n.get())
1301 .unwrap_or(1),
1302 chunk_size: 1000,
1303 adaptive_balancing: true,
1304 memory_threshold_mb: 512,
1305 numa_aware: true,
1306 work_stealing: true,
1307 }
1308 }
1309}
1310
1311impl ParallelBatchProcessor {
1312 pub fn new(config: ParallelBatchConfig) -> Result<Self> {
1314 rayon::ThreadPoolBuilder::new()
1316 .num_threads(config.num_workers)
1317 .build_global()
1318 .ok(); Ok(Self { config })
1321 }
1322
1323 pub fn num_workers(&self) -> usize {
1325 self.config.num_workers
1326 }
1327
1328 pub fn process_parallel<T, F, R>(&self, items: Vec<T>, process_fn: F) -> Result<Vec<R>>
1330 where
1331 T: Send + Sync,
1332 F: Fn(&T) -> R + Send + Sync,
1333 R: Send,
1334 {
1335 let results: Vec<R> = items.par_iter().map(process_fn).collect();
1337
1338 Ok(results)
1339 }
1340
1341 pub fn process_with_load_balancing<T, F, R>(
1348 &self,
1349 items: Vec<T>,
1350 process_fn: F,
1351 ) -> Result<Vec<R>>
1352 where
1353 T: Send + Sync,
1354 F: Fn(&T) -> R + Send + Sync,
1355 R: Send,
1356 {
1357 let results: Vec<R> = items.par_iter().map(process_fn).collect();
1359
1360 Ok(results)
1361 }
1362
1363 pub fn process_memory_efficient<T, F, R>(&self, items: Vec<T>, process_fn: F) -> Result<Vec<R>>
1370 where
1371 T: Send + Sync,
1372 F: Fn(&T) -> R + Send + Sync,
1373 R: Send,
1374 {
1375 let chunk_size =
1377 (self.config.memory_threshold_mb * 1024 * 1024) / (std::mem::size_of::<T>().max(1));
1378
1379 let chunk_size = chunk_size.min(self.config.chunk_size).max(100);
1380
1381 let results: Vec<R> = items
1382 .par_chunks(chunk_size)
1383 .flat_map(|chunk| chunk.iter().map(&process_fn).collect::<Vec<_>>())
1384 .collect();
1385
1386 Ok(results)
1387 }
1388
1389 pub fn process_nested_parallel<T, F, R>(
1396 &self,
1397 items: Vec<Vec<T>>,
1398 process_fn: F,
1399 ) -> Result<Vec<Vec<R>>>
1400 where
1401 T: Send + Sync,
1402 F: Fn(&T) -> R + Send + Sync,
1403 R: Send,
1404 {
1405 let results: Vec<Vec<R>> = items
1406 .par_iter()
1407 .map(|batch| batch.iter().map(&process_fn).collect())
1408 .collect();
1409
1410 Ok(results)
1411 }
1412
1413 pub fn get_stats(&self) -> ParallelProcessingStats {
1415 ParallelProcessingStats {
1416 num_workers: self.config.num_workers,
1417 profiler_report: "Stats available".to_string(),
1418 memory_usage: 0,
1419 }
1420 }
1421}
1422
1423#[derive(Debug, Clone)]
1425pub struct ParallelProcessingStats {
1426 pub num_workers: usize,
1427 pub profiler_report: String,
1428 pub memory_usage: usize,
1429}