Skip to content

Commit 43c73c5

Browse files
committed
feat: add S3 result backend
1 parent 2a10233 commit 43c73c5

11 files changed

Lines changed: 653 additions & 158 deletions

File tree

examples/example_broker.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@
1010

1111
import boto3
1212
import dotenv
13-
from taskiq_redis import RedisAsyncResultBackend
1413

15-
from taskiq_sqs import SQSBroker
14+
from taskiq_sqs import S3Bucket, S3ResultBackend, SQSBroker
1615

1716

1817
dotenv.load_dotenv()
@@ -24,7 +23,7 @@
2423
boto3.client("sqs").create_queue(QueueName=QUEUE_NAME)
2524

2625
broker = SQSBroker(QUEUE_URL, sqs_region_override="us-east-1").with_result_backend(
27-
RedisAsyncResultBackend(redis_url="redis://localhost:6379")
26+
S3ResultBackend(bucket=S3Bucket(name="response-bucket"))
2827
)
2928

3029

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
"taskiq>=0.12.1",
3535
"asyncer~=0.0.5",
3636
"boto3~=1.34.34",
37+
"aiobotocore>=2.13.3",
3738
]
3839

3940
# [project.urls]
@@ -60,6 +61,7 @@ types = [
6061
"mypy>=2.1.0",
6162
"mypy-boto3-sqs>=1.34.101",
6263
"boto3-stubs[essential]>=1.34.84",
64+
"types-aiobotocore[essential]>=3.7.0",
6365
]
6466
examples = [
6567
"python-dotenv>=1.2.2",
@@ -139,6 +141,7 @@ ignore = [
139141
"D",
140142
"S101", # assert usage
141143
"SLF001", # private member accessed
144+
"PLR2004", # magic values
142145
]
143146
"examples/*" = [
144147
"T201", # print

src/taskiq_sqs/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
from taskiq_sqs.broker import SQSBroker
2+
from taskiq_sqs.bucket import S3Bucket
3+
from taskiq_sqs.result_backend import S3ResultBackend
24

35

4-
__all__ = ["SQSBroker"]
6+
__all__ = [
7+
"S3Bucket",
8+
"S3ResultBackend",
9+
"SQSBroker",
10+
]

src/taskiq_sqs/broker.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from taskiq.message import BrokerMessage
1515

1616
from taskiq_sqs.aws import get_container_credentials
17+
from taskiq_sqs.exceptions import BrokerInitError
1718

1819

1920
if TYPE_CHECKING:
@@ -27,10 +28,6 @@ def stamp() -> int: # noqa: D103
2728
return int(datetime.now(tz=timezone.utc).timestamp())
2829

2930

30-
class SQSBrokerError(Exception):
31-
"""Generic SQS broker error."""
32-
33-
3431
class SQSBroker(AsyncBroker):
3532
"""AWS SQS TaskIQ broker."""
3633

@@ -47,7 +44,7 @@ def __init__( # noqa: D107
4744
super().__init__(result_backend, task_id_generator)
4845

4946
if not sqs_queue_url or not sqs_queue_url.startswith("http"):
50-
raise SQSBrokerError("A valid SQS queue url is required")
47+
raise BrokerInitError(details="A valid SQS queue url is required")
5148

5249
# NOTE: This bypasses the normal order of operations for boto3 auth and
5350
# goes straight to using the ECS role creds from the metadata
@@ -61,7 +58,7 @@ def __init__( # noqa: D107
6158
self._creds_expiration: datetime | None = None
6259

6360
if max_number_of_messages > 10: # noqa: PLR2004
64-
raise SQSBrokerError("MaxNumberOfMessages can be no greater than 10")
61+
raise BrokerInitError(details="MaxNumberOfMessages can be no greater than 10")
6562

6663
self.wait_time_seconds = max(wait_time_seconds, 0)
6764
self.max_number_of_messages = max(max_number_of_messages, 1)
@@ -100,8 +97,7 @@ async def _get_queue(self) -> "Queue":
10097
)
10198

10299
if not self._sqs_queue:
103-
exc_message = "SQS queue not found"
104-
raise Exception(exc_message) # noqa: TRY002
100+
raise BrokerInitError(details="SQS queue not found")
105101

106102
return self._sqs_queue
107103

src/taskiq_sqs/bucket.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class S3Bucket:
6+
"""
7+
Represents an S3 bucket configuration.
8+
9+
Attributes:
10+
name: The name of the bucjet.
11+
declare: Whether to create the bucket on startup if it not exists yet.
12+
"""
13+
name: str
14+
declare: bool = True

src/taskiq_sqs/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AWS_DEFAULT_REGION = "us-east-1"

src/taskiq_sqs/exceptions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from taskiq.exceptions import TaskiqError
2+
3+
4+
class BaseTaskiqSQSError(TaskiqError):
5+
"""Base error from taskiq-sqs."""
6+
7+
8+
class BrokerInitError(BaseTaskiqSQSError):
9+
"""Error during broker initialization."""
10+
11+
__template__ = "Error during broker initialization: {details}"
12+
details: str
13+
14+
15+
class ResultBackendError(BaseTaskiqSQSError):
16+
"""Base error for all taskiq-aio-sqs broker exceptions."""
17+
18+
__template__ = "Unexpected error occurred: {code}"
19+
code: str | None = None
20+
21+
22+
class BucketNotFoundError(BaseTaskiqSQSError):
23+
"""Error if bucket not found."""
24+
25+
__template__ = "Bucket '{bucket_name}' not found during initialization and declare=False"
26+
bucket_name: str
27+
28+
class ResultIsMissingError(BaseTaskiqSQSError):
29+
"""Error if there is no result when we trying to get it."""
30+
31+
__template__ = "Result for task {task_id} is missing in the result backend"
32+
task_id: str

src/taskiq_sqs/result_backend.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from pathlib import Path
2+
from typing import TYPE_CHECKING, Any, TypeVar
3+
4+
from aiobotocore.session import get_session
5+
from botocore.exceptions import ClientError
6+
from taskiq import AsyncResultBackend
7+
from taskiq.abc.serializer import TaskiqSerializer
8+
from taskiq.compat import model_dump, model_validate
9+
from taskiq.result import TaskiqResult
10+
from taskiq.serializers import JSONSerializer
11+
12+
from taskiq_sqs import constants, exceptions
13+
from taskiq_sqs.bucket import S3Bucket
14+
15+
16+
if TYPE_CHECKING:
17+
from types_aiobotocore_s3.client import S3Client
18+
19+
_ReturnType = TypeVar("_ReturnType")
20+
21+
22+
class S3ResultBackend(AsyncResultBackend[_ReturnType]):
23+
"""TaskIQ result backend that uses S3."""
24+
25+
def __init__(
26+
self,
27+
bucket: S3Bucket,
28+
base_path: str = "",
29+
endpoint_url: str | None = None,
30+
aws_region_name: str = constants.AWS_DEFAULT_REGION,
31+
aws_access_key_id: str | None = None,
32+
aws_secret_access_key: str | None = None,
33+
serializer: TaskiqSerializer | None = None,
34+
) -> None:
35+
"""
36+
Constructs a new S3 result backend.
37+
38+
:param bucket_name: name of the bucket on S3.
39+
:param base_path: base path for results.
40+
:param endpoint_url: endpoint URL for S3.
41+
:param aws_region_name: AWS region, default is 'us-east-1'.
42+
:param aws_access_key_id: AWS access key ID.
43+
:param aws_secret_access_key: AWS secret access key.
44+
:param serializer: serializer to use.
45+
"""
46+
self._aws_region = aws_region_name
47+
self._aws_endpoint_url = endpoint_url
48+
self._aws_access_key_id = aws_access_key_id
49+
self._aws_secret_access_key = aws_secret_access_key
50+
self._bucket = bucket
51+
self._base_path = base_path
52+
self._session = get_session()
53+
self._serializer = serializer or JSONSerializer()
54+
55+
async def _get_client(self) -> "S3Client":
56+
"""
57+
Retrieves the S3 client, creating it if necessary.
58+
59+
Returns:
60+
S3Client: The initialized S3 client.
61+
"""
62+
self._client_context_creator = self._session.create_client(
63+
"s3",
64+
region_name=self._aws_region,
65+
endpoint_url=self._aws_endpoint_url,
66+
aws_access_key_id=self._aws_access_key_id,
67+
aws_secret_access_key=self._aws_secret_access_key,
68+
)
69+
return await self._client_context_creator.__aenter__()
70+
71+
async def startup(self) -> None:
72+
"""Initialize the result backend."""
73+
self._s3_client = await self._get_client()
74+
await self._ensure_bucket_exists()
75+
return await super().startup()
76+
77+
async def _ensure_bucket_exists(self) -> None:
78+
try:
79+
await self._s3_client.head_bucket(Bucket=self._bucket.name)
80+
except ClientError as exc:
81+
code = exc.response.get("Error", {}).get("Code")
82+
if code not in ("404", "NoSuchBucket"):
83+
raise exceptions.ResultBackendError(code=code) from exc
84+
if not self._bucket.declare:
85+
raise exceptions.BucketNotFoundError(bucket_name=self._bucket.name) from exc
86+
await self._create_bucket()
87+
88+
async def _create_bucket(self) -> None:
89+
create_kwargs: dict[str, Any] = {"Bucket": self._bucket.name}
90+
if self._aws_region and self._aws_region != constants.AWS_DEFAULT_REGION:
91+
create_kwargs["CreateBucketConfiguration"] = {"LocationConstraint": self._aws_region}
92+
try:
93+
await self._s3_client.create_bucket(**create_kwargs)
94+
except ClientError as exc:
95+
if exc.response.get("Error", {}).get("Code") != "BucketAlreadyOwnedByYou": # can be raise between workers
96+
raise
97+
98+
async def shutdown(self) -> None:
99+
"""Shut down the result backend."""
100+
await self._client_context_creator.__aexit__(None, None, None)
101+
return await super().shutdown()
102+
103+
async def set_result(
104+
self,
105+
task_id: str,
106+
result: TaskiqResult[_ReturnType],
107+
) -> None:
108+
"""
109+
Set result in your backend.
110+
111+
:param task_id: current task id.
112+
:param result: result of execution.
113+
"""
114+
if self._base_path:
115+
task_id = str(Path(self._base_path) / task_id)
116+
117+
await self._s3_client.put_object(
118+
Bucket=self._bucket.name,
119+
Key=task_id,
120+
Body=self._serializer.dumpb(model_dump(result)),
121+
)
122+
123+
async def get_result(
124+
self,
125+
task_id: str,
126+
with_logs: bool = False,
127+
) -> TaskiqResult[_ReturnType]:
128+
"""
129+
Here you must retrieve result by id.
130+
131+
Logs is a part of a result. Here we have a parameter whether you want to fetch result with logs or not,
132+
because logs can have a lot of info and sometimes it's critical to get only needed information.
133+
134+
:param task_id: id of a task.
135+
:param with_logs: whether to fetch logs.
136+
:return: result.
137+
"""
138+
result = None
139+
if self._base_path:
140+
task_id = str(Path(self._base_path) / task_id)
141+
try:
142+
if response := await self._s3_client.get_object(
143+
Bucket=self._bucket.name,
144+
Key=task_id,
145+
):
146+
async with response["Body"] as stream:
147+
result = await stream.read()
148+
except ClientError as exc:
149+
code = exc.response.get("Error", {}).get("Code")
150+
if code in ["NoSuchKey", "404"]:
151+
raise exceptions.ResultIsMissingError(task_id=task_id) from exc
152+
raise exceptions.ResultBackendError(code=code) from exc
153+
if result is None:
154+
raise exceptions.ResultIsMissingError(task_id=task_id)
155+
156+
taskiq_result = model_validate(
157+
TaskiqResult[_ReturnType],
158+
self._serializer.loadb(result),
159+
)
160+
161+
if not with_logs:
162+
taskiq_result.log = None
163+
164+
return taskiq_result
165+
166+
async def is_result_ready(self, task_id: str) -> bool:
167+
"""
168+
Check if result exists.
169+
170+
:param task_id: id of a task.
171+
:return: True if result is ready.
172+
"""
173+
if self._base_path:
174+
task_id = str(Path(self._base_path) / task_id)
175+
try:
176+
if await self._s3_client.head_object(Bucket=self._bucket.name, Key=task_id):
177+
return True
178+
except ClientError as exc:
179+
code = exc.response.get("Error", {}).get("Code")
180+
if code in ["NoSuchKey", "404"]:
181+
pass
182+
else:
183+
raise exceptions.ResultBackendError(
184+
code=code,
185+
) from exc
186+
return False

0 commit comments

Comments
 (0)