Skip to main content

oxirs_gql/
dataloader.rs

1//! DataLoader implementation for efficient batching and caching
2//!
3//! This module provides the DataLoader pattern implementation to prevent N+1 queries
4//! in GraphQL resolvers by batching requests and caching results.
5
6use anyhow::{anyhow, Result};
7use async_trait::async_trait;
8use std::collections::HashMap;
9use std::hash::Hash;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12use tokio::sync::{Mutex, RwLock};
13use tokio::time::sleep;
14
15/// Trait for batch loading functions
16#[async_trait]
17pub trait BatchLoadFn<K, V>: Send + Sync
18where
19    K: Send + Sync + Clone + Hash + Eq,
20    V: Send + Sync + Clone,
21{
22    async fn load(&self, keys: Vec<K>) -> Result<HashMap<K, V>>;
23}
24
25/// Configuration for DataLoader behavior
26#[derive(Debug, Clone)]
27pub struct DataLoaderConfig {
28    /// Maximum number of keys to batch together
29    pub max_batch_size: usize,
30    /// Maximum time to wait before dispatching a batch
31    pub batch_delay: Duration,
32    /// Cache TTL for loaded values
33    pub cache_ttl: Duration,
34    /// Maximum cache size
35    pub max_cache_size: usize,
36    /// Enable caching
37    pub enable_cache: bool,
38}
39
40impl Default for DataLoaderConfig {
41    fn default() -> Self {
42        Self {
43            max_batch_size: 100,
44            batch_delay: Duration::from_millis(10),
45            cache_ttl: Duration::from_secs(300), // 5 minutes
46            max_cache_size: 1000,
47            enable_cache: true,
48        }
49    }
50}
51
52/// Cached entry with TTL
53#[derive(Debug, Clone)]
54struct CachedEntry<V> {
55    value: V,
56    created_at: Instant,
57    ttl: Duration,
58}
59
60impl<V> CachedEntry<V> {
61    fn new(value: V, ttl: Duration) -> Self {
62        Self {
63            value,
64            created_at: Instant::now(),
65            ttl,
66        }
67    }
68
69    fn is_expired(&self) -> bool {
70        self.created_at.elapsed() > self.ttl
71    }
72}
73
74/// Pending batch of requests
75#[derive(Debug)]
76struct PendingBatch<K, V>
77where
78    K: Send + Sync + Clone + Hash + Eq,
79    V: Send + Sync + Clone,
80{
81    keys: Vec<K>,
82    resolvers: Vec<tokio::sync::oneshot::Sender<Result<Option<V>>>>,
83    created_at: Instant,
84}
85
86impl<K, V> PendingBatch<K, V>
87where
88    K: Send + Sync + Clone + Hash + Eq,
89    V: Send + Sync + Clone,
90{
91    fn new() -> Self {
92        Self {
93            keys: Vec::new(),
94            resolvers: Vec::new(),
95            created_at: Instant::now(),
96        }
97    }
98
99    fn add_request(&mut self, key: K, resolver: tokio::sync::oneshot::Sender<Result<Option<V>>>) {
100        self.keys.push(key);
101        self.resolvers.push(resolver);
102    }
103
104    fn should_dispatch(&self, config: &DataLoaderConfig) -> bool {
105        self.keys.len() >= config.max_batch_size || self.created_at.elapsed() >= config.batch_delay
106    }
107}
108
109/// DataLoader for efficient batching and caching
110pub struct DataLoader<K, V>
111where
112    K: Send + Sync + Clone + Hash + Eq + 'static,
113    V: Send + Sync + Clone + 'static,
114{
115    batch_fn: Arc<dyn BatchLoadFn<K, V>>,
116    config: DataLoaderConfig,
117    cache: Arc<RwLock<HashMap<K, CachedEntry<V>>>>,
118    pending_batch: Arc<Mutex<Option<PendingBatch<K, V>>>>,
119    stats: Arc<RwLock<DataLoaderStats>>,
120    /// Handle to the background batch-dispatcher task. Retained so the task can
121    /// be aborted when the DataLoader is dropped (see the `Drop` impl),
122    /// preventing a leaked infinite loop per short-lived, per-request loader.
123    dispatcher_handle: Option<tokio::task::JoinHandle<()>>,
124}
125
126/// DataLoader performance statistics
127#[derive(Debug, Default, Clone)]
128pub struct DataLoaderStats {
129    pub requests_total: u64,
130    pub cache_hits: u64,
131    pub cache_misses: u64,
132    pub batches_dispatched: u64,
133    pub avg_batch_size: f64,
134    pub total_load_time: Duration,
135}
136
137impl DataLoaderStats {
138    pub fn cache_hit_ratio(&self) -> f64 {
139        if self.requests_total == 0 {
140            0.0
141        } else {
142            self.cache_hits as f64 / self.requests_total as f64
143        }
144    }
145
146    pub fn avg_load_time(&self) -> Duration {
147        if self.batches_dispatched == 0 {
148            Duration::from_secs(0)
149        } else {
150            self.total_load_time / self.batches_dispatched as u32
151        }
152    }
153}
154
155impl<K, V> DataLoader<K, V>
156where
157    K: Send + Sync + Clone + Hash + Eq + 'static,
158    V: Send + Sync + Clone + 'static,
159{
160    /// Create a new DataLoader with the given batch function
161    pub fn new(batch_fn: Arc<dyn BatchLoadFn<K, V>>) -> Self {
162        Self::with_config(batch_fn, DataLoaderConfig::default())
163    }
164
165    /// Create a new DataLoader with custom configuration
166    pub fn with_config(batch_fn: Arc<dyn BatchLoadFn<K, V>>, config: DataLoaderConfig) -> Self {
167        let mut loader = Self {
168            batch_fn,
169            config,
170            cache: Arc::new(RwLock::new(HashMap::new())),
171            pending_batch: Arc::new(Mutex::new(None)),
172            stats: Arc::new(RwLock::new(DataLoaderStats::default())),
173            dispatcher_handle: None,
174        };
175
176        // Start the batch dispatcher and retain its handle for shutdown.
177        loader.dispatcher_handle = Some(loader.start_batch_dispatcher());
178        loader
179    }
180
181    /// Explicitly stop the background batch-dispatcher task.
182    ///
183    /// Called automatically on `Drop`, but exposed so callers can shut a loader
184    /// down deterministically.
185    pub fn shutdown(&self) {
186        if let Some(handle) = &self.dispatcher_handle {
187            handle.abort();
188        }
189    }
190
191    /// Load a single value by key
192    pub async fn load(&self, key: K) -> Result<Option<V>> {
193        self.update_stats_request().await;
194
195        // Check cache first
196        if self.config.enable_cache {
197            if let Some(cached_value) = self.get_from_cache(&key).await {
198                self.update_stats_cache_hit().await;
199                return Ok(Some(cached_value));
200            }
201        }
202
203        self.update_stats_cache_miss().await;
204
205        // Create a oneshot channel for the result
206        let (tx, rx) = tokio::sync::oneshot::channel();
207
208        // Add to pending batch
209        self.add_to_batch(key, tx).await;
210
211        // Wait for result
212        rx.await.map_err(|_| anyhow!("DataLoader batch failed"))?
213    }
214
215    /// Load multiple values by keys
216    pub async fn load_many(&self, keys: Vec<K>) -> Result<HashMap<K, V>> {
217        if keys.is_empty() {
218            return Ok(HashMap::new());
219        }
220
221        // Check cache for all keys
222        let mut results = HashMap::new();
223        let mut missing_keys = Vec::new();
224
225        if self.config.enable_cache {
226            for key in keys {
227                match self.get_from_cache(&key).await {
228                    Some(cached_value) => {
229                        results.insert(key, cached_value);
230                        self.update_stats_cache_hit().await;
231                    }
232                    _ => {
233                        missing_keys.push(key);
234                        self.update_stats_cache_miss().await;
235                    }
236                }
237                self.update_stats_request().await;
238            }
239        } else {
240            missing_keys = keys;
241            for _ in &missing_keys {
242                self.update_stats_request().await;
243                self.update_stats_cache_miss().await;
244            }
245        }
246
247        if missing_keys.is_empty() {
248            return Ok(results);
249        }
250
251        // Load missing keys in batches
252        let batch_results = self.load_batch_direct(missing_keys).await?;
253        results.extend(batch_results);
254
255        Ok(results)
256    }
257
258    /// Prime the cache with a value
259    pub async fn prime(&self, key: K, value: V) {
260        if self.config.enable_cache {
261            self.set_in_cache(key, value).await;
262        }
263    }
264
265    /// Clear the cache
266    pub async fn clear_cache(&self) {
267        if self.config.enable_cache {
268            let mut cache = self.cache.write().await;
269            cache.clear();
270        }
271    }
272
273    /// Get performance statistics
274    pub async fn get_stats(&self) -> DataLoaderStats {
275        self.stats.read().await.clone()
276    }
277
278    /// Clear statistics
279    pub async fn clear_stats(&self) {
280        let mut stats = self.stats.write().await;
281        *stats = DataLoaderStats::default();
282    }
283
284    async fn get_from_cache(&self, key: &K) -> Option<V> {
285        let cache = self.cache.read().await;
286        if let Some(entry) = cache.get(key) {
287            if !entry.is_expired() {
288                return Some(entry.value.clone());
289            }
290        }
291        None
292    }
293
294    async fn set_in_cache(&self, key: K, value: V) {
295        let mut cache = self.cache.write().await;
296
297        // Evict expired entries
298        self.evict_expired_entries(&mut cache);
299
300        // Enforce size limit
301        if cache.len() >= self.config.max_cache_size {
302            // Simple LRU: remove one entry
303            if let Some(first_key) = cache.keys().next().cloned() {
304                cache.remove(&first_key);
305            }
306        }
307
308        cache.insert(key, CachedEntry::new(value, self.config.cache_ttl));
309    }
310
311    fn evict_expired_entries(&self, cache: &mut HashMap<K, CachedEntry<V>>) {
312        let expired_keys: Vec<K> = cache
313            .iter()
314            .filter(|(_, entry)| entry.is_expired())
315            .map(|(key, _)| key.clone())
316            .collect();
317
318        for key in expired_keys {
319            cache.remove(&key);
320        }
321    }
322
323    async fn add_to_batch(
324        &self,
325        key: K,
326        resolver: tokio::sync::oneshot::Sender<Result<Option<V>>>,
327    ) {
328        let mut pending = self.pending_batch.lock().await;
329        if pending.is_none() {
330            *pending = Some(PendingBatch::new());
331        }
332
333        if let Some(batch) = pending.as_mut() {
334            batch.add_request(key, resolver);
335        }
336    }
337
338    async fn load_batch_direct(&self, keys: Vec<K>) -> Result<HashMap<K, V>> {
339        let start_time = Instant::now();
340        let result = self.batch_fn.load(keys).await;
341        let load_time = start_time.elapsed();
342
343        // Update stats
344        self.update_stats_batch_dispatched(load_time).await;
345
346        match result {
347            Ok(loaded_values) => {
348                // Cache the results
349                if self.config.enable_cache {
350                    for (key, value) in &loaded_values {
351                        self.set_in_cache(key.clone(), value.clone()).await;
352                    }
353                }
354                Ok(loaded_values)
355            }
356            Err(e) => Err(e),
357        }
358    }
359
360    fn start_batch_dispatcher(&self) -> tokio::task::JoinHandle<()> {
361        let pending_batch = Arc::clone(&self.pending_batch);
362        let batch_fn = Arc::clone(&self.batch_fn);
363        let config = self.config.clone();
364        let cache = Arc::clone(&self.cache);
365        let stats = Arc::clone(&self.stats);
366
367        tokio::spawn(async move {
368            loop {
369                sleep(config.batch_delay).await;
370
371                let batch_to_dispatch = {
372                    let mut pending = pending_batch.lock().await;
373                    if let Some(batch) = pending.as_ref() {
374                        if batch.should_dispatch(&config) {
375                            pending.take()
376                        } else {
377                            None
378                        }
379                    } else {
380                        None
381                    }
382                };
383
384                if let Some(batch) = batch_to_dispatch {
385                    Self::dispatch_batch(batch, &batch_fn, &config, &cache, &stats).await;
386                }
387            }
388        })
389    }
390
391    async fn dispatch_batch(
392        batch: PendingBatch<K, V>,
393        batch_fn: &Arc<dyn BatchLoadFn<K, V>>,
394        config: &DataLoaderConfig,
395        cache: &Arc<RwLock<HashMap<K, CachedEntry<V>>>>,
396        stats: &Arc<RwLock<DataLoaderStats>>,
397    ) {
398        let start_time = Instant::now();
399        let batch_size = batch.keys.len();
400
401        // Update stats
402        {
403            let mut stats = stats.write().await;
404            stats.batches_dispatched += 1;
405            stats.avg_batch_size =
406                (stats.avg_batch_size + batch_size as f64) / stats.batches_dispatched as f64;
407        }
408
409        match batch_fn.load(batch.keys.clone()).await {
410            Ok(loaded_values) => {
411                let load_time = start_time.elapsed();
412
413                // Update load time stats
414                {
415                    let mut stats = stats.write().await;
416                    stats.total_load_time += load_time;
417                }
418
419                // Cache results if enabled
420                if config.enable_cache {
421                    let mut cache = cache.write().await;
422                    for (key, value) in &loaded_values {
423                        cache.insert(
424                            key.clone(),
425                            CachedEntry::new(value.clone(), config.cache_ttl),
426                        );
427                    }
428                }
429
430                // Send results to resolvers
431                for (key, resolver) in batch.keys.into_iter().zip(batch.resolvers) {
432                    let result = loaded_values.get(&key).cloned();
433                    let _ = resolver.send(Ok(result));
434                }
435            }
436            Err(e) => {
437                // Send error to all resolvers
438                for resolver in batch.resolvers {
439                    let _ = resolver.send(Err(anyhow!("Batch load failed: {}", e)));
440                }
441            }
442        }
443    }
444
445    async fn update_stats_request(&self) {
446        let mut stats = self.stats.write().await;
447        stats.requests_total += 1;
448    }
449
450    async fn update_stats_cache_hit(&self) {
451        let mut stats = self.stats.write().await;
452        stats.cache_hits += 1;
453    }
454
455    async fn update_stats_cache_miss(&self) {
456        let mut stats = self.stats.write().await;
457        stats.cache_misses += 1;
458    }
459
460    async fn update_stats_batch_dispatched(&self, load_time: Duration) {
461        let mut stats = self.stats.write().await;
462        stats.batches_dispatched += 1;
463        stats.total_load_time += load_time;
464    }
465}
466
467impl<K, V> Drop for DataLoader<K, V>
468where
469    K: Send + Sync + Clone + Hash + Eq + 'static,
470    V: Send + Sync + Clone + 'static,
471{
472    fn drop(&mut self) {
473        // Abort the background dispatcher so a short-lived (e.g. per-request)
474        // DataLoader does not leak an infinite polling task for the lifetime of
475        // the process.
476        if let Some(handle) = &self.dispatcher_handle {
477            handle.abort();
478        }
479    }
480}
481
482/// Factory for creating common DataLoaders
483pub struct DataLoaderFactory {
484    default_config: DataLoaderConfig,
485}
486
487impl DataLoaderFactory {
488    pub fn new() -> Self {
489        Self {
490            default_config: DataLoaderConfig::default(),
491        }
492    }
493
494    pub fn with_config(config: DataLoaderConfig) -> Self {
495        Self {
496            default_config: config,
497        }
498    }
499
500    /// Create a DataLoader for loading RDF subjects
501    pub fn create_subject_loader(
502        &self,
503        store: Arc<crate::RdfStore>,
504    ) -> DataLoader<String, serde_json::Value> {
505        let batch_fn = Arc::new(SubjectBatchLoader { store });
506        DataLoader::with_config(batch_fn, self.default_config.clone())
507    }
508
509    /// Create a DataLoader for loading RDF predicates
510    pub fn create_predicate_loader(
511        &self,
512        store: Arc<crate::RdfStore>,
513    ) -> DataLoader<String, Vec<String>> {
514        let batch_fn = Arc::new(PredicateBatchLoader { store });
515        DataLoader::with_config(batch_fn, self.default_config.clone())
516    }
517}
518
519impl Default for DataLoaderFactory {
520    fn default() -> Self {
521        Self::new()
522    }
523}
524
525/// Batch loader for RDF subjects
526struct SubjectBatchLoader {
527    store: Arc<crate::RdfStore>,
528}
529
530#[async_trait]
531impl BatchLoadFn<String, serde_json::Value> for SubjectBatchLoader {
532    async fn load(&self, keys: Vec<String>) -> Result<HashMap<String, serde_json::Value>> {
533        let mut results = HashMap::new();
534
535        for key in keys {
536            // Build SPARQL query for subject
537            let query = format!("SELECT ?p ?o WHERE {{ <{key}> ?p ?o }}");
538
539            match self.store.query(&query) {
540                Ok(crate::QueryResults::Solutions(solutions)) => {
541                    let mut triples = Vec::new();
542                    for solution in solutions {
543                        if let (Some(p), Some(o)) = (
544                            solution.get(
545                                &oxirs_core::model::Variable::new("p")
546                                    .expect("parse should succeed for valid input"),
547                            ),
548                            solution.get(
549                                &oxirs_core::model::Variable::new("o")
550                                    .expect("parse should succeed for valid input"),
551                            ),
552                        ) {
553                            triples.push(serde_json::json!({
554                                "predicate": p.to_string(),
555                                "object": o.to_string()
556                            }));
557                        }
558                    }
559                    results.insert(key, serde_json::json!(triples));
560                }
561                _ => {
562                    // Insert empty result for subjects with no data
563                    results.insert(key, serde_json::json!([]));
564                }
565            }
566        }
567
568        Ok(results)
569    }
570}
571
572/// Batch loader for RDF predicates
573struct PredicateBatchLoader {
574    store: Arc<crate::RdfStore>,
575}
576
577#[async_trait]
578impl BatchLoadFn<String, Vec<String>> for PredicateBatchLoader {
579    async fn load(&self, keys: Vec<String>) -> Result<HashMap<String, Vec<String>>> {
580        let mut results = HashMap::new();
581
582        for key in keys {
583            // Build SPARQL query for predicate values
584            let query = format!("SELECT DISTINCT ?s WHERE {{ ?s <{key}> ?o }}");
585
586            match self.store.query(&query) {
587                Ok(crate::QueryResults::Solutions(solutions)) => {
588                    let mut subjects = Vec::new();
589                    for solution in solutions {
590                        if let Some(s) = solution.get(
591                            &oxirs_core::model::Variable::new("s")
592                                .expect("parse should succeed for valid input"),
593                        ) {
594                            subjects.push(s.to_string());
595                        }
596                    }
597                    results.insert(key, subjects);
598                }
599                _ => {
600                    results.insert(key, Vec::new());
601                }
602            }
603        }
604
605        Ok(results)
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    struct TestBatchLoader;
614
615    #[async_trait]
616    impl BatchLoadFn<i32, String> for TestBatchLoader {
617        async fn load(&self, keys: Vec<i32>) -> Result<HashMap<i32, String>> {
618            // Simulate some work
619            sleep(Duration::from_millis(10)).await;
620
621            let mut results = HashMap::new();
622            for key in keys {
623                results.insert(key, format!("value_{key}"));
624            }
625            Ok(results)
626        }
627    }
628
629    #[tokio::test]
630    async fn test_dataloader_basic() {
631        let batch_fn = Arc::new(TestBatchLoader);
632        let loader = DataLoader::new(batch_fn);
633
634        let result = loader.load(1).await.expect("should succeed");
635        assert_eq!(result, Some("value_1".to_string()));
636
637        let stats = loader.get_stats().await;
638        assert_eq!(stats.requests_total, 1);
639    }
640
641    #[tokio::test]
642    async fn test_dataloader_caching() {
643        let batch_fn = Arc::new(TestBatchLoader);
644        let loader = DataLoader::new(batch_fn);
645
646        // First load - cache miss
647        let result1 = loader.load(1).await.expect("should succeed");
648        assert_eq!(result1, Some("value_1".to_string()));
649
650        // Second load - cache hit
651        let result2 = loader.load(1).await.expect("should succeed");
652        assert_eq!(result2, Some("value_1".to_string()));
653
654        let stats = loader.get_stats().await;
655        assert_eq!(stats.requests_total, 2);
656        assert_eq!(stats.cache_hits, 1);
657        assert_eq!(stats.cache_misses, 1);
658    }
659
660    #[tokio::test]
661    async fn test_dataloader_load_many() {
662        let batch_fn = Arc::new(TestBatchLoader);
663        let loader = DataLoader::new(batch_fn);
664
665        let keys = vec![1, 2, 3];
666        let results = loader.load_many(keys).await.expect("should succeed");
667
668        assert_eq!(results.len(), 3);
669        assert_eq!(results.get(&1), Some(&"value_1".to_string()));
670        assert_eq!(results.get(&2), Some(&"value_2".to_string()));
671        assert_eq!(results.get(&3), Some(&"value_3".to_string()));
672    }
673
674    #[tokio::test]
675    async fn regression_dispatcher_task_aborted_on_shutdown() {
676        let batch_fn = Arc::new(TestBatchLoader);
677        let loader = DataLoader::new(batch_fn);
678
679        // A dispatcher task must have been spawned and retained.
680        assert!(loader.dispatcher_handle.is_some());
681
682        loader.shutdown();
683        // Give the runtime a moment to process the abort.
684        for _ in 0..20 {
685            if loader
686                .dispatcher_handle
687                .as_ref()
688                .map(|h| h.is_finished())
689                .unwrap_or(false)
690            {
691                break;
692            }
693            tokio::time::sleep(Duration::from_millis(10)).await;
694        }
695        assert!(
696            loader
697                .dispatcher_handle
698                .as_ref()
699                .expect("handle")
700                .is_finished(),
701            "dispatcher task must be aborted after shutdown"
702        );
703    }
704
705    #[tokio::test]
706    async fn regression_drop_runs_without_panic() {
707        // Constructing and dropping a per-request loader must not panic and must
708        // abort its background task (exercised via the Drop impl).
709        {
710            let loader = DataLoader::new(Arc::new(TestBatchLoader));
711            let _ = loader.load(1).await.expect("load ok");
712        }
713        // If Drop leaked/hung the task, subsequent scheduling would still work;
714        // reaching here without hang/panic is the assertion.
715        tokio::task::yield_now().await;
716    }
717}