Skip to content

Commit 4fc745b

Browse files
fix: bound UploadPart memory with adaptive internal part size (#80)
1 parent 89ecfb6 commit 4fc745b

9 files changed

Lines changed: 146 additions & 40 deletions

File tree

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.14

s3proxy/concurrency.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import structlog
1414

15+
from s3proxy.crypto import memory_bounded_part_size
1516
from s3proxy.errors import S3Error
1617
from s3proxy.metrics import MEMORY_LIMIT_BYTES, MEMORY_REJECTIONS, MEMORY_RESERVED_BYTES
1718

@@ -163,8 +164,11 @@ async def release(self, bytes_reserved: int) -> None:
163164
def estimate_memory_footprint(method: str, content_length: int) -> int:
164165
"""Estimate memory needed for a request.
165166
166-
Streaming PUTs hold an 8MB plaintext buffer + 8MB ciphertext simultaneously,
167-
so large PUTs need 2x MAX_BUFFER_SIZE. Small PUTs buffer the whole body + ciphertext.
167+
Small PUTs buffer the whole body + ciphertext. Larger PUTs stream and buffer
168+
one internal part at a time, so reserve exactly that internal part size --
169+
the same value the upload path uses (memory_bounded_part_size). This keeps
170+
the reservation honest: the limiter then admits only as many concurrent
171+
uploads as actually fit the budget, instead of under-counting and OOMing.
168172
GETs reserve a baseline here; encrypted GETs acquire additional memory in the handler.
169173
"""
170174
if method in ("HEAD", "DELETE"):
@@ -175,7 +179,7 @@ def estimate_memory_footprint(method: str, content_length: int) -> int:
175179
return MIN_RESERVATION
176180
if content_length <= MAX_BUFFER_SIZE:
177181
return max(MIN_RESERVATION, content_length * 2)
178-
return MAX_BUFFER_SIZE * 2
182+
return memory_bounded_part_size(content_length)
179183

180184

181185
# Module-level convenience functions delegating to the default instance

s3proxy/crypto.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
# Sweet spot: 8MB balances part count vs per-part overhead
3838
MAX_BUFFER_SIZE = 8 * 1024 * 1024 # 8 MB per internal part
3939

40+
# Internal part numbers are allocated in fixed ranges of this size per client
41+
# part (see state.manager), so a single client part may not expand into more
42+
# than this many internal parts without colliding into the next part's range.
43+
MAX_INTERNAL_PARTS_PER_CLIENT = 20
44+
4045
# Framed internal-part format.
4146
# S3's 10,000-part limit forces large internal parts for big objects, but we do
4247
# not want to hold a whole part (plaintext + ciphertext) in memory. So a part is
@@ -135,6 +140,32 @@ def calculate_optimal_part_size(content_length: int) -> int:
135140
return PART_SIZE
136141

137142

143+
def memory_bounded_part_size(
144+
content_length: int, max_parts: int = MAX_INTERNAL_PARTS_PER_CLIENT
145+
) -> int:
146+
"""Smallest internal part size that bounds memory while respecting limits.
147+
148+
The framed upload path buffers exactly one internal part at a time, so peak
149+
memory tracks the part size. We therefore want parts as small as possible —
150+
but two limits stop us going arbitrarily small:
151+
152+
* each internal part must be >= ~MAX_BUFFER_SIZE (avoids tiny <5MB S3 parts
153+
and per-part overhead), and
154+
* a client part may expand into at most ``max_parts`` internal parts
155+
(the per-client part-number allocation range).
156+
157+
So we use as many parts as those limits allow and split evenly. For barman's
158+
512MB client parts this yields ~26MB parts (20 of them) instead of 64MB,
159+
roughly halving per-request memory, and it adapts up only for client parts
160+
large enough to need it.
161+
"""
162+
if content_length <= MAX_BUFFER_SIZE:
163+
return content_length or 1
164+
parts = min(max_parts, content_length // MAX_BUFFER_SIZE)
165+
parts = max(parts, 1)
166+
return -(-content_length // parts) # ceil: even split, never exceeds `parts`
167+
168+
138169
@dataclass(slots=True)
139170
class EncryptedData:
140171
"""Container for encrypted data and metadata."""

s3proxy/handlers/multipart/upload_part.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
import asyncio
6-
import gc
76
import hashlib
87
import time
98
from collections import deque
@@ -97,9 +96,11 @@ async def handle_upload_part(self, request: Request, creds: S3Credentials) -> Re
9796
and content_length > crypto.STREAMING_THRESHOLD
9897
)
9998

100-
# Calculate optimal part size
101-
optimal_part_size = crypto.calculate_optimal_part_size(content_length)
102-
estimated_parts = max(1, (content_length + optimal_part_size - 1) // optimal_part_size)
99+
# Smallest internal part that bounds memory while staying within the
100+
# per-client part-number allocation range (so we never collide and
101+
# never buffer more than necessary).
102+
internal_part_size = crypto.memory_bounded_part_size(content_length)
103+
estimated_parts = max(1, -(-content_length // internal_part_size))
103104

104105
use_framed = (
105106
(is_unsigned or is_large_signed) and not needs_chunked_decode and content_length > 0
@@ -109,7 +110,7 @@ async def handle_upload_part(self, request: Request, creds: S3Credentials) -> Re
109110
bucket=bucket,
110111
key=key,
111112
part_number=part_num,
112-
optimal_part_size_mb=f"{optimal_part_size / 1024 / 1024:.2f}MB",
113+
internal_part_size_mb=f"{internal_part_size / 1024 / 1024:.2f}MB",
113114
estimated_internal_parts=estimated_parts,
114115
is_unsigned=is_unsigned,
115116
is_large_signed=is_large_signed,
@@ -142,7 +143,7 @@ async def handle_upload_part(self, request: Request, creds: S3Credentials) -> Re
142143
part_num,
143144
state,
144145
content_length,
145-
optimal_part_size,
146+
internal_part_size,
146147
internal_part_start,
147148
)
148149
else:
@@ -160,7 +161,7 @@ async def handle_upload_part(self, request: Request, creds: S3Credentials) -> Re
160161
is_streaming_sig,
161162
is_large_signed,
162163
needs_chunked_decode,
163-
optimal_part_size,
164+
internal_part_size,
164165
internal_part_start,
165166
)
166167

@@ -403,12 +404,9 @@ async def _stream_and_upload_framed(
403404
)
404405
frame_idx += 1
405406

406-
part_ciphertext = bytes(ciphertext)
407-
del ciphertext
408-
gc.collect()
409-
410407
upload_start = time.monotonic()
411-
resp = await client.upload_part(bucket, key, upload_id, ipn, part_ciphertext)
408+
resp = await client.upload_part(bucket, key, upload_id, ipn, ciphertext)
409+
del ciphertext
412410
etag = resp["ETag"].strip('"')
413411
logger.info(
414412
"INTERNAL_PART_UPLOADED",

s3proxy/state/manager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import structlog
44
from structlog.stdlib import BoundLogger
55

6+
from ..crypto import MAX_INTERNAL_PARTS_PER_CLIENT
67
from .models import (
78
MultipartUploadState,
89
PartMetadata,
@@ -13,8 +14,9 @@
1314

1415
logger: BoundLogger = structlog.get_logger(__name__)
1516

16-
# Maximum internal parts per client part (for range allocation)
17-
MAX_INTERNAL_PARTS_PER_CLIENT = 20
17+
# Re-exported from crypto so part sizing and part-number allocation share one
18+
# source of truth.
19+
__all__ = ["MultipartStateManager", "MAX_INTERNAL_PARTS_PER_CLIENT"]
1820

1921

2022
class MultipartStateManager:

tests/unit/test_concurrency_limit.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,12 +331,40 @@ def test_estimate_memory_footprint_put_small(self):
331331
assert footprint == 2 * 1024 * 1024
332332

333333
def test_estimate_memory_footprint_put_large(self):
334-
"""PUT with large file should use 2x buffer size (buffer + ciphertext)."""
334+
"""Large PUT reserves the real internal-part buffer the upload holds."""
335335
import s3proxy.concurrency as concurrency_module
336-
337-
# 100MB file → 16MB footprint (8MB buffer + 8MB ciphertext simultaneously)
338-
footprint = concurrency_module.estimate_memory_footprint("PUT", 100 * 1024 * 1024)
339-
assert footprint == concurrency_module.MAX_BUFFER_SIZE * 2
336+
from s3proxy import crypto
337+
338+
for mb in (50, 100, 512, 1024):
339+
cl = mb * 1024 * 1024
340+
footprint = concurrency_module.estimate_memory_footprint("PUT", cl)
341+
assert footprint == crypto.memory_bounded_part_size(cl)
342+
343+
def test_large_uploads_bounded_below_pod_memory(self):
344+
"""Regression for the barman OOM. Two linked invariants:
345+
1. an internal part never expands beyond the per-client allocation range
346+
(or part numbers collide) -- for ANY client part size, and
347+
2. the reservation tracks the real buffer, so admitted x footprint never
348+
exceeds the budget (limiter guarantee), and barman-scale parts admit
349+
only ~2 concurrent (the old flat-16MB estimate admitted ~4 -> OOM).
350+
"""
351+
import s3proxy.concurrency as concurrency_module
352+
from s3proxy import crypto
353+
from s3proxy.state import MAX_INTERNAL_PARTS_PER_CLIENT
354+
355+
budget = concurrency_module.get_memory_limit()
356+
for mb in (50, 128, 320, 512, 1024, 4096):
357+
cl = mb * 1024 * 1024
358+
part = crypto.memory_bounded_part_size(cl)
359+
internal_parts = -(-cl // part)
360+
assert internal_parts <= MAX_INTERNAL_PARTS_PER_CLIENT, "would collide part numbers"
361+
footprint = concurrency_module.estimate_memory_footprint("PUT", cl)
362+
# limiter guarantee: total admitted memory never exceeds the budget
363+
assert (budget // footprint) * footprint <= budget
364+
365+
# barman-scale parts: bounded to ~2 concurrent on the default 64MB budget
366+
footprint_512 = concurrency_module.estimate_memory_footprint("PUT", 512 * 1024 * 1024)
367+
assert budget // footprint_512 <= 2
340368

341369
def test_estimate_memory_footprint_get(self):
342370
"""GET should always use fixed buffer size."""

tests/unit/test_framed_crypto.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,34 @@ def test_frame_nonces_unique():
6666
assert len(nonces) == 1000
6767
# And distinct from the legacy part nonce so an upload never reuses one.
6868
assert crypto.derive_part_nonce(UPLOAD_ID, PART) not in nonces
69+
70+
71+
MB = 1024 * 1024
72+
73+
74+
@pytest.mark.parametrize(
75+
"content_mb",
76+
[9, 16, 50, 100, 160, 200, 512, 1024, 4096, 10_000],
77+
)
78+
def test_memory_bounded_part_size_respects_limits(content_mb):
79+
"""For any client part size: never expand beyond the per-client allocation
80+
range (or S3 part numbers collide), and never create a non-final part below
81+
S3's 5MB minimum."""
82+
cl = content_mb * MB
83+
size = crypto.memory_bounded_part_size(cl)
84+
parts = -(-cl // size)
85+
assert parts <= crypto.MAX_INTERNAL_PARTS_PER_CLIENT
86+
# every part except possibly the last is `size`; the last is the remainder
87+
last = cl - (parts - 1) * size
88+
if parts > 1:
89+
assert last >= crypto.MIN_PART_SIZE
90+
assert size >= crypto.MIN_PART_SIZE
91+
92+
93+
def test_memory_bounded_part_size_is_small_until_forced_larger():
94+
"""Small/mid client parts stay ~8MB; size grows only when the 20-part cap
95+
forces it (e.g. barman's 512MB parts -> ~26MB, not 64MB)."""
96+
assert crypto.memory_bounded_part_size(50 * MB) <= 9 * MB
97+
assert crypto.memory_bounded_part_size(160 * MB) <= 9 * MB
98+
barman = crypto.memory_bounded_part_size(512 * MB)
99+
assert 20 * MB <= barman <= 32 * MB

tests/unit/test_memory_concurrency.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
77
Memory estimation logic:
88
- PUT ≤8MB: content_length * 2 (body + ciphertext buffer)
9-
- PUT >8MB: MAX_BUFFER_SIZE * 2 (16MB, streaming buffer + ciphertext)
9+
- PUT >8MB: memory_bounded_part_size(content_length) (the one internal part the
10+
streaming/framed upload path actually buffers at a time)
1011
- GET: MAX_BUFFER_SIZE (8MB baseline, handler acquires more for encrypted decrypts)
1112
- POST: MIN_RESERVATION (64KB, metadata only)
1213
- HEAD/DELETE: 0 (no buffering, bypass limit)
@@ -46,12 +47,17 @@ def test_small_file_uses_content_length_x2(self):
4647
footprint = concurrency_module.estimate_memory_footprint("PUT", 100 * 1024)
4748
assert footprint == 200 * 1024
4849

49-
def test_large_file_uses_double_buffer(self):
50-
"""PUT with 100MB file should reserve 16MB (buffer + ciphertext)."""
50+
def test_large_file_reserves_real_internal_part(self):
51+
"""Large PUTs must reserve the actual internal-part buffer the upload
52+
path holds (memory_bounded_part_size), not a flat guess -- otherwise the
53+
limiter under-counts and admits too many concurrent uploads (the OOM)."""
5154
import s3proxy.concurrency as concurrency_module
55+
from s3proxy import crypto
5256

53-
footprint = concurrency_module.estimate_memory_footprint("PUT", 100 * 1024 * 1024)
54-
assert footprint == concurrency_module.MAX_BUFFER_SIZE * 2 # 16MB
57+
for mb in (50, 100, 512, 1024):
58+
cl = mb * 1024 * 1024
59+
footprint = concurrency_module.estimate_memory_footprint("PUT", cl)
60+
assert footprint == crypto.memory_bounded_part_size(cl)
5561

5662
def test_minimum_reservation_enforced(self):
5763
"""0-byte file should still reserve MIN_RESERVATION (64KB)."""
@@ -263,10 +269,10 @@ async def test_mixed_workload_scenario(self):
263269

264270
reservations = []
265271

266-
# 2 large streaming uploads (16MB each = 32MB)
272+
# 2 large streaming uploads (320MB -> 16MB internal part each = 32MB)
267273
for _ in range(2):
268-
footprint = concurrency_module.estimate_memory_footprint("PUT", 100 * 1024 * 1024)
269-
assert footprint == 16 * 1024 * 1024 # buffer + ciphertext
274+
footprint = concurrency_module.estimate_memory_footprint("PUT", 320 * 1024 * 1024)
275+
assert footprint == 16 * 1024 * 1024 # one 16MB internal part
270276
reserved = await concurrency_module.try_acquire_memory(footprint)
271277
reservations.append(reserved)
272278

tests/unit/test_streaming_framed_upload.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ async def _measure_framed_peak(client_part_size: int) -> int:
115115
1,
116116
types.SimpleNamespace(dek=crypto.generate_dek()),
117117
client_part_size,
118-
crypto.PART_SIZE, # same internal part size barman uses for 512MB parts
118+
crypto.memory_bounded_part_size(client_part_size), # size the handler picks
119119
1,
120120
)
121121
_, peak = tracemalloc.get_traced_memory()
@@ -125,13 +125,18 @@ async def _measure_framed_peak(client_part_size: int) -> int:
125125

126126
@pytest.mark.asyncio
127127
async def test_framed_upload_memory_is_independent_of_part_size():
128-
"""A 256MB client part peaks no higher than a 64MB one: ciphertext is built
129-
one internal part at a time, not scaled by total client part size."""
128+
"""Peak memory tracks one internal part, not the client part size. With the
129+
adaptive sizing a 512MB barman part uses ~26MB internal parts and peaks well
130+
under what a single 64MB-part request used to (~80MB+), and a 1GB client
131+
part does not peak meaningfully higher."""
130132
small = await _measure_framed_peak(64 * 1024 * 1024)
131-
large = await _measure_framed_peak(256 * 1024 * 1024)
132-
133-
# Must stay below the old buffered path (~2× part_size ≈ 257MB per request).
134-
assert small < 220 * 1024 * 1024, f"small peak {small / 1024 / 1024:.1f}MB"
135-
assert large < 220 * 1024 * 1024, f"large peak {large / 1024 / 1024:.1f}MB"
136-
# Larger client part must not scale memory linearly with part count.
137-
assert large <= small * 1.5 + crypto.FRAME_PLAINTEXT_SIZE
133+
barman = await _measure_framed_peak(512 * 1024 * 1024)
134+
huge = await _measure_framed_peak(1024 * 1024 * 1024)
135+
136+
# One internal part + frame overhead. Generous ceiling that still catches a
137+
# regression to buffering a whole 64MB part (~80MB) or the client part.
138+
ceiling = 70 * 1024 * 1024
139+
assert barman < ceiling, f"barman peak {barman / 1024 / 1024:.1f}MB"
140+
assert small < ceiling, f"small peak {small / 1024 / 1024:.1f}MB"
141+
# Memory must not scale with client part size.
142+
assert huge <= barman * 1.5 + crypto.FRAME_PLAINTEXT_SIZE

0 commit comments

Comments
 (0)