python-wrap 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
python_wrap/batching.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Coalesce many single-item calls into one batched call: `batch_calls`.
|
|
2
|
+
|
|
3
|
+
This is the DataLoader pattern. You write a function that takes a *list* of items and
|
|
4
|
+
returns their results; `batch_calls` turns it into a function that takes *one* item. Calls
|
|
5
|
+
that arrive close together are collected and sent to your function as a single batch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import concurrent.futures
|
|
12
|
+
import contextlib
|
|
13
|
+
import functools
|
|
14
|
+
import threading
|
|
15
|
+
import warnings
|
|
16
|
+
import weakref
|
|
17
|
+
from collections.abc import Awaitable, Callable, Coroutine, Hashable, Mapping, Sequence
|
|
18
|
+
from typing import Any, Concatenate, ParamSpec, Protocol, TypeGuard, TypeVar, cast, overload
|
|
19
|
+
|
|
20
|
+
from python_wrap import _core
|
|
21
|
+
from python_wrap.errors import BatchError
|
|
22
|
+
|
|
23
|
+
__all__ = ["AsyncBatched", "Batched", "batch_calls"]
|
|
24
|
+
|
|
25
|
+
P = ParamSpec("P")
|
|
26
|
+
P2 = ParamSpec("P2")
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
R = TypeVar("R")
|
|
29
|
+
S = TypeVar("S")
|
|
30
|
+
_O = TypeVar("_O")
|
|
31
|
+
R_co = TypeVar("R_co", covariant=True)
|
|
32
|
+
|
|
33
|
+
_GroupKey = tuple[int, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Batched(Protocol[P, R_co]):
|
|
37
|
+
"""A sync function wrapped by `batch_calls`: call it with one item, get its result."""
|
|
38
|
+
|
|
39
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
40
|
+
|
|
41
|
+
@overload
|
|
42
|
+
def __get__(
|
|
43
|
+
self: Batched[Concatenate[type[_O], P2], R_co], instance: None, owner: type[_O]
|
|
44
|
+
) -> Batched[P2, R_co]: ... # under @classmethod: bind the class
|
|
45
|
+
@overload
|
|
46
|
+
def __get__(self, instance: None, owner: type | None = None) -> Batched[P, R_co]: ...
|
|
47
|
+
@overload
|
|
48
|
+
def __get__(
|
|
49
|
+
self: Batched[Concatenate[type[_O], P2], R_co], instance: _O, owner: type | None = None
|
|
50
|
+
) -> Batched[P2, R_co]: ...
|
|
51
|
+
@overload
|
|
52
|
+
def __get__(
|
|
53
|
+
self: Batched[Concatenate[_O, P2], R_co], instance: _O, owner: type | None = None
|
|
54
|
+
) -> Batched[P2, R_co]: ...
|
|
55
|
+
@overload
|
|
56
|
+
def __get__(self, instance: object, owner: type | None = None) -> Batched[P, R_co]: ...
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def pending(self) -> int:
|
|
60
|
+
"""Number of distinct items queued and not yet sent to the batch function."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def flush(self) -> None:
|
|
64
|
+
"""Send every queued batch now, in the calling thread, and wait for it."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncBatched(Protocol[P, R_co]):
|
|
69
|
+
"""An `async def` function wrapped by `batch_calls`: `await loader(item)`."""
|
|
70
|
+
|
|
71
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R_co]: ...
|
|
72
|
+
|
|
73
|
+
@overload
|
|
74
|
+
def __get__(
|
|
75
|
+
self: AsyncBatched[Concatenate[type[_O], P2], R_co], instance: None, owner: type[_O]
|
|
76
|
+
) -> AsyncBatched[P2, R_co]: ... # under @classmethod: bind the class
|
|
77
|
+
@overload
|
|
78
|
+
def __get__(self, instance: None, owner: type | None = None) -> AsyncBatched[P, R_co]: ...
|
|
79
|
+
@overload
|
|
80
|
+
def __get__(
|
|
81
|
+
self: AsyncBatched[Concatenate[type[_O], P2], R_co], instance: _O, owner: type | None = None
|
|
82
|
+
) -> AsyncBatched[P2, R_co]: ...
|
|
83
|
+
@overload
|
|
84
|
+
def __get__(
|
|
85
|
+
self: AsyncBatched[Concatenate[_O, P2], R_co], instance: _O, owner: type | None = None
|
|
86
|
+
) -> AsyncBatched[P2, R_co]: ...
|
|
87
|
+
@overload
|
|
88
|
+
def __get__(self, instance: object, owner: type | None = None) -> AsyncBatched[P, R_co]: ...
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def pending(self) -> int:
|
|
92
|
+
"""Number of distinct items queued and not yet sent to the batch function."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
def aflush(self) -> Coroutine[Any, Any, None]:
|
|
96
|
+
"""Send every batch queued on the running loop now and wait for them to finish."""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _BatchDecorator(Protocol):
|
|
101
|
+
@overload
|
|
102
|
+
def __call__(
|
|
103
|
+
self, func: Callable[[list[T]], Awaitable[Sequence[R] | Mapping[Any, R]]], /
|
|
104
|
+
) -> AsyncBatched[[T], R]: ...
|
|
105
|
+
@overload
|
|
106
|
+
def __call__(
|
|
107
|
+
self, func: Callable[[S, list[T]], Awaitable[Sequence[R] | Mapping[Any, R]]], /
|
|
108
|
+
) -> AsyncBatched[[S, T], R]: ...
|
|
109
|
+
@overload
|
|
110
|
+
def __call__(
|
|
111
|
+
self, func: Callable[[list[T]], Sequence[R] | Mapping[Any, R]], /
|
|
112
|
+
) -> Batched[[T], R]: ...
|
|
113
|
+
@overload
|
|
114
|
+
def __call__(
|
|
115
|
+
self, func: Callable[[S, list[T]], Sequence[R] | Mapping[Any, R]], /
|
|
116
|
+
) -> Batched[[S, T], R]: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --------------------------------------------------------------------------- shared
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _is_mapping(value: object) -> TypeGuard[Mapping[Any, Any]]:
|
|
123
|
+
return isinstance(value, Mapping)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_sequence(value: object) -> TypeGuard[Sequence[Any]]:
|
|
127
|
+
return isinstance(value, Sequence)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _split(name: str, result: object, keys: list[Hashable]) -> list[tuple[bool, Any]]:
|
|
131
|
+
"""Match a batch result to its keys: ``(True, value)`` or ``(False, BatchError)``."""
|
|
132
|
+
if _is_mapping(result):
|
|
133
|
+
mapping = result
|
|
134
|
+
return [
|
|
135
|
+
(True, mapping[k])
|
|
136
|
+
if k in mapping
|
|
137
|
+
else (False, BatchError(f"{name}: the batch result has no entry for key {k!r}"))
|
|
138
|
+
for k in keys
|
|
139
|
+
]
|
|
140
|
+
if isinstance(result, (str, bytes, bytearray)) or not _is_sequence(result):
|
|
141
|
+
message = (
|
|
142
|
+
f"{name}: the batch function must return a sequence or a mapping, "
|
|
143
|
+
f"got {type(result).__name__}"
|
|
144
|
+
)
|
|
145
|
+
return [(False, BatchError(message)) for _ in keys]
|
|
146
|
+
values = result
|
|
147
|
+
if len(values) != len(keys):
|
|
148
|
+
message = f"{name}: the batch function returned {len(values)} results for {len(keys)} items"
|
|
149
|
+
return [(False, BatchError(message)) for _ in keys]
|
|
150
|
+
return [(True, v) for v in values]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _split_args(name: str, args: tuple[Any, ...]) -> tuple[tuple[Any, ...], Any]:
|
|
154
|
+
if not args:
|
|
155
|
+
raise TypeError(f"{name}() takes one item to load")
|
|
156
|
+
return args[:-1], args[-1]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _Counter:
|
|
160
|
+
"""Thread-safe count of queued items, mirrored onto ``wrapper.pending``."""
|
|
161
|
+
|
|
162
|
+
def __init__(self) -> None:
|
|
163
|
+
self.lock = threading.Lock()
|
|
164
|
+
self.value = 0
|
|
165
|
+
self.wrapper: Any = None
|
|
166
|
+
|
|
167
|
+
def add(self, n: int) -> None:
|
|
168
|
+
with self.lock:
|
|
169
|
+
self.value += n
|
|
170
|
+
self.wrapper.pending = self.value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------- sync
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _SyncEntry:
|
|
177
|
+
__slots__ = ("future", "item")
|
|
178
|
+
|
|
179
|
+
def __init__(self, item: Any) -> None:
|
|
180
|
+
self.item = item
|
|
181
|
+
self.future: concurrent.futures.Future[Any] = concurrent.futures.Future()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _SyncBatch:
|
|
185
|
+
__slots__ = ("entries", "group", "prefix")
|
|
186
|
+
|
|
187
|
+
def __init__(self, group: _GroupKey, prefix: tuple[Any, ...]) -> None:
|
|
188
|
+
self.group = group
|
|
189
|
+
self.prefix = prefix
|
|
190
|
+
self.entries: dict[Hashable, _SyncEntry] = {}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _batch_sync(
|
|
194
|
+
func: Callable[..., Any],
|
|
195
|
+
max_size: int,
|
|
196
|
+
max_wait: float,
|
|
197
|
+
key_of: Callable[[Any], Hashable],
|
|
198
|
+
) -> Callable[..., Any]:
|
|
199
|
+
name = _core.qualname(func)
|
|
200
|
+
lock = threading.Lock()
|
|
201
|
+
open_batches: dict[_GroupKey, _SyncBatch] = {}
|
|
202
|
+
counter = _Counter()
|
|
203
|
+
warned = False
|
|
204
|
+
|
|
205
|
+
def warn_if_in_event_loop() -> None:
|
|
206
|
+
nonlocal warned
|
|
207
|
+
if asyncio._get_running_loop() is None:
|
|
208
|
+
return
|
|
209
|
+
warned = True
|
|
210
|
+
warnings.warn(
|
|
211
|
+
f"{name} is a sync batch_calls loader called from a running event loop: it "
|
|
212
|
+
"blocks the loop while it waits for its batch (up to max_wait) and returns a "
|
|
213
|
+
"plain value, not a coroutine, so it cannot be awaited or passed to "
|
|
214
|
+
"asyncio.create_task(); decorate an `async def` batch function instead",
|
|
215
|
+
RuntimeWarning,
|
|
216
|
+
stacklevel=3,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def detach(batch: _SyncBatch) -> bool:
|
|
220
|
+
"""Close ``batch`` to new items. Call with ``lock`` held. False if already closed."""
|
|
221
|
+
if open_batches.get(batch.group) is not batch:
|
|
222
|
+
return False
|
|
223
|
+
del open_batches[batch.group]
|
|
224
|
+
counter.add(-len(batch.entries))
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
def run(batch: _SyncBatch) -> None:
|
|
228
|
+
keys = list(batch.entries)
|
|
229
|
+
entries = list(batch.entries.values())
|
|
230
|
+
try:
|
|
231
|
+
result = func(*batch.prefix, [e.item for e in entries])
|
|
232
|
+
outcomes = _split(name, result, keys)
|
|
233
|
+
except BaseException as exc:
|
|
234
|
+
for e in entries:
|
|
235
|
+
e.future.set_exception(exc)
|
|
236
|
+
if not isinstance(exc, Exception):
|
|
237
|
+
raise
|
|
238
|
+
return
|
|
239
|
+
for e, (ok, value) in zip(entries, outcomes, strict=True):
|
|
240
|
+
if ok:
|
|
241
|
+
e.future.set_result(value)
|
|
242
|
+
else:
|
|
243
|
+
e.future.set_exception(value)
|
|
244
|
+
|
|
245
|
+
@functools.wraps(func)
|
|
246
|
+
def wrapper(*args: Any) -> Any:
|
|
247
|
+
if not warned:
|
|
248
|
+
warn_if_in_event_loop()
|
|
249
|
+
prefix, item = _split_args(name, args)
|
|
250
|
+
k = key_of(item)
|
|
251
|
+
group = tuple(map(id, prefix))
|
|
252
|
+
with lock:
|
|
253
|
+
batch = open_batches.get(group)
|
|
254
|
+
leader = batch is None
|
|
255
|
+
if batch is None:
|
|
256
|
+
batch = open_batches[group] = _SyncBatch(group, prefix)
|
|
257
|
+
entry = batch.entries.get(k)
|
|
258
|
+
if entry is None:
|
|
259
|
+
entry = batch.entries[k] = _SyncEntry(item)
|
|
260
|
+
counter.add(1)
|
|
261
|
+
future = entry.future
|
|
262
|
+
full = len(batch.entries) >= max_size and detach(batch)
|
|
263
|
+
if full:
|
|
264
|
+
run(batch)
|
|
265
|
+
elif leader:
|
|
266
|
+
# The first caller of a window keeps time for it: it waits up to max_wait for
|
|
267
|
+
# its result (another thread may fill the batch and run it meanwhile), then
|
|
268
|
+
# runs the batch itself. No timer thread is involved.
|
|
269
|
+
done, _ = concurrent.futures.wait([future], timeout=max_wait)
|
|
270
|
+
if not done:
|
|
271
|
+
with lock:
|
|
272
|
+
mine = detach(batch)
|
|
273
|
+
if mine:
|
|
274
|
+
run(batch)
|
|
275
|
+
return future.result()
|
|
276
|
+
|
|
277
|
+
def flush() -> None:
|
|
278
|
+
with lock:
|
|
279
|
+
batches = list(open_batches.values())
|
|
280
|
+
for batch in batches:
|
|
281
|
+
detach(batch)
|
|
282
|
+
for batch in batches:
|
|
283
|
+
run(batch)
|
|
284
|
+
|
|
285
|
+
counter.wrapper = wrapper
|
|
286
|
+
wrapper.pending = 0 # type: ignore[attr-defined]
|
|
287
|
+
wrapper.flush = flush # type: ignore[attr-defined]
|
|
288
|
+
return wrapper
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --------------------------------------------------------------------------- async
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class _AsyncEntry:
|
|
295
|
+
__slots__ = ("future", "item", "waiters")
|
|
296
|
+
|
|
297
|
+
def __init__(self, item: Any, loop: asyncio.AbstractEventLoop) -> None:
|
|
298
|
+
self.item = item
|
|
299
|
+
self.future: asyncio.Future[Any] = loop.create_future()
|
|
300
|
+
self.waiters = 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _AsyncBatch:
|
|
304
|
+
__slots__ = ("entries", "group", "prefix", "timer")
|
|
305
|
+
|
|
306
|
+
def __init__(self, group: _GroupKey, prefix: tuple[Any, ...]) -> None:
|
|
307
|
+
self.group = group
|
|
308
|
+
self.prefix = prefix
|
|
309
|
+
self.entries: dict[Hashable, _AsyncEntry] = {}
|
|
310
|
+
self.timer: asyncio.Task[None] | None = None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class _LoopState:
|
|
314
|
+
"""Per-event-loop bookkeeping: open batches and batches being run."""
|
|
315
|
+
|
|
316
|
+
def __init__(self) -> None:
|
|
317
|
+
self.open: dict[_GroupKey, _AsyncBatch] = {}
|
|
318
|
+
self.running: set[asyncio.Future[None]] = set()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _consume(task: asyncio.Task[Any]) -> None:
|
|
322
|
+
if not task.cancelled():
|
|
323
|
+
task.exception()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _batch_async(
|
|
327
|
+
func: Callable[..., Awaitable[Any]],
|
|
328
|
+
max_size: int,
|
|
329
|
+
max_wait: float,
|
|
330
|
+
key_of: Callable[[Any], Hashable],
|
|
331
|
+
) -> Callable[..., Any]:
|
|
332
|
+
name = _core.qualname(func)
|
|
333
|
+
loops: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _LoopState] = (
|
|
334
|
+
weakref.WeakKeyDictionary()
|
|
335
|
+
)
|
|
336
|
+
counter = _Counter()
|
|
337
|
+
|
|
338
|
+
async def run(batch: _AsyncBatch) -> None:
|
|
339
|
+
keys = list(batch.entries)
|
|
340
|
+
entries = list(batch.entries.values())
|
|
341
|
+
try:
|
|
342
|
+
result = await func(*batch.prefix, [e.item for e in entries])
|
|
343
|
+
outcomes = _split(name, result, keys)
|
|
344
|
+
except asyncio.CancelledError:
|
|
345
|
+
for e in entries:
|
|
346
|
+
e.future.cancel()
|
|
347
|
+
raise
|
|
348
|
+
except BaseException as exc:
|
|
349
|
+
for e in entries:
|
|
350
|
+
e.future.set_exception(exc)
|
|
351
|
+
e.future.exception() # mark retrieved: every waiter may be gone
|
|
352
|
+
if not isinstance(exc, Exception):
|
|
353
|
+
raise
|
|
354
|
+
return
|
|
355
|
+
# The entries' futures are still pending: callers that cancel only detach.
|
|
356
|
+
for e, (ok, value) in zip(entries, outcomes, strict=True):
|
|
357
|
+
if ok:
|
|
358
|
+
e.future.set_result(value)
|
|
359
|
+
else:
|
|
360
|
+
e.future.set_exception(value)
|
|
361
|
+
e.future.exception()
|
|
362
|
+
|
|
363
|
+
def start(state: _LoopState, batch: _AsyncBatch) -> None:
|
|
364
|
+
"""Close ``batch`` and run it in its own task (shared by all its callers)."""
|
|
365
|
+
if state.open.get(batch.group) is not batch: # pragma: no cover - defensive
|
|
366
|
+
return
|
|
367
|
+
del state.open[batch.group]
|
|
368
|
+
counter.add(-len(batch.entries))
|
|
369
|
+
timer, batch.timer = batch.timer, None
|
|
370
|
+
if timer is not None and timer is not asyncio.current_task():
|
|
371
|
+
timer.cancel()
|
|
372
|
+
task = asyncio.get_running_loop().create_task(run(batch))
|
|
373
|
+
state.running.add(task)
|
|
374
|
+
task.add_done_callback(state.running.discard)
|
|
375
|
+
task.add_done_callback(_consume)
|
|
376
|
+
|
|
377
|
+
async def run_inline(state: _LoopState, batch: _AsyncBatch, entry: _AsyncEntry) -> Any:
|
|
378
|
+
"""Close ``batch`` and run it in the calling task; return ``entry``'s result."""
|
|
379
|
+
del state.open[batch.group]
|
|
380
|
+
counter.add(-len(batch.entries))
|
|
381
|
+
# Stands in for a batch task, so `aflush()` waits for this run too.
|
|
382
|
+
running = asyncio.get_running_loop().create_future()
|
|
383
|
+
state.running.add(running)
|
|
384
|
+
try:
|
|
385
|
+
with contextlib.suppress(Exception): # delivered through entry.future
|
|
386
|
+
await run(batch)
|
|
387
|
+
finally:
|
|
388
|
+
state.running.discard(running)
|
|
389
|
+
running.set_result(None)
|
|
390
|
+
return entry.future.result()
|
|
391
|
+
|
|
392
|
+
async def wait_then_start(state: _LoopState, batch: _AsyncBatch) -> None:
|
|
393
|
+
await asyncio.sleep(max_wait) # a window, not a backoff: real time
|
|
394
|
+
start(state, batch)
|
|
395
|
+
|
|
396
|
+
@functools.wraps(func)
|
|
397
|
+
async def wrapper(*args: Any) -> Any:
|
|
398
|
+
prefix, item = _split_args(name, args)
|
|
399
|
+
k = key_of(item)
|
|
400
|
+
group = tuple(map(id, prefix))
|
|
401
|
+
loop = asyncio.get_running_loop()
|
|
402
|
+
state = loops.get(loop)
|
|
403
|
+
if state is None:
|
|
404
|
+
state = loops[loop] = _LoopState()
|
|
405
|
+
batch = state.open.get(group)
|
|
406
|
+
fresh = batch is None
|
|
407
|
+
if batch is None:
|
|
408
|
+
batch = state.open[group] = _AsyncBatch(group, prefix)
|
|
409
|
+
entry = batch.entries.get(k)
|
|
410
|
+
if entry is None:
|
|
411
|
+
entry = batch.entries[k] = _AsyncEntry(item, loop)
|
|
412
|
+
counter.add(1)
|
|
413
|
+
entry.waiters += 1
|
|
414
|
+
if len(batch.entries) >= max_size:
|
|
415
|
+
if len(batch.entries) == 1 and entry.waiters == 1:
|
|
416
|
+
# This caller is the batch's only waiter (e.g. max_size=1): run it right
|
|
417
|
+
# here, in the calling task, with no extra task and no loop hops.
|
|
418
|
+
# Cancelling this caller cancels only its own batch.
|
|
419
|
+
return await run_inline(state, batch, entry)
|
|
420
|
+
start(state, batch)
|
|
421
|
+
elif fresh:
|
|
422
|
+
batch.timer = loop.create_task(wait_then_start(state, batch))
|
|
423
|
+
try:
|
|
424
|
+
return await asyncio.shield(entry.future)
|
|
425
|
+
except asyncio.CancelledError:
|
|
426
|
+
entry.waiters -= 1
|
|
427
|
+
if entry.waiters == 0 and state.open.get(group) is batch:
|
|
428
|
+
# Nobody wants this item any more and it has not been sent: drop it.
|
|
429
|
+
del batch.entries[k]
|
|
430
|
+
counter.add(-1)
|
|
431
|
+
entry.future.cancel()
|
|
432
|
+
if not batch.entries:
|
|
433
|
+
del state.open[group]
|
|
434
|
+
if batch.timer is not None:
|
|
435
|
+
batch.timer.cancel()
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
async def aflush() -> None:
|
|
439
|
+
state = loops.get(asyncio.get_running_loop())
|
|
440
|
+
if state is None:
|
|
441
|
+
return
|
|
442
|
+
for batch in list(state.open.values()):
|
|
443
|
+
start(state, batch)
|
|
444
|
+
if state.running:
|
|
445
|
+
await asyncio.wait(list(state.running))
|
|
446
|
+
|
|
447
|
+
counter.wrapper = wrapper
|
|
448
|
+
wrapper.pending = 0 # type: ignore[attr-defined]
|
|
449
|
+
wrapper.aflush = aflush # type: ignore[attr-defined]
|
|
450
|
+
return wrapper
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# --------------------------------------------------------------------------- public
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def batch_calls(
|
|
457
|
+
*,
|
|
458
|
+
max_size: int = 100,
|
|
459
|
+
max_wait: float = 0.01,
|
|
460
|
+
key: Callable[[Any], Hashable] | None = None,
|
|
461
|
+
) -> _BatchDecorator:
|
|
462
|
+
"""Coalesce concurrent single-item calls into one call of a batch function.
|
|
463
|
+
|
|
464
|
+
Decorate a function that takes a **list of items** and returns their results. The
|
|
465
|
+
decorated object is called with **one item** and returns that item's result. Calls
|
|
466
|
+
made within `max_wait` seconds of the first call of a window — or until `max_size`
|
|
467
|
+
distinct items are queued — are sent to the function together, as one list.
|
|
468
|
+
|
|
469
|
+
**Output contract.** The batch function receives the distinct items in arrival order
|
|
470
|
+
and must return either:
|
|
471
|
+
|
|
472
|
+
* a **sequence** (list, tuple, ...; not `str`/`bytes`) with exactly one result per
|
|
473
|
+
item, in the same order; or
|
|
474
|
+
* a **mapping** from each item's key to its result. The key is `key(item)` when
|
|
475
|
+
`key=` is given, otherwise the item itself. Extra entries are ignored.
|
|
476
|
+
|
|
477
|
+
If the sequence has the wrong length, or the return value is neither a sequence nor a
|
|
478
|
+
mapping, every caller in the batch gets a `BatchError`. If a mapping lacks a key, only
|
|
479
|
+
the callers of that item get a `BatchError`; the others get their results. If the
|
|
480
|
+
batch function raises, every caller in the batch gets that exception.
|
|
481
|
+
|
|
482
|
+
**Deduplication.** Items with the same key in one window are sent once and their
|
|
483
|
+
callers share the result. The key must be hashable.
|
|
484
|
+
|
|
485
|
+
* `async def` batch functions: callers on one event loop `await loader(item)`. A
|
|
486
|
+
window starts with the first call and closes after `max_wait`; the batch runs in its
|
|
487
|
+
own task, so cancelling one caller does not cancel it for the others. A cancelled
|
|
488
|
+
caller whose item has not been sent yet removes it from the batch (unless another
|
|
489
|
+
caller is waiting for the same key). Batches never span event loops.
|
|
490
|
+
* Sync batch functions: callers are threads, each blocking until its result is ready.
|
|
491
|
+
The first caller of a window waits up to `max_wait` (or until the batch fills) and
|
|
492
|
+
then runs the batch in its own thread; no timer thread is started. If a caller
|
|
493
|
+
fills the batch to `max_size`, it runs the batch at once. Calling a sync loader
|
|
494
|
+
sequentially from one thread therefore costs up to `max_wait` per call.
|
|
495
|
+
A sync loader is **not** awaitable: `await loader(1)` or
|
|
496
|
+
`asyncio.create_task(loader(1))` fails (the call returns a plain value, after
|
|
497
|
+
blocking the event loop while it waits). Called from a running event loop, it
|
|
498
|
+
emits a one-time `RuntimeWarning`; in async code decorate an `async def` batch
|
|
499
|
+
function instead.
|
|
500
|
+
* When a call fills an async batch to `max_size` and its caller is the batch's only
|
|
501
|
+
waiter (always the case with `max_size=1`), the batch runs right in the calling
|
|
502
|
+
task, without an extra task or event-loop round trip.
|
|
503
|
+
|
|
504
|
+
On a method, the batch function is called as `func(self, items)`, and calls on
|
|
505
|
+
different instances (or classes, for a class method) go to separate batches.
|
|
506
|
+
|
|
507
|
+
The wrapper also has:
|
|
508
|
+
|
|
509
|
+
* `.pending` — the number of distinct items queued and not yet sent.
|
|
510
|
+
* `.flush()` (sync) — send every queued batch now, in the calling thread.
|
|
511
|
+
* `await .aflush()` (async) — send the batches queued on the running loop now and
|
|
512
|
+
wait until every batch running on that loop has finished.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
max_size: Send a batch as soon as it holds this many distinct items (>= 1).
|
|
516
|
+
max_wait: Seconds to wait for more items after the first call of a window (>= 0).
|
|
517
|
+
key: Function of an item returning the hashable key used for deduplication and
|
|
518
|
+
for looking results up in a mapping. Defaults to the item itself.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
A decorator producing a `Batched` or `AsyncBatched` wrapper.
|
|
522
|
+
|
|
523
|
+
Raises:
|
|
524
|
+
ValueError: If `max_size` < 1 or `max_wait` < 0.
|
|
525
|
+
TypeError: When called, if the item's key is not hashable.
|
|
526
|
+
BatchError: When called, if the batch function broke the output contract.
|
|
527
|
+
|
|
528
|
+
Example:
|
|
529
|
+
>>> @batch_calls(max_size=50, max_wait=0.005)
|
|
530
|
+
... async def load_users(ids: list[int]) -> dict[int, str]:
|
|
531
|
+
... return {i: f"user{i}" for i in ids}
|
|
532
|
+
>>> # await load_users(1), from many tasks at once -> one call with [1, ...]
|
|
533
|
+
"""
|
|
534
|
+
if type(max_size) is not int or max_size < 1:
|
|
535
|
+
raise ValueError(f"batch_calls max_size must be an integer >= 1, got {max_size!r}")
|
|
536
|
+
if max_wait < 0:
|
|
537
|
+
raise ValueError(f"batch_calls max_wait must be non-negative, got {max_wait!r}")
|
|
538
|
+
user_key = key
|
|
539
|
+
|
|
540
|
+
def key_of(item: Any) -> Hashable:
|
|
541
|
+
k = item if user_key is None else user_key(item)
|
|
542
|
+
try:
|
|
543
|
+
hash(k)
|
|
544
|
+
except TypeError:
|
|
545
|
+
raise TypeError(
|
|
546
|
+
f"batch_calls: key of type {type(k).__name__!r} is not hashable; "
|
|
547
|
+
"pass key=<callable> returning a hashable key"
|
|
548
|
+
) from None
|
|
549
|
+
return cast(Hashable, k)
|
|
550
|
+
|
|
551
|
+
def decorate(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
552
|
+
if _core.is_async(func):
|
|
553
|
+
return _batch_async(func, max_size, max_wait, key_of)
|
|
554
|
+
return _batch_sync(func, max_size, max_wait, key_of)
|
|
555
|
+
|
|
556
|
+
return cast(_BatchDecorator, decorate)
|