Skip to main content

oxirs_shacl/validation/
distributed.rs

1//! Distributed SHACL Validation
2//!
3//! This module provides distributed validation capabilities for scaling SHACL
4//! validation across multiple nodes in a cluster. It enables:
5//!
6//! - Partition-based validation distribution
7//! - Load balancing across validators
8//! - Fault tolerance and retry mechanisms
9//! - Result aggregation and consistency
10//!
11//! # Architecture
12//!
13//! The distributed validation system uses a coordinator-worker pattern:
14//! - Coordinator: Partitions work, distributes to workers, aggregates results
15//! - Workers: Execute validation on assigned partitions
16//!
17//! # Communication
18//!
19//! Supports multiple communication backends:
20//! - In-memory (for testing and single-node)
21//! - gRPC (recommended for production)
22//! - HTTP/REST
23
24use crate::{Shape, ShapeId, ValidationConfig, ValidationReport, ValidationViolation};
25use indexmap::IndexMap;
26use oxirs_core::model::Term;
27use scirs2_core::random::{rng, RngExt};
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::sync::Arc;
31use std::time::{Duration, Instant};
32use tokio::sync::RwLock;
33
34/// Configuration for distributed validation
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DistributedValidationConfig {
37    /// Number of partitions to create
38    pub num_partitions: usize,
39    /// Replication factor for fault tolerance
40    pub replication_factor: usize,
41    /// Timeout for worker responses
42    pub worker_timeout: Duration,
43    /// Maximum retries for failed partitions
44    pub max_retries: usize,
45    /// Load balancing strategy
46    pub load_balancing: LoadBalancingStrategy,
47    /// Enable result caching
48    pub enable_caching: bool,
49    /// Cache TTL
50    pub cache_ttl: Duration,
51    /// Consistency level
52    pub consistency_level: ConsistencyLevel,
53    /// Enable distributed tracing
54    pub enable_tracing: bool,
55}
56
57impl Default for DistributedValidationConfig {
58    fn default() -> Self {
59        Self {
60            num_partitions: 10,
61            replication_factor: 2,
62            worker_timeout: Duration::from_secs(30),
63            max_retries: 3,
64            load_balancing: LoadBalancingStrategy::RoundRobin,
65            enable_caching: true,
66            cache_ttl: Duration::from_secs(300),
67            consistency_level: ConsistencyLevel::Eventual,
68            enable_tracing: false,
69        }
70    }
71}
72
73/// Load balancing strategies
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub enum LoadBalancingStrategy {
76    /// Round-robin distribution
77    RoundRobin,
78    /// Distribute based on current load
79    LeastLoaded,
80    /// Hash-based consistent distribution
81    ConsistentHashing,
82    /// Random distribution
83    Random,
84}
85
86/// Consistency levels for distributed validation
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub enum ConsistencyLevel {
89    /// Best effort, no guarantees
90    BestEffort,
91    /// Eventually consistent
92    Eventual,
93    /// All replicas must agree
94    Strong,
95}
96
97/// Distributed validation coordinator
98pub struct DistributedValidator {
99    /// Configuration
100    config: DistributedValidationConfig,
101    /// Registered workers
102    workers: Arc<RwLock<Vec<WorkerInfo>>>,
103    /// Validation shapes
104    shapes: Arc<RwLock<IndexMap<ShapeId, Shape>>>,
105    /// Validation configuration
106    _validation_config: ValidationConfig,
107    /// Statistics
108    stats: Arc<RwLock<DistributedStats>>,
109    /// Result cache
110    cache: Arc<RwLock<HashMap<String, CachedResult>>>,
111}
112
113/// Information about a worker node
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct WorkerInfo {
116    /// Worker ID
117    pub worker_id: String,
118    /// Worker address
119    pub address: String,
120    /// Current load (0.0 to 1.0)
121    pub current_load: f64,
122    /// Health status
123    pub healthy: bool,
124    /// Last heartbeat time
125    pub last_heartbeat: chrono::DateTime<chrono::Utc>,
126    /// Total validations processed
127    pub validations_processed: usize,
128    /// Average response time in milliseconds
129    pub avg_response_time_ms: u64,
130}
131
132/// Cached validation result
133#[derive(Debug, Clone)]
134struct CachedResult {
135    report: ValidationReport,
136    created: Instant,
137}
138
139/// Statistics for distributed validation
140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141pub struct DistributedStats {
142    /// Total validations distributed
143    pub total_distributed: usize,
144    /// Total partitions created
145    pub total_partitions: usize,
146    /// Failed partitions
147    pub failed_partitions: usize,
148    /// Retried partitions
149    pub retried_partitions: usize,
150    /// Cache hits
151    pub cache_hits: usize,
152    /// Cache misses
153    pub cache_misses: usize,
154    /// Average distribution time
155    pub avg_distribution_time_ms: f64,
156    /// Average aggregation time
157    pub avg_aggregation_time_ms: f64,
158}
159
160/// A partition of focus nodes to validate
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ValidationPartition {
163    /// Partition ID
164    pub partition_id: usize,
165    /// Focus nodes in this partition
166    pub focus_nodes: Vec<Term>,
167    /// Assigned worker
168    pub assigned_worker: Option<String>,
169    /// Retry count
170    pub retry_count: usize,
171}
172
173/// Result from a worker
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct PartitionResult {
176    /// Partition ID
177    pub partition_id: usize,
178    /// Worker ID that processed it
179    pub worker_id: String,
180    /// Validation violations
181    pub violations: Vec<ValidationViolation>,
182    /// Processing time
183    pub processing_time: Duration,
184    /// Success status
185    pub success: bool,
186    /// Error message (if failed)
187    pub error: Option<String>,
188}
189
190impl DistributedValidator {
191    /// Create a new distributed validator
192    pub fn new(
193        shapes: IndexMap<ShapeId, Shape>,
194        config: DistributedValidationConfig,
195        validation_config: ValidationConfig,
196    ) -> Self {
197        Self {
198            config,
199            workers: Arc::new(RwLock::new(Vec::new())),
200            shapes: Arc::new(RwLock::new(shapes)),
201            _validation_config: validation_config,
202            stats: Arc::new(RwLock::new(DistributedStats::default())),
203            cache: Arc::new(RwLock::new(HashMap::new())),
204        }
205    }
206
207    /// Register a worker node
208    pub async fn register_worker(&self, worker: WorkerInfo) {
209        let mut workers = self.workers.write().await;
210        workers.push(worker);
211    }
212
213    /// Unregister a worker node
214    pub async fn unregister_worker(&self, worker_id: &str) {
215        let mut workers = self.workers.write().await;
216        workers.retain(|w| w.worker_id != worker_id);
217    }
218
219    /// Get healthy workers
220    async fn get_healthy_workers(&self) -> Vec<WorkerInfo> {
221        let workers = self.workers.read().await;
222        workers.iter().filter(|w| w.healthy).cloned().collect()
223    }
224
225    /// Validate data distributed across workers
226    pub async fn validate_distributed(
227        &self,
228        focus_nodes: Vec<Term>,
229    ) -> Result<ValidationReport, DistributedError> {
230        let start = Instant::now();
231
232        // Check cache
233        let cache_key = self.compute_cache_key(&focus_nodes);
234        if self.config.enable_caching {
235            if let Some(cached) = self.check_cache(&cache_key).await {
236                let mut stats = self.stats.write().await;
237                stats.cache_hits += 1;
238                return Ok(cached);
239            }
240            let mut stats = self.stats.write().await;
241            stats.cache_misses += 1;
242        }
243
244        // Get healthy workers
245        let workers = self.get_healthy_workers().await;
246        if workers.is_empty() {
247            return Err(DistributedError::NoWorkersAvailable);
248        }
249
250        // Partition the focus nodes
251        let partitions = self.partition_nodes(&focus_nodes).await;
252
253        // Distribute partitions to workers
254        let assigned_partitions = self.assign_partitions(partitions, &workers).await?;
255
256        // Execute validation on workers
257        let results = self.execute_distributed(&assigned_partitions).await?;
258
259        // Aggregate results
260        let report = self.aggregate_results(results).await?;
261
262        // Update stats
263        {
264            let mut stats = self.stats.write().await;
265            stats.total_distributed += 1;
266            let elapsed = start.elapsed().as_millis() as f64;
267            stats.avg_distribution_time_ms =
268                (stats.avg_distribution_time_ms * (stats.total_distributed - 1) as f64 + elapsed)
269                    / stats.total_distributed as f64;
270        }
271
272        // Cache result
273        if self.config.enable_caching {
274            self.cache_result(&cache_key, &report).await;
275        }
276
277        Ok(report)
278    }
279
280    /// Partition focus nodes for distribution
281    async fn partition_nodes(&self, focus_nodes: &[Term]) -> Vec<ValidationPartition> {
282        let num_partitions = self.config.num_partitions.min(focus_nodes.len());
283        let partition_size = (focus_nodes.len() + num_partitions - 1) / num_partitions;
284
285        let mut partitions = Vec::with_capacity(num_partitions);
286
287        for (i, chunk) in focus_nodes.chunks(partition_size).enumerate() {
288            partitions.push(ValidationPartition {
289                partition_id: i,
290                focus_nodes: chunk.to_vec(),
291                assigned_worker: None,
292                retry_count: 0,
293            });
294        }
295
296        // Update stats
297        {
298            let mut stats = self.stats.write().await;
299            stats.total_partitions += partitions.len();
300        }
301
302        partitions
303    }
304
305    /// Assign partitions to workers
306    async fn assign_partitions(
307        &self,
308        mut partitions: Vec<ValidationPartition>,
309        workers: &[WorkerInfo],
310    ) -> Result<Vec<ValidationPartition>, DistributedError> {
311        match self.config.load_balancing {
312            LoadBalancingStrategy::RoundRobin => {
313                for (i, partition) in partitions.iter_mut().enumerate() {
314                    let worker_idx = i % workers.len();
315                    partition.assigned_worker = Some(workers[worker_idx].worker_id.clone());
316                }
317            }
318            LoadBalancingStrategy::LeastLoaded => {
319                // Sort workers by load
320                let mut sorted_workers: Vec<_> = workers.iter().collect();
321                sorted_workers.sort_by(|a, b| {
322                    a.current_load
323                        .partial_cmp(&b.current_load)
324                        .unwrap_or(std::cmp::Ordering::Equal)
325                });
326
327                for (i, partition) in partitions.iter_mut().enumerate() {
328                    let worker_idx = i % sorted_workers.len();
329                    partition.assigned_worker = Some(sorted_workers[worker_idx].worker_id.clone());
330                }
331            }
332            LoadBalancingStrategy::ConsistentHashing => {
333                for partition in partitions.iter_mut() {
334                    // Use partition ID for consistent hashing
335                    let hash = partition.partition_id;
336                    let worker_idx = hash % workers.len();
337                    partition.assigned_worker = Some(workers[worker_idx].worker_id.clone());
338                }
339            }
340            LoadBalancingStrategy::Random => {
341                let mut random = rng();
342                for partition in partitions.iter_mut() {
343                    let worker_idx = random.random_range(0..workers.len());
344                    partition.assigned_worker = Some(workers[worker_idx].worker_id.clone());
345                }
346            }
347        }
348
349        Ok(partitions)
350    }
351
352    /// Execute validation on distributed workers
353    async fn execute_distributed(
354        &self,
355        partitions: &[ValidationPartition],
356    ) -> Result<Vec<PartitionResult>, DistributedError> {
357        // In a real implementation, this would:
358        // 1. Send partitions to workers via gRPC/HTTP
359        // 2. Wait for results with timeout
360        // 3. Handle failures and retries
361
362        // For now, simulate distributed execution locally
363        let mut results = Vec::with_capacity(partitions.len());
364        let shapes = self.shapes.read().await;
365
366        for partition in partitions {
367            let start = Instant::now();
368
369            // Simulate validation on worker
370            let violations = self
371                .simulate_worker_validation(&partition.focus_nodes, &shapes)
372                .await;
373
374            results.push(PartitionResult {
375                partition_id: partition.partition_id,
376                worker_id: partition
377                    .assigned_worker
378                    .clone()
379                    .unwrap_or_else(|| "local".to_string()),
380                violations,
381                processing_time: start.elapsed(),
382                success: true,
383                error: None,
384            });
385        }
386
387        Ok(results)
388    }
389
390    /// Simulate worker validation (placeholder for actual distributed execution)
391    async fn simulate_worker_validation(
392        &self,
393        _focus_nodes: &[Term],
394        _shapes: &IndexMap<ShapeId, Shape>,
395    ) -> Vec<ValidationViolation> {
396        // In a real implementation, this would execute actual SHACL validation
397        // For now, return empty violations (all valid)
398        Vec::new()
399    }
400
401    /// Aggregate results from all workers
402    async fn aggregate_results(
403        &self,
404        results: Vec<PartitionResult>,
405    ) -> Result<ValidationReport, DistributedError> {
406        let mut report = ValidationReport::new();
407        let mut all_violations = Vec::new();
408
409        for result in results {
410            if !result.success {
411                if let Some(error) = &result.error {
412                    tracing::error!(
413                        "Partition {} failed on worker {}: {}",
414                        result.partition_id,
415                        result.worker_id,
416                        error
417                    );
418
419                    // Update stats
420                    let mut stats = self.stats.write().await;
421                    stats.failed_partitions += 1;
422                }
423            } else {
424                all_violations.extend(result.violations);
425            }
426        }
427
428        // Deduplicate violations (in case of replication)
429        all_violations.sort_by(|a, b| a.focus_node.cmp(&b.focus_node));
430        all_violations.dedup_by(|a, b| {
431            a.focus_node == b.focus_node
432                && a.source_shape == b.source_shape
433                && a.source_constraint_component == b.source_constraint_component
434        });
435
436        // Add violations to report
437        for violation in all_violations {
438            report.add_violation(violation);
439        }
440
441        Ok(report)
442    }
443
444    /// Compute cache key for a set of focus nodes
445    fn compute_cache_key(&self, focus_nodes: &[Term]) -> String {
446        use std::collections::hash_map::DefaultHasher;
447        use std::hash::{Hash, Hasher};
448
449        let mut hasher = DefaultHasher::new();
450        for node in focus_nodes {
451            node.to_string().hash(&mut hasher);
452        }
453        format!("{:x}", hasher.finish())
454    }
455
456    /// Check cache for result
457    async fn check_cache(&self, key: &str) -> Option<ValidationReport> {
458        let cache = self.cache.read().await;
459        if let Some(cached) = cache.get(key) {
460            if cached.created.elapsed() < self.config.cache_ttl {
461                return Some(cached.report.clone());
462            }
463        }
464        None
465    }
466
467    /// Cache a validation result
468    async fn cache_result(&self, key: &str, report: &ValidationReport) {
469        let mut cache = self.cache.write().await;
470        cache.insert(
471            key.to_string(),
472            CachedResult {
473                report: report.clone(),
474                created: Instant::now(),
475            },
476        );
477
478        // Clean up expired entries
479        cache.retain(|_, v| v.created.elapsed() < self.config.cache_ttl);
480    }
481
482    /// Get distributed validation statistics
483    pub async fn get_stats(&self) -> DistributedStats {
484        self.stats.read().await.clone()
485    }
486
487    /// Reset statistics
488    pub async fn reset_stats(&self) {
489        let mut stats = self.stats.write().await;
490        *stats = DistributedStats::default();
491    }
492
493    /// Update shapes
494    pub async fn update_shapes(&self, shapes: IndexMap<ShapeId, Shape>) {
495        let mut current_shapes = self.shapes.write().await;
496        *current_shapes = shapes;
497
498        // Clear cache when shapes change
499        let mut cache = self.cache.write().await;
500        cache.clear();
501    }
502
503    /// Health check all workers
504    pub async fn health_check(&self) -> Vec<(String, bool)> {
505        let mut workers = self.workers.write().await;
506        let mut results = Vec::new();
507
508        for worker in workers.iter_mut() {
509            // In a real implementation, this would ping the worker
510            let elapsed = chrono::Utc::now().signed_duration_since(worker.last_heartbeat);
511            let healthy = elapsed.num_seconds() < 60;
512            worker.healthy = healthy;
513            results.push((worker.worker_id.clone(), healthy));
514        }
515
516        results
517    }
518}
519
520/// Errors that can occur during distributed validation
521#[derive(Debug, Clone)]
522pub enum DistributedError {
523    /// No workers available
524    NoWorkersAvailable,
525    /// All workers failed
526    AllWorkersFailed,
527    /// Timeout waiting for results
528    Timeout,
529    /// Communication error
530    Communication(String),
531    /// Aggregation error
532    Aggregation(String),
533}
534
535impl std::fmt::Display for DistributedError {
536    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537        match self {
538            DistributedError::NoWorkersAvailable => write!(f, "No workers available"),
539            DistributedError::AllWorkersFailed => write!(f, "All workers failed"),
540            DistributedError::Timeout => write!(f, "Timeout waiting for results"),
541            DistributedError::Communication(msg) => write!(f, "Communication error: {}", msg),
542            DistributedError::Aggregation(msg) => write!(f, "Aggregation error: {}", msg),
543        }
544    }
545}
546
547impl std::error::Error for DistributedError {}
548
549/// Builder for distributed validator
550pub struct DistributedValidatorBuilder {
551    config: DistributedValidationConfig,
552    shapes: IndexMap<ShapeId, Shape>,
553    validation_config: ValidationConfig,
554    workers: Vec<WorkerInfo>,
555}
556
557impl DistributedValidatorBuilder {
558    /// Create a new builder
559    pub fn new() -> Self {
560        Self {
561            config: DistributedValidationConfig::default(),
562            shapes: IndexMap::new(),
563            validation_config: ValidationConfig::default(),
564            workers: Vec::new(),
565        }
566    }
567
568    /// Set number of partitions
569    pub fn num_partitions(mut self, n: usize) -> Self {
570        self.config.num_partitions = n;
571        self
572    }
573
574    /// Set load balancing strategy
575    pub fn load_balancing(mut self, strategy: LoadBalancingStrategy) -> Self {
576        self.config.load_balancing = strategy;
577        self
578    }
579
580    /// Set consistency level
581    pub fn consistency_level(mut self, level: ConsistencyLevel) -> Self {
582        self.config.consistency_level = level;
583        self
584    }
585
586    /// Set worker timeout
587    pub fn worker_timeout(mut self, timeout: Duration) -> Self {
588        self.config.worker_timeout = timeout;
589        self
590    }
591
592    /// Set shapes to validate
593    pub fn shapes(mut self, shapes: IndexMap<ShapeId, Shape>) -> Self {
594        self.shapes = shapes;
595        self
596    }
597
598    /// Set validation configuration
599    pub fn validation_config(mut self, config: ValidationConfig) -> Self {
600        self.validation_config = config;
601        self
602    }
603
604    /// Add a worker
605    pub fn add_worker(mut self, worker: WorkerInfo) -> Self {
606        self.workers.push(worker);
607        self
608    }
609
610    /// Enable caching
611    pub fn enable_caching(mut self, enable: bool) -> Self {
612        self.config.enable_caching = enable;
613        self
614    }
615
616    /// Build the distributed validator
617    pub async fn build(self) -> DistributedValidator {
618        let validator = DistributedValidator::new(self.shapes, self.config, self.validation_config);
619
620        // Register workers
621        for worker in self.workers {
622            validator.register_worker(worker).await;
623        }
624
625        validator
626    }
627}
628
629impl Default for DistributedValidatorBuilder {
630    fn default() -> Self {
631        Self::new()
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[tokio::test]
640    async fn test_distributed_validator_creation() {
641        let shapes = IndexMap::new();
642        let config = DistributedValidationConfig::default();
643        let validation_config = ValidationConfig::default();
644
645        let _validator = DistributedValidator::new(shapes, config, validation_config);
646    }
647
648    #[tokio::test]
649    async fn test_worker_registration() {
650        let shapes = IndexMap::new();
651        let validator = DistributedValidator::new(
652            shapes,
653            DistributedValidationConfig::default(),
654            ValidationConfig::default(),
655        );
656
657        let worker = WorkerInfo {
658            worker_id: "worker-1".to_string(),
659            address: "localhost:8080".to_string(),
660            current_load: 0.0,
661            healthy: true,
662            last_heartbeat: chrono::Utc::now(),
663            validations_processed: 0,
664            avg_response_time_ms: 0,
665        };
666
667        validator.register_worker(worker).await;
668
669        let workers = validator.get_healthy_workers().await;
670        assert_eq!(workers.len(), 1);
671        assert_eq!(workers[0].worker_id, "worker-1");
672    }
673
674    #[tokio::test]
675    async fn test_partition_creation() {
676        let shapes = IndexMap::new();
677        let validator = DistributedValidator::new(
678            shapes,
679            DistributedValidationConfig {
680                num_partitions: 3,
681                ..Default::default()
682            },
683            ValidationConfig::default(),
684        );
685
686        let focus_nodes = vec![
687            Term::NamedNode(oxirs_core::model::NamedNode::new_unchecked(
688                "http://example.org/1",
689            )),
690            Term::NamedNode(oxirs_core::model::NamedNode::new_unchecked(
691                "http://example.org/2",
692            )),
693            Term::NamedNode(oxirs_core::model::NamedNode::new_unchecked(
694                "http://example.org/3",
695            )),
696            Term::NamedNode(oxirs_core::model::NamedNode::new_unchecked(
697                "http://example.org/4",
698            )),
699            Term::NamedNode(oxirs_core::model::NamedNode::new_unchecked(
700                "http://example.org/5",
701            )),
702        ];
703
704        let partitions = validator.partition_nodes(&focus_nodes).await;
705        assert_eq!(partitions.len(), 3);
706    }
707
708    #[tokio::test]
709    async fn test_round_robin_assignment() {
710        let shapes = IndexMap::new();
711        let validator = DistributedValidator::new(
712            shapes,
713            DistributedValidationConfig::default(),
714            ValidationConfig::default(),
715        );
716
717        let partitions = vec![
718            ValidationPartition {
719                partition_id: 0,
720                focus_nodes: vec![],
721                assigned_worker: None,
722                retry_count: 0,
723            },
724            ValidationPartition {
725                partition_id: 1,
726                focus_nodes: vec![],
727                assigned_worker: None,
728                retry_count: 0,
729            },
730        ];
731
732        let workers = vec![
733            WorkerInfo {
734                worker_id: "w1".to_string(),
735                address: "".to_string(),
736                current_load: 0.0,
737                healthy: true,
738                last_heartbeat: chrono::Utc::now(),
739                validations_processed: 0,
740                avg_response_time_ms: 0,
741            },
742            WorkerInfo {
743                worker_id: "w2".to_string(),
744                address: "".to_string(),
745                current_load: 0.0,
746                healthy: true,
747                last_heartbeat: chrono::Utc::now(),
748                validations_processed: 0,
749                avg_response_time_ms: 0,
750            },
751        ];
752
753        let assigned = validator
754            .assign_partitions(partitions, &workers)
755            .await
756            .expect("operation should succeed");
757
758        assert_eq!(assigned[0].assigned_worker, Some("w1".to_string()));
759        assert_eq!(assigned[1].assigned_worker, Some("w2".to_string()));
760    }
761
762    #[tokio::test]
763    async fn test_builder_pattern() {
764        let validator = DistributedValidatorBuilder::new()
765            .num_partitions(5)
766            .load_balancing(LoadBalancingStrategy::LeastLoaded)
767            .enable_caching(true)
768            .add_worker(WorkerInfo {
769                worker_id: "worker-1".to_string(),
770                address: "localhost:8080".to_string(),
771                current_load: 0.0,
772                healthy: true,
773                last_heartbeat: chrono::Utc::now(),
774                validations_processed: 0,
775                avg_response_time_ms: 0,
776            })
777            .build()
778            .await;
779
780        let workers = validator.get_healthy_workers().await;
781        assert_eq!(workers.len(), 1);
782    }
783
784    #[tokio::test]
785    async fn test_no_workers_error() {
786        let shapes = IndexMap::new();
787        let validator = DistributedValidator::new(
788            shapes,
789            DistributedValidationConfig::default(),
790            ValidationConfig::default(),
791        );
792
793        let focus_nodes = vec![Term::NamedNode(
794            oxirs_core::model::NamedNode::new_unchecked("http://example.org/1"),
795        )];
796
797        let result = validator.validate_distributed(focus_nodes).await;
798        assert!(matches!(result, Err(DistributedError::NoWorkersAvailable)));
799    }
800
801    #[tokio::test]
802    async fn test_stats_tracking() {
803        let shapes = IndexMap::new();
804        let validator = DistributedValidator::new(
805            shapes,
806            DistributedValidationConfig::default(),
807            ValidationConfig::default(),
808        );
809
810        let stats = validator.get_stats().await;
811        assert_eq!(stats.total_distributed, 0);
812        assert_eq!(stats.cache_hits, 0);
813    }
814}