Coverage for src/lexigram/graphql/dataloader/loader.py: 94%
77 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""DataLoaderProtocol implementation.
3This module provides a DataLoaderProtocol implementation for batching
4and caching data fetches in GraphQL resolvers to solve the N+1 problem.
5"""
7from __future__ import annotations
9import asyncio
10from collections.abc import Awaitable, Callable, Hashable, Sequence
11from typing import (
12 Generic,
13 TypeVar,
14)
16from lexigram.graphql.config import DataLoaderConfig
17from lexigram.graphql.dataloader.cache import InMemoryCache, LoaderCache
18from lexigram.logging import get_logger
20logger = get_logger(__name__)
22K = TypeVar("K", bound=Hashable)
23V = TypeVar("V")
26class DataLoaderProtocol(Generic[K, V]):
27 """DataLoaderProtocol for batching and caching.
29 DataLoaderProtocol is a utility for batching and caching data fetches
30 to efficiently resolve GraphQL queries and avoid the N+1 problem.
32 Example:
33 ```python
34 async def batch_load_users(ids: list[str]) -> list[User | None]:
35 users = await db.get_users_by_ids(ids)
36 # Return in same order as input ids
37 user_map = {u.id: u for u in users}
38 return list(map(lambda id: user_map.get(id), ids))
40 loader = DataLoaderProtocol(batch_load_users)
42 # These will be batched into a single call
43 user1 = await loader.load("1")
44 user2 = await loader.load("2")
45 ```
46 """
48 def __init__(
49 self,
50 batch_fn: Callable[[list[K]], Awaitable[list[V | None]]],
51 config: DataLoaderConfig | None = None,
52 cache: LoaderCache[K, V] | None = None,
53 ) -> None:
54 """Initialize the DataLoaderProtocol.
56 Args:
57 batch_fn: Function that loads a batch of values by keys.
58 config: DataLoaderProtocol configuration.
59 cache: Optional cache implementation.
60 """
61 self._batch_fn = batch_fn
62 self._config = config or DataLoaderConfig()
63 self._cache = cache if cache is not None else InMemoryCache[K, V]()
65 # Thread-safe state
66 self._lock = asyncio.Lock()
67 self._pending: dict[K, asyncio.Future[V | None]] = {}
68 self._dispatch_task: asyncio.Task[None] | None = None
70 @property
71 def config(self) -> DataLoaderConfig:
72 """Get configuration."""
73 return self._config
75 async def load(self, key: K) -> V | None:
76 """Load a value by key.
78 The load will be batched with other loads that occur
79 in the same tick of the event loop.
81 Args:
82 key: The key to load.
84 Returns:
85 The loaded value or None if not found.
87 Raises:
88 DataLoaderError: If loading fails.
89 """
90 # Check cache first
91 if self._config.cache_enabled and self._cache.has(key):
92 return self._cache.get(key)
94 # Acquire lock for pending dict
95 async with self._lock:
96 # Check if already pending
97 if key in self._pending:
98 future = self._pending[key]
99 else:
100 # Create new future
101 future = asyncio.Future[V | None]()
102 self._pending[key] = future
104 # Schedule dispatch if not already scheduled
105 if self._dispatch_task is None or self._dispatch_task.done():
106 self._dispatch_task = asyncio.create_task(self._dispatch_batch())
107 logger.debug("DataLoaderProtocol dispatch task scheduled")
109 # Wait for result (outside lock)
110 return await future
112 async def load_many(self, keys: Sequence[K]) -> list[V | None]:
113 """Load multiple values by keys.
115 Args:
116 keys: List of keys to load.
118 Returns:
119 List of values in the same order as keys.
120 """
121 return await asyncio.gather(*[self.load(key) for key in keys])
123 async def _dispatch_batch(self) -> None:
124 """Dispatch the pending batch."""
125 # Extract pending items (thread-safe)
126 async with self._lock:
127 if not self._pending:
128 return
130 # Get all pending keys
131 keys = list(self._pending.keys())
132 futures = dict(self._pending)
134 # Clear pending (others can add new ones)
135 self._pending.clear()
137 logger.debug("DataLoaderProtocol dispatching batch of %d keys", len(keys))
139 # Respect max batch size (outside lock)
140 if self._config.max_batch_size > 0:
141 # Split into chunks if needed
142 for i in range(0, len(keys), self._config.max_batch_size):
143 chunk_keys = keys[i : i + self._config.max_batch_size]
144 chunk_futures = {k: futures[k] for k in chunk_keys}
145 await self._execute_batch(chunk_keys, chunk_futures)
146 else:
147 await self._execute_batch(keys, futures)
149 async def _execute_batch(
150 self,
151 keys: list[K],
152 futures: dict[K, asyncio.Future[V | None]],
153 ) -> None:
154 """Execute a batch load.
156 Args:
157 keys: Keys to load in this batch.
158 futures: Futures to resolve for these keys.
159 """
160 try:
161 # Execute batch function
162 values = await self._batch_fn(keys)
164 # Validate response
165 if len(values) != len(keys):
166 raise ValueError(
167 f"batch_fn returned {len(values)} values for {len(keys)} keys. "
168 f"Must return same number of values as keys (use None for missing).",
169 )
171 # Resolve futures and cache results
172 for key, value in zip(keys, values, strict=True):
173 if self._config.cache_enabled and value is not None:
174 self._cache.set(key, value)
176 future = futures[key]
177 if not future.done():
178 future.set_result(value)
180 logger.debug("DataLoaderProtocol batch completed successfully")
182 except Exception as e: # noqa: BLE001 — dataloader must propagate any batch error to all pending futures
183 # Reject all pending futures
184 logger.error("DataLoaderProtocol batch failed: %s", e, exc_info=True)
186 for future in futures.values():
187 if not future.done():
188 future.set_exception(e)
190 def prime(self, key: K, value: V) -> None:
191 """Prime the cache with a value.
193 Use this to add values to the cache that were loaded
194 through other means.
196 Args:
197 key: The key.
198 value: The value to cache.
199 """
200 if self._config.cache_enabled:
201 self._cache.set(key, value)
203 def clear(self, key: K) -> None:
204 """Clear a cached value.
206 Args:
207 key: The key to clear.
208 """
209 self._cache.delete(key)
211 def clear_all(self) -> None:
212 """Clear all cached values."""
213 self._cache.clear()
216def create_loader(
217 batch_fn: Callable[[list[K]], Awaitable[list[V | None]]],
218 batch_size: int = 100,
219 cache_enabled: bool = True,
220) -> DataLoaderProtocol[K, V]:
221 """Create a DataLoaderProtocol (convenience function).
223 Args:
224 batch_fn: Batch load function.
225 batch_size: Maximum batch size.
226 cache_enabled: Whether to enable caching.
228 Returns:
229 Configured DataLoaderProtocol.
231 Example:
232 ```python
233 user_loader = create_loader(
234 batch_load_users,
235 batch_size=50,
236 )
238 user = await user_loader.load("123")
239 ```
240 """
241 config = DataLoaderConfig(
242 max_batch_size=batch_size,
243 cache_enabled=cache_enabled,
244 )
245 return DataLoaderProtocol(batch_fn, config=config)
248__all__ = ["DataLoaderProtocol", "create_loader"]