timedbatch 0.2.0__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.
- timedbatch/__init__.py +21 -0
- timedbatch/_abatcher.py +195 -0
- timedbatch/_abatching.py +91 -0
- timedbatch/_batcher.py +156 -0
- timedbatch/_batching.py +60 -0
- timedbatch/_core.py +119 -0
- timedbatch/_engine.py +116 -0
- timedbatch/_errors.py +35 -0
- timedbatch/_limits.py +52 -0
- timedbatch/py.typed +0 -0
- timedbatch-0.2.0.dist-info/METADATA +247 -0
- timedbatch-0.2.0.dist-info/RECORD +14 -0
- timedbatch-0.2.0.dist-info/WHEEL +4 -0
- timedbatch-0.2.0.dist-info/licenses/LICENSE +21 -0
timedbatch/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Batch items by a max count and a max age, flushing when either limit is hit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._abatcher import AsyncTimedBatcher
|
|
6
|
+
from ._abatching import abatched_by_time
|
|
7
|
+
from ._batcher import TimedBatcher
|
|
8
|
+
from ._batching import batched_by_time
|
|
9
|
+
from ._errors import BatcherClosed, BatcherFull, FlushError
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AsyncTimedBatcher",
|
|
15
|
+
"BatcherClosed",
|
|
16
|
+
"BatcherFull",
|
|
17
|
+
"FlushError",
|
|
18
|
+
"TimedBatcher",
|
|
19
|
+
"abatched_by_time",
|
|
20
|
+
"batched_by_time",
|
|
21
|
+
]
|
timedbatch/_abatcher.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The asyncio counterpart of TimedBatcher, for a single event loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Generic
|
|
11
|
+
|
|
12
|
+
from ._core import Buffer, Ledger, T, wait_event
|
|
13
|
+
from ._errors import BatcherClosed, BatcherFull
|
|
14
|
+
from ._limits import check_limits, check_timeout
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncTimedBatcher(Generic[T]):
|
|
18
|
+
"""Collect items from coroutines and await ``flush(batch)`` in batches.
|
|
19
|
+
|
|
20
|
+
The same promises as :class:`TimedBatcher`: delivery on size, age (also while
|
|
21
|
+
idle), :meth:`flush` and :meth:`aclose`; one batch at a time, in order; every
|
|
22
|
+
accepted item delivered once or handed back once through ``on_error`` or a
|
|
23
|
+
:class:`FlushError`; ``max_pending`` makes :meth:`add` wait. Use it from one event
|
|
24
|
+
loop only, and close it (``async with``): there is no exit hook for coroutines. If
|
|
25
|
+
the flush task is cancelled (for example because the loop ends), the batcher closes
|
|
26
|
+
and the items it still held are handed back by the next call.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
flush: Callable[[list[T]], Awaitable[object]],
|
|
32
|
+
*,
|
|
33
|
+
max_items: int,
|
|
34
|
+
max_seconds: float,
|
|
35
|
+
max_pending: int | None = None,
|
|
36
|
+
on_error: Callable[[Exception, list[T]], object] | None = None,
|
|
37
|
+
clock: Callable[[], float] = time.monotonic,
|
|
38
|
+
) -> None:
|
|
39
|
+
if not callable(flush):
|
|
40
|
+
raise TypeError("flush must be callable")
|
|
41
|
+
items, seconds, self._max_pending = check_limits(max_items, max_seconds, max_pending)
|
|
42
|
+
self._sink = flush
|
|
43
|
+
self._on_error = on_error
|
|
44
|
+
self._buffer: Buffer[T] = Buffer(items, seconds, clock)
|
|
45
|
+
self._ledger: Ledger[T] = Ledger()
|
|
46
|
+
self._accepted = self._done = self._wanted = 0
|
|
47
|
+
self._closed = False
|
|
48
|
+
self._stopped: BaseException | None = None
|
|
49
|
+
self._task: asyncio.Task[None] | None = None
|
|
50
|
+
self._wake = self._progress = asyncio.Event()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def pending(self) -> int:
|
|
54
|
+
"""Items accepted and not yet delivered or handed back."""
|
|
55
|
+
return self._accepted - self._done
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def closed(self) -> bool:
|
|
59
|
+
"""Whether :meth:`aclose` has been called or the flush task has stopped."""
|
|
60
|
+
return self._closed
|
|
61
|
+
|
|
62
|
+
async def add(self, item: T, timeout: float | None = None) -> None:
|
|
63
|
+
"""Accept ``item``, waiting up to ``timeout`` seconds for room under ``max_pending``."""
|
|
64
|
+
timeout = check_timeout(timeout)
|
|
65
|
+
self._check_open()
|
|
66
|
+
self._start()
|
|
67
|
+
limit = self._max_pending
|
|
68
|
+
if limit is not None and self.pending >= limit:
|
|
69
|
+
if self._inside():
|
|
70
|
+
raise BatcherFull("add() inside the flush callback cannot wait for room")
|
|
71
|
+
await self._wait(lambda: self.pending < limit, timeout, BatcherFull)
|
|
72
|
+
self._check_open()
|
|
73
|
+
self._buffer.append(item)
|
|
74
|
+
self._accepted += 1
|
|
75
|
+
self._wake.set()
|
|
76
|
+
|
|
77
|
+
async def flush(self, timeout: float | None = None) -> None:
|
|
78
|
+
"""Deliver every item accepted so far; raise ``TimeoutError`` if it takes longer."""
|
|
79
|
+
timeout = check_timeout(timeout)
|
|
80
|
+
self._refuse_inside("flush")
|
|
81
|
+
if self._task is not None:
|
|
82
|
+
target = self._wanted = max(self._wanted, self._accepted)
|
|
83
|
+
self._wake.set()
|
|
84
|
+
await self._wait(lambda: self._done >= target, timeout, TimeoutError)
|
|
85
|
+
self._ledger.raise_any()
|
|
86
|
+
|
|
87
|
+
async def aclose(self) -> None:
|
|
88
|
+
"""Stop accepting items, deliver the rest and stop the task. Idempotent."""
|
|
89
|
+
self._refuse_inside("aclose")
|
|
90
|
+
self._closed = True
|
|
91
|
+
if self._task is not None and not self._task.done():
|
|
92
|
+
self._wake.set()
|
|
93
|
+
# wait() neither cancels the task if this caller is cancelled nor re-raises
|
|
94
|
+
# the task's own cancellation into a caller that was not cancelled.
|
|
95
|
+
await asyncio.wait({self._task})
|
|
96
|
+
self._reap()
|
|
97
|
+
self._ledger.raise_any()
|
|
98
|
+
|
|
99
|
+
async def __aenter__(self) -> AsyncTimedBatcher[T]:
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
async def __aexit__(
|
|
103
|
+
self,
|
|
104
|
+
exc_type: type[BaseException] | None,
|
|
105
|
+
exc: BaseException | None,
|
|
106
|
+
tb: TracebackType | None,
|
|
107
|
+
) -> None:
|
|
108
|
+
await self.aclose()
|
|
109
|
+
|
|
110
|
+
def _reap(self) -> None:
|
|
111
|
+
# A task that stopped abnormally (cancelled, even before it ever ran, or killed
|
|
112
|
+
# by a BaseException from the sink) closes the batcher and hands back its items.
|
|
113
|
+
task = self._task
|
|
114
|
+
if task is None or not task.done() or self._stopped is not None:
|
|
115
|
+
return
|
|
116
|
+
error = asyncio.CancelledError() if task.cancelled() else task.exception()
|
|
117
|
+
if error is None:
|
|
118
|
+
return
|
|
119
|
+
self._stopped, self._closed = error, True
|
|
120
|
+
while (batch := self._buffer.take(True)) is not None:
|
|
121
|
+
self._ledger.record(error, batch)
|
|
122
|
+
self._finish(batch)
|
|
123
|
+
|
|
124
|
+
def _check_open(self) -> None:
|
|
125
|
+
self._reap()
|
|
126
|
+
self._ledger.raise_any()
|
|
127
|
+
if self._stopped is not None:
|
|
128
|
+
raise BatcherClosed("the flush task stopped") from self._stopped
|
|
129
|
+
if self._closed:
|
|
130
|
+
raise BatcherClosed("the batcher is closed")
|
|
131
|
+
|
|
132
|
+
def _start(self) -> None:
|
|
133
|
+
if self._task is None:
|
|
134
|
+
self._wake, self._progress = asyncio.Event(), asyncio.Event()
|
|
135
|
+
self._task = asyncio.get_running_loop().create_task(self._loop())
|
|
136
|
+
|
|
137
|
+
def _inside(self) -> bool:
|
|
138
|
+
return self._task is not None and asyncio.current_task() is self._task
|
|
139
|
+
|
|
140
|
+
def _refuse_inside(self, name: str) -> None:
|
|
141
|
+
if self._inside():
|
|
142
|
+
raise RuntimeError(f"{name}() cannot be called from inside the flush callback")
|
|
143
|
+
|
|
144
|
+
async def _wait(
|
|
145
|
+
self, ready: Callable[[], bool], timeout: float | None, error: type[Exception]
|
|
146
|
+
) -> None:
|
|
147
|
+
loop = asyncio.get_running_loop()
|
|
148
|
+
deadline = None if timeout is None else loop.time() + timeout
|
|
149
|
+
while not ready():
|
|
150
|
+
assert self._task is not None
|
|
151
|
+
if self._task.done():
|
|
152
|
+
self._check_open()
|
|
153
|
+
left = None if deadline is None else deadline - loop.time()
|
|
154
|
+
if left is not None and left <= 0:
|
|
155
|
+
raise error("timed out waiting for the flush task")
|
|
156
|
+
await wait_event(self._progress, left)
|
|
157
|
+
|
|
158
|
+
async def _loop(self) -> None:
|
|
159
|
+
while True:
|
|
160
|
+
batch = self._buffer.take(self._closed or self._wanted > self._done)
|
|
161
|
+
if batch is None:
|
|
162
|
+
if self._closed:
|
|
163
|
+
return
|
|
164
|
+
self._wake.clear()
|
|
165
|
+
await wait_event(self._wake, self._buffer.remaining())
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
await self._sink(list(batch))
|
|
169
|
+
except Exception as error:
|
|
170
|
+
await self._report(error, batch)
|
|
171
|
+
except BaseException as error:
|
|
172
|
+
self._ledger.record(error, batch)
|
|
173
|
+
self._finish(batch)
|
|
174
|
+
raise
|
|
175
|
+
self._finish(batch)
|
|
176
|
+
|
|
177
|
+
async def _report(self, error: Exception, batch: list[T]) -> None:
|
|
178
|
+
if self._on_error is None:
|
|
179
|
+
self._ledger.record(error, batch)
|
|
180
|
+
return
|
|
181
|
+
try:
|
|
182
|
+
outcome = self._on_error(error, list(batch))
|
|
183
|
+
if inspect.isawaitable(outcome):
|
|
184
|
+
await outcome
|
|
185
|
+
except BaseException as handler_error:
|
|
186
|
+
self._ledger.record(handler_error, batch)
|
|
187
|
+
if not isinstance(handler_error, Exception):
|
|
188
|
+
self._finish(batch)
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
def _finish(self, batch: list[T]) -> None:
|
|
192
|
+
self._done += len(batch)
|
|
193
|
+
# Waiters hold the old event; swapping in a fresh one wakes them all exactly once.
|
|
194
|
+
self._progress.set()
|
|
195
|
+
self._progress = asyncio.Event()
|
timedbatch/_abatching.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Batch an async iterable by count and age, flushing on time even while it is idle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Callable
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
from ._limits import check_limits
|
|
12
|
+
|
|
13
|
+
_T = TypeVar("_T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def abatched_by_time(
|
|
17
|
+
aiterable: AsyncIterable[_T],
|
|
18
|
+
max_items: int,
|
|
19
|
+
max_seconds: float,
|
|
20
|
+
*,
|
|
21
|
+
clock: Callable[[], float] = time.monotonic,
|
|
22
|
+
) -> AsyncGenerator[list[_T], None]:
|
|
23
|
+
"""Yield batches from an async iterable, flushing on ``max_items`` or ``max_seconds``.
|
|
24
|
+
|
|
25
|
+
Unlike :func:`batched_by_time`, the age limit fires while the source is idle: a
|
|
26
|
+
partial batch is yielded ``max_seconds`` after its first item even if no further
|
|
27
|
+
item arrives. The pending read of the source is kept across those flushes, never
|
|
28
|
+
cancelled, so no item is lost; it is cancelled only when the iterator is closed. If
|
|
29
|
+
the source raises, the items already read are yielded first. ``clock`` must tick in
|
|
30
|
+
seconds; real waiting uses the event loop. Close it early with ``aclose()`` (or
|
|
31
|
+
:func:`contextlib.aclosing`): the read in flight is cancelled, and an item it had
|
|
32
|
+
already taken from the source is dropped, together with the partial batch. Each read
|
|
33
|
+
of the source runs in its own task, so context variables set by the source do not
|
|
34
|
+
carry over from one item to the next.
|
|
35
|
+
"""
|
|
36
|
+
items, seconds, _ = check_limits(max_items, max_seconds)
|
|
37
|
+
if not isinstance(aiterable, AsyncIterable):
|
|
38
|
+
raise TypeError(f"expected an async iterable, not {type(aiterable).__name__}")
|
|
39
|
+
return _abatches(aiterable.__aiter__(), items, seconds, clock)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _abatches(
|
|
43
|
+
source: AsyncIterator[_T], max_items: int, max_seconds: float, clock: Callable[[], float]
|
|
44
|
+
) -> AsyncGenerator[list[_T], None]:
|
|
45
|
+
batch: list[_T] = []
|
|
46
|
+
started = 0.0
|
|
47
|
+
pending: asyncio.Future[Any] | None = None
|
|
48
|
+
try:
|
|
49
|
+
while True:
|
|
50
|
+
if pending is None:
|
|
51
|
+
try:
|
|
52
|
+
step = source.__anext__()
|
|
53
|
+
except StopAsyncIteration:
|
|
54
|
+
break
|
|
55
|
+
except Exception:
|
|
56
|
+
if batch:
|
|
57
|
+
yield batch
|
|
58
|
+
raise
|
|
59
|
+
pending = asyncio.ensure_future(step)
|
|
60
|
+
timeout = None
|
|
61
|
+
if batch and not math.isinf(max_seconds):
|
|
62
|
+
timeout = max(0.0, started + max_seconds - clock())
|
|
63
|
+
# asyncio.wait, unlike wait_for, leaves the read running when it times out.
|
|
64
|
+
done, _ = await asyncio.wait({pending}, timeout=timeout)
|
|
65
|
+
if not done:
|
|
66
|
+
yield batch
|
|
67
|
+
batch = []
|
|
68
|
+
continue
|
|
69
|
+
read, pending = pending, None
|
|
70
|
+
try:
|
|
71
|
+
item = read.result()
|
|
72
|
+
except StopAsyncIteration:
|
|
73
|
+
break
|
|
74
|
+
except Exception:
|
|
75
|
+
if batch:
|
|
76
|
+
yield batch
|
|
77
|
+
raise
|
|
78
|
+
if not batch:
|
|
79
|
+
started = clock()
|
|
80
|
+
batch.append(item)
|
|
81
|
+
if len(batch) >= max_items or clock() - started >= max_seconds:
|
|
82
|
+
yield batch
|
|
83
|
+
batch = []
|
|
84
|
+
if batch:
|
|
85
|
+
yield batch
|
|
86
|
+
finally:
|
|
87
|
+
if pending is not None:
|
|
88
|
+
pending.cancel()
|
|
89
|
+
await asyncio.wait({pending})
|
|
90
|
+
if not pending.cancelled():
|
|
91
|
+
pending.exception()
|
timedbatch/_batcher.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""A thread-safe push batcher that flushes on size, age, demand, close and exit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
from ._core import Buffer, T, is_async
|
|
12
|
+
from ._engine import Engine
|
|
13
|
+
from ._errors import BatcherClosed
|
|
14
|
+
from ._limits import check_limits, check_timeout
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TimedBatcher(Engine[T]):
|
|
18
|
+
"""Collect items from any thread and hand them to ``flush`` in batches.
|
|
19
|
+
|
|
20
|
+
A batch is delivered when it holds ``max_items`` items, when its oldest item is
|
|
21
|
+
``max_seconds`` old, on :meth:`flush` and on :meth:`close`. Batches go to ``flush``
|
|
22
|
+
one at a time, in the order the items were accepted, and never exceed ``max_items``.
|
|
23
|
+
Every accepted item is delivered once or handed back once: to ``on_error(exc, batch)``
|
|
24
|
+
or inside a :class:`FlushError` raised by the next ``add``, ``poll``, ``flush`` or
|
|
25
|
+
``close``. ``max_pending`` bounds items accepted but not yet delivered; ``add`` blocks
|
|
26
|
+
for room. With ``background=False`` there is no thread: due batches are delivered by
|
|
27
|
+
the caller on ``add`` and ``poll``, which makes an injected ``clock`` fully
|
|
28
|
+
deterministic. ``flush_at_exit`` closes a forgotten batcher when the interpreter exits.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
flush: Callable[[list[T]], object],
|
|
34
|
+
*,
|
|
35
|
+
max_items: int,
|
|
36
|
+
max_seconds: float,
|
|
37
|
+
max_pending: int | None = None,
|
|
38
|
+
on_error: Callable[[Exception, list[T]], object] | None = None,
|
|
39
|
+
clock: Callable[[], float] = time.monotonic,
|
|
40
|
+
background: bool = True,
|
|
41
|
+
flush_at_exit: bool = True,
|
|
42
|
+
) -> None:
|
|
43
|
+
if not callable(flush):
|
|
44
|
+
raise TypeError("flush must be callable")
|
|
45
|
+
if is_async(flush) or is_async(on_error):
|
|
46
|
+
raise TypeError("flush and on_error must be plain functions; see AsyncTimedBatcher")
|
|
47
|
+
items, seconds, pending = check_limits(max_items, max_seconds, max_pending)
|
|
48
|
+
super().__init__(flush, on_error, Buffer(items, seconds, clock), pending)
|
|
49
|
+
self._at_exit = flush_at_exit
|
|
50
|
+
self._thread: threading.Thread | None = None
|
|
51
|
+
if background:
|
|
52
|
+
self._thread = threading.Thread(target=self._run, name="timedbatch", daemon=True)
|
|
53
|
+
self._thread.start()
|
|
54
|
+
if flush_at_exit:
|
|
55
|
+
atexit.register(self._close_at_exit)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def pending(self) -> int:
|
|
59
|
+
"""Items accepted and not yet delivered or handed back."""
|
|
60
|
+
with self._lock:
|
|
61
|
+
return self._accepted - self._done
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def closed(self) -> bool:
|
|
65
|
+
"""Whether :meth:`close` has been called."""
|
|
66
|
+
return self._closed
|
|
67
|
+
|
|
68
|
+
def add(self, item: T, timeout: float | None = None) -> None:
|
|
69
|
+
"""Accept ``item``, waiting up to ``timeout`` seconds for room under ``max_pending``.
|
|
70
|
+
|
|
71
|
+
Every exception it raises means the item was not accepted: :class:`BatcherClosed`,
|
|
72
|
+
:class:`BatcherFull`, or :class:`FlushError` for earlier failures not reported yet.
|
|
73
|
+
"""
|
|
74
|
+
timeout = check_timeout(timeout)
|
|
75
|
+
with self._lock:
|
|
76
|
+
self._ledger.raise_any()
|
|
77
|
+
if self._closed:
|
|
78
|
+
raise BatcherClosed("the batcher is closed")
|
|
79
|
+
inside = self._delivering == threading.get_ident()
|
|
80
|
+
self._wait_for_room(timeout, inside)
|
|
81
|
+
self._buffer.append(item)
|
|
82
|
+
self._accepted += 1
|
|
83
|
+
if self._thread is not None:
|
|
84
|
+
if len(self._buffer) in (1, self._buffer.max_items):
|
|
85
|
+
self._wake.notify()
|
|
86
|
+
return
|
|
87
|
+
if not inside:
|
|
88
|
+
# A batch failing here is reported by the next call, so that a FlushError from
|
|
89
|
+
# add() always means the item was not accepted.
|
|
90
|
+
self._drain()
|
|
91
|
+
|
|
92
|
+
def poll(self) -> None:
|
|
93
|
+
"""Deliver every batch that is due now and report failures not reported yet."""
|
|
94
|
+
self._refuse_inside("poll")
|
|
95
|
+
if self._thread is None:
|
|
96
|
+
self._drain()
|
|
97
|
+
else:
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._wake.notify()
|
|
100
|
+
self._raise_failures()
|
|
101
|
+
|
|
102
|
+
def flush(self, timeout: float | None = None) -> None:
|
|
103
|
+
"""Deliver every item accepted so far; raise ``TimeoutError`` if it takes longer.
|
|
104
|
+
|
|
105
|
+
Without a background thread the caller delivers, so ``timeout`` has no effect.
|
|
106
|
+
"""
|
|
107
|
+
timeout = check_timeout(timeout)
|
|
108
|
+
self._refuse_inside("flush")
|
|
109
|
+
with self._lock:
|
|
110
|
+
target = self._wanted = max(self._wanted, self._accepted)
|
|
111
|
+
self._wake.notify()
|
|
112
|
+
if self._thread is None:
|
|
113
|
+
self._drain()
|
|
114
|
+
else:
|
|
115
|
+
self._wait_until_done(target, timeout)
|
|
116
|
+
self._raise_failures()
|
|
117
|
+
|
|
118
|
+
def close(self, timeout: float | None = None) -> None:
|
|
119
|
+
"""Stop accepting items, deliver the rest and stop the thread. Idempotent.
|
|
120
|
+
|
|
121
|
+
If this times out or is interrupted, the exit hook stays registered, so what is
|
|
122
|
+
left is still delivered when the interpreter exits.
|
|
123
|
+
"""
|
|
124
|
+
self._shutdown(check_timeout(timeout), unregister=True)
|
|
125
|
+
|
|
126
|
+
def __enter__(self) -> TimedBatcher[T]:
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(
|
|
130
|
+
self,
|
|
131
|
+
exc_type: type[BaseException] | None,
|
|
132
|
+
exc: BaseException | None,
|
|
133
|
+
tb: TracebackType | None,
|
|
134
|
+
) -> None:
|
|
135
|
+
self.close()
|
|
136
|
+
|
|
137
|
+
def _close_at_exit(self) -> None:
|
|
138
|
+
# The atexit docs leave unregistering from inside a handler undefined.
|
|
139
|
+
self._shutdown(None, unregister=False)
|
|
140
|
+
|
|
141
|
+
def _shutdown(self, timeout: float | None, unregister: bool) -> None:
|
|
142
|
+
self._refuse_inside("close")
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._closed = True
|
|
145
|
+
self._wake.notify()
|
|
146
|
+
self._progress.notify_all()
|
|
147
|
+
if self._thread is None:
|
|
148
|
+
self._drain()
|
|
149
|
+
else:
|
|
150
|
+
self._thread.join(timeout)
|
|
151
|
+
if self._thread.is_alive():
|
|
152
|
+
raise TimeoutError("the flush thread is still delivering")
|
|
153
|
+
# Only now: an interrupted or timed out close must leave the exit flush in place.
|
|
154
|
+
if unregister and self._at_exit:
|
|
155
|
+
atexit.unregister(self._close_at_exit)
|
|
156
|
+
self._raise_failures()
|
timedbatch/_batching.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Batch an iterable by both a maximum count and a maximum time window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from ._limits import check_limits
|
|
10
|
+
|
|
11
|
+
_T = TypeVar("_T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def batched_by_time(
|
|
15
|
+
iterable: Iterable[_T],
|
|
16
|
+
max_items: int,
|
|
17
|
+
max_seconds: float,
|
|
18
|
+
*,
|
|
19
|
+
clock: Callable[[], float] = time.monotonic,
|
|
20
|
+
) -> Iterator[list[_T]]:
|
|
21
|
+
"""Yield batches of items, flushing when either limit is reached.
|
|
22
|
+
|
|
23
|
+
A batch is emitted as soon as it holds ``max_items`` items or ``max_seconds``
|
|
24
|
+
have elapsed since its first item arrived, whichever comes first. The window is
|
|
25
|
+
checked only when an item arrives, so an idle source holds its partial batch
|
|
26
|
+
until the next item (use :func:`abatched_by_time` or :class:`TimedBatcher` to
|
|
27
|
+
flush on time while idle). Remaining items are flushed when the source is
|
|
28
|
+
exhausted. If the source raises, the items already read are yielded first and the
|
|
29
|
+
exception is raised on the next step. ``max_seconds`` may be ``math.inf`` to batch
|
|
30
|
+
by count only.
|
|
31
|
+
|
|
32
|
+
The ``clock`` returns a monotonic timestamp in seconds. Inject it to make the
|
|
33
|
+
behaviour deterministic in tests; it defaults to :func:`time.monotonic`.
|
|
34
|
+
"""
|
|
35
|
+
items, seconds, _ = check_limits(max_items, max_seconds)
|
|
36
|
+
return _batches(iter(iterable), items, seconds, clock)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _batches(
|
|
40
|
+
source: Iterator[_T], max_items: int, max_seconds: float, clock: Callable[[], float]
|
|
41
|
+
) -> Iterator[list[_T]]:
|
|
42
|
+
batch: list[_T] = []
|
|
43
|
+
started = 0.0
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
item = next(source)
|
|
47
|
+
except StopIteration:
|
|
48
|
+
break
|
|
49
|
+
except Exception:
|
|
50
|
+
if batch:
|
|
51
|
+
yield batch
|
|
52
|
+
raise
|
|
53
|
+
if not batch:
|
|
54
|
+
started = clock()
|
|
55
|
+
batch.append(item)
|
|
56
|
+
if len(batch) >= max_items or clock() - started >= max_seconds:
|
|
57
|
+
yield batch
|
|
58
|
+
batch = []
|
|
59
|
+
if batch:
|
|
60
|
+
yield batch
|
timedbatch/_core.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""State shared by the push-style batchers: the buffer and the failure ledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from ._errors import FlushError
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Buffer(Generic[T]):
|
|
17
|
+
"""Items waiting for delivery, with the time each one arrived. Not thread-safe."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, max_items: int, max_seconds: float, clock: Callable[[], float]) -> None:
|
|
20
|
+
self.max_items = max_items
|
|
21
|
+
self.max_seconds = max_seconds
|
|
22
|
+
self.clock = clock
|
|
23
|
+
self._items: list[T] = []
|
|
24
|
+
# One stamp per item: after a slow delivery the buffer can hold more than one
|
|
25
|
+
# batch, and the next batch's age must come from its own oldest item.
|
|
26
|
+
self._stamps: list[float] = []
|
|
27
|
+
|
|
28
|
+
def __len__(self) -> int:
|
|
29
|
+
return len(self._items)
|
|
30
|
+
|
|
31
|
+
def append(self, item: T) -> None:
|
|
32
|
+
self._stamps.append(self.clock())
|
|
33
|
+
self._items.append(item)
|
|
34
|
+
|
|
35
|
+
def remaining(self) -> float | None:
|
|
36
|
+
"""Seconds until the oldest item is due, or ``None`` when nothing will come due."""
|
|
37
|
+
if not self._items or math.isinf(self.max_seconds):
|
|
38
|
+
return None
|
|
39
|
+
return self._stamps[0] + self.max_seconds - self.clock()
|
|
40
|
+
|
|
41
|
+
def take(self, force: bool) -> list[T] | None:
|
|
42
|
+
"""Remove and return the next batch if it is due (or ``force``), else ``None``."""
|
|
43
|
+
if not self._items:
|
|
44
|
+
return None
|
|
45
|
+
if (
|
|
46
|
+
not force
|
|
47
|
+
and len(self._items) < self.max_items
|
|
48
|
+
and self.clock() - self._stamps[0] < self.max_seconds
|
|
49
|
+
):
|
|
50
|
+
return None
|
|
51
|
+
batch = self._items[: self.max_items]
|
|
52
|
+
del self._items[: self.max_items]
|
|
53
|
+
del self._stamps[: self.max_items]
|
|
54
|
+
return batch
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Ledger(Generic[T]):
|
|
58
|
+
"""Failed batches not yet reported to the producer."""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._batches: list[list[T]] = []
|
|
62
|
+
self._errors: list[BaseException] = []
|
|
63
|
+
|
|
64
|
+
def record(self, error: BaseException, batch: list[T]) -> None:
|
|
65
|
+
self._batches.append(batch)
|
|
66
|
+
self._errors.append(error)
|
|
67
|
+
|
|
68
|
+
def raise_any(self) -> None:
|
|
69
|
+
if self._batches:
|
|
70
|
+
batches, errors = self._batches, self._errors
|
|
71
|
+
self._batches, self._errors = [], []
|
|
72
|
+
raise FlushError(list(batches), errors)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def call_sink(
|
|
76
|
+
sink: Callable[[list[T]], object],
|
|
77
|
+
on_error: Callable[[Exception, list[T]], object] | None,
|
|
78
|
+
batch: list[T],
|
|
79
|
+
) -> BaseException | None:
|
|
80
|
+
"""Deliver ``batch``; return the exception that still needs reporting, if any."""
|
|
81
|
+
try:
|
|
82
|
+
# The sink gets its own list, so a sink that clears it cannot empty the copy we
|
|
83
|
+
# hand back on failure.
|
|
84
|
+
_plain(sink(list(batch)))
|
|
85
|
+
except Exception as error:
|
|
86
|
+
if on_error is None:
|
|
87
|
+
return error
|
|
88
|
+
try:
|
|
89
|
+
_plain(on_error(error, list(batch)))
|
|
90
|
+
except BaseException as handler_error:
|
|
91
|
+
return handler_error
|
|
92
|
+
except BaseException as error:
|
|
93
|
+
return error
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_async(function: object) -> bool:
|
|
98
|
+
"""Whether calling ``function`` returns a coroutine (functions and callable objects)."""
|
|
99
|
+
call = type(function).__call__
|
|
100
|
+
return inspect.iscoroutinefunction(function) or inspect.iscoroutinefunction(call)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _plain(outcome: object) -> None:
|
|
104
|
+
# A coroutine that is never awaited does nothing: treat it as a failed delivery.
|
|
105
|
+
if inspect.isawaitable(outcome):
|
|
106
|
+
if inspect.iscoroutine(outcome):
|
|
107
|
+
outcome.close()
|
|
108
|
+
raise TypeError("the flush function returned an awaitable; use AsyncTimedBatcher")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def wait_event(event: asyncio.Event, timeout: float | None) -> None:
|
|
112
|
+
"""Wait for ``event`` at most ``timeout`` seconds, without raising on timeout."""
|
|
113
|
+
# asyncio.wait instead of wait_for: on 3.10 and 3.11 wait_for can swallow a
|
|
114
|
+
# cancellation that arrives as the event fires.
|
|
115
|
+
waiter = asyncio.ensure_future(event.wait())
|
|
116
|
+
try:
|
|
117
|
+
await asyncio.wait({waiter}, timeout=timeout)
|
|
118
|
+
finally:
|
|
119
|
+
waiter.cancel()
|
timedbatch/_engine.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The locking and delivery machinery behind TimedBatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Generic
|
|
9
|
+
|
|
10
|
+
from ._core import Buffer, Ledger, T, call_sink
|
|
11
|
+
from ._errors import BatcherClosed, BatcherFull
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Engine(Generic[T]):
|
|
15
|
+
"""Buffer, counters and the one-at-a-time delivery loop, guarded by one lock."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
sink: Callable[[list[T]], object],
|
|
20
|
+
on_error: Callable[[Exception, list[T]], object] | None,
|
|
21
|
+
buffer: Buffer[T],
|
|
22
|
+
max_pending: int | None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._sink = sink
|
|
25
|
+
self._on_error = on_error
|
|
26
|
+
self._buffer = buffer
|
|
27
|
+
self._max_pending = max_pending
|
|
28
|
+
self._ledger: Ledger[T] = Ledger()
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
self._wake = threading.Condition(self._lock)
|
|
31
|
+
self._progress = threading.Condition(self._lock)
|
|
32
|
+
self._order = threading.Lock()
|
|
33
|
+
self._accepted = self._done = self._wanted = 0
|
|
34
|
+
self._closed = False
|
|
35
|
+
self._delivering: int | None = None
|
|
36
|
+
|
|
37
|
+
def _refuse_inside(self, name: str) -> None:
|
|
38
|
+
with self._lock:
|
|
39
|
+
if self._delivering == threading.get_ident():
|
|
40
|
+
raise RuntimeError(f"{name}() cannot be called from inside the flush callback")
|
|
41
|
+
|
|
42
|
+
def _raise_failures(self) -> None:
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._ledger.raise_any()
|
|
45
|
+
|
|
46
|
+
def _wait_for_room(self, timeout: float | None, inside: bool) -> None:
|
|
47
|
+
limit = self._max_pending
|
|
48
|
+
if limit is None or self._accepted - self._done < limit:
|
|
49
|
+
return
|
|
50
|
+
if inside:
|
|
51
|
+
raise BatcherFull("add() inside the flush callback cannot wait for room")
|
|
52
|
+
# Waiting uses the real clock: the injected one only measures batch age.
|
|
53
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
54
|
+
while self._accepted - self._done >= limit:
|
|
55
|
+
left = None if deadline is None else deadline - time.monotonic()
|
|
56
|
+
if left is not None and left <= 0:
|
|
57
|
+
raise BatcherFull(f"no room under max_pending={limit} within {timeout} seconds")
|
|
58
|
+
self._progress.wait(_bounded(left))
|
|
59
|
+
if self._closed:
|
|
60
|
+
raise BatcherClosed("the batcher closed while add() was waiting")
|
|
61
|
+
|
|
62
|
+
def _wait_until_done(self, target: int, timeout: float | None) -> None:
|
|
63
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
64
|
+
with self._lock:
|
|
65
|
+
while self._done < target:
|
|
66
|
+
left = None if deadline is None else deadline - time.monotonic()
|
|
67
|
+
if left is not None and left <= 0:
|
|
68
|
+
raise TimeoutError("flush() timed out before every item was delivered")
|
|
69
|
+
self._progress.wait(_bounded(left))
|
|
70
|
+
|
|
71
|
+
def _next_batch(self) -> list[T] | None:
|
|
72
|
+
batch = self._buffer.take(self._closed or self._wanted > self._done)
|
|
73
|
+
if batch is not None:
|
|
74
|
+
self._delivering = threading.get_ident()
|
|
75
|
+
return batch
|
|
76
|
+
|
|
77
|
+
def _deliver(self, batch: list[T]) -> BaseException | None:
|
|
78
|
+
error = call_sink(self._sink, self._on_error, batch)
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._delivering = None
|
|
81
|
+
if error is not None:
|
|
82
|
+
self._ledger.record(error, batch)
|
|
83
|
+
self._done += len(batch)
|
|
84
|
+
self._progress.notify_all()
|
|
85
|
+
return error
|
|
86
|
+
|
|
87
|
+
def _drain(self) -> None:
|
|
88
|
+
with self._order:
|
|
89
|
+
while True:
|
|
90
|
+
with self._lock:
|
|
91
|
+
batch = self._next_batch()
|
|
92
|
+
if batch is None:
|
|
93
|
+
return
|
|
94
|
+
error = self._deliver(batch)
|
|
95
|
+
if error is not None and not isinstance(error, Exception):
|
|
96
|
+
raise error
|
|
97
|
+
|
|
98
|
+
def _run(self) -> None:
|
|
99
|
+
with self._lock:
|
|
100
|
+
while True:
|
|
101
|
+
batch = self._next_batch()
|
|
102
|
+
if batch is None:
|
|
103
|
+
if self._closed:
|
|
104
|
+
return
|
|
105
|
+
self._wake.wait(_bounded(self._buffer.remaining()))
|
|
106
|
+
continue
|
|
107
|
+
self._lock.release()
|
|
108
|
+
try:
|
|
109
|
+
self._deliver(batch)
|
|
110
|
+
finally:
|
|
111
|
+
self._lock.acquire()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _bounded(seconds: float | None) -> float | None:
|
|
115
|
+
# Condition.wait raises OverflowError past TIMEOUT_MAX, which would kill the thread.
|
|
116
|
+
return None if seconds is None else min(seconds, threading.TIMEOUT_MAX)
|
timedbatch/_errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exceptions raised by the batchers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FlushError(Exception):
|
|
9
|
+
"""One or more batches could not be delivered.
|
|
10
|
+
|
|
11
|
+
``batches`` holds every failed batch, in delivery order, and ``errors`` the exception
|
|
12
|
+
each one raised, so the caller can retry or store them: no item is dropped silently.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, batches: list[list[Any]], errors: list[BaseException]) -> None:
|
|
16
|
+
if not errors or len(batches) != len(errors):
|
|
17
|
+
raise ValueError("FlushError needs one error per failed batch, and at least one")
|
|
18
|
+
count = sum(len(batch) for batch in batches)
|
|
19
|
+
noun = "batch" if len(batches) == 1 else "batches"
|
|
20
|
+
items = "item" if count == 1 else "items"
|
|
21
|
+
super().__init__(f"{len(batches)} {noun} ({count} {items}) failed to flush: {errors[0]!r}")
|
|
22
|
+
self.batches = batches
|
|
23
|
+
self.errors = errors
|
|
24
|
+
self.__cause__ = errors[0]
|
|
25
|
+
|
|
26
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
27
|
+
return (type(self), (self.batches, self.errors))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BatcherClosed(RuntimeError):
|
|
31
|
+
"""The batcher was closed, so it accepts no more items."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BatcherFull(TimeoutError):
|
|
35
|
+
"""``add`` gave up waiting for room under ``max_pending``; the item was not accepted."""
|
timedbatch/_limits.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Validation of the batching limits shared by every entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import operator
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from numbers import Real
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _count(name: str, value: object) -> int:
|
|
12
|
+
if isinstance(value, bool):
|
|
13
|
+
raise TypeError(f"{name} must be an int, not bool")
|
|
14
|
+
try:
|
|
15
|
+
# operator.index accepts int and integer types such as NumPy's, never floats.
|
|
16
|
+
count = operator.index(value) # type: ignore[arg-type]
|
|
17
|
+
except TypeError:
|
|
18
|
+
raise TypeError(f"{name} must be an int, not {type(value).__name__}") from None
|
|
19
|
+
if count < 1:
|
|
20
|
+
raise ValueError(f"{name} must be at least 1")
|
|
21
|
+
return count
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _seconds(name: str, value: object) -> float:
|
|
25
|
+
if isinstance(value, bool) or not isinstance(value, (Real, Decimal)):
|
|
26
|
+
raise TypeError(f"{name} must be a real number, not {type(value).__name__}")
|
|
27
|
+
seconds = float(value)
|
|
28
|
+
# NaN compares false with everything, so it would silently disable a limit.
|
|
29
|
+
if math.isnan(seconds):
|
|
30
|
+
raise ValueError(f"{name} must not be NaN")
|
|
31
|
+
return seconds
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_limits(
|
|
35
|
+
max_items: object, max_seconds: object, max_pending: object = None
|
|
36
|
+
) -> tuple[int, float, int | None]:
|
|
37
|
+
"""Return the limits as exact types, or raise ``TypeError`` / ``ValueError``."""
|
|
38
|
+
items = _count("max_items", max_items)
|
|
39
|
+
seconds = _seconds("max_seconds", max_seconds)
|
|
40
|
+
if seconds <= 0:
|
|
41
|
+
raise ValueError("max_seconds must be greater than 0")
|
|
42
|
+
if max_pending is None:
|
|
43
|
+
return items, seconds, None
|
|
44
|
+
pending = _count("max_pending", max_pending)
|
|
45
|
+
if pending < items:
|
|
46
|
+
raise ValueError("max_pending must be at least max_items")
|
|
47
|
+
return items, seconds, pending
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_timeout(timeout: object) -> float | None:
|
|
51
|
+
"""Return a wait timeout in seconds (``None`` waits forever); negative means now."""
|
|
52
|
+
return None if timeout is None else _seconds("timeout", timeout)
|
timedbatch/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: timedbatch
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Batch items by a max count and a max age, for iterables, threads and asyncio, without losing items
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/timedbatch
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/timedbatch/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/timedbatch/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: asyncio,back-pressure,batch,batching,buffer,chunk,flush,iterator,streaming,time-window
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
33
|
+
Requires-Dist: twine; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# timedbatch
|
|
37
|
+
|
|
38
|
+
Batch items by a max count and a max age, flushing when either limit is hit. Typed, no
|
|
39
|
+
dependencies, for plain iterables, for threads and for asyncio.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
pip install timedbatch
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Which one to use
|
|
48
|
+
|
|
49
|
+
| You have | Use | Flushes while idle |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| An iterable you loop over | `batched_by_time` | No: the age is checked when an item arrives |
|
|
52
|
+
| An async iterable | `abatched_by_time` | Yes |
|
|
53
|
+
| Many places calling `add(item)` from threads | `TimedBatcher` | Yes, from a background thread |
|
|
54
|
+
| Many coroutines calling `await add(item)` | `AsyncTimedBatcher` | Yes, from a background task |
|
|
55
|
+
|
|
56
|
+
A batch is delivered as soon as it holds `max_items` items or its oldest item is
|
|
57
|
+
`max_seconds` old, whichever comes first. `max_seconds` may be `math.inf` to batch by
|
|
58
|
+
count only.
|
|
59
|
+
|
|
60
|
+
## Iterables
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from timedbatch import batched_by_time
|
|
64
|
+
|
|
65
|
+
now = 0.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def clock() -> float: # inject a clock and tests never sleep
|
|
69
|
+
return now
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def events():
|
|
73
|
+
global now
|
|
74
|
+
for moment, name in [(0.0, "a"), (0.5, "b"), (0.6, "c"), (9.0, "d"), (9.1, "e")]:
|
|
75
|
+
now = moment
|
|
76
|
+
yield name
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
for batch in batched_by_time(events(), max_items=2, max_seconds=5.0, clock=clock):
|
|
80
|
+
print(batch) # ['a', 'b']
|
|
81
|
+
# ['c', 'd']
|
|
82
|
+
# ['e']
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`['c', 'd']` closes on age: `d` arrives 8.4 seconds after `c`. If the source raises, the
|
|
86
|
+
items already read are yielded first and the exception follows.
|
|
87
|
+
|
|
88
|
+
## Threads: `TimedBatcher`
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from timedbatch import TimedBatcher
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_rows(batch: list[int]) -> None:
|
|
95
|
+
print("insert", batch)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
with TimedBatcher(write_rows, max_items=2, max_seconds=1.0) as batcher:
|
|
99
|
+
for row in range(5):
|
|
100
|
+
batcher.add(row) # safe from any number of threads
|
|
101
|
+
# insert [0, 1]
|
|
102
|
+
# insert [2, 3]
|
|
103
|
+
# insert [4]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
What it promises:
|
|
107
|
+
|
|
108
|
+
- **Size and age.** A full batch goes out at once; a partial one goes out `max_seconds`
|
|
109
|
+
after its oldest item, even if nothing else is added.
|
|
110
|
+
- **Order and no overlap.** One background thread calls your function, one batch at a
|
|
111
|
+
time, in the order the items were accepted. A batch never exceeds `max_items`.
|
|
112
|
+
- **Nothing lost, nothing twice.** Every accepted item is delivered once or handed back
|
|
113
|
+
once. When `add` raises, the item was not accepted (one exception: with
|
|
114
|
+
`background=False`, an interrupt raised inside your function passes through `add`,
|
|
115
|
+
and its batch comes back in the next `FlushError`).
|
|
116
|
+
- **Failures.** Give `on_error(exc, batch)` to decide what to do with a failed batch.
|
|
117
|
+
Without it, failed batches are kept and raised as a `FlushError` (with `.batches` and
|
|
118
|
+
`.errors`) by the next `add`, `poll`, `flush` or `close`.
|
|
119
|
+
- **Back-pressure.** `max_pending` caps items accepted but not yet delivered. `add` waits
|
|
120
|
+
for room, or raises `BatcherFull` (a `TimeoutError`) after `timeout` seconds.
|
|
121
|
+
- **Shutdown.** `close()` (or leaving the `with` block) delivers the rest and stops the
|
|
122
|
+
thread; later calls to `add` raise `BatcherClosed`. A batcher you forget to close, or
|
|
123
|
+
whose `close(timeout=...)` timed out, is closed when the interpreter exits normally,
|
|
124
|
+
through `sys.exit` or an uncaught exception (`flush_at_exit=True`). That exit flush
|
|
125
|
+
waits for your function, so give it its own timeouts. Nothing can flush after
|
|
126
|
+
`os._exit`, a kill signal, or in a child created by `os.fork` (it has no thread).
|
|
127
|
+
- **No deadlocks from inside.** Calling `flush`, `poll` or `close` from the flush
|
|
128
|
+
function raises `RuntimeError`, and an `add` there that would wait raises `BatcherFull`.
|
|
129
|
+
|
|
130
|
+
Failures come back with their items:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from timedbatch import FlushError, TimedBatcher
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def flaky(batch: list[str]) -> None:
|
|
137
|
+
raise ConnectionError("database is down")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
batcher = TimedBatcher(flaky, max_items=10, max_seconds=60.0)
|
|
141
|
+
batcher.add("order-1")
|
|
142
|
+
batcher.add("order-2")
|
|
143
|
+
try:
|
|
144
|
+
batcher.close()
|
|
145
|
+
except FlushError as error:
|
|
146
|
+
print(error.batches) # [['order-1', 'order-2']]
|
|
147
|
+
print(error) # 1 batch (2 items) failed to flush: ConnectionError('database is down')
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Deterministic tests
|
|
151
|
+
|
|
152
|
+
`background=False` removes the thread: due batches are delivered by the caller inside
|
|
153
|
+
`add` and `poll()`, so an injected clock drives every age decision (and the `timeout`
|
|
154
|
+
of `flush` and `close` has nothing to wait for).
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from timedbatch import TimedBatcher
|
|
158
|
+
|
|
159
|
+
now = 0.0
|
|
160
|
+
sent: list[list[str]] = []
|
|
161
|
+
batcher = TimedBatcher(
|
|
162
|
+
sent.append, max_items=100, max_seconds=5.0, clock=lambda: now, background=False
|
|
163
|
+
)
|
|
164
|
+
batcher.add("x")
|
|
165
|
+
now = 4.9
|
|
166
|
+
batcher.poll()
|
|
167
|
+
print(sent) # []
|
|
168
|
+
now = 5.0
|
|
169
|
+
batcher.poll()
|
|
170
|
+
print(sent) # [['x']]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## asyncio
|
|
174
|
+
|
|
175
|
+
`abatched_by_time` yields a partial batch on time even while the source waits. It keeps
|
|
176
|
+
the pending read across those flushes (it uses `asyncio.wait`, which does not cancel on
|
|
177
|
+
timeout), so no item is lost. Each read runs in its own task, so context variables the
|
|
178
|
+
source sets do not carry from one item to the next. Closing it early drops the partial
|
|
179
|
+
batch and the read in flight.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
import asyncio
|
|
183
|
+
from timedbatch import abatched_by_time
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def ticks():
|
|
187
|
+
yield 1
|
|
188
|
+
yield 2
|
|
189
|
+
await asyncio.sleep(0.3) # the source goes quiet
|
|
190
|
+
yield 3
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def main() -> None:
|
|
194
|
+
async for batch in abatched_by_time(ticks(), max_items=10, max_seconds=0.1):
|
|
195
|
+
print(batch) # [1, 2]
|
|
196
|
+
# [3]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
asyncio.run(main())
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`AsyncTimedBatcher` is `TimedBatcher` for one event loop, with an async flush function
|
|
203
|
+
(`on_error` may be sync or async). Close it with `async with` or `await aclose()`:
|
|
204
|
+
coroutines cannot run at interpreter exit. If its flush task is cancelled, for example
|
|
205
|
+
because the loop ended first, the batcher closes and the next call hands back what it
|
|
206
|
+
held. `TimedBatcher` refuses an `async def` flush function instead of dropping batches.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import asyncio
|
|
210
|
+
from timedbatch import AsyncTimedBatcher
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def send(batch: list[str]) -> None:
|
|
214
|
+
print("send", batch)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def main() -> None:
|
|
218
|
+
async with AsyncTimedBatcher(send, max_items=3, max_seconds=1.0, max_pending=100) as out:
|
|
219
|
+
for word in ["a", "b", "c", "d"]:
|
|
220
|
+
await out.add(word) # waits when 100 items are already pending
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
asyncio.run(main())
|
|
224
|
+
# send ['a', 'b', 'c']
|
|
225
|
+
# send ['d']
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Why
|
|
229
|
+
|
|
230
|
+
Count-based batching is everywhere (`itertools.batched`, `more-itertools` `chunked`),
|
|
231
|
+
but none of it closes a batch on elapsed time. Code that ships logs, events or metrics
|
|
232
|
+
almost always wants both bounds: cap the size so a batch never grows without limit, and
|
|
233
|
+
cap the latency so a slow stream still drains on schedule. The hard part is the rest:
|
|
234
|
+
flushing an idle buffer, not losing items when the sink fails or the program exits,
|
|
235
|
+
slowing producers down when the sink falls behind. `timedbatch` does that once, with tests
|
|
236
|
+
that run hundreds of random timelines against a reference model and hammer the batchers
|
|
237
|
+
from many threads and tasks.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT. See [LICENSE](LICENSE).
|
|
242
|
+
|
|
243
|
+
## Links
|
|
244
|
+
|
|
245
|
+
- Website: https://lianes8studio.com
|
|
246
|
+
- GitHub: https://github.com/JaviLianes8
|
|
247
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
timedbatch/__init__.py,sha256=wA-qVQD-gl5METI1vGdVs2ndAuO2h2QZDAIq0g9vogU,524
|
|
2
|
+
timedbatch/_abatcher.py,sha256=A7qNh28XYRbNG60VStPp8IOpxIdb_xWLsjphA1LG0wk,7811
|
|
3
|
+
timedbatch/_abatching.py,sha256=oVzcvgxTH0C5rhPjxDg2zubIjBVWVvNef7e89lqp8XE,3465
|
|
4
|
+
timedbatch/_batcher.py,sha256=YxwYzQ1leF-KWdWnZEPytXE54Oc7abT7_aF7sG3hZ98,6208
|
|
5
|
+
timedbatch/_batching.py,sha256=-2n_oGVSvYLpCEr3ltZpJ0XXuwYsv_Uxb4yrjkPlC5g,2005
|
|
6
|
+
timedbatch/_core.py,sha256=-KSq-jz2nEpESAvuUzj4T8elt5-6DAbjcqKa5YhGHTg,4130
|
|
7
|
+
timedbatch/_engine.py,sha256=JLUR9o-Kqlj60z2O2DD6P3q_GG4yDE2kyrmERc5kZj4,4495
|
|
8
|
+
timedbatch/_errors.py,sha256=dbI8v13zpp6EjpTXiZekGE8BuoyBQy8TJwvpd-ophiI,1295
|
|
9
|
+
timedbatch/_limits.py,sha256=oOh1RUEOiVNdzkmAvgW5lmOho61HZ-JRneQW0zA3uUE,1942
|
|
10
|
+
timedbatch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
timedbatch-0.2.0.dist-info/METADATA,sha256=VxW2jewvU2EdmYiDWOOwCm9btxxt3QqxqY6gV7klSN8,8569
|
|
12
|
+
timedbatch-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
timedbatch-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
|
|
14
|
+
timedbatch-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|