Skip to content

Commit ef438e0

Browse files
perf: stream large copy paths to bound memory and event-loop stalls (#69)
1 parent ae97716 commit ef438e0

5 files changed

Lines changed: 1266 additions & 68 deletions

File tree

s3proxy/handlers/base.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import base64
55
import re
6+
from collections.abc import AsyncIterator
67
from datetime import datetime
78
from typing import NoReturn
89
from urllib.parse import parse_qs, unquote
@@ -234,27 +235,28 @@ async def _download_encrypted_single(
234235
wrapped_dek = base64.b64decode(wrapped_dek_b64)
235236
return crypto.decrypt_object(ciphertext, wrapped_dek, self.keyring.key_by_id(kid))
236237

237-
async def _download_encrypted_multipart(
238+
async def _iter_multipart_plaintext(
238239
self,
239240
client: S3Client,
240241
bucket: str,
241242
key: str,
242243
meta,
244+
dek: bytes,
243245
range_start: int | None = None,
244246
range_end: int | None = None,
245-
) -> bytes:
246-
"""Download and decrypt multipart encrypted object, optionally with range."""
247-
dek = crypto.unwrap_key(meta.wrapped_dek, self.keyring.key_by_id(meta.kid))
248-
sorted_parts = sorted(meta.parts, key=lambda p: p.part_number)
247+
) -> AsyncIterator[bytes]:
248+
"""Yield decrypted plaintext for each part of a multipart-encrypted object.
249249
250-
plaintext_chunks = []
250+
Parts outside the requested byte range are skipped. Parts that partially
251+
overlap the range are trimmed before yielding.
252+
"""
253+
sorted_parts = sorted(meta.parts, key=lambda p: p.part_number)
251254
pt_offset = 0
252255
ct_offset = 0
253256

254257
for part in sorted_parts:
255258
part_pt_end = pt_offset + part.plaintext_size - 1
256259

257-
# Check if part is in range (or no range specified)
258260
in_range = range_start is None or (
259261
part_pt_end >= range_start and pt_offset <= range_end
260262
)
@@ -266,18 +268,34 @@ async def _download_encrypted_multipart(
266268
ciphertext = await body.read()
267269
part_plaintext = crypto.decrypt(ciphertext, dek)
268270

269-
# Trim if range specified
270271
if range_start is not None:
271272
trim_start = max(0, range_start - pt_offset)
272273
trim_end = min(part.plaintext_size, range_end - pt_offset + 1)
273274
part_plaintext = part_plaintext[trim_start:trim_end]
274275

275-
plaintext_chunks.append(part_plaintext)
276+
yield part_plaintext
276277

277278
pt_offset = part_pt_end + 1
278279
ct_offset += part.ciphertext_size
279280

280-
return b"".join(plaintext_chunks)
281+
async def _download_encrypted_multipart(
282+
self,
283+
client: S3Client,
284+
bucket: str,
285+
key: str,
286+
meta,
287+
range_start: int | None = None,
288+
range_end: int | None = None,
289+
) -> bytes:
290+
"""Download and decrypt multipart encrypted object, optionally with range."""
291+
dek = crypto.unwrap_key(meta.wrapped_dek, self.keyring.key_by_id(meta.kid))
292+
chunks = [
293+
chunk
294+
async for chunk in self._iter_multipart_plaintext(
295+
client, bucket, key, meta, dek, range_start, range_end
296+
)
297+
]
298+
return b"".join(chunks)
281299

282300
async def forward_request(self, request: Request, creds: S3Credentials) -> Response:
283301
"""Forward unhandled requests to S3.

0 commit comments

Comments
 (0)