5656
5757logger = 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
6064def 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
119120def _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