async-batcher 0.1.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.
- async_batcher/__init__.py +1 -0
- async_batcher/aws/__init__.py +0 -0
- async_batcher/aws/dynamodb/__init__.py +0 -0
- async_batcher/aws/dynamodb/get.py +94 -0
- async_batcher/aws/dynamodb/write.py +90 -0
- async_batcher/batcher.py +147 -0
- async_batcher/ml/__init__.py +0 -0
- async_batcher/ml/keras/__init__.py +0 -0
- async_batcher/ml/keras/model.py +46 -0
- async_batcher/ml/sklearn/__init__.py +0 -0
- async_batcher/ml/sklearn/model.py +47 -0
- async_batcher/scylladb/__init__.py +0 -0
- async_batcher/scylladb/update.py +45 -0
- async_batcher-0.1.0.dist-info/METADATA +99 -0
- async_batcher-0.1.0.dist-info/RECORD +16 -0
- async_batcher-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import aioboto3
|
|
7
|
+
|
|
8
|
+
from async_batcher.batcher import AsyncBatcher
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from aiobotocore.config import AioConfig
|
|
12
|
+
from types_aiobotocore_dynamodb import DynamoDBServiceResource
|
|
13
|
+
from types_aiobotocore_dynamodb.type_defs import TableAttributeValueTypeDef
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(kw_only=True)
|
|
17
|
+
class GetItem:
|
|
18
|
+
table_name: str
|
|
19
|
+
key: dict[str, TableAttributeValueTypeDef]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncDynamoDbGetBatcher(AsyncBatcher[GetItem, dict[str, Any]]):
|
|
23
|
+
"""Batcher for DynamoDB GetItem operation. It uses aioboto3 to interact with DynamoDB.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
region_name: The region to use.
|
|
27
|
+
use_ssl: Whether to use SSL/TLS.
|
|
28
|
+
verify: Whether to verify SSL certificates.
|
|
29
|
+
endpoint_url: The complete URL to use for the constructed client. This is useful for local testing.
|
|
30
|
+
config: The configuration for the session.
|
|
31
|
+
aioboto3_session: The aioboto3 session to use. If not provided, a new session is created.
|
|
32
|
+
batch_size: The maximum number of items to process in a single batch. The default is 100 items,
|
|
33
|
+
which is the maximum number of items that can be processed in a single batch.
|
|
34
|
+
sleep_time (float, optional): The time to sleep between checking if the result is ready in seconds.
|
|
35
|
+
Defaults to 0.01. Set it to a value close to the expected time to process a batch
|
|
36
|
+
buffering_time (float, optional): The time to sleep after processing a batch or checking the buffer
|
|
37
|
+
in seconds. Defaults to 0.001.
|
|
38
|
+
You can increase this value if you don't need a low latency, but want to reduce the number of
|
|
39
|
+
processed batches.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
region_name: str | None = None,
|
|
46
|
+
use_ssl: bool | None = None,
|
|
47
|
+
verify: bool | None = None,
|
|
48
|
+
endpoint_url: str | None = None,
|
|
49
|
+
config: AioConfig | None = None,
|
|
50
|
+
aioboto3_session: aioboto3.Session | None = None,
|
|
51
|
+
batch_size: int = 100,
|
|
52
|
+
sleep_time: float = 0.01,
|
|
53
|
+
buffering_time: float = 0.001,
|
|
54
|
+
):
|
|
55
|
+
super().__init__(batch_size=batch_size, sleep_time=sleep_time, buffering_time=buffering_time)
|
|
56
|
+
self.region_name = region_name
|
|
57
|
+
self.use_ssl = use_ssl
|
|
58
|
+
self.verify = verify
|
|
59
|
+
self.endpoint_url = endpoint_url
|
|
60
|
+
self.config = config
|
|
61
|
+
self.aioboto3_session = aioboto3_session or aioboto3.Session()
|
|
62
|
+
|
|
63
|
+
async def process_batch(self, batch: list[GetItem]) -> list[dict[str, TableAttributeValueTypeDef]]:
|
|
64
|
+
indexed_items: dict[tuple, int] = {}
|
|
65
|
+
request_items = {}
|
|
66
|
+
tables_keys = {}
|
|
67
|
+
for ind, item in enumerate(batch):
|
|
68
|
+
if item.table_name not in request_items:
|
|
69
|
+
request_items[item.table_name] = {"Keys": []}
|
|
70
|
+
tables_keys[item.table_name] = sorted(item.key.keys())
|
|
71
|
+
# TODO: support ProjectionExpression and ConsistentRead
|
|
72
|
+
request_items[item.table_name]["Keys"].append(item.key)
|
|
73
|
+
indexed_items[(item.table_name, *[item.key[key] for key in tables_keys[item.table_name]])] = ind
|
|
74
|
+
|
|
75
|
+
dynamodb: DynamoDBServiceResource
|
|
76
|
+
async with self.aioboto3_session.resource(
|
|
77
|
+
"dynamodb",
|
|
78
|
+
region_name=self.region_name,
|
|
79
|
+
use_ssl=self.use_ssl,
|
|
80
|
+
verify=self.verify,
|
|
81
|
+
endpoint_url=self.endpoint_url,
|
|
82
|
+
config=self.config,
|
|
83
|
+
) as dynamodb:
|
|
84
|
+
response = await dynamodb.batch_get_item(
|
|
85
|
+
RequestItems=request_items,
|
|
86
|
+
ReturnConsumedCapacity="NONE",
|
|
87
|
+
)
|
|
88
|
+
result: list[None | dict[str, TableAttributeValueTypeDef]] = [None] * len(batch)
|
|
89
|
+
# TODO: handle UnprocessedKeys
|
|
90
|
+
for table in response["Responses"]:
|
|
91
|
+
for item in response["Responses"][table]:
|
|
92
|
+
index = indexed_items[(table, *[item[key] for key in tables_keys[table]])]
|
|
93
|
+
result[index] = item
|
|
94
|
+
return result
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Literal
|
|
5
|
+
|
|
6
|
+
import aioboto3
|
|
7
|
+
|
|
8
|
+
from async_batcher.batcher import AsyncBatcher
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from aiobotocore.config import AioConfig
|
|
12
|
+
from types_aiobotocore_dynamodb import DynamoDBServiceResource
|
|
13
|
+
from types_aiobotocore_dynamodb.type_defs import TableAttributeValueTypeDef
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(kw_only=True)
|
|
17
|
+
class WriteOperation:
|
|
18
|
+
operation: Literal["PUT", "DELETE"]
|
|
19
|
+
table_name: str
|
|
20
|
+
data: dict[str, TableAttributeValueTypeDef]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncDynamoDbWriteBatcher(AsyncBatcher[WriteOperation, None]):
|
|
24
|
+
"""Batcher for DynamoDB WriteOperation. It uses aioboto3 to interact with DynamoDB.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
region_name: The region to use.
|
|
28
|
+
use_ssl: Whether to use SSL/TLS.
|
|
29
|
+
verify: Whether to verify SSL certificates.
|
|
30
|
+
endpoint_url: The complete URL to use for the constructed client. This is useful for local testing.
|
|
31
|
+
config: The configuration for the session.
|
|
32
|
+
aioboto3_session: The aioboto3 session to use. If not provided, a new session is created.
|
|
33
|
+
batch_size: The maximum number of items to process in a single batch. The default is 25 items,
|
|
34
|
+
which is the maximum number of items that can be processed in a single batch.
|
|
35
|
+
sleep_time (float, optional): The time to sleep between checking if the result is ready in seconds.
|
|
36
|
+
Defaults to 0.01. Set it to a value close to the expected time to process a batch
|
|
37
|
+
buffering_time (float, optional): The time to sleep after processing a batch or checking the buffer
|
|
38
|
+
in seconds. Defaults to 0.001.
|
|
39
|
+
You can increase this value if you don't need a low latency, but want to reduce the number of
|
|
40
|
+
processed batches.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
region_name: str | None = None,
|
|
47
|
+
use_ssl: bool | None = None,
|
|
48
|
+
verify: bool | None = None,
|
|
49
|
+
endpoint_url: str | None = None,
|
|
50
|
+
config: AioConfig | None = None,
|
|
51
|
+
aioboto3_session: aioboto3.Session | None = None,
|
|
52
|
+
batch_size: int = 25,
|
|
53
|
+
sleep_time: float = 0.01,
|
|
54
|
+
buffering_time: float = 0.001,
|
|
55
|
+
):
|
|
56
|
+
super().__init__(batch_size=batch_size, sleep_time=sleep_time, buffering_time=buffering_time)
|
|
57
|
+
self.region_name = region_name
|
|
58
|
+
self.use_ssl = use_ssl
|
|
59
|
+
self.verify = verify
|
|
60
|
+
self.endpoint_url = endpoint_url
|
|
61
|
+
self.config = config
|
|
62
|
+
self.aioboto3_session = aioboto3_session or aioboto3.Session()
|
|
63
|
+
|
|
64
|
+
async def process_batch(self, batch: list[WriteOperation]) -> None:
|
|
65
|
+
request_items = {}
|
|
66
|
+
for operation in batch:
|
|
67
|
+
if operation.table_name not in request_items:
|
|
68
|
+
request_items[operation.table_name] = []
|
|
69
|
+
request = {}
|
|
70
|
+
if operation.operation == "PUT":
|
|
71
|
+
request["PutRequest"] = {"Item": operation.data}
|
|
72
|
+
elif operation.operation == "DELETE":
|
|
73
|
+
request["DeleteRequest"] = {"Key": operation.data}
|
|
74
|
+
request_items[operation.table_name].append(request)
|
|
75
|
+
|
|
76
|
+
dynamodb: DynamoDBServiceResource
|
|
77
|
+
async with self.aioboto3_session.resource(
|
|
78
|
+
"dynamodb",
|
|
79
|
+
region_name=self.region_name,
|
|
80
|
+
use_ssl=self.use_ssl,
|
|
81
|
+
verify=self.verify,
|
|
82
|
+
endpoint_url=self.endpoint_url,
|
|
83
|
+
config=self.config,
|
|
84
|
+
) as dynamodb:
|
|
85
|
+
# TODO: return something useful
|
|
86
|
+
await dynamodb.batch_write_item(
|
|
87
|
+
RequestItems=request_items,
|
|
88
|
+
ReturnConsumedCapacity="NONE",
|
|
89
|
+
ReturnItemCollectionMetrics="NONE",
|
|
90
|
+
)
|
async_batcher/batcher.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from collections import deque
|
|
8
|
+
from threading import Thread
|
|
9
|
+
from typing import Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
S = TypeVar("S")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncBatcher(Generic[T, S], abc.ABC, Thread):
|
|
16
|
+
"""A generic class for batching and processing items asynchronously.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
batch_size (int, optional): The max number of items to process in a batch. Defaults to -1 (no limit).
|
|
20
|
+
sleep_time (float, optional): The time to sleep between checking if the result is ready in seconds.
|
|
21
|
+
Defaults to 0.01. Set it to a value close to the expected time to process a batch
|
|
22
|
+
buffering_time (float, optional): The time to sleep after processing a batch or checking the buffer
|
|
23
|
+
in seconds. Defaults to 0.001.
|
|
24
|
+
You can increase this value if you don't need a low latency, but want to reduce the number of
|
|
25
|
+
processed batches.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_buffer = deque[tuple[uuid.UUID, T]]
|
|
29
|
+
_results = dict[uuid.UUID, S]
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
batch_size: int = -1,
|
|
36
|
+
sleep_time: float = 0.01,
|
|
37
|
+
buffering_time: float = 0.001,
|
|
38
|
+
):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.batch_size = batch_size
|
|
41
|
+
self.sleep_time = sleep_time
|
|
42
|
+
self.buffering_time = buffering_time
|
|
43
|
+
self._buffer = deque()
|
|
44
|
+
self._results = {}
|
|
45
|
+
self._current_batch = 0
|
|
46
|
+
self._should_stop = False
|
|
47
|
+
self._force_stop = False
|
|
48
|
+
self._is_running = False
|
|
49
|
+
|
|
50
|
+
@abc.abstractmethod
|
|
51
|
+
async def process_batch(self, batch: list[T]) -> list[S] | None:
|
|
52
|
+
"""Process a batch of items.
|
|
53
|
+
|
|
54
|
+
This method should be overridden by the user to define how to process a batch of items.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
async def _process_single(self, *, item: T) -> S:
|
|
58
|
+
"""Process a single item.
|
|
59
|
+
|
|
60
|
+
This method adds the item to the buffer and waits for the result to be ready.
|
|
61
|
+
It's used by the `process` method to add an item to the buffer and get the result when it's ready.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
item (T): The item to process.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
S: The result of processing the item.
|
|
68
|
+
"""
|
|
69
|
+
started_at = asyncio.get_event_loop().time()
|
|
70
|
+
query_id = uuid.uuid4()
|
|
71
|
+
last_checked_batch = self._current_batch
|
|
72
|
+
self.logger.debug(f"Adding item {query_id} to the buffer.")
|
|
73
|
+
self._buffer.append((query_id, item))
|
|
74
|
+
while True:
|
|
75
|
+
if self._current_batch > last_checked_batch:
|
|
76
|
+
last_checked_batch = self._current_batch
|
|
77
|
+
if query_id in self._results:
|
|
78
|
+
elapsed_time = asyncio.get_event_loop().time() - started_at
|
|
79
|
+
self.logger.debug(f"Item {query_id} is ready after {elapsed_time} seconds.")
|
|
80
|
+
return self._results.pop(query_id)
|
|
81
|
+
await asyncio.sleep(self.sleep_time)
|
|
82
|
+
|
|
83
|
+
async def process(self, item: T) -> S:
|
|
84
|
+
"""Add an item to the buffer and get the result when it's ready.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
item (T): The item to process.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
S: The result of processing the item.
|
|
91
|
+
"""
|
|
92
|
+
if not self._is_running:
|
|
93
|
+
raise RuntimeError("The batcher is not running.")
|
|
94
|
+
result = await asyncio.get_event_loop().create_task(self._process_single(item=item))
|
|
95
|
+
if isinstance(result, Exception):
|
|
96
|
+
raise result
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
async def _arun(self):
|
|
100
|
+
"""Run the batcher asynchronously."""
|
|
101
|
+
self._is_running = True
|
|
102
|
+
while not self._should_stop or (not self._force_stop and len(self._buffer) > 0):
|
|
103
|
+
ids = []
|
|
104
|
+
batch = []
|
|
105
|
+
while self._buffer and (len(batch) < self.batch_size or self.batch_size == -1):
|
|
106
|
+
query_id, item = self._buffer.popleft()
|
|
107
|
+
batch.append(item)
|
|
108
|
+
ids.append(query_id)
|
|
109
|
+
if batch:
|
|
110
|
+
started_at = asyncio.get_event_loop().time()
|
|
111
|
+
try:
|
|
112
|
+
results = await asyncio.get_event_loop().create_task(self.process_batch(batch=batch))
|
|
113
|
+
if results is None:
|
|
114
|
+
results = [None] * len(batch)
|
|
115
|
+
if len(results) != len(batch):
|
|
116
|
+
raise ValueError(f"Expected to get {len(batch)} results, but got {len(results)}.")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
self.logger.error("Error processing batch", exc_info=True)
|
|
119
|
+
results = [e] * len(batch)
|
|
120
|
+
for query_id, result in zip(ids, results):
|
|
121
|
+
self._results[query_id] = result
|
|
122
|
+
elapsed_time = asyncio.get_event_loop().time() - started_at
|
|
123
|
+
self.logger.debug(
|
|
124
|
+
f"Processed batch {self._current_batch} of {len(batch)} elements"
|
|
125
|
+
f" in {elapsed_time} seconds."
|
|
126
|
+
)
|
|
127
|
+
self._current_batch += 1
|
|
128
|
+
await asyncio.sleep(max(self.buffering_time - elapsed_time, 0))
|
|
129
|
+
else:
|
|
130
|
+
self.logger.debug("No items to process. Sleeping.")
|
|
131
|
+
await asyncio.sleep(self.buffering_time)
|
|
132
|
+
self._is_running = False
|
|
133
|
+
|
|
134
|
+
def run(self):
|
|
135
|
+
"""Run the batcher thread."""
|
|
136
|
+
asyncio.run(self._arun())
|
|
137
|
+
|
|
138
|
+
def stop(self, force: bool = False):
|
|
139
|
+
"""Stop the batcher thread.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
force (bool, optional): Whether to force stop the batcher without waiting for processing
|
|
143
|
+
the remaining buffer items. Defaults to False.
|
|
144
|
+
"""
|
|
145
|
+
if force:
|
|
146
|
+
self._force_stop = True
|
|
147
|
+
self._should_stop = True
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from async_batcher.batcher import AsyncBatcher
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from concurrent.futures import Executor
|
|
10
|
+
|
|
11
|
+
from keras import Model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KerasAsyncBatcher(AsyncBatcher):
|
|
15
|
+
"""Batcher for Keras models.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
model: The Keras model to use for prediction.
|
|
19
|
+
executor: The executor to use for running the prediction.
|
|
20
|
+
batch_size (int, optional): The max number of items to process in a batch. Defaults to -1 (no limit).
|
|
21
|
+
sleep_time (float, optional): The time to sleep between checking if the result is ready in seconds.
|
|
22
|
+
Defaults to 0.01. Set it to a value close to the expected time to process a batch
|
|
23
|
+
buffering_time (float, optional): The time to sleep after processing a batch or checking the buffer
|
|
24
|
+
in seconds. Defaults to 0.001.
|
|
25
|
+
You can increase this value if you don't need a low latency, but want to reduce the number of
|
|
26
|
+
processed batches.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
model: Model,
|
|
33
|
+
executor: Executor | None = None,
|
|
34
|
+
batch_size: int = -1,
|
|
35
|
+
sleep_time: float = 0.01,
|
|
36
|
+
buffering_time: float = 0.001,
|
|
37
|
+
):
|
|
38
|
+
super().__init__(batch_size=batch_size, sleep_time=sleep_time, buffering_time=buffering_time)
|
|
39
|
+
self.model = model
|
|
40
|
+
self.executor = executor
|
|
41
|
+
|
|
42
|
+
async def process_batch(self, batch):
|
|
43
|
+
result = await asyncio.get_event_loop().run_in_executor(
|
|
44
|
+
self.executor, lambda _batch: self.model.predict(_batch, batch_size=len(_batch)), batch
|
|
45
|
+
)
|
|
46
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from async_batcher.batcher import AsyncBatcher
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from concurrent.futures import Executor
|
|
10
|
+
|
|
11
|
+
from sklearn.base import BaseEstimator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SklearnAsyncBatcher(AsyncBatcher):
|
|
15
|
+
"""Batcher for scikit-learn models.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
model: The scikit-learn model to use for prediction.
|
|
19
|
+
executor: The executor to use for running the prediction.
|
|
20
|
+
batch_size (int, optional): The max number of items to process in a batch. Defaults to -1 (no limit).
|
|
21
|
+
sleep_time (float, optional): The time to sleep between checking if the result is ready in seconds.
|
|
22
|
+
Defaults to 0.01. Set it to a value close to the expected time to process a batch
|
|
23
|
+
buffering_time (float, optional): The time to sleep after processing a batch or checking the buffer
|
|
24
|
+
in seconds. Defaults to 0.001.
|
|
25
|
+
You can increase this value if you don't need a low latency, but want to reduce the number of
|
|
26
|
+
processed batches.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
model: BaseEstimator,
|
|
33
|
+
executor: Executor | None = None,
|
|
34
|
+
batch_size: int = -1,
|
|
35
|
+
sleep_time: float = 0.01,
|
|
36
|
+
buffering_time: float = 0.001,
|
|
37
|
+
):
|
|
38
|
+
super().__init__(batch_size=batch_size, sleep_time=sleep_time, buffering_time=buffering_time)
|
|
39
|
+
self.model = model
|
|
40
|
+
self.executor = executor
|
|
41
|
+
|
|
42
|
+
async def process_batch(self, batch):
|
|
43
|
+
if hasattr(self.model, "predict"):
|
|
44
|
+
result = await asyncio.get_event_loop().run_in_executor(self.executor, self.model.predict, batch)
|
|
45
|
+
return result
|
|
46
|
+
else:
|
|
47
|
+
raise AttributeError("Model does not have a predict method")
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
from cassandra.cqlengine.query import BatchQuery
|
|
7
|
+
|
|
8
|
+
from async_batcher.batcher import AsyncBatcher
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from cassandra.cqlengine.models import Model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(kw_only=True)
|
|
15
|
+
class WriteOperation:
|
|
16
|
+
operation: Literal["INSERT", "UPDATE", "DELETE"]
|
|
17
|
+
model: Model
|
|
18
|
+
key: dict[str, Any] | None = None
|
|
19
|
+
data: dict[str, Any] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncScyllaDbWriteBatcher(AsyncBatcher[WriteOperation, None]):
|
|
23
|
+
async def process_batch(self, *, batch: list[WriteOperation]) -> list[None | Exception]:
|
|
24
|
+
results = []
|
|
25
|
+
with BatchQuery() as b:
|
|
26
|
+
for op in batch:
|
|
27
|
+
if op.operation == "INSERT":
|
|
28
|
+
if op.data is None:
|
|
29
|
+
results.append(ValueError("data must be provided for INSERT operations"))
|
|
30
|
+
else:
|
|
31
|
+
op.model.batch(b).create(**op.data)
|
|
32
|
+
results.append(None)
|
|
33
|
+
elif op.operation == "UPDATE":
|
|
34
|
+
if op.key is None or op.data is None:
|
|
35
|
+
results.append(ValueError("key and data must be provided for UPDATE operations"))
|
|
36
|
+
else:
|
|
37
|
+
op.model.objects(**op.key).batch(b).update(**op.data)
|
|
38
|
+
results.append(None)
|
|
39
|
+
elif op.operation == "DELETE":
|
|
40
|
+
if op.key is None:
|
|
41
|
+
results.append(ValueError("key must be provided for DELETE operations"))
|
|
42
|
+
else:
|
|
43
|
+
op.model.objects(**op.key).batch(b).delete()
|
|
44
|
+
results.append(None)
|
|
45
|
+
return results
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: async-batcher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A service to batch your http requests.
|
|
5
|
+
Home-page: https://github.com/hussein-awala/async-batcher
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: python,asyncio,rest,grpc,fastapi
|
|
8
|
+
Author: Hussein Awala
|
|
9
|
+
Author-email: hussein@awala.fr
|
|
10
|
+
Requires-Python: >=3.8,<4.0
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Provides-Extra: aws
|
|
19
|
+
Provides-Extra: keras
|
|
20
|
+
Provides-Extra: scylla
|
|
21
|
+
Provides-Extra: sklearn
|
|
22
|
+
Requires-Dist: aioboto3 (>=12.3,<13.0) ; extra == "aws"
|
|
23
|
+
Requires-Dist: keras (>=2.12,<3.0) ; extra == "keras"
|
|
24
|
+
Requires-Dist: scikit-learn (>=1.2,<2.0) ; extra == "sklearn"
|
|
25
|
+
Requires-Dist: scylla-driver (>=3,<4) ; extra == "scylla"
|
|
26
|
+
Project-URL: Bug Tracker, https://github.com/hussein-awala/async-batcher/issues
|
|
27
|
+
Project-URL: Documentation, https://github.com/hussein-awala/async-batcher/blob/main/README.md
|
|
28
|
+
Project-URL: Repository, https://github.com/hussein-awala/async-batcher
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Async Batcher
|
|
32
|
+
|
|
33
|
+
This project provides a Python library to batch the asynchronous requests and handle them in batches.
|
|
34
|
+
|
|
35
|
+
## How to use
|
|
36
|
+
|
|
37
|
+
To use the library, you need to install the package in your environment. You can install the package using pip:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install async-batcher
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then, you can create a new `AsyncBatcher` class by implementing the `process_batch` method:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from async_batcher.batcher import AsyncBatcher
|
|
47
|
+
|
|
48
|
+
class MyAsyncBatcher(AsyncBatcher):
|
|
49
|
+
async def process_batch(self, batch):
|
|
50
|
+
# Process the batch
|
|
51
|
+
print(batch)
|
|
52
|
+
|
|
53
|
+
# Create a new instance of the `MyAsyncBatcher` class
|
|
54
|
+
async_batcher = MyAsyncBatcher(batch_size=20)
|
|
55
|
+
async_batcher.start()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Benchmark
|
|
59
|
+
|
|
60
|
+
To evaluate the performance of the `AsyncBatcher` library, we used the [Keras example](examples/keras)
|
|
61
|
+
with [locust](https://locust.io/) to simulate multiple users making requests to the server.
|
|
62
|
+
|
|
63
|
+
In this example, we have a tensorflow model that takes ~11ms to make a single prediction and ~13ms to process
|
|
64
|
+
a batch of 200 predictions.
|
|
65
|
+
|
|
66
|
+
For this benchmark, we ran the FastAPI server with Uvicorn with 12 workers,
|
|
67
|
+
on a MacBook Pro with Apple M2 Max chip (12 cores).
|
|
68
|
+
|
|
69
|
+
Predict endpoint:
|
|
70
|
+
|
|
71
|
+

|
|
72
|
+
|
|
73
|
+
Optimized Predict endpoint:
|
|
74
|
+
|
|
75
|
+

|
|
76
|
+
|
|
77
|
+
As we can see from the graphs, for the `/predict` endpoint, the failure rate started to increase linearly at the RPS
|
|
78
|
+
of ~284 requests/second, and the 95th percentile response time was ~1100ms at this point. Then with the increase of
|
|
79
|
+
the RPS, the number of successful requests was ~130 requests/second in average, with a high failure rate
|
|
80
|
+
(~300 from ~400 requests/second).
|
|
81
|
+
|
|
82
|
+
For the `/optimized_predict` endpoint, the failure was smaller than 3 requests/second (0.00625%) during the whole test,
|
|
83
|
+
the average response time was increasing slightly with the increase of the RPS, and it reached ~120ms in average at
|
|
84
|
+
the end of the test (> 480 requests/second), with a 95th percentile response time almost stable and smaller than 500ms.
|
|
85
|
+
|
|
86
|
+
You can find check the reports for more details:
|
|
87
|
+
- [predict__report.html](assets/predict__report.html)
|
|
88
|
+
- [optimized_predict__report.html](assets/optimized_predict__report.html)
|
|
89
|
+
## Use cases
|
|
90
|
+
|
|
91
|
+
The `AsyncBatcher` library can be used in any application that needs to handle asynchronous requests in batches,
|
|
92
|
+
such as:
|
|
93
|
+
- Serving machine learning models that optimize the batch processing (e.g. TensorFlow, PyTorch, Scikit-learn, etc.)
|
|
94
|
+
- Storing multiple records in a database in a single query to optimize the I/O operations (or to reduce the cost of the
|
|
95
|
+
database operations, e.g. AWS DynamoDB)
|
|
96
|
+
- Sending multiple messages in a single request to optimize the network operations (or to reduce the cost of the network
|
|
97
|
+
operations, e.g. Kafka, RabbitMQ, AWS SQS, AWS SNS, etc.)
|
|
98
|
+
|
|
99
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
async_batcher/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
2
|
+
async_batcher/aws/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
async_batcher/aws/dynamodb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
async_batcher/aws/dynamodb/get.py,sha256=fkYjx4RhhiQ0uXF6Me5nTGZ9MSNYmaAG8rZ9_B681mI,4063
|
|
5
|
+
async_batcher/aws/dynamodb/write.py,sha256=bWuSaF435nEhix9TeaQFNSBwqAhtY-8yE1lZYDtRXa0,3679
|
|
6
|
+
async_batcher/batcher.py,sha256=M1CxKnoRW-0zadBFaVdclRTup9jD3Jw26O033cuUz_o,5683
|
|
7
|
+
async_batcher/ml/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
async_batcher/ml/keras/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
async_batcher/ml/keras/model.py,sha256=x9t9YFcbR6BM7SjQyv1mh5Nikg9SigWF9wkdODLbNlU,1657
|
|
10
|
+
async_batcher/ml/sklearn/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
async_batcher/ml/sklearn/model.py,sha256=hh_xtR0o24V5Yk8OH7tQONR0C-FD4OAv8tPRKT6rctg,1765
|
|
12
|
+
async_batcher/scylladb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
async_batcher/scylladb/update.py,sha256=a3X8Bea8n_bwJYw-SrZ-dMgSy2noQnBMNkXXM3AOs7Q,1745
|
|
14
|
+
async_batcher-0.1.0.dist-info/METADATA,sha256=TA1GlST7JFBf_62ih3FURPXp7tJiEvPEzaRDYamFDMs,4205
|
|
15
|
+
async_batcher-0.1.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
16
|
+
async_batcher-0.1.0.dist-info/RECORD,,
|