1use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::sync::Arc;
10use tokio::sync::RwLock;
11use tracing::{debug, error, info};
12
13use crate::raft::OxirsNodeId;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct DistributedQueryPlan {
18 pub query_id: String,
19 pub original_sparql: String,
20 pub subqueries: Vec<SubqueryPlan>,
21 pub join_operations: Vec<JoinOperation>,
22 pub aggregation_plan: Option<AggregationPlan>,
23 pub estimated_cost: f64,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SubqueryPlan {
29 pub subquery_id: String,
30 pub target_node: OxirsNodeId,
31 pub sparql_fragment: String,
32 pub variables: Vec<String>,
33 pub estimated_rows: u64,
34 pub estimated_latency_ms: u64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct JoinOperation {
40 pub left_subquery: String,
41 pub right_subquery: String,
42 pub join_variables: Vec<String>,
43 pub join_type: JoinType,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub enum JoinType {
48 Inner,
49 Left,
50 Optional,
51 Union,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct AggregationPlan {
57 pub group_by: Vec<String>,
58 pub aggregates: Vec<AggregateFunction>,
59 pub having_conditions: Vec<String>,
60 pub order_by: Vec<OrderByClause>,
61 pub limit: Option<u64>,
62 pub offset: Option<u64>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct AggregateFunction {
67 pub function: String,
68 pub variable: String,
69 pub alias: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct OrderByClause {
74 pub variable: String,
75 pub ascending: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct QueryStats {
81 pub query_id: String,
82 pub execution_time_ms: u64,
83 pub nodes_involved: u32,
84 pub total_intermediate_results: u64,
85 pub final_result_count: u64,
86 pub network_transfer_bytes: u64,
87 pub cache_hits: u32,
88 pub cache_misses: u32,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
93pub struct ResultBinding {
94 pub variables: BTreeMap<String, String>,
95}
96
97impl Default for ResultBinding {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl ResultBinding {
104 pub fn new() -> Self {
105 Self {
106 variables: BTreeMap::new(),
107 }
108 }
109
110 pub fn add_binding(&mut self, variable: String, value: String) {
111 self.variables.insert(variable, value);
112 }
113
114 pub fn get(&self, variable: &str) -> Option<&String> {
115 self.variables.get(variable)
116 }
117
118 pub fn merge(&self, other: &ResultBinding) -> Option<ResultBinding> {
119 let mut merged = self.clone();
120 for (var, val) in &other.variables {
121 if let Some(existing) = merged.variables.get(var) {
122 if existing != val {
123 return None; }
125 } else {
126 merged.variables.insert(var.clone(), val.clone());
127 }
128 }
129 Some(merged)
130 }
131}
132
133#[derive(Debug)]
135pub struct DistributedQueryExecutor {
136 #[allow(dead_code)]
137 node_id: OxirsNodeId,
138 cluster_nodes: Arc<RwLock<HashSet<OxirsNodeId>>>,
139 query_cache: Arc<RwLock<HashMap<String, Vec<ResultBinding>>>>,
140 statistics: Arc<RwLock<HashMap<String, QueryStats>>>,
141}
142
143impl DistributedQueryExecutor {
144 pub fn new(node_id: OxirsNodeId) -> Self {
145 Self {
146 node_id,
147 cluster_nodes: Arc::new(RwLock::new(HashSet::new())),
148 query_cache: Arc::new(RwLock::new(HashMap::new())),
149 statistics: Arc::new(RwLock::new(HashMap::new())),
150 }
151 }
152
153 pub async fn add_node(&self, node_id: OxirsNodeId) {
155 let mut nodes = self.cluster_nodes.write().await;
156 nodes.insert(node_id);
157 info!("Added node {} to distributed query executor", node_id);
158 }
159
160 pub async fn remove_node(&self, node_id: OxirsNodeId) {
162 let mut nodes = self.cluster_nodes.write().await;
163 nodes.remove(&node_id);
164 info!("Removed node {} from distributed query executor", node_id);
165 }
166
167 pub async fn execute_query(&self, sparql: &str) -> Result<Vec<ResultBinding>> {
169 let query_id = uuid::Uuid::new_v4().to_string();
170 let start_time = std::time::Instant::now();
171
172 info!("Executing distributed query {}: {}", query_id, sparql);
173
174 if let Some(cached_results) = self.check_cache(sparql).await {
176 info!("Cache hit for query {}", query_id);
177 return Ok(cached_results);
178 }
179
180 let plan = self.create_execution_plan(&query_id, sparql).await?;
182
183 let subquery_results = self.execute_subqueries(&plan).await?;
185
186 let final_results = self.combine_results(&plan, subquery_results).await?;
188
189 self.cache_results(sparql, &final_results).await;
191
192 let execution_time = start_time.elapsed().as_millis() as u64;
194 self.record_statistics(&query_id, &plan, &final_results, execution_time)
195 .await;
196
197 info!(
198 "Completed distributed query {} in {}ms, {} results",
199 query_id,
200 execution_time,
201 final_results.len()
202 );
203
204 Ok(final_results)
205 }
206
207 async fn create_execution_plan(
209 &self,
210 query_id: &str,
211 sparql: &str,
212 ) -> Result<DistributedQueryPlan> {
213 let parsed = self.parse_sparql(sparql)?;
215
216 let data_distribution = self.analyze_data_distribution().await?;
218
219 let subqueries = self.create_subqueries(&parsed, &data_distribution).await?;
221
222 let join_operations = self.plan_joins(&subqueries)?;
224
225 let aggregation_plan = self.create_aggregation_plan(&parsed)?;
227
228 let estimated_cost = self.estimate_cost(&subqueries, &join_operations).await;
230
231 Ok(DistributedQueryPlan {
232 query_id: query_id.to_string(),
233 original_sparql: sparql.to_string(),
234 subqueries,
235 join_operations,
236 aggregation_plan,
237 estimated_cost,
238 })
239 }
240
241 fn parse_sparql(&self, sparql: &str) -> Result<ParsedQuery> {
243 let mut variables = Vec::new();
246 let mut triple_patterns = Vec::new();
247 let filters = Vec::new();
248
249 if let Some(select_part) = sparql.split("WHERE").next() {
251 if select_part.contains("SELECT") {
252 let vars_part = select_part.replace("SELECT", "").trim().to_string();
253 if vars_part != "*" {
254 variables = vars_part
255 .split_whitespace()
256 .filter(|v| v.starts_with('?'))
257 .map(|v| v.to_string())
258 .collect();
259 }
260 }
261 }
262
263 if let Some(where_part) = sparql.split("WHERE").nth(1) {
265 let clean_where = where_part.replace(['{', '}'], "");
266 for line in clean_where.lines() {
267 let line = line.trim();
268 if !line.is_empty() && line.contains(' ') {
269 let parts: Vec<&str> = line.split_whitespace().collect();
270 if parts.len() >= 3 {
271 triple_patterns.push(TriplePattern {
272 subject: parts[0].to_string(),
273 predicate: parts[1].to_string(),
274 object: parts[2].replace('.', ""),
275 });
276 }
277 }
278 }
279 }
280
281 Ok(ParsedQuery {
282 variables,
283 triple_patterns,
284 filters,
285 limit: None,
286 offset: None,
287 order_by: Vec::new(),
288 })
289 }
290
291 async fn analyze_data_distribution(&self) -> Result<DataDistribution> {
293 let nodes = self.cluster_nodes.read().await;
294 let mut distribution = DataDistribution {
295 node_triple_counts: HashMap::new(),
296 predicate_distribution: HashMap::new(),
297 subject_distribution: HashMap::new(),
298 };
299
300 for &node_id in nodes.iter() {
302 distribution.node_triple_counts.insert(node_id, 10000); }
304
305 Ok(distribution)
306 }
307
308 async fn create_subqueries(
310 &self,
311 parsed: &ParsedQuery,
312 _distribution: &DataDistribution,
313 ) -> Result<Vec<SubqueryPlan>> {
314 let mut subqueries = Vec::new();
315 let nodes: Vec<_> = self.cluster_nodes.read().await.iter().cloned().collect();
316
317 if nodes.is_empty() {
318 return Err(anyhow::anyhow!("No nodes available for query execution"));
319 }
320
321 for (i, triple_pattern) in parsed.triple_patterns.iter().enumerate() {
323 let target_node = nodes[i % nodes.len()];
324
325 let sparql_fragment = format!(
326 "SELECT {} WHERE {{ {} {} {} }}",
327 parsed.variables.join(" "),
328 triple_pattern.subject,
329 triple_pattern.predicate,
330 triple_pattern.object
331 );
332
333 subqueries.push(SubqueryPlan {
334 subquery_id: format!("subquery_{i}"),
335 target_node,
336 sparql_fragment,
337 variables: parsed.variables.clone(),
338 estimated_rows: 1000, estimated_latency_ms: 50,
340 });
341 }
342
343 Ok(subqueries)
344 }
345
346 fn plan_joins(&self, subqueries: &[SubqueryPlan]) -> Result<Vec<JoinOperation>> {
348 let mut joins = Vec::new();
349
350 for i in 0..subqueries.len().saturating_sub(1) {
352 let left = &subqueries[i];
353 let right = &subqueries[i + 1];
354
355 let common_vars: Vec<String> = left
357 .variables
358 .iter()
359 .filter(|v| right.variables.contains(v))
360 .cloned()
361 .collect();
362
363 if !common_vars.is_empty() {
364 joins.push(JoinOperation {
365 left_subquery: left.subquery_id.clone(),
366 right_subquery: right.subquery_id.clone(),
367 join_variables: common_vars,
368 join_type: JoinType::Inner,
369 });
370 }
371 }
372
373 Ok(joins)
374 }
375
376 fn create_aggregation_plan(&self, parsed: &ParsedQuery) -> Result<Option<AggregationPlan>> {
378 if parsed.order_by.is_empty() && parsed.limit.is_none() {
380 return Ok(None);
381 }
382
383 Ok(Some(AggregationPlan {
384 group_by: Vec::new(),
385 aggregates: Vec::new(),
386 having_conditions: Vec::new(),
387 order_by: parsed.order_by.clone(),
388 limit: parsed.limit,
389 offset: parsed.offset,
390 }))
391 }
392
393 async fn estimate_cost(&self, subqueries: &[SubqueryPlan], joins: &[JoinOperation]) -> f64 {
395 let mut total_cost = 0.0;
396
397 for subquery in subqueries {
399 total_cost += subquery.estimated_rows as f64 * 0.001; total_cost += subquery.estimated_latency_ms as f64 * 0.01; }
402
403 for _join in joins {
405 total_cost += 10.0; }
407
408 total_cost
409 }
410
411 async fn execute_subqueries(
413 &self,
414 plan: &DistributedQueryPlan,
415 ) -> Result<HashMap<String, Vec<ResultBinding>>> {
416 let mut results = HashMap::new();
417 let mut handles = Vec::new();
418
419 for subquery in &plan.subqueries {
420 let subquery_clone = subquery.clone();
421 let handle =
422 tokio::spawn(async move { Self::execute_single_subquery(subquery_clone).await });
423 handles.push((subquery.subquery_id.clone(), handle));
424 }
425
426 for (subquery_id, handle) in handles {
428 match handle.await {
429 Ok(Ok(subquery_results)) => {
430 results.insert(subquery_id, subquery_results);
431 }
432 Ok(Err(e)) => {
433 error!("Subquery {} failed: {}", subquery_id, e);
434 return Err(e);
435 }
436 Err(e) => {
437 error!("Subquery {} task failed: {}", subquery_id, e);
438 return Err(anyhow::anyhow!("Task execution failed: {}", e));
439 }
440 }
441 }
442
443 Ok(results)
444 }
445
446 async fn execute_single_subquery(subquery: SubqueryPlan) -> Result<Vec<ResultBinding>> {
448 debug!(
449 "Executing subquery {} on node {}",
450 subquery.subquery_id, subquery.target_node
451 );
452
453 let client = reqwest::Client::builder()
455 .timeout(tokio::time::Duration::from_millis(
456 subquery.estimated_latency_ms * 3,
457 ))
458 .build()
459 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?;
460
461 let endpoint_url = format!("http://node-{}/sparql", subquery.target_node);
463
464 let response = client
465 .post(&endpoint_url)
466 .header("Content-Type", "application/sparql-query")
467 .header("Accept", "application/sparql-results+json")
468 .body(subquery.sparql_fragment.clone())
469 .send()
470 .await;
471
472 match response {
473 Ok(resp) if resp.status().is_success() => {
474 let json: serde_json::Value = resp
475 .json()
476 .await
477 .map_err(|e| anyhow::anyhow!("Failed to parse JSON response: {}", e))?;
478
479 Self::parse_sparql_json_results(json)
480 }
481 Ok(resp) => {
482 let status = resp.status();
486 error!(
487 "Node {} returned error status {} for subquery {}",
488 subquery.target_node, status, subquery.subquery_id
489 );
490 Err(anyhow::anyhow!(
491 "subquery {} on node {} failed with HTTP status {}",
492 subquery.subquery_id,
493 subquery.target_node,
494 status
495 ))
496 }
497 Err(e) => {
498 error!(
502 "Failed to reach node {} for subquery {}: {}",
503 subquery.target_node, subquery.subquery_id, e
504 );
505 Err(anyhow::anyhow!(
506 "subquery {} could not reach node {}: {}",
507 subquery.subquery_id,
508 subquery.target_node,
509 e
510 ))
511 }
512 }
513 }
514
515 fn parse_sparql_json_results(json: serde_json::Value) -> Result<Vec<ResultBinding>> {
517 let bindings_array = json
518 .get("results")
519 .and_then(|r| r.get("bindings"))
520 .and_then(|b| b.as_array())
521 .ok_or_else(|| anyhow::anyhow!("Invalid SPARQL JSON results format"))?;
522
523 let mut results = Vec::new();
524 for binding_obj in bindings_array {
525 if let Some(binding_map) = binding_obj.as_object() {
526 let mut result_binding = ResultBinding::new();
527
528 for (var_name, var_value) in binding_map {
529 if let Some(value_obj) = var_value.as_object() {
530 let value = value_obj
531 .get("value")
532 .and_then(|v| v.as_str())
533 .unwrap_or("")
534 .to_string();
535 result_binding.add_binding(format!("?{var_name}"), value);
536 }
537 }
538 results.push(result_binding);
539 }
540 }
541
542 Ok(results)
543 }
544
545 async fn combine_results(
547 &self,
548 plan: &DistributedQueryPlan,
549 subquery_results: HashMap<String, Vec<ResultBinding>>,
550 ) -> Result<Vec<ResultBinding>> {
551 let mut current_results = Vec::new();
552
553 if let Some(first_subquery) = plan.subqueries.first() {
555 if let Some(first_results) = subquery_results.get(&first_subquery.subquery_id) {
556 current_results = first_results.clone();
557 }
558 }
559
560 for join in &plan.join_operations {
562 if let Some(right_results) = subquery_results.get(&join.right_subquery) {
563 current_results = self
564 .execute_join(¤t_results, right_results, join)
565 .await?;
566 }
567 }
568
569 if let Some(agg_plan) = &plan.aggregation_plan {
571 current_results = self.apply_aggregation(current_results, agg_plan).await?;
572 }
573
574 Ok(current_results)
575 }
576
577 async fn execute_join(
579 &self,
580 left_results: &[ResultBinding],
581 right_results: &[ResultBinding],
582 join: &JoinOperation,
583 ) -> Result<Vec<ResultBinding>> {
584 let mut joined_results = Vec::new();
585
586 match join.join_type {
587 JoinType::Inner => {
588 for left_binding in left_results {
589 for right_binding in right_results {
590 if self.bindings_compatible(
591 left_binding,
592 right_binding,
593 &join.join_variables,
594 ) {
595 if let Some(merged) = left_binding.merge(right_binding) {
596 joined_results.push(merged);
597 }
598 }
599 }
600 }
601 }
602 JoinType::Left => {
603 for left_binding in left_results {
604 let mut found_match = false;
605 for right_binding in right_results {
606 if self.bindings_compatible(
607 left_binding,
608 right_binding,
609 &join.join_variables,
610 ) {
611 if let Some(merged) = left_binding.merge(right_binding) {
612 joined_results.push(merged);
613 found_match = true;
614 }
615 }
616 }
617 if !found_match {
618 joined_results.push(left_binding.clone());
619 }
620 }
621 }
622 JoinType::Optional => {
623 joined_results = Box::pin(self.execute_join(
625 left_results,
626 right_results,
627 &JoinOperation {
628 left_subquery: join.left_subquery.clone(),
629 right_subquery: join.right_subquery.clone(),
630 join_variables: join.join_variables.clone(),
631 join_type: JoinType::Left,
632 },
633 ))
634 .await?;
635 }
636 JoinType::Union => {
637 joined_results.extend_from_slice(left_results);
638 joined_results.extend_from_slice(right_results);
639 joined_results.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}")));
641 joined_results.dedup();
642 }
643 }
644
645 Ok(joined_results)
646 }
647
648 fn bindings_compatible(
650 &self,
651 left: &ResultBinding,
652 right: &ResultBinding,
653 join_variables: &[String],
654 ) -> bool {
655 for var in join_variables {
656 if let (Some(left_val), Some(right_val)) = (left.get(var), right.get(var)) {
657 if left_val != right_val {
658 return false;
659 }
660 }
661 }
662 true
663 }
664
665 async fn apply_aggregation(
667 &self,
668 mut results: Vec<ResultBinding>,
669 agg_plan: &AggregationPlan,
670 ) -> Result<Vec<ResultBinding>> {
671 if !agg_plan.order_by.is_empty() {
673 results.sort_by(|a, b| {
674 for order_clause in &agg_plan.order_by {
675 let empty_string = String::new();
676 let a_val = a.get(&order_clause.variable).unwrap_or(&empty_string);
677 let b_val = b.get(&order_clause.variable).unwrap_or(&empty_string);
678 let cmp = if order_clause.ascending {
679 a_val.cmp(b_val)
680 } else {
681 b_val.cmp(a_val)
682 };
683 if cmp != std::cmp::Ordering::Equal {
684 return cmp;
685 }
686 }
687 std::cmp::Ordering::Equal
688 });
689 }
690
691 if let Some(offset) = agg_plan.offset {
693 if offset < results.len() as u64 {
694 results = results.into_iter().skip(offset as usize).collect();
695 } else {
696 results.clear();
697 }
698 }
699
700 if let Some(limit) = agg_plan.limit {
701 results.truncate(limit as usize);
702 }
703
704 Ok(results)
705 }
706
707 async fn check_cache(&self, sparql: &str) -> Option<Vec<ResultBinding>> {
709 let cache = self.query_cache.read().await;
710 cache.get(sparql).cloned()
711 }
712
713 async fn cache_results(&self, sparql: &str, results: &[ResultBinding]) {
715 let mut cache = self.query_cache.write().await;
716 cache.insert(sparql.to_string(), results.to_vec());
717
718 if cache.len() > 1000 {
720 let keys_to_remove: Vec<_> = cache.keys().take(100).cloned().collect();
721 for key in keys_to_remove {
722 cache.remove(&key);
723 }
724 }
725 }
726
727 async fn record_statistics(
729 &self,
730 query_id: &str,
731 plan: &DistributedQueryPlan,
732 results: &[ResultBinding],
733 execution_time_ms: u64,
734 ) {
735 let stats = QueryStats {
736 query_id: query_id.to_string(),
737 execution_time_ms,
738 nodes_involved: plan.subqueries.len() as u32,
739 total_intermediate_results: plan.subqueries.iter().map(|s| s.estimated_rows).sum(),
740 final_result_count: results.len() as u64,
741 network_transfer_bytes: 0, cache_hits: 0,
743 cache_misses: 1,
744 };
745
746 let mut statistics = self.statistics.write().await;
747 statistics.insert(query_id.to_string(), stats);
748
749 if statistics.len() > 10000 {
751 let keys_to_remove: Vec<_> = statistics.keys().take(1000).cloned().collect();
752 for key in keys_to_remove {
753 statistics.remove(&key);
754 }
755 }
756 }
757
758 pub async fn get_statistics(&self) -> HashMap<String, QueryStats> {
760 self.statistics.read().await.clone()
761 }
762
763 pub async fn clear_cache(&self) {
765 let mut cache = self.query_cache.write().await;
766 cache.clear();
767 info!("Query cache cleared");
768 }
769}
770
771#[derive(Debug, Clone)]
773struct ParsedQuery {
774 variables: Vec<String>,
775 triple_patterns: Vec<TriplePattern>,
776 #[allow(dead_code)]
777 filters: Vec<String>,
778 limit: Option<u64>,
779 offset: Option<u64>,
780 order_by: Vec<OrderByClause>,
781}
782
783#[derive(Debug, Clone)]
784struct TriplePattern {
785 subject: String,
786 predicate: String,
787 object: String,
788}
789
790#[derive(Debug, Clone)]
792struct DataDistribution {
793 node_triple_counts: HashMap<OxirsNodeId, u64>,
794 #[allow(dead_code)]
795 predicate_distribution: HashMap<String, Vec<OxirsNodeId>>,
796 #[allow(dead_code)]
797 subject_distribution: HashMap<String, Vec<OxirsNodeId>>,
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 #[tokio::test]
805 async fn test_distributed_query_executor_creation() {
806 let executor = DistributedQueryExecutor::new(1);
807 executor.add_node(2).await;
808 executor.add_node(3).await;
809
810 let nodes = executor.cluster_nodes.read().await;
811 assert_eq!(nodes.len(), 2);
812 assert!(nodes.contains(&2));
813 assert!(nodes.contains(&3));
814 }
815
816 #[tokio::test]
817 async fn test_result_binding() {
818 let mut binding1 = ResultBinding::new();
819 binding1.add_binding("?x".to_string(), "value1".to_string());
820
821 let mut binding2 = ResultBinding::new();
822 binding2.add_binding("?y".to_string(), "value2".to_string());
823
824 let merged = binding1.merge(&binding2).unwrap();
825 assert_eq!(merged.get("?x"), Some(&"value1".to_string()));
826 assert_eq!(merged.get("?y"), Some(&"value2".to_string()));
827 }
828
829 #[tokio::test]
830 async fn test_result_binding_conflict() {
831 let mut binding1 = ResultBinding::new();
832 binding1.add_binding("?x".to_string(), "value1".to_string());
833
834 let mut binding2 = ResultBinding::new();
835 binding2.add_binding("?x".to_string(), "value2".to_string());
836
837 let merged = binding1.merge(&binding2);
838 assert!(merged.is_none()); }
840
841 #[test]
842 fn test_sparql_parsing() {
843 let executor = DistributedQueryExecutor::new(1);
844 let sparql = "SELECT ?x ?y WHERE { ?x <predicate> ?y }";
845 let parsed = executor.parse_sparql(sparql).unwrap();
846
847 assert_eq!(parsed.variables, vec!["?x", "?y"]);
848 assert_eq!(parsed.triple_patterns.len(), 1);
849 }
850
851 #[tokio::test]
852 async fn test_query_execution_errors_on_unreachable_node() {
853 let executor = DistributedQueryExecutor::new(1);
856 executor.add_node(2).await;
857 executor.add_node(3).await;
858
859 let sparql = "SELECT ?x WHERE { ?x <type> <Person> }";
860 let result = executor.execute_query(sparql).await;
861
862 assert!(
863 result.is_err(),
864 "unreachable nodes must produce an error, not fabricated results"
865 );
866 }
867
868 #[tokio::test]
869 async fn test_execute_single_subquery_no_fabricated_triples_on_failure() {
870 let subquery = SubqueryPlan {
874 subquery_id: "sq-fail".to_string(),
875 target_node: 424242,
876 sparql_fragment: "SELECT ?s WHERE { ?s ?p ?o }".to_string(),
877 variables: vec!["?s".to_string()],
878 estimated_rows: 100,
879 estimated_latency_ms: 20,
880 };
881
882 let err = DistributedQueryExecutor::execute_single_subquery(subquery)
883 .await
884 .expect_err("unreachable subquery must return Err");
885 let msg = err.to_string();
886 assert!(
887 !msg.contains("example.org"),
888 "error must not contain fabricated data: {msg}"
889 );
890 }
891
892 #[tokio::test]
893 async fn test_query_caching_returns_cached_results() {
894 let executor = DistributedQueryExecutor::new(1);
897 executor.add_node(2).await;
898
899 let sparql = "SELECT ?x WHERE { ?x <type> <Person> }";
900
901 let mut cached = ResultBinding::new();
902 cached.add_binding("?x".to_string(), "urn:example:alice".to_string());
903 let expected = vec![cached];
904 executor.cache_results(sparql, &expected).await;
905
906 let results = executor
907 .execute_query(sparql)
908 .await
909 .expect("cache hit must succeed without network access");
910
911 assert_eq!(results, expected);
912 }
913}