Coverage for src/lexigram/graphql/dataloader/batch.py: 91%
65 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"""Batch loading utilities.
3This module provides utilities for defining and executing
4batch load functions.
5"""
7from __future__ import annotations
9import asyncio
10from collections.abc import Awaitable, Callable, Hashable
11from typing import (
12 Any,
13 Generic,
14 TypeVar,
15)
17from lexigram.graphql.exceptions import GraphQLError
18from lexigram.logging import get_logger
20logger = get_logger(__name__)
22K = TypeVar("K", bound=Hashable)
23V = TypeVar("V")
26# Type alias for batch functions
27BatchFunction = Callable[[list[K]], Awaitable[list[V | None]]]
30def batch_load(
31 fn: (
32 Callable[[list[K]], Awaitable[list[V]]]
33 | Callable[[list[K]], Awaitable[dict[K, V]]]
34 ),
35 key_fn: Callable[[V], K] | None = None,
36) -> BatchFunction[K, V]:
37 """Create a batch load function from a loader.
39 This decorator/wrapper helps create properly ordered batch
40 functions from common data loading patterns.
42 Args:
43 fn: Function that loads data by keys.
44 key_fn: Optional function to extract key from value.
46 Returns:
47 Batch function that returns values in input order.
49 Example:
50 ```python
51 # From a function returning a list
52 @batch_load(key_fn=lambda u: u.id)
53 async def load_users(ids: list[str]) -> list[User]:
54 return await db.query(User).filter(User.id.in_(ids)).all()
56 # From a function returning a dict
57 @batch_load
58 async def load_users(ids: list[str]) -> dict[str, User]:
59 users = await db.query(User).filter(User.id.in_(ids)).all()
60 return {u.id: u for u in users}
61 ```
62 """
64 async def wrapper(keys: list[K]) -> list[V | None]:
65 try:
66 result = await fn(keys)
68 # Handle dict result
69 if isinstance(result, dict):
70 return [result.get(key) for key in keys]
72 # Handle list result - need key_fn to map
73 if key_fn is not None:
74 value_map = {key_fn(v): v for v in result}
75 return [value_map.get(key) for key in keys]
77 # If no key_fn and list returned, assume same order
78 if len(result) == len(keys):
79 return list(result)
81 raise GraphQLError("Cannot map batch results to keys without key_fn")
83 except Exception as _batch_err: # noqa: BLE001 — batch wrapper must log any error from user-supplied batch functions before re-raising
84 logger.exception("Batch load error")
85 raise
87 return wrapper
90class BatchScheduler(Generic[K, V]):
91 """Scheduler for batching async operations.
93 Collects operations and executes them in batches
94 for efficiency.
96 Example:
97 ```python
98 scheduler = BatchScheduler(
99 batch_fn=batch_load_users,
100 batch_size=50,
101 batch_delay_ms=10,
102 )
104 # Schedule loads
105 user1 = await scheduler.schedule("1")
106 user2 = await scheduler.schedule("2")
107 ```
108 """
110 def __init__(
111 self,
112 batch_fn: BatchFunction[K, V],
113 batch_size: int = 100,
114 batch_delay_ms: float = 2.0,
115 ) -> None:
116 """Initialize the scheduler.
118 Args:
119 batch_fn: Batch load function.
120 batch_size: Maximum batch size.
121 batch_delay_ms: Delay in milliseconds before executing the batch.
122 A small non-zero value (default 2ms) allows more keys to accumulate,
123 improving efficiency at a slight latency cost.
124 """
125 self._batch_fn = batch_fn
126 self._batch_size = batch_size
127 self._batch_delay_ms = batch_delay_ms
129 self._pending: list[tuple[K, asyncio.Future[V | None]]] = []
130 self._scheduled = False
131 self._background_tasks: set[asyncio.Task[Any]] = set()
133 async def schedule(self, key: K) -> V | None:
134 """Schedule a load operation.
136 Args:
137 key: Key to load.
139 Returns:
140 Loaded value.
141 """
142 future: asyncio.Future[V | None] = asyncio.Future()
143 self._pending.append((key, future))
145 # Schedule batch execution
146 if not self._scheduled:
147 self._scheduled = True
149 if self._batch_delay_ms > 0:
150 await asyncio.sleep(self._batch_delay_ms / 1000)
152 task = asyncio.create_task(self._execute())
153 self._background_tasks.add(task)
154 task.add_done_callback(self._background_tasks.discard)
156 # Execute immediately if batch is full
157 if len(self._pending) >= self._batch_size:
158 await self._execute()
160 return await future
162 async def _execute(self) -> None:
163 """Execute pending batch."""
164 self._scheduled = False
166 if not self._pending:
167 return
169 # Get pending items
170 items = list(self._pending)
171 self._pending = []
173 keys = [item[0] for item in items]
174 futures = [item[1] for item in items]
176 try:
177 values = await self._batch_fn(keys)
179 for future, value in zip(futures, values, strict=True):
180 if not future.done():
181 future.set_result(value)
183 except (RuntimeError, TypeError, ValueError) as e:
184 # Set exception on ALL pending futures that haven't been resolved
185 for future in futures:
186 if not future.done():
187 future.set_exception(e)
190__all__ = ["BatchFunction", "BatchScheduler", "batch_load"]