Skip to content

Commit 1aa83bf

Browse files
alambarejlahovnik
authored andcommitted
perf: reduce memory footprint and add backpressure limit
Files were fully loaded into memory The backpressure limit prevents the explosion of buffer size when waiting on long pending requests
1 parent 658923a commit 1aa83bf

1 file changed

Lines changed: 63 additions & 45 deletions

File tree

eodag/utils/s3.py

Lines changed: 63 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656

5757
logger = logging.getLogger("eodag.utils.s3")
5858

59+
MIME_OCTET_STREAM = "application/octet-stream"
60+
61+
# Backpressure configuration
62+
BACKPRESSURE_DOWNLOAD_CHUNKS = 10 # Total chunks (downloading + buffered) per file
5963

6064
def fetch_range(
6165
bucket_name: str, key_name: str, start: int, end: int, client_s3: S3Client
@@ -98,11 +102,6 @@ class S3FileInfo:
98102
# These fields hold the state for downloading
99103
#: Offset in the logical (global) file stream where this file starts.
100104
file_start_offset: int = 0
101-
#: Mapping of futures to their start byte offsets, used to track download progress.
102-
#: Each future corresponds to a chunk of data being downloaded.
103-
#: The key is the future object, and the value is the start byte offset of that
104-
#: chunk in the logical file stream.
105-
futures: dict = field(default_factory=dict)
106105
#: Buffers for downloaded data chunks, mapping start byte offsets to the actual data.
107106
#: This allows for partial downloads and efficient memory usage.
108107
#: The key is the start byte offset, and the value is the bytes data for that
@@ -114,6 +113,8 @@ class S3FileInfo:
114113
#: This allows the streaming process to continue from where it left off,
115114
#: ensuring that all data is eventually yielded without duplication.
116115
next_yield: int = 0
116+
#: Queue of pending ranges that haven't been submitted yet
117+
pending_ranges: list[tuple[int, int]] = field(default_factory=list)
117118

118119

119120
def _prepare_file_in_zip(f_info: S3FileInfo, s3_client: S3Client):
@@ -192,49 +193,66 @@ def _chunks_from_s3_objects(
192193
) -> Iterator[tuple[int, Iterator[bytes]]]:
193194
"""Download chunks from S3 objects in parallel, respecting byte ranges and file order."""
194195
# Prepare ranges and futures per file
196+
pending_chunks_count = 0
195197
for f_info in files_info:
196198
ranges = _compute_file_ranges(f_info, byte_range, range_size)
197199

198-
if not ranges:
199-
# Mark as inactive (no futures)
200-
f_info.futures = {}
201-
f_info.buffers = {}
202-
f_info.next_yield = 0
203-
continue
204-
205200
f_info.buffers = {}
206201
f_info.next_yield = 0
207202

208-
futures = {}
209-
# start,end are absolute offsets in the S3 object (data_start_offset already applied)
210-
for start, end in ranges:
211-
future = executor.submit(
212-
fetch_range,
213-
f_info.bucket_name,
214-
f_info.key,
215-
start,
216-
end,
217-
s3_client,
218-
)
219-
# Track both start and end so we can compute the yielded length precisely
220-
futures[future] = (start, end)
203+
if not ranges:
204+
# Mark as inactive
205+
f_info.pending_ranges = []
206+
continue
207+
208+
f_info.pending_ranges = ranges
221209

222-
f_info.futures = futures
210+
pending_chunks_count += len(ranges)
223211

224212
# Keep only files that actually have something to download
225-
active_indices = [i for i, fi in enumerate(files_info) if fi.futures]
213+
active_indices = [i for i, fi in enumerate(files_info) if fi.pending_ranges]
214+
215+
# Combine all futures to wait on globally (initially empty)
216+
all_futures = {}
217+
218+
# Track outstanding chunks and next file index
219+
total_outstanding_chunks = 0
220+
next_file_index = 0 # Track which file to submit from next
221+
222+
def submit_tasks_to_limit() -> bool:
223+
"""Submit S3 chunk download tasks up to the global backpressure limit, prioritizing files sequentially."""
224+
nonlocal all_futures, total_outstanding_chunks, next_file_index
225+
226+
while total_outstanding_chunks < BACKPRESSURE_DOWNLOAD_CHUNKS:
227+
if next_file_index >= len(files_info):
228+
break # No more files with pending ranges
229+
230+
f_info = files_info[next_file_index]
231+
if f_info.pending_ranges:
232+
start, end = f_info.pending_ranges.pop(0)
233+
future = executor.submit(
234+
fetch_range,
235+
f_info.bucket_name,
236+
f_info.key,
237+
start,
238+
end,
239+
s3_client,
240+
)
241+
all_futures[future] = (f_info, start, end)
242+
total_outstanding_chunks += 1
226243

227-
# Combine all futures to wait on globally
228-
all_futures = {
229-
fut: (f_info, start, end)
230-
for f_info in (files_info[i] for i in active_indices)
231-
for fut, (start, end) in f_info.futures.items()
232-
}
244+
if not f_info.pending_ranges:
245+
next_file_index += 1 # Move to next file when current is exhausted
246+
else:
247+
next_file_index += 1 # Skip completed files
248+
# If we reach here, no files have pending ranges
249+
250+
return False
233251

234252
def make_chunks_generator(target_info: S3FileInfo) -> Iterator[bytes]:
235253
"""Create a generator bound to a specific file info (no late-binding bug)."""
236254
info = target_info # bind
237-
nonlocal all_futures
255+
nonlocal all_futures, total_outstanding_chunks, pending_chunks_count
238256
while info.next_yield < info.size:
239257
# First, try to flush anything already buffered for this file
240258
next_start = info.next_yield
@@ -245,10 +263,14 @@ def make_chunks_generator(target_info: S3FileInfo) -> Iterator[bytes]:
245263
raise InvalidDataError(
246264
f"Expected bytes, got {type(chunk).__name__} in stream chunks: {chunk}"
247265
)
266+
chunk_len = len(chunk)
267+
248268
yield chunk
249-
next_start += len(chunk)
269+
next_start += chunk_len
250270
info.next_yield = next_start
251271
flushed = True
272+
total_outstanding_chunks -= 1
273+
pending_chunks_count -= 1
252274

253275
if info.next_yield >= info.size:
254276
break
@@ -258,7 +280,7 @@ def make_chunks_generator(target_info: S3FileInfo) -> Iterator[bytes]:
258280
continue
259281

260282
# Nothing to flush for this file: wait for more futures to complete globally
261-
if not all_futures:
283+
if not pending_chunks_count:
262284
# No more incoming data anywhere; stop to avoid waiting on an empty set
263285
break
264286

@@ -270,6 +292,10 @@ def make_chunks_generator(target_info: S3FileInfo) -> Iterator[bytes]:
270292
rel_start = start - f_info.data_start_offset
271293
f_info.buffers[rel_start] = data
272294

295+
submit_tasks_to_limit()
296+
297+
submit_tasks_to_limit()
298+
273299
# Yield per-file generators with their original indices
274300
for idx in active_indices:
275301
yield idx, make_chunks_generator(files_info[idx])
@@ -308,22 +334,14 @@ def _build_stream_response(
308334
:return: Streaming HTTP response with appropriate content, headers, and media type.
309335
"""
310336

311-
def _wrap_generator_with_cleanup(
312-
generator: Iterable[bytes], executor: ThreadPoolExecutor
313-
) -> Iterator[bytes]:
314-
try:
315-
yield from generator
316-
finally:
317-
executor.shutdown(wait=True)
318-
319337
def _build_response(
320338
content_gen: Iterable[bytes],
321339
media_type: str,
322340
filename: Optional[str] = None,
323341
size: Optional[int] = None,
324342
) -> StreamResponse:
325343
return StreamResponse(
326-
content=_wrap_generator_with_cleanup(content_gen, executor),
344+
content=content_gen,
327345
media_type=media_type,
328346
headers={"Accept-Ranges": "bytes"},
329347
filename=filename,

0 commit comments

Comments
 (0)