Skip to main content

oxirs_gql/
batch_resolver.rs

1//! DataLoader pattern for GraphQL N+1 resolution.
2//!
3//! Implements the classic DataLoader pattern that batches individual key lookups
4//! into a single batch call, dramatically reducing N+1 query problems in GraphQL
5//! resolvers. Includes an LRU-style per-request cache.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10/// Errors that can occur during batch loading.
11#[derive(Debug)]
12pub enum BatchError {
13    LoadFailed(String),
14    KeyNotFound(String),
15}
16
17impl std::fmt::Display for BatchError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            BatchError::LoadFailed(msg) => write!(f, "Batch load failed: {}", msg),
21            BatchError::KeyNotFound(key) => write!(f, "Key not found: {}", key),
22        }
23    }
24}
25
26impl std::error::Error for BatchError {}
27
28/// A trait for types that can batch-load items by string key.
29pub trait BatchLoader: Send + Sync {
30    type Item: Clone + Send;
31
32    /// Load multiple items at once. Returns a map from key to item.
33    /// Keys not present in the returned map are treated as missing.
34    fn load_batch(&self, keys: &[String]) -> Result<HashMap<String, Self::Item>, BatchError>;
35}
36
37/// A DataLoader that batches and caches item lookups.
38///
39/// Items are queued individually via `queue()`, then fetched all at once
40/// with `resolve_all()`. Subsequent lookups of cached keys skip the loader.
41pub struct BatchResolver<T: Clone + Send> {
42    loader: Arc<dyn BatchLoader<Item = T>>,
43    max_batch_size: usize,
44    cache: HashMap<String, T>,
45    queue: Vec<String>,
46    batches_executed: usize,
47}
48
49impl<T: Clone + Send + 'static> BatchResolver<T> {
50    /// Create a new `BatchResolver` with the given loader and maximum batch size.
51    ///
52    /// `max_batch_size` controls how many keys are sent per `load_batch` call.
53    /// A value of 0 is treated as unlimited (all queued keys in one batch).
54    pub fn new(loader: Arc<dyn BatchLoader<Item = T>>, max_batch_size: usize) -> Self {
55        BatchResolver {
56            loader,
57            max_batch_size,
58            cache: HashMap::new(),
59            queue: Vec::new(),
60            batches_executed: 0,
61        }
62    }
63
64    /// Add a key to the pending queue.
65    ///
66    /// Duplicates are ignored: a key already in the cache or already queued
67    /// will not be queued again.
68    pub fn queue(&mut self, key: impl Into<String>) {
69        let key = key.into();
70        if !self.cache.contains_key(&key) && !self.queue.contains(&key) {
71            self.queue.push(key);
72        }
73    }
74
75    /// Execute all pending batches, populating the internal cache.
76    ///
77    /// Keys are split into batches of at most `max_batch_size` and passed to
78    /// the loader. After this call `pending_count()` returns 0.
79    pub fn resolve_all(&mut self) -> Result<(), BatchError> {
80        if self.queue.is_empty() {
81            return Ok(());
82        }
83
84        let keys: Vec<String> = self.queue.drain(..).collect();
85
86        // Split into batches
87        let batch_size = if self.max_batch_size == 0 {
88            keys.len()
89        } else {
90            self.max_batch_size
91        };
92
93        for chunk in keys.chunks(batch_size) {
94            let loaded = self.loader.load_batch(chunk)?;
95            for (k, v) in loaded {
96                self.cache.insert(k, v);
97            }
98            self.batches_executed += 1;
99        }
100
101        Ok(())
102    }
103
104    /// Retrieve a cached item by key.
105    ///
106    /// Returns `None` if the key was never resolved or was missing from the loader.
107    pub fn get(&self, key: &str) -> Option<&T> {
108        self.cache.get(key)
109    }
110
111    /// Number of items in the cache.
112    pub fn cache_size(&self) -> usize {
113        self.cache.len()
114    }
115
116    /// Clear the internal cache (does not clear the queue).
117    pub fn clear_cache(&mut self) {
118        self.cache.clear();
119    }
120
121    /// Number of keys currently pending resolution.
122    pub fn pending_count(&self) -> usize {
123        self.queue.len()
124    }
125
126    /// Total number of `load_batch` calls made so far.
127    pub fn batches_executed(&self) -> usize {
128        self.batches_executed
129    }
130}
131
132// ── Concrete test loader ──────────────────────────────────────────────────────
133
134/// A test loader that uppercases keys.
135///
136/// - If a key starts with `"error:"` the whole batch fails.
137/// - If a key starts with `"missing:"` the key is omitted from the result.
138pub struct UppercaseLoader;
139
140impl BatchLoader for UppercaseLoader {
141    type Item = String;
142
143    fn load_batch(&self, keys: &[String]) -> Result<HashMap<String, String>, BatchError> {
144        for key in keys {
145            if key.starts_with("error:") {
146                return Err(BatchError::LoadFailed(key.clone()));
147            }
148        }
149        let mut map = HashMap::new();
150        for key in keys {
151            if !key.starts_with("missing:") {
152                map.insert(key.clone(), key.to_uppercase());
153            }
154        }
155        Ok(map)
156    }
157}
158
159/// A loader that counts how many times `load_batch` is called.
160pub struct CountingLoader {
161    pub call_count: std::sync::atomic::AtomicUsize,
162}
163
164impl Default for CountingLoader {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl CountingLoader {
171    pub fn new() -> Self {
172        CountingLoader {
173            call_count: std::sync::atomic::AtomicUsize::new(0),
174        }
175    }
176
177    pub fn calls(&self) -> usize {
178        self.call_count.load(std::sync::atomic::Ordering::SeqCst)
179    }
180}
181
182impl BatchLoader for CountingLoader {
183    type Item = String;
184
185    fn load_batch(&self, keys: &[String]) -> Result<HashMap<String, String>, BatchError> {
186        self.call_count
187            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
188        Ok(keys.iter().map(|k| (k.clone(), k.to_uppercase())).collect())
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn uppercase_resolver(max_batch: usize) -> BatchResolver<String> {
197        BatchResolver::new(Arc::new(UppercaseLoader), max_batch)
198    }
199
200    // ── Single key ────────────────────────────────────────────────────────────
201
202    #[test]
203    fn test_single_key_resolve() {
204        let mut r = uppercase_resolver(10);
205        r.queue("hello");
206        r.resolve_all().expect("should succeed");
207        assert_eq!(r.get("hello"), Some(&"HELLO".to_string()));
208    }
209
210    #[test]
211    fn test_single_key_before_resolve_returns_none() {
212        let r = uppercase_resolver(10);
213        assert_eq!(r.get("anything"), None);
214    }
215
216    // ── Multiple keys ─────────────────────────────────────────────────────────
217
218    #[test]
219    fn test_multiple_keys_single_batch() {
220        let mut r = uppercase_resolver(100);
221        r.queue("a");
222        r.queue("b");
223        r.queue("c");
224        r.resolve_all().expect("should succeed");
225        assert_eq!(r.get("a"), Some(&"A".to_string()));
226        assert_eq!(r.get("b"), Some(&"B".to_string()));
227        assert_eq!(r.get("c"), Some(&"C".to_string()));
228    }
229
230    // ── Batch splitting ───────────────────────────────────────────────────────
231
232    #[test]
233    fn test_batch_splitting_into_two() {
234        let mut r = uppercase_resolver(2);
235        r.queue("a");
236        r.queue("b");
237        r.queue("c");
238        r.queue("d");
239        r.resolve_all().expect("should succeed");
240        // 4 keys / 2 per batch = 2 batches
241        assert_eq!(r.batches_executed(), 2);
242        assert_eq!(r.get("a"), Some(&"A".to_string()));
243        assert_eq!(r.get("d"), Some(&"D".to_string()));
244    }
245
246    #[test]
247    fn test_batch_splitting_exact_multiple() {
248        let mut r = uppercase_resolver(3);
249        for k in ["x1", "x2", "x3", "x4", "x5", "x6"] {
250            r.queue(k);
251        }
252        r.resolve_all().expect("should succeed");
253        assert_eq!(r.batches_executed(), 2);
254    }
255
256    #[test]
257    fn test_batch_splitting_uneven() {
258        let mut r = uppercase_resolver(3);
259        for k in ["x1", "x2", "x3", "x4", "x5"] {
260            r.queue(k);
261        }
262        r.resolve_all().expect("should succeed");
263        assert_eq!(r.batches_executed(), 2); // 3 + 2
264    }
265
266    // ── Cache hits ────────────────────────────────────────────────────────────
267
268    #[test]
269    fn test_cache_hit_avoids_re_load() {
270        let loader = Arc::new(CountingLoader::new());
271        let loader_dyn: Arc<dyn BatchLoader<Item = String>> =
272            Arc::clone(&loader) as Arc<dyn BatchLoader<Item = String>>;
273        let mut r: BatchResolver<String> = BatchResolver::new(loader_dyn, 10);
274
275        r.queue("key");
276        r.resolve_all().expect("should succeed");
277        assert_eq!(loader.calls(), 1);
278
279        // Queue same key again — should be skipped (already cached)
280        r.queue("key");
281        r.resolve_all().expect("should succeed");
282        assert_eq!(loader.calls(), 1); // no new call
283    }
284
285    #[test]
286    fn test_cache_size_after_resolve() {
287        let mut r = uppercase_resolver(10);
288        r.queue("a");
289        r.queue("b");
290        r.resolve_all().expect("should succeed");
291        assert_eq!(r.cache_size(), 2);
292    }
293
294    // ── clear_cache ───────────────────────────────────────────────────────────
295
296    #[test]
297    fn test_clear_cache() {
298        let mut r = uppercase_resolver(10);
299        r.queue("a");
300        r.resolve_all().expect("should succeed");
301        assert_eq!(r.cache_size(), 1);
302        r.clear_cache();
303        assert_eq!(r.cache_size(), 0);
304        assert_eq!(r.get("a"), None);
305    }
306
307    #[test]
308    fn test_clear_cache_allows_reload() {
309        let loader = Arc::new(CountingLoader::new());
310        let loader_dyn: Arc<dyn BatchLoader<Item = String>> =
311            Arc::clone(&loader) as Arc<dyn BatchLoader<Item = String>>;
312        let mut r: BatchResolver<String> = BatchResolver::new(loader_dyn, 10);
313
314        r.queue("key");
315        r.resolve_all().expect("should succeed");
316        assert_eq!(loader.calls(), 1);
317
318        r.clear_cache();
319
320        r.queue("key");
321        r.resolve_all().expect("should succeed");
322        assert_eq!(loader.calls(), 2); // reloaded after cache clear
323    }
324
325    // ── pending_count ─────────────────────────────────────────────────────────
326
327    #[test]
328    fn test_pending_count_before_resolve() {
329        let mut r = uppercase_resolver(10);
330        assert_eq!(r.pending_count(), 0);
331        r.queue("a");
332        r.queue("b");
333        assert_eq!(r.pending_count(), 2);
334    }
335
336    #[test]
337    fn test_pending_count_after_resolve() {
338        let mut r = uppercase_resolver(10);
339        r.queue("a");
340        r.resolve_all().expect("should succeed");
341        assert_eq!(r.pending_count(), 0);
342    }
343
344    // ── batches_executed ──────────────────────────────────────────────────────
345
346    #[test]
347    fn test_batches_executed_zero_initially() {
348        let r = uppercase_resolver(10);
349        assert_eq!(r.batches_executed(), 0);
350    }
351
352    #[test]
353    fn test_batches_executed_increments() {
354        let mut r = uppercase_resolver(1);
355        r.queue("a");
356        r.queue("b");
357        r.resolve_all().expect("should succeed");
358        assert_eq!(r.batches_executed(), 2);
359    }
360
361    #[test]
362    fn test_batches_executed_accumulates() {
363        let mut r = uppercase_resolver(10);
364        r.queue("a");
365        r.resolve_all().expect("should succeed");
366        r.queue("b");
367        r.resolve_all().expect("should succeed");
368        assert_eq!(r.batches_executed(), 2);
369    }
370
371    // ── Error propagation ─────────────────────────────────────────────────────
372
373    #[test]
374    fn test_error_propagation() {
375        let mut r = uppercase_resolver(10);
376        r.queue("error:bad");
377        let result = r.resolve_all();
378        assert!(result.is_err());
379        match result.unwrap_err() {
380            BatchError::LoadFailed(msg) => assert!(msg.contains("error:bad")),
381            e => panic!("wrong error: {:?}", e),
382        }
383    }
384
385    #[test]
386    fn test_error_in_second_batch() {
387        let mut r = uppercase_resolver(2);
388        r.queue("ok1");
389        r.queue("ok2");
390        r.queue("error:fail");
391        let result = r.resolve_all();
392        assert!(result.is_err());
393    }
394
395    // ── Deduplication ─────────────────────────────────────────────────────────
396
397    #[test]
398    fn test_dedup_queued_keys() {
399        let mut r = uppercase_resolver(10);
400        r.queue("a");
401        r.queue("a");
402        r.queue("a");
403        assert_eq!(r.pending_count(), 1);
404    }
405
406    #[test]
407    fn test_dedup_does_not_re_queue_cached() {
408        let mut r = uppercase_resolver(10);
409        r.queue("a");
410        r.resolve_all().expect("should succeed");
411        r.queue("a"); // already cached
412        assert_eq!(r.pending_count(), 0);
413    }
414
415    // ── Missing keys ──────────────────────────────────────────────────────────
416
417    #[test]
418    fn test_missing_key_returns_none() {
419        let mut r = uppercase_resolver(10);
420        r.queue("missing:nothere");
421        r.resolve_all().expect("should succeed");
422        assert_eq!(r.get("missing:nothere"), None);
423    }
424
425    #[test]
426    fn test_missing_key_does_not_affect_cache_size() {
427        let mut r = uppercase_resolver(10);
428        r.queue("ok");
429        r.queue("missing:nope");
430        r.resolve_all().expect("should succeed");
431        assert_eq!(r.cache_size(), 1);
432    }
433
434    // ── resolve_all with empty queue ──────────────────────────────────────────
435
436    #[test]
437    fn test_resolve_all_empty_queue_ok() {
438        let mut r = uppercase_resolver(10);
439        let result = r.resolve_all();
440        assert!(result.is_ok());
441        assert_eq!(r.batches_executed(), 0);
442    }
443
444    // ── max_batch_size = 0 (unlimited) ────────────────────────────────────────
445
446    #[test]
447    fn test_unlimited_batch_size() {
448        let mut r = uppercase_resolver(0);
449        for i in 0..100 {
450            r.queue(format!("key{}", i));
451        }
452        r.resolve_all().expect("should succeed");
453        // All in one batch
454        assert_eq!(r.batches_executed(), 1);
455        assert_eq!(r.cache_size(), 100);
456    }
457
458    // ── Correct values returned ───────────────────────────────────────────────
459
460    #[test]
461    fn test_correct_uppercase_values() {
462        let mut r = uppercase_resolver(10);
463        r.queue("foo");
464        r.queue("bar");
465        r.queue("baz");
466        r.resolve_all().expect("should succeed");
467        assert_eq!(r.get("foo"), Some(&"FOO".to_string()));
468        assert_eq!(r.get("bar"), Some(&"BAR".to_string()));
469        assert_eq!(r.get("baz"), Some(&"BAZ".to_string()));
470    }
471
472    // ── Two resolvers share same loader ───────────────────────────────────────
473
474    #[test]
475    fn test_two_resolvers_independent_cache() {
476        let loader1: Arc<dyn BatchLoader<Item = String>> = Arc::new(UppercaseLoader);
477        let loader2: Arc<dyn BatchLoader<Item = String>> = Arc::new(UppercaseLoader);
478        let mut r1: BatchResolver<String> = BatchResolver::new(loader1, 10);
479        let mut r2: BatchResolver<String> = BatchResolver::new(loader2, 10);
480
481        r1.queue("hello");
482        r1.resolve_all().expect("should succeed");
483
484        // r2 has its own independent cache
485        assert_eq!(r2.get("hello"), None);
486        r2.queue("hello");
487        r2.resolve_all().expect("should succeed");
488        assert_eq!(r2.get("hello"), Some(&"HELLO".to_string()));
489    }
490}