async_sqs_consumer 1.1.0__tar.gz
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_sqs_consumer-1.1.0/PKG-INFO +132 -0
- async_sqs_consumer-1.1.0/README.md +112 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/__init__.py +0 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/py.typed +0 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/queue.py +221 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/resources.py +72 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/types.py +56 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/utils/__init__.py +47 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/utils/retry.py +152 -0
- async_sqs_consumer-1.1.0/async_sqs_consumer/worker.py +377 -0
- async_sqs_consumer-1.1.0/pyproject.toml +51 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: async_sqs_consumer
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary:
|
|
5
|
+
Keywords: AWS,AWS,SQS,sqs,consumer,async,worker
|
|
6
|
+
Author: Diego Restrepo
|
|
7
|
+
Author-email: Diego Restrepo <drestrepo@fluidattacks.com>
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: aioboto3>=15.5.0,<16
|
|
15
|
+
Requires-Dist: jsonschema>=4.26.0,<5
|
|
16
|
+
Requires-Dist: pyrate-limiter>=4.0.2,<5
|
|
17
|
+
Requires-Python: >=3.10, <4
|
|
18
|
+
Project-URL: Repository, https://github.com/drestrepom/async_sqs_consumer
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Async SQS consumer
|
|
22
|
+
|
|
23
|
+
Python asynchronous (**async** / **await**) worker for consuming messages
|
|
24
|
+
from AWS SQS.
|
|
25
|
+
|
|
26
|
+
This is a hobby project, if you find the project interesting
|
|
27
|
+
any contribution is welcome.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
You must create an instance of the worker with the url of the queue.
|
|
32
|
+
|
|
33
|
+
Aws credentials are taken from environment variables, you must set the
|
|
34
|
+
following environment variables. Or you can provide a Context object with the
|
|
35
|
+
aws credentials `async_sqs_consumer.types.Context`
|
|
36
|
+
|
|
37
|
+
- `AWS_ACCESS_KEY_ID`
|
|
38
|
+
- `AWS_SECRET_ACCESS_KEY`
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
|
|
42
|
+
You can get the queue url with the follow aws cli command
|
|
43
|
+
`aws sqs get-queue-url --queue-name xxxxxx`
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# test_worker.py
|
|
47
|
+
|
|
48
|
+
from async_sqs_consumer.worker import (
|
|
49
|
+
Worker,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
worker = Worker(
|
|
53
|
+
queue_url="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@worker.task("report")
|
|
58
|
+
async def report(text: str) -> None:
|
|
59
|
+
print(text)
|
|
60
|
+
|
|
61
|
+
if __name__: "__main__":
|
|
62
|
+
worker.start()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Now you can initialize the worker `python test_worker.py`
|
|
66
|
+
|
|
67
|
+
Now you need to post a message for the worker to process
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import json
|
|
71
|
+
import boto3
|
|
72
|
+
import uuid
|
|
73
|
+
|
|
74
|
+
client = boto3.client("sqs")
|
|
75
|
+
|
|
76
|
+
client.send_message(
|
|
77
|
+
QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
|
|
78
|
+
MessageBody=json.dumps(
|
|
79
|
+
{
|
|
80
|
+
"task": "report",
|
|
81
|
+
"id": uuid.uuid4().hex,
|
|
82
|
+
"args": ["hello world"],
|
|
83
|
+
}
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Or you can use aioboto3
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import asyncio
|
|
92
|
+
import json
|
|
93
|
+
import aioboto3
|
|
94
|
+
import uuid
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def main() -> None:
|
|
98
|
+
session = aioboto3.Session()
|
|
99
|
+
async with session.client("sqs") as client:
|
|
100
|
+
await client.send_message(
|
|
101
|
+
QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
|
|
102
|
+
MessageBody=json.dumps(
|
|
103
|
+
{
|
|
104
|
+
"task": "report",
|
|
105
|
+
"id": uuid.uuid4().hex,
|
|
106
|
+
"args": ["hello world"],
|
|
107
|
+
}
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
asyncio.run(main())
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
To publish the messages they must have the following structure
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"task": {"type": "string"},
|
|
123
|
+
"id": {"type": "string"},
|
|
124
|
+
"args": {"type": "array"},
|
|
125
|
+
"kwargs": {"type": "object"},
|
|
126
|
+
"retries": {"type": "number"},
|
|
127
|
+
"eta": {"type": "string"},
|
|
128
|
+
"expires": {"type": "string"},
|
|
129
|
+
},
|
|
130
|
+
"required": ["task", "id"],
|
|
131
|
+
}
|
|
132
|
+
```
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Async SQS consumer
|
|
2
|
+
|
|
3
|
+
Python asynchronous (**async** / **await**) worker for consuming messages
|
|
4
|
+
from AWS SQS.
|
|
5
|
+
|
|
6
|
+
This is a hobby project, if you find the project interesting
|
|
7
|
+
any contribution is welcome.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
You must create an instance of the worker with the url of the queue.
|
|
12
|
+
|
|
13
|
+
Aws credentials are taken from environment variables, you must set the
|
|
14
|
+
following environment variables. Or you can provide a Context object with the
|
|
15
|
+
aws credentials `async_sqs_consumer.types.Context`
|
|
16
|
+
|
|
17
|
+
- `AWS_ACCESS_KEY_ID`
|
|
18
|
+
- `AWS_SECRET_ACCESS_KEY`
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
|
|
22
|
+
You can get the queue url with the follow aws cli command
|
|
23
|
+
`aws sqs get-queue-url --queue-name xxxxxx`
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
# test_worker.py
|
|
27
|
+
|
|
28
|
+
from async_sqs_consumer.worker import (
|
|
29
|
+
Worker,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
worker = Worker(
|
|
33
|
+
queue_url="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@worker.task("report")
|
|
38
|
+
async def report(text: str) -> None:
|
|
39
|
+
print(text)
|
|
40
|
+
|
|
41
|
+
if __name__: "__main__":
|
|
42
|
+
worker.start()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Now you can initialize the worker `python test_worker.py`
|
|
46
|
+
|
|
47
|
+
Now you need to post a message for the worker to process
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import json
|
|
51
|
+
import boto3
|
|
52
|
+
import uuid
|
|
53
|
+
|
|
54
|
+
client = boto3.client("sqs")
|
|
55
|
+
|
|
56
|
+
client.send_message(
|
|
57
|
+
QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
|
|
58
|
+
MessageBody=json.dumps(
|
|
59
|
+
{
|
|
60
|
+
"task": "report",
|
|
61
|
+
"id": uuid.uuid4().hex,
|
|
62
|
+
"args": ["hello world"],
|
|
63
|
+
}
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or you can use aioboto3
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import asyncio
|
|
72
|
+
import json
|
|
73
|
+
import aioboto3
|
|
74
|
+
import uuid
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def main() -> None:
|
|
78
|
+
session = aioboto3.Session()
|
|
79
|
+
async with session.client("sqs") as client:
|
|
80
|
+
await client.send_message(
|
|
81
|
+
QueueUrl="https://sqs.us-east-1.amazonaws.com/xxxxxxx/queue_name",
|
|
82
|
+
MessageBody=json.dumps(
|
|
83
|
+
{
|
|
84
|
+
"task": "report",
|
|
85
|
+
"id": uuid.uuid4().hex,
|
|
86
|
+
"args": ["hello world"],
|
|
87
|
+
}
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
To publish the messages they must have the following structure
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"task": {"type": "string"},
|
|
103
|
+
"id": {"type": "string"},
|
|
104
|
+
"args": {"type": "array"},
|
|
105
|
+
"kwargs": {"type": "object"},
|
|
106
|
+
"retries": {"type": "number"},
|
|
107
|
+
"eta": {"type": "string"},
|
|
108
|
+
"expires": {"type": "string"},
|
|
109
|
+
},
|
|
110
|
+
"required": ["task", "id"],
|
|
111
|
+
}
|
|
112
|
+
```
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from .resources import (
|
|
2
|
+
get_sqs_client,
|
|
3
|
+
RESOURCE_OPTIONS_SQS,
|
|
4
|
+
SESSION,
|
|
5
|
+
)
|
|
6
|
+
from .types import (
|
|
7
|
+
AwsCredentials,
|
|
8
|
+
)
|
|
9
|
+
from .utils import (
|
|
10
|
+
TASK_NAME_PREFIX,
|
|
11
|
+
)
|
|
12
|
+
from .utils.retry import (
|
|
13
|
+
retry,
|
|
14
|
+
)
|
|
15
|
+
from aiohttp.client_exceptions import (
|
|
16
|
+
ClientConnectorError,
|
|
17
|
+
ClientPayloadError,
|
|
18
|
+
ServerDisconnectedError,
|
|
19
|
+
ServerTimeoutError,
|
|
20
|
+
)
|
|
21
|
+
import asyncio
|
|
22
|
+
from asyncio import (
|
|
23
|
+
get_event_loop,
|
|
24
|
+
sleep,
|
|
25
|
+
)
|
|
26
|
+
from mypy_boto3_sqs.client import (
|
|
27
|
+
SQSClient,
|
|
28
|
+
)
|
|
29
|
+
from botocore.exceptions import (
|
|
30
|
+
ClientError,
|
|
31
|
+
ConnectTimeoutError,
|
|
32
|
+
HTTPClientError,
|
|
33
|
+
ReadTimeoutError,
|
|
34
|
+
)
|
|
35
|
+
from contextlib import (
|
|
36
|
+
suppress,
|
|
37
|
+
)
|
|
38
|
+
import logging
|
|
39
|
+
from typing import (
|
|
40
|
+
Any,
|
|
41
|
+
Callable,
|
|
42
|
+
Coroutine,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
LOGGER = logging.getLogger(__name__)
|
|
46
|
+
NETWORK_ERRORS = (
|
|
47
|
+
asyncio.TimeoutError,
|
|
48
|
+
ClientConnectorError,
|
|
49
|
+
ClientError,
|
|
50
|
+
ClientPayloadError,
|
|
51
|
+
ConnectionResetError,
|
|
52
|
+
ConnectTimeoutError,
|
|
53
|
+
HTTPClientError,
|
|
54
|
+
ReadTimeoutError,
|
|
55
|
+
ServerDisconnectedError,
|
|
56
|
+
ServerTimeoutError,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_max_number_of_messages(max_number_of_messages: int | None) -> int:
|
|
61
|
+
max_number_of_messages = max_number_of_messages or 10
|
|
62
|
+
return min(10, max_number_of_messages)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@retry(exceptions=NETWORK_ERRORS, tries=3, delay=1)
|
|
66
|
+
async def get_queue_messages(
|
|
67
|
+
*,
|
|
68
|
+
queue_url: str,
|
|
69
|
+
credentials: AwsCredentials | None = None,
|
|
70
|
+
client: SQSClient | None = None,
|
|
71
|
+
visibility_timeout: int | None = None,
|
|
72
|
+
wait_time_seconds: int | None = None,
|
|
73
|
+
max_number_of_messages: int | None = None,
|
|
74
|
+
) -> list[dict[str, object]]:
|
|
75
|
+
client = client or await get_sqs_client(credentials)
|
|
76
|
+
max_number_of_messages = set_max_number_of_messages(max_number_of_messages)
|
|
77
|
+
try:
|
|
78
|
+
response = await client.receive_message( # type: ignore[misc]
|
|
79
|
+
QueueUrl=queue_url,
|
|
80
|
+
MaxNumberOfMessages=max_number_of_messages,
|
|
81
|
+
VisibilityTimeout=visibility_timeout or 60,
|
|
82
|
+
WaitTimeSeconds=wait_time_seconds or 0,
|
|
83
|
+
)
|
|
84
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
85
|
+
LOGGER.error(
|
|
86
|
+
"There was an error with the response",
|
|
87
|
+
extra={
|
|
88
|
+
"extra": {
|
|
89
|
+
"client": client,
|
|
90
|
+
"queue_url": queue_url,
|
|
91
|
+
"max_number_of_messages": max_number_of_messages,
|
|
92
|
+
"exc": exc,
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return response.get("Messages", [])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@retry(exceptions=NETWORK_ERRORS, tries=3, delay=1)
|
|
101
|
+
async def delete_messages(
|
|
102
|
+
queue_url: str,
|
|
103
|
+
receipt_handle: dict[str, object],
|
|
104
|
+
credentials: AwsCredentials | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
try:
|
|
107
|
+
client = await get_sqs_client(credentials)
|
|
108
|
+
await client.delete_message(
|
|
109
|
+
ReceiptHandle=receipt_handle,
|
|
110
|
+
QueueUrl=queue_url,
|
|
111
|
+
)
|
|
112
|
+
except NETWORK_ERRORS as exc:
|
|
113
|
+
LOGGER.exception(
|
|
114
|
+
"There was an error deleting messages",
|
|
115
|
+
extra={
|
|
116
|
+
"extra": {
|
|
117
|
+
"receipt_handle": receipt_handle,
|
|
118
|
+
"queue_url": queue_url,
|
|
119
|
+
"exc": exc,
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Queue: # pylint: disable=too-many-instance-attributes
|
|
126
|
+
def __init__( # pylint: disable=too-many-arguments
|
|
127
|
+
self,
|
|
128
|
+
url: str,
|
|
129
|
+
priority: int | None = None,
|
|
130
|
+
authentication: AwsCredentials | None = None,
|
|
131
|
+
polling_interval: float | None = None,
|
|
132
|
+
visibility_timeout: int | None = None,
|
|
133
|
+
max_queue_parallel_messages: int | None = None,
|
|
134
|
+
enabled: bool | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
self.url = url
|
|
137
|
+
self.priority = priority or 1
|
|
138
|
+
self.authentication = authentication
|
|
139
|
+
self.polling_interval = polling_interval or 1.0
|
|
140
|
+
self.visibility_timeout = visibility_timeout or 60
|
|
141
|
+
self._max_queue_parallel_messages = max_queue_parallel_messages
|
|
142
|
+
self._polling = False
|
|
143
|
+
self.enabled = True if enabled is None else enabled
|
|
144
|
+
|
|
145
|
+
async def get_messages(
|
|
146
|
+
self, sqs_client: object, max_number_of_messages: int | None = None
|
|
147
|
+
) -> list[dict[str, object]]:
|
|
148
|
+
with suppress(asyncio.CancelledError):
|
|
149
|
+
messages = await get_queue_messages(
|
|
150
|
+
queue_url=self.url,
|
|
151
|
+
client=sqs_client,
|
|
152
|
+
visibility_timeout=self.visibility_timeout,
|
|
153
|
+
max_number_of_messages=max_number_of_messages,
|
|
154
|
+
)
|
|
155
|
+
return messages
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
def is_task_limit_exceeded(
|
|
159
|
+
self, max_parallel_messages: int | None
|
|
160
|
+
) -> bool:
|
|
161
|
+
number_of_tasks = len(
|
|
162
|
+
[
|
|
163
|
+
task
|
|
164
|
+
for task in asyncio.all_tasks(get_event_loop())
|
|
165
|
+
if task.get_name().startswith(TASK_NAME_PREFIX)
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
return number_of_tasks > (
|
|
169
|
+
self._max_queue_parallel_messages or max_parallel_messages or 1024
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def start_polling(
|
|
173
|
+
self,
|
|
174
|
+
callback: Callable[
|
|
175
|
+
[dict[str, object], str], Coroutine[object, object, None]
|
|
176
|
+
],
|
|
177
|
+
queue_alias: str,
|
|
178
|
+
max_parallel_messages: int | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
self._polling = self._polling or True
|
|
181
|
+
async with SESSION.client(**RESOURCE_OPTIONS_SQS) as sqs_client:
|
|
182
|
+
while self._polling:
|
|
183
|
+
if self.is_task_limit_exceeded(max_parallel_messages):
|
|
184
|
+
await sleep(self.polling_interval)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
with suppress(asyncio.CancelledError):
|
|
188
|
+
messages = await get_queue_messages(
|
|
189
|
+
queue_url=self.url,
|
|
190
|
+
client=sqs_client,
|
|
191
|
+
visibility_timeout=self.visibility_timeout,
|
|
192
|
+
)
|
|
193
|
+
await asyncio.gather(
|
|
194
|
+
*[
|
|
195
|
+
callback(message, queue_alias)
|
|
196
|
+
for message in messages
|
|
197
|
+
]
|
|
198
|
+
)
|
|
199
|
+
await sleep(self.polling_interval)
|
|
200
|
+
|
|
201
|
+
def stop_polling(self) -> None:
|
|
202
|
+
self._polling = False
|
|
203
|
+
|
|
204
|
+
async def delete_messages(
|
|
205
|
+
self,
|
|
206
|
+
receipt_handle: dict[str, Any],
|
|
207
|
+
) -> None:
|
|
208
|
+
async with SESSION.client(**RESOURCE_OPTIONS_SQS) as sqs_client:
|
|
209
|
+
await sqs_client.delete_message(
|
|
210
|
+
ReceiptHandle=receipt_handle,
|
|
211
|
+
QueueUrl=self.url,
|
|
212
|
+
)
|
|
213
|
+
LOGGER.info(
|
|
214
|
+
"Deleted messages parameters",
|
|
215
|
+
extra={
|
|
216
|
+
"extra": {
|
|
217
|
+
"sqs_client": self.url,
|
|
218
|
+
"receipt_handle": receipt_handle,
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from .types import (
|
|
2
|
+
AwsCredentials,
|
|
3
|
+
)
|
|
4
|
+
import aioboto3 # type: ignore[import-untyped]
|
|
5
|
+
from aiobotocore.config import ( # type: ignore[import-untyped]
|
|
6
|
+
AioConfig,
|
|
7
|
+
)
|
|
8
|
+
from contextlib import (
|
|
9
|
+
AsyncExitStack,
|
|
10
|
+
)
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
RESOURCE_OPTIONS_SQS = {
|
|
16
|
+
"aws_access_key_id": (None),
|
|
17
|
+
"aws_secret_access_key": (None),
|
|
18
|
+
"config": AioConfig(
|
|
19
|
+
# The time in seconds till a timeout exception is thrown when
|
|
20
|
+
# attempting to make a connection. [60]
|
|
21
|
+
connect_timeout=15,
|
|
22
|
+
# Maximum amount of simultaneously opened connections. [10]
|
|
23
|
+
# https://docs.aiohttp.org/en/stable/client_advanced.html#limiting-connection-pool-size
|
|
24
|
+
max_pool_connections=2000,
|
|
25
|
+
# The time in seconds till a timeout exception is thrown when
|
|
26
|
+
# attempting to read from a connection. [60]
|
|
27
|
+
read_timeout=30,
|
|
28
|
+
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html
|
|
29
|
+
retries={"max_attempts": 10, "mode": "standard"},
|
|
30
|
+
# Signature version for signing URLs
|
|
31
|
+
# https://boto3.amazonaws.com/v1/documentation/api/1.9.42/guide/s3.html#generating-presigned-urls
|
|
32
|
+
signature_version="s3v4",
|
|
33
|
+
),
|
|
34
|
+
"endpoint_url": None,
|
|
35
|
+
"region_name": "us-east-1",
|
|
36
|
+
"service_name": "sqs",
|
|
37
|
+
"use_ssl": True,
|
|
38
|
+
"verify": True,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
SESSION = aioboto3.Session()
|
|
42
|
+
CONTEXT_STACK_SQS = None
|
|
43
|
+
RESOURCE_SQS = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def sqs_startup(credentials: AwsCredentials | None = None) -> None:
|
|
47
|
+
# pylint: disable=global-statement
|
|
48
|
+
global CONTEXT_STACK_SQS, RESOURCE_SQS
|
|
49
|
+
|
|
50
|
+
CONTEXT_STACK_SQS = AsyncExitStack()
|
|
51
|
+
if credentials:
|
|
52
|
+
RESOURCE_OPTIONS_SQS["aws_access_key_id"] = credentials.access_key_id
|
|
53
|
+
RESOURCE_OPTIONS_SQS[
|
|
54
|
+
"aws_secret_access_key"
|
|
55
|
+
] = credentials.secret_access_key
|
|
56
|
+
RESOURCE_OPTIONS_SQS["aws_session_token"] = credentials.session_token
|
|
57
|
+
|
|
58
|
+
RESOURCE_SQS = await CONTEXT_STACK_SQS.enter_async_context(
|
|
59
|
+
SESSION.client(**RESOURCE_OPTIONS_SQS)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def sqs_shutdown() -> None:
|
|
64
|
+
if CONTEXT_STACK_SQS:
|
|
65
|
+
await CONTEXT_STACK_SQS.aclose()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def get_sqs_client(credentials: AwsCredentials | None = None) -> Any:
|
|
69
|
+
if RESOURCE_SQS is None:
|
|
70
|
+
await sqs_startup(credentials)
|
|
71
|
+
|
|
72
|
+
return RESOURCE_SQS
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from enum import (
|
|
2
|
+
Enum,
|
|
3
|
+
)
|
|
4
|
+
from typing import (
|
|
5
|
+
NamedTuple,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
MESSAGE_SCHEMA = {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"properties": {
|
|
11
|
+
"task": {"type": "string"},
|
|
12
|
+
"id": {"type": "string"},
|
|
13
|
+
"args": {"type": "array"},
|
|
14
|
+
"kwargs": {"type": "object"},
|
|
15
|
+
"retries": {"type": "number"},
|
|
16
|
+
"eta": {"type": "string"},
|
|
17
|
+
"expires": {"type": "string"},
|
|
18
|
+
},
|
|
19
|
+
"required": ["task", "id"],
|
|
20
|
+
}
|
|
21
|
+
SNS_NOTIFICATION_SCHEMA = {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"Type": {"type": "string"},
|
|
25
|
+
"MessageId": {"type": "string"},
|
|
26
|
+
"TopicArn": {"type": "string"},
|
|
27
|
+
"Message": {"type": "string"},
|
|
28
|
+
"Timestamp": {"type": "string"},
|
|
29
|
+
"SignatureVersion": {"type": "string"},
|
|
30
|
+
"Signature": {"type": "string"},
|
|
31
|
+
"SigningCertURL": {"type": "string"},
|
|
32
|
+
"UnsubscribeURL": {"type": "string"},
|
|
33
|
+
},
|
|
34
|
+
"required": ["Type", "Message", "Timestamp"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AwsCredentials(NamedTuple):
|
|
39
|
+
access_key_id: str
|
|
40
|
+
secret_access_key: str
|
|
41
|
+
session_token: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MessageType(Enum):
|
|
45
|
+
CUSTOM = "CUSTOM"
|
|
46
|
+
SNS_NOTIFICATION = "SNS_NOTIFICATION"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SqsOptions(NamedTuple):
|
|
50
|
+
visibility_timeout: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Context(NamedTuple):
|
|
54
|
+
queue_url: str
|
|
55
|
+
aws_credentials: AwsCredentials | None = None
|
|
56
|
+
sqs: SqsOptions | None = None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from ..types import (
|
|
2
|
+
MESSAGE_SCHEMA,
|
|
3
|
+
MessageType,
|
|
4
|
+
SNS_NOTIFICATION_SCHEMA,
|
|
5
|
+
)
|
|
6
|
+
from contextlib import (
|
|
7
|
+
suppress,
|
|
8
|
+
)
|
|
9
|
+
from jsonschema import (
|
|
10
|
+
validate,
|
|
11
|
+
)
|
|
12
|
+
from jsonschema.exceptions import (
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
TASK_NAME_PREFIX = "task_queue"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FailedValidation(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_message(
|
|
24
|
+
message: dict[str, object], message_type: MessageType
|
|
25
|
+
) -> bool:
|
|
26
|
+
with suppress(ValidationError):
|
|
27
|
+
if message_type == MessageType.CUSTOM:
|
|
28
|
+
validate(message, MESSAGE_SCHEMA)
|
|
29
|
+
else:
|
|
30
|
+
validate(message, SNS_NOTIFICATION_SCHEMA)
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def inverse_proportional_distribution(
|
|
36
|
+
amount: int, priorities: list[int]
|
|
37
|
+
) -> list[float]:
|
|
38
|
+
inv_priorities = [1 / p for p in priorities]
|
|
39
|
+
total_inv_priority = sum(inv_priorities)
|
|
40
|
+
inv_proportions = [inv_p / total_inv_priority for inv_p in inv_priorities]
|
|
41
|
+
amounts = [amount * p for p in inv_proportions]
|
|
42
|
+
total_amounts = sum(amounts)
|
|
43
|
+
amounts = [
|
|
44
|
+
int(round(a + (amount - total_amounts) / len(amounts)))
|
|
45
|
+
for a in amounts
|
|
46
|
+
]
|
|
47
|
+
return amounts
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# pylint: disable=broad-except
|
|
2
|
+
# pylint: disable=too-many-arguments
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
from functools import (
|
|
6
|
+
partial,
|
|
7
|
+
)
|
|
8
|
+
import logging
|
|
9
|
+
import random
|
|
10
|
+
import traceback
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Callable,
|
|
14
|
+
Literal,
|
|
15
|
+
Type,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logging_logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _log_exception(
|
|
22
|
+
func: partial,
|
|
23
|
+
exc: Exception,
|
|
24
|
+
logger: logging.Logger | None = None,
|
|
25
|
+
log_traceback: bool = False,
|
|
26
|
+
_delay: float | int | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
if logger is not None:
|
|
29
|
+
try:
|
|
30
|
+
func_qualname = func.func.__qualname__
|
|
31
|
+
except AttributeError:
|
|
32
|
+
func_qualname = str(func.func)
|
|
33
|
+
logger.warning(
|
|
34
|
+
"%s: %s in %s.%s, retrying in %s seconds...",
|
|
35
|
+
exc.__class__.__qualname__,
|
|
36
|
+
exc,
|
|
37
|
+
func.func.__module__,
|
|
38
|
+
func_qualname,
|
|
39
|
+
_delay,
|
|
40
|
+
)
|
|
41
|
+
if log_traceback:
|
|
42
|
+
logger.warning(traceback.format_exc())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _increase_delay(
|
|
46
|
+
_delay: float,
|
|
47
|
+
backoff: float | None = None,
|
|
48
|
+
jitter: Literal[1] | Literal[0] = 0,
|
|
49
|
+
max_delay: float | None = None,
|
|
50
|
+
) -> float:
|
|
51
|
+
_delay *= backoff or 1
|
|
52
|
+
|
|
53
|
+
if isinstance(jitter, tuple):
|
|
54
|
+
_delay += random.uniform(*jitter)
|
|
55
|
+
else:
|
|
56
|
+
_delay += jitter
|
|
57
|
+
|
|
58
|
+
if max_delay is not None:
|
|
59
|
+
_delay = min(_delay, max_delay)
|
|
60
|
+
|
|
61
|
+
return _delay
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def __retry_internal(
|
|
65
|
+
func: partial,
|
|
66
|
+
tries: int,
|
|
67
|
+
exceptions: Type[Exception] | tuple[type[Exception], ...] | None = None,
|
|
68
|
+
delay: float | None = None,
|
|
69
|
+
max_delay: float | None = None,
|
|
70
|
+
backoff: float | None = None,
|
|
71
|
+
jitter: Literal[1] | Literal[0] = 0,
|
|
72
|
+
logger: logging.Logger | None = None,
|
|
73
|
+
log_traceback: bool = False,
|
|
74
|
+
on_exception: Callable[[Exception], bool] | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
exceptions = exceptions or Exception
|
|
77
|
+
_tries, _delay = tries, (delay or 0.0)
|
|
78
|
+
logger = logger or logging_logger
|
|
79
|
+
while _tries >= 0:
|
|
80
|
+
try:
|
|
81
|
+
return await func()
|
|
82
|
+
except exceptions as exc:
|
|
83
|
+
if on_exception is not None and on_exception(exc):
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
_tries -= 1
|
|
87
|
+
_log_exception(func, exc, logger, log_traceback, _delay)
|
|
88
|
+
|
|
89
|
+
await asyncio.sleep(_delay)
|
|
90
|
+
_delay = _increase_delay(_delay, backoff, jitter, max_delay)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def retry( # pylint: disable=too-many-arguments
|
|
94
|
+
tries: int,
|
|
95
|
+
exceptions: Type[Exception] | tuple[type[Exception], ...] = Exception,
|
|
96
|
+
delay: float | None = None,
|
|
97
|
+
max_delay: float | None = None,
|
|
98
|
+
backoff: float | None = None,
|
|
99
|
+
jitter: Literal[1] | Literal[0] = 0,
|
|
100
|
+
logger: logging.Logger | None = None,
|
|
101
|
+
log_traceback: bool = False,
|
|
102
|
+
on_exception: Callable[[Exception], bool] | None = None,
|
|
103
|
+
) -> Callable[[Callable], Callable]:
|
|
104
|
+
def decorator(func: Callable[..., str]) -> Any:
|
|
105
|
+
@functools.wraps(func)
|
|
106
|
+
async def wrapper(
|
|
107
|
+
*fargs: list[str], **fkwargs: dict[str, str]
|
|
108
|
+
) -> None:
|
|
109
|
+
args = fargs if fargs else []
|
|
110
|
+
kwargs = fkwargs if fkwargs else {}
|
|
111
|
+
return await __retry_internal(
|
|
112
|
+
partial(func, *args, **kwargs),
|
|
113
|
+
tries,
|
|
114
|
+
exceptions,
|
|
115
|
+
delay,
|
|
116
|
+
max_delay,
|
|
117
|
+
backoff,
|
|
118
|
+
jitter,
|
|
119
|
+
logger,
|
|
120
|
+
log_traceback,
|
|
121
|
+
on_exception,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return wrapper
|
|
125
|
+
|
|
126
|
+
return decorator
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def retry_call( # pylint: disable=too-many-arguments
|
|
130
|
+
func: partial | Callable[..., object],
|
|
131
|
+
tries: int,
|
|
132
|
+
exceptions: Type[Exception] | tuple[type[Exception], ...] = Exception,
|
|
133
|
+
delay: float | None = None,
|
|
134
|
+
max_delay: float | None = None,
|
|
135
|
+
backoff: float | None = None,
|
|
136
|
+
jitter: Literal[1] | Literal[0] = 0,
|
|
137
|
+
logger: logging.Logger | None = None,
|
|
138
|
+
fargs: tuple[object, ...] | None = None,
|
|
139
|
+
fkwargs: dict[str, object] | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
args = fargs if fargs else tuple()
|
|
142
|
+
kwargs = fkwargs if fkwargs else {}
|
|
143
|
+
return await __retry_internal(
|
|
144
|
+
partial(func, *args, **kwargs),
|
|
145
|
+
tries,
|
|
146
|
+
exceptions,
|
|
147
|
+
delay,
|
|
148
|
+
max_delay,
|
|
149
|
+
backoff,
|
|
150
|
+
jitter,
|
|
151
|
+
logger,
|
|
152
|
+
)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
from .queue import (
|
|
2
|
+
Queue,
|
|
3
|
+
)
|
|
4
|
+
from .resources import (
|
|
5
|
+
RESOURCE_OPTIONS_SQS,
|
|
6
|
+
SESSION,
|
|
7
|
+
)
|
|
8
|
+
from .types import (
|
|
9
|
+
MessageType,
|
|
10
|
+
)
|
|
11
|
+
from .utils import (
|
|
12
|
+
inverse_proportional_distribution,
|
|
13
|
+
TASK_NAME_PREFIX,
|
|
14
|
+
validate_message,
|
|
15
|
+
)
|
|
16
|
+
from .utils.retry import (
|
|
17
|
+
retry_call,
|
|
18
|
+
)
|
|
19
|
+
import asyncio
|
|
20
|
+
from contextlib import (
|
|
21
|
+
suppress,
|
|
22
|
+
)
|
|
23
|
+
from datetime import (
|
|
24
|
+
datetime,
|
|
25
|
+
timezone,
|
|
26
|
+
)
|
|
27
|
+
from functools import (
|
|
28
|
+
partial,
|
|
29
|
+
)
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import math
|
|
33
|
+
import signal
|
|
34
|
+
import sys
|
|
35
|
+
from typing import (
|
|
36
|
+
Any,
|
|
37
|
+
Callable,
|
|
38
|
+
Literal,
|
|
39
|
+
)
|
|
40
|
+
from uuid import (
|
|
41
|
+
uuid4,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logging.basicConfig(level=logging.INFO)
|
|
45
|
+
|
|
46
|
+
LOGGER = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Worker:
|
|
50
|
+
def __init__(
|
|
51
|
+
self, queues: dict[str, Queue], max_workers: int | None = None
|
|
52
|
+
) -> None:
|
|
53
|
+
self.queues = queues
|
|
54
|
+
self.running = False
|
|
55
|
+
self.handlers_by_queue: dict[
|
|
56
|
+
str, dict[str, Callable[..., object]]
|
|
57
|
+
] = {}
|
|
58
|
+
self._events: dict[str, list[Callable[[], None]]] = {}
|
|
59
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
60
|
+
self._tasks: list[asyncio.Task] = []
|
|
61
|
+
self._max_workers = max_workers or 1024
|
|
62
|
+
|
|
63
|
+
def _sigint_handler(self, *_args: object) -> None:
|
|
64
|
+
sys.stdout.write("\b\b\r")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
logging.getLogger("system").warning(
|
|
67
|
+
"Received <ctrl+c> interrupt [SIGINT]"
|
|
68
|
+
)
|
|
69
|
+
self._stop_worker()
|
|
70
|
+
|
|
71
|
+
def _sigterm_handler(self, *_args: object) -> None:
|
|
72
|
+
logging.getLogger("system").warning(
|
|
73
|
+
"Received termination signal [SIGTERM]"
|
|
74
|
+
)
|
|
75
|
+
self._stop_worker()
|
|
76
|
+
|
|
77
|
+
def _stop_worker(self, *_: object) -> None:
|
|
78
|
+
if self._loop:
|
|
79
|
+
LOGGER.debug("Stop worker")
|
|
80
|
+
self.running = False
|
|
81
|
+
for queue in self.queues.values():
|
|
82
|
+
queue.stop_polling()
|
|
83
|
+
for event in self._events.get("shutdown", []):
|
|
84
|
+
self._loop.run_until_complete(event()) # type: ignore
|
|
85
|
+
|
|
86
|
+
map(lambda task: task.cancel(), self._tasks)
|
|
87
|
+
|
|
88
|
+
# make sure all tasks are finished before finishing the
|
|
89
|
+
# entire process
|
|
90
|
+
self._loop.stop()
|
|
91
|
+
|
|
92
|
+
def task(
|
|
93
|
+
self, name: str, queue_name: str | None = None
|
|
94
|
+
) -> Callable[..., None]:
|
|
95
|
+
"""A decorator to add a task that could be handled by the worker.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name (str): tasks received with this name will be
|
|
99
|
+
processed by the decorated function
|
|
100
|
+
Returns:
|
|
101
|
+
Callable[[Callable[[], None]], None]: _description_
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
queue_name = queue_name or "default"
|
|
105
|
+
|
|
106
|
+
def decorator(func: Callable[[], object]) -> None:
|
|
107
|
+
queue_ = queue_name or "default"
|
|
108
|
+
if queue_ not in self.handlers_by_queue:
|
|
109
|
+
self.handlers_by_queue[queue_] = {name: func}
|
|
110
|
+
else:
|
|
111
|
+
self.handlers_by_queue[queue_][name] = func
|
|
112
|
+
|
|
113
|
+
return decorator
|
|
114
|
+
|
|
115
|
+
def on_event(
|
|
116
|
+
self, event_name: Literal["startup", "shutdown"]
|
|
117
|
+
) -> Callable[[Callable[[], None]], None]:
|
|
118
|
+
"""Yo can define event handlers that need to be executed before
|
|
119
|
+
the application startups, or when the application is shutting down.
|
|
120
|
+
The valid events are `startup`, `shutdown`.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
event_name (Literal["startup", "shutdown"]):
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Callable[[Callable[[], None]], None]:
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def decorator(func: Callable[[], None]) -> None:
|
|
130
|
+
if event_name not in self._events:
|
|
131
|
+
self._events[event_name] = [func]
|
|
132
|
+
else:
|
|
133
|
+
self._events[event_name] = [*self._events[event_name], func]
|
|
134
|
+
|
|
135
|
+
return decorator
|
|
136
|
+
|
|
137
|
+
def start(
|
|
138
|
+
self, event_loop: asyncio.AbstractEventLoop | None = None
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Start the workert
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
event_loop (asyncio.AbstractEventLoop | None, optional):
|
|
144
|
+
if an eventloop is already running you can run the worker in it.
|
|
145
|
+
Defaults to None.
|
|
146
|
+
"""
|
|
147
|
+
self.running = True
|
|
148
|
+
loop = event_loop or asyncio.get_event_loop()
|
|
149
|
+
self._loop = loop
|
|
150
|
+
|
|
151
|
+
if self._loop and self._loop.is_closed():
|
|
152
|
+
self._loop = asyncio.new_event_loop()
|
|
153
|
+
asyncio.set_event_loop(self._loop)
|
|
154
|
+
|
|
155
|
+
for signame in ("SIGINT", "SIGTERM"):
|
|
156
|
+
self._loop.add_signal_handler(
|
|
157
|
+
getattr(signal, signame), partial(self._stop_worker, self)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
signal.siginterrupt(signal.SIGTERM, False)
|
|
161
|
+
signal.siginterrupt(signal.SIGUSR1, False)
|
|
162
|
+
signal.signal(signal.SIGINT, partial(self._sigint_handler, self))
|
|
163
|
+
signal.signal(signal.SIGTERM, partial(self._sigterm_handler, self))
|
|
164
|
+
|
|
165
|
+
for event in self._events.get("startup", []):
|
|
166
|
+
self._loop.run_until_complete(event()) # type: ignore
|
|
167
|
+
loop.create_task(self._poll_messages())
|
|
168
|
+
self._loop.run_forever()
|
|
169
|
+
|
|
170
|
+
def _finish_message(
|
|
171
|
+
self,
|
|
172
|
+
_future: asyncio.Future | None,
|
|
173
|
+
*,
|
|
174
|
+
handler_name: str,
|
|
175
|
+
task_id: str,
|
|
176
|
+
queue_alias: str,
|
|
177
|
+
start_time: float,
|
|
178
|
+
receipt_handle: object | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
start_date = datetime.fromtimestamp(start_time, tz=timezone.utc)
|
|
181
|
+
end_date = datetime.now(tz=timezone.utc)
|
|
182
|
+
total_seconds = end_date - start_date
|
|
183
|
+
if _future and _future.exception() is not None:
|
|
184
|
+
LOGGER.warning(
|
|
185
|
+
"Failed to execute task %s[%s]", handler_name, task_id
|
|
186
|
+
)
|
|
187
|
+
LOGGER.exception(
|
|
188
|
+
"Following exception",
|
|
189
|
+
exc_info=_future.exception(),
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
LOGGER.info(
|
|
193
|
+
"Task %s[%s] succeeded in %ss: None",
|
|
194
|
+
handler_name,
|
|
195
|
+
task_id,
|
|
196
|
+
total_seconds.seconds,
|
|
197
|
+
)
|
|
198
|
+
if receipt_handle:
|
|
199
|
+
self._delete_message(receipt_handle, queue_alias)
|
|
200
|
+
|
|
201
|
+
def _delete_message(
|
|
202
|
+
self,
|
|
203
|
+
receipt_handle: object,
|
|
204
|
+
queue_alias: str,
|
|
205
|
+
) -> None:
|
|
206
|
+
asyncio.ensure_future(
|
|
207
|
+
self.queues[queue_alias].delete_messages(
|
|
208
|
+
receipt_handle # type: ignore[arg-type]
|
|
209
|
+
),
|
|
210
|
+
loop=self._loop,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _available_workers_to_consume(self) -> int:
|
|
214
|
+
current_tasks = [
|
|
215
|
+
task
|
|
216
|
+
for task in asyncio.all_tasks(asyncio.get_event_loop())
|
|
217
|
+
if task.get_name().startswith(TASK_NAME_PREFIX)
|
|
218
|
+
]
|
|
219
|
+
return self._max_workers - len(current_tasks)
|
|
220
|
+
|
|
221
|
+
def _get_tasks_by_queue(
|
|
222
|
+
self, queue_alias: str
|
|
223
|
+
) -> tuple[asyncio.Task, ...]:
|
|
224
|
+
return tuple(
|
|
225
|
+
task
|
|
226
|
+
for task in asyncio.all_tasks(asyncio.get_event_loop())
|
|
227
|
+
if task.get_name().startswith(f"{TASK_NAME_PREFIX}_{queue_alias}")
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def queue_message_to_worker(
|
|
231
|
+
self,
|
|
232
|
+
*,
|
|
233
|
+
body: dict[str, Any],
|
|
234
|
+
message_type: MessageType,
|
|
235
|
+
queue_alias: str,
|
|
236
|
+
receipt_handler: str,
|
|
237
|
+
) -> bool:
|
|
238
|
+
task_id = body.get("id") or uuid4().hex
|
|
239
|
+
task_name = body.get("task") or message_type.value.lower()
|
|
240
|
+
LOGGER.info("Task %s[%s] received", task_name, task_id)
|
|
241
|
+
|
|
242
|
+
if not self._loop:
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
handler = self.handlers_by_queue.get(queue_alias, {}).get(task_name)
|
|
246
|
+
if not handler:
|
|
247
|
+
LOGGER.warning(
|
|
248
|
+
"The task %s has not a candidate to be proceeded",
|
|
249
|
+
task_name,
|
|
250
|
+
)
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
task = self._loop.create_task(
|
|
254
|
+
retry_call(
|
|
255
|
+
handler,
|
|
256
|
+
fargs=tuple(body.get("args", [])),
|
|
257
|
+
fkwargs=(
|
|
258
|
+
body
|
|
259
|
+
if message_type == MessageType.SNS_NOTIFICATION
|
|
260
|
+
else body.get("kwargs", {})
|
|
261
|
+
),
|
|
262
|
+
tries=body.get("retries", 1),
|
|
263
|
+
),
|
|
264
|
+
name=(
|
|
265
|
+
f"{TASK_NAME_PREFIX}_{queue_alias}"
|
|
266
|
+
f"_{task_name}_{uuid4().hex[:8]}"
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
task.add_done_callback(
|
|
270
|
+
partial(
|
|
271
|
+
self._finish_message,
|
|
272
|
+
receipt_handle=receipt_handler,
|
|
273
|
+
queue_alias=queue_alias,
|
|
274
|
+
start_time=datetime.now().timestamp(),
|
|
275
|
+
handler_name=task_name,
|
|
276
|
+
task_id=task_id,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
async def _process_queue_message(
|
|
282
|
+
self, message_content: dict[str, Any], queue_alias: str
|
|
283
|
+
) -> bool:
|
|
284
|
+
try:
|
|
285
|
+
body = json.loads(message_content["Body"])
|
|
286
|
+
except json.JSONDecodeError:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
message_type = (
|
|
290
|
+
MessageType.SNS_NOTIFICATION
|
|
291
|
+
if body.get("Type") == "Notification"
|
|
292
|
+
else MessageType.CUSTOM
|
|
293
|
+
)
|
|
294
|
+
if not validate_message(body, message_type):
|
|
295
|
+
LOGGER.error("Failed to validate message")
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
await self.queue_message_to_worker(
|
|
299
|
+
body=body,
|
|
300
|
+
message_type=message_type,
|
|
301
|
+
queue_alias=queue_alias,
|
|
302
|
+
receipt_handler=message_content["ReceiptHandle"],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
async def _consumer_callback(
|
|
308
|
+
self, message: dict[str, Any], queue_alias: str
|
|
309
|
+
) -> None:
|
|
310
|
+
with suppress(asyncio.CancelledError):
|
|
311
|
+
success = await self._process_queue_message(message, queue_alias)
|
|
312
|
+
if not success:
|
|
313
|
+
await self.queues[queue_alias].delete_messages(
|
|
314
|
+
message["ReceiptHandle"]
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def _queue_n_messages(
|
|
318
|
+
self,
|
|
319
|
+
*,
|
|
320
|
+
queue: Queue,
|
|
321
|
+
queue_alias: str,
|
|
322
|
+
sqs_client: object,
|
|
323
|
+
required_messages: int,
|
|
324
|
+
) -> None:
|
|
325
|
+
for _ in range(math.ceil(required_messages / 10)):
|
|
326
|
+
messages = await queue.get_messages(
|
|
327
|
+
sqs_client,
|
|
328
|
+
max_number_of_messages=(required_messages),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if len(messages) == 0:
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
await asyncio.gather(
|
|
335
|
+
*[
|
|
336
|
+
self._consumer_callback(message, queue_alias)
|
|
337
|
+
for message in messages
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
async def _poll_messages(self) -> None:
|
|
342
|
+
queues = list(sorted(self.queues.items(), key=lambda x: x[1].priority))
|
|
343
|
+
queues = [item for item in queues if item[1].enabled]
|
|
344
|
+
async with SESSION.client(**RESOURCE_OPTIONS_SQS) as sqs_client:
|
|
345
|
+
while True:
|
|
346
|
+
if self._available_workers_to_consume() < 1:
|
|
347
|
+
await asyncio.sleep(1)
|
|
348
|
+
continue
|
|
349
|
+
for index, (queue_alias, queue) in enumerate(queues):
|
|
350
|
+
required_messages = math.ceil(
|
|
351
|
+
inverse_proportional_distribution(
|
|
352
|
+
self._available_workers_to_consume(),
|
|
353
|
+
[q[1].priority for q in queues[index:]],
|
|
354
|
+
)[0]
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# it is necessary to request the messages several times
|
|
358
|
+
# because sqs only allows to obtain 10 messages at the
|
|
359
|
+
# same time
|
|
360
|
+
await self._queue_n_messages(
|
|
361
|
+
queue=queue,
|
|
362
|
+
queue_alias=queue_alias,
|
|
363
|
+
sqs_client=sqs_client,
|
|
364
|
+
required_messages=required_messages,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
LOGGER.info(
|
|
368
|
+
"Queue messages info",
|
|
369
|
+
extra={
|
|
370
|
+
"extra": {
|
|
371
|
+
"queue": queue,
|
|
372
|
+
"queue_alias": queue_alias,
|
|
373
|
+
"sqs_client": sqs_client,
|
|
374
|
+
"required_messages": required_messages,
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "async_sqs_consumer"
|
|
3
|
+
version = "1.1.0"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = [{ name = "Diego Restrepo", email = "drestrepo@fluidattacks.com" }]
|
|
6
|
+
requires-python = ">=3.10,<4"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = [
|
|
9
|
+
"AWS",
|
|
10
|
+
"AWS",
|
|
11
|
+
"SQS",
|
|
12
|
+
"sqs",
|
|
13
|
+
"consumer",
|
|
14
|
+
"async",
|
|
15
|
+
"worker",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"aioboto3>=15.5.0,<16",
|
|
27
|
+
"jsonschema>=4.26.0,<5",
|
|
28
|
+
"pyrate-limiter>=4.0.2,<5",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/drestrepom/async_sqs_consumer"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"prospector>=1.7.7,<2",
|
|
37
|
+
"black>=22.8.0,<23",
|
|
38
|
+
"isort>=5.10.1,<6",
|
|
39
|
+
"mypy>=1.19.1",
|
|
40
|
+
"types-jsonschema>=4.21.0",
|
|
41
|
+
"boto3-stubs[essential]>=1.34.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.uv]
|
|
45
|
+
|
|
46
|
+
[tool.uv.build-backend]
|
|
47
|
+
module-root = ""
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.9.26,<0.10.0"]
|
|
51
|
+
build-backend = "uv_build"
|