Skip to content

Commit 14534e4

Browse files
authored
feat: add middleware to automatically offload large task payload (#39)
1 parent f8f89f7 commit 14534e4

16 files changed

Lines changed: 523 additions & 414 deletions

‎.github/workflows/code-check.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
strategy:
2929
matrix:
3030
os: [ubuntu-latest] # eventually add `windows-latest` and `macos-latest`
31-
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
31+
python-version: [ "3.11", "3.12", "3.13", "3.14" ]
3232
services:
3333
ministack:
3434
image: ministackorg/ministack:1.3.53

‎README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,33 @@ async def main() -> None:
8181
if __name__ == "__main__":
8282
asyncio.run(main())
8383
```
84+
85+
## Offloading large messages to S3
86+
87+
SQS messages are limited to 256 KiB. `S3OffloadMiddleware` transparently uploads task payloads that exceed a configurable threshold to S3 before sending them to the queue, and replaces the message with a reference to the uploaded object. The worker downloads the original payload back from S3 before executing the task, and (by default) removes it from S3 afterwards.
88+
89+
```python
90+
import asyncio
91+
from taskiq_sqs import S3Bucket, S3OffloadMiddleware, SQSBroker
92+
93+
broker = SQSBroker("http://localhost:4566/000000000000/my-queue")
94+
broker.add_middlewares(
95+
S3OffloadMiddleware(
96+
bucket=S3Bucket(name="offload-bucket"), # created automatically if it doesn't exist
97+
max_message_size=200_000, # payloads larger than this many bytes are offloaded to S3
98+
),
99+
)
100+
101+
@broker.task
102+
async def process_document(content: str) -> int:
103+
return len(content)
104+
105+
106+
async def main() -> None:
107+
await broker.startup()
108+
await process_document.kiq("x" * 1_000_000) # too large for SQS, transparently offloaded to S3
109+
await broker.shutdown()
110+
111+
if __name__ == "__main__":
112+
asyncio.run(main())
113+
```

‎pyproject.toml‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ classifiers = [
1414
"Programming Language :: Python",
1515
"Programming Language :: Python :: 3",
1616
"Programming Language :: Python :: 3 :: Only",
17-
"Programming Language :: Python :: 3.10",
1817
"Programming Language :: Python :: 3.11",
1918
"Programming Language :: Python :: 3.12",
2019
"Programming Language :: Python :: 3.13",
@@ -28,10 +27,11 @@ classifiers = [
2827
"Operating System :: OS Independent",
2928
]
3029
keywords = ["taskiq", "broker", "aws", "sqs"]
31-
requires-python = ">=3.10"
30+
requires-python = ">=3.11"
3231
dependencies = [
33-
"taskiq>=0.12.1",
32+
"taskiq>=0.12.6",
3433
"aiobotocore>=2.13.3",
34+
"capo-s3>=0.15.0",
3535
]
3636

3737
[project.urls]
@@ -44,29 +44,28 @@ dev = [
4444
{include-group = "lint"},
4545
{include-group = "test"},
4646
{include-group = "types"},
47-
"prek>=0.5.0",
47+
"prek>=0.5.3",
4848
]
4949
test = [
50-
"pytest>=9.0.3",
51-
"pytest-asyncio>=0.23.8",
50+
"pytest>=9.1.1",
51+
"pytest-asyncio>=1.4.0",
5252
"pytest-codspeed>=5.0.3",
5353
]
5454
lint = [
55-
"bandit>=1.9.4",
56-
"ruff>=0.16.5",
57-
"zizmor>=1.29.0",
55+
"ruff>=0.16.7",
56+
"zizmor>=1.30.1",
5857
]
5958
types = [
6059
"mypy>=2.3.1",
6160
"types-aiobotocore[essential]>=3.7.0",
6261
]
6362
examples = [
64-
"python-dotenv>=1.2.2",
63+
"python-dotenv>=1.2.3",
6564
]
6665

6766

6867
[build-system]
69-
requires = ["uv_build>=0.11,<0.12"]
68+
requires = ["uv_build>=0.12,<0.13"]
7069
build-backend = "uv_build"
7170

7271
[tool.uv.build-backend]
@@ -104,7 +103,7 @@ omit = [
104103

105104
[tool.ruff]
106105
line-length = 120
107-
target-version = "py310"
106+
target-version = "py311"
108107

109108
[tool.ruff.lint]
110109
select = ["ALL"]
@@ -146,6 +145,10 @@ ignore = [
146145
"D",
147146
"INP001",
148147
]
148+
"src/taskiq_sqs/middleware.py" = [
149+
"PLR0913", # too many arguments
150+
"PLR0917", # too many positional arguments
151+
]
149152

150153
[tool.ruff.lint.pydocstyle]
151154
convention = "google"

‎src/taskiq_sqs/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from taskiq_sqs.broker import SQSBroker
2-
from taskiq_sqs.bucket import S3Bucket
2+
from taskiq_sqs.middleware import S3OffloadMiddleware
33
from taskiq_sqs.result_backend import S3ResultBackend
44

55

66
__all__ = [
7-
"S3Bucket",
7+
"S3OffloadMiddleware",
88
"S3ResultBackend",
99
"SQSBroker",
1010
]

‎src/taskiq_sqs/broker.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from taskiq_sqs import constants
1313
from taskiq_sqs.exceptions import BrokerInitError
14-
from taskiq_sqs.queue import SQSQueue
14+
from taskiq_sqs.types import SQSQueue
1515

1616

1717
if TYPE_CHECKING:

‎src/taskiq_sqs/constants.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55

66
MAX_WAIT_TIME_SECONDS: Final[int] = 20
77
MAX_NUMBER_OF_MESSAGES: Final[int] = 10
8+
9+
SQS_MAX_MESSAGE_SIZE_BYTES: Final[int] = 262_144
10+
DEFAULT_S3_OFFLOAD_THRESHOLD_BYTES: Final[int] = 200_000

‎src/taskiq_sqs/exceptions.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,12 @@ class ResultIsMissingError(BaseTaskiqSQSError):
3838

3939
__template__ = "Result for task {task_id} is missing in the result backend"
4040
task_id: str
41+
42+
43+
class OffloadedPayloadMissingError(BaseTaskiqSQSError):
44+
"""Error if a message references an S3-offloaded payload that can't be found."""
45+
46+
__template__ = "Offloaded payload for task {task_id} is missing in bucket '{bucket_name}' (key: {key})"
47+
task_id: str
48+
bucket_name: str
49+
key: str

‎src/taskiq_sqs/middleware.py‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import contextlib
2+
import logging
3+
from typing import Any
4+
5+
import capo_s3
6+
from taskiq.abc.middleware import TaskiqMiddleware
7+
from taskiq.abc.serializer import TaskiqSerializer
8+
from taskiq.message import TaskiqMessage
9+
from taskiq.serializers import JSONSerializer
10+
11+
from taskiq_sqs import constants, exceptions
12+
from taskiq_sqs.types import S3Bucket
13+
14+
15+
logger = logging.getLogger(__name__)
16+
17+
OFFLOAD_KEY_LABEL = "s3_offload_key"
18+
19+
20+
class S3OffloadMiddleware(TaskiqMiddleware):
21+
"""
22+
Offloads large task payloads to S3 instead of sending them through SQS.
23+
24+
SQS messages are limited to 256 KiB, so payloads that would exceed a configured threshold are uploaded to S3 before
25+
the message is sent, and the message itself only carries a reference (bucket key) to the uploaded payload.
26+
On the worker side, the original arguments are downloaded back from S3 before task execution.
27+
"""
28+
29+
def __init__(
30+
self,
31+
bucket: S3Bucket,
32+
max_message_size: int = constants.DEFAULT_S3_OFFLOAD_THRESHOLD_BYTES,
33+
base_path: str = "",
34+
endpoint_url: str | None = None,
35+
aws_region_name: str = constants.AWS_DEFAULT_REGION,
36+
aws_access_key_id: str | None = None,
37+
aws_secret_access_key: str | None = None,
38+
delete_after_execute: bool = True,
39+
serializer: TaskiqSerializer | None = None,
40+
) -> None:
41+
"""
42+
Constructs a new S3 offload middleware.
43+
44+
:param bucket: S3 bucket configuration.
45+
:param max_message_size: payloads larger than this many bytes are offloaded to S3.
46+
:param base_path: base path (prefix) for offloaded payloads.
47+
:param endpoint_url: endpoint URL for S3.
48+
:param aws_region_name: AWS region, default is 'us-east-1'.
49+
:param aws_access_key_id: AWS access key ID.
50+
:param aws_secret_access_key: AWS secret access key.
51+
:param delete_after_execute: whether to delete the offloaded payload from S3 after the task has been executed
52+
by the worker.
53+
:param serializer: serializer used to encode/decode offloaded payloads.
54+
"""
55+
super().__init__()
56+
self._bucket = bucket
57+
self._max_message_size = max_message_size
58+
self._base_path = base_path
59+
self._aws_endpoint_url = endpoint_url
60+
self._aws_region = aws_region_name
61+
self._aws_access_key_id = aws_access_key_id
62+
self._aws_secret_access_key = aws_secret_access_key
63+
self._delete_after_execute = delete_after_execute
64+
self._serializer = serializer or JSONSerializer()
65+
66+
async def startup(self) -> None:
67+
"""Initialize the S3 client and ensure the bucket exists."""
68+
credentials = None
69+
if self._aws_access_key_id and self._aws_secret_access_key:
70+
credentials = capo_s3.Credentials(
71+
access_key=self._aws_access_key_id,
72+
secret_key=self._aws_secret_access_key,
73+
)
74+
self._s3_client = capo_s3.AsyncS3Client(
75+
region=self._aws_region,
76+
endpoint=self._aws_endpoint_url,
77+
credentials=credentials,
78+
force_path_style=True,
79+
)
80+
await self._s3_client.__aenter__()
81+
try:
82+
await self._ensure_bucket_exists()
83+
except Exception:
84+
await self._s3_client.__aexit__(None, None, None)
85+
raise
86+
87+
async def shutdown(self) -> None:
88+
"""Shut down the S3 client."""
89+
await self._s3_client.__aexit__(None, None, None)
90+
91+
async def _ensure_bucket_exists(self) -> None:
92+
try:
93+
await self._s3_client.head_bucket(bucket=self._bucket["name"])
94+
except capo_s3.errors.NotFound:
95+
if not self._bucket.get("declare", True):
96+
raise exceptions.BucketNotFoundError(bucket_name=self._bucket["name"]) from None
97+
await self._create_bucket()
98+
99+
async def _create_bucket(self) -> None:
100+
create_kwargs: dict[str, Any] = {}
101+
if self._aws_region and self._aws_region != constants.AWS_DEFAULT_REGION:
102+
create_kwargs["create_bucket_configuration"] = {"location_constraint": self._aws_region}
103+
with contextlib.suppress(capo_s3.errors.BucketAlreadyOwnedByYou):
104+
await self._s3_client.create_bucket(bucket=self._bucket["name"], **create_kwargs)
105+
106+
def _build_key(self, task_id: str) -> str:
107+
key = f"{task_id}.json"
108+
if self._base_path:
109+
key = f"{self._base_path.rstrip('/')}/{key}"
110+
return key
111+
112+
async def pre_send(self, message: TaskiqMessage) -> TaskiqMessage:
113+
"""
114+
Offload the message payload to S3 if it's too large to send through SQS.
115+
116+
:param message: message to send.
117+
:return: message with args/kwargs replaced by an S3 reference, if offloaded.
118+
"""
119+
payload = self._serializer.dumpb({"args": message.args, "kwargs": message.kwargs})
120+
if len(payload) <= self._max_message_size:
121+
return message
122+
123+
key = self._build_key(message.task_id)
124+
await self._s3_client.put_object(bucket=self._bucket["name"], key=key, body=payload)
125+
logger.debug("Offloaded payload of task '%s' to s3://%s/%s", message.task_id, self._bucket["name"], key)
126+
127+
message.args = []
128+
message.kwargs = {}
129+
message.labels[OFFLOAD_KEY_LABEL] = key
130+
return message
131+
132+
async def pre_execute(self, message: TaskiqMessage) -> TaskiqMessage:
133+
"""
134+
Restore the original message payload from S3, if it was offloaded.
135+
136+
:param message: incoming parsed taskiq message.
137+
:return: message with the original args/kwargs restored.
138+
"""
139+
key = message.labels.get(OFFLOAD_KEY_LABEL)
140+
if key is None:
141+
return message
142+
143+
try:
144+
async with self._s3_client.get_object(bucket=self._bucket["name"], key=key) as output:
145+
body = b"".join([chunk async for chunk in output["body"]])
146+
except capo_s3.errors.NoSuchKey as exc:
147+
raise exceptions.OffloadedPayloadMissingError(
148+
task_id=message.task_id,
149+
bucket_name=self._bucket["name"],
150+
key=key,
151+
) from exc
152+
153+
payload = self._serializer.loadb(body)
154+
message.args = payload["args"]
155+
message.kwargs = payload["kwargs"]
156+
return message
157+
158+
async def post_execute(self, message: TaskiqMessage, result: Any) -> None: # noqa: ARG002
159+
"""
160+
Delete the offloaded payload from S3 once the task has been executed.
161+
162+
:param message: processed message.
163+
:param result: result of execution for current task, not used.
164+
"""
165+
key = message.labels.pop(OFFLOAD_KEY_LABEL, None)
166+
if key is None or not self._delete_after_execute:
167+
return
168+
await self._s3_client.delete_object(bucket=self._bucket["name"], key=key)

‎src/taskiq_sqs/result_backend.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from taskiq.serializers import JSONSerializer
1010

1111
from taskiq_sqs import constants, exceptions
12-
from taskiq_sqs.bucket import S3Bucket
12+
from taskiq_sqs.types import S3Bucket
1313

1414

1515
if TYPE_CHECKING:
@@ -79,17 +79,17 @@ async def startup(self) -> None:
7979

8080
async def _ensure_bucket_exists(self) -> None:
8181
try:
82-
await self._s3_client.head_bucket(Bucket=self._bucket.name)
82+
await self._s3_client.head_bucket(Bucket=self._bucket["name"])
8383
except ClientError as exc:
8484
code = exc.response.get("Error", {}).get("Code")
8585
if code not in ("404", "NoSuchBucket"):
8686
raise exceptions.ResultBackendError(code=code) from exc
87-
if not self._bucket.declare:
88-
raise exceptions.BucketNotFoundError(bucket_name=self._bucket.name) from exc
87+
if not self._bucket.get("declare", True):
88+
raise exceptions.BucketNotFoundError(bucket_name=self._bucket["name"]) from exc
8989
await self._create_bucket()
9090

9191
async def _create_bucket(self) -> None:
92-
create_kwargs: dict[str, Any] = {"Bucket": self._bucket.name}
92+
create_kwargs: dict[str, Any] = {"Bucket": self._bucket["name"]}
9393
if self._aws_region and self._aws_region != constants.AWS_DEFAULT_REGION:
9494
create_kwargs["CreateBucketConfiguration"] = {"LocationConstraint": self._aws_region}
9595
try:
@@ -118,7 +118,7 @@ async def set_result(
118118
task_id = f"{self._base_path.rstrip('/')}/{task_id}"
119119

120120
await self._s3_client.put_object(
121-
Bucket=self._bucket.name,
121+
Bucket=self._bucket["name"],
122122
Key=task_id,
123123
Body=self._serializer.dumpb(model_dump(result)),
124124
)
@@ -143,7 +143,7 @@ async def get_result(
143143
task_id = f"{self._base_path.rstrip('/')}/{task_id}"
144144
try:
145145
if response := await self._s3_client.get_object(
146-
Bucket=self._bucket.name,
146+
Bucket=self._bucket["name"],
147147
Key=task_id,
148148
):
149149
async with response["Body"] as stream:
@@ -176,7 +176,7 @@ async def is_result_ready(self, task_id: str) -> bool:
176176
if self._base_path:
177177
task_id = f"{self._base_path.rstrip('/')}/{task_id}"
178178
try:
179-
if await self._s3_client.head_object(Bucket=self._bucket.name, Key=task_id):
179+
if await self._s3_client.head_object(Bucket=self._bucket["name"], Key=task_id):
180180
return True
181181
except ClientError as exc:
182182
code = exc.response.get("Error", {}).get("Code")

‎src/taskiq_sqs/types/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from taskiq_sqs.types.bucket import S3Bucket
2+
from taskiq_sqs.types.queue import SQSQueue
3+
4+
5+
__all__ = [
6+
"S3Bucket",
7+
"SQSQueue",
8+
]

0 commit comments

Comments
 (0)