Skip to content

Commit 804e8a3

Browse files
[s3] Add range_chunk_size param to read using multiple GET requests (#887)
* Add range_chunk_size parameter for S3 reading optimization This commit adds a new `range_chunk_size` parameter to the S3 reader that allows reading large files in smaller chunks. This is useful when you only need to read small portions of large S3 files, as it prevents S3-compatible storage systems from queueing up the entire file internally. Key changes: - Added range_chunk_size parameter to smart_open.s3.open() and related classes - Modified _SeekableRawReader to support chunked reading with proper boundary handling - Added comprehensive test coverage including adversarial testing for retry logic - Improved error handling for edge cases (negative offsets, empty files, etc.) When range_chunk_size is None (default), behavior is unchanged - single request for the whole file to minimize per-request costs on S3. * Update help.txt * Doc update :house: Remote-Dev: homespace * Lint violations :house: Remote-Dev: homespace * Possible help text fix? :house: Remote-Dev: homespace * Switch to bytearray for accumulating bytes :house: Remote-Dev: homespace * Refactoring based on one suggested by @ddelange 🏠 Remote-Dev: homespace * Delete whitespace 🏠 Remote-Dev: homespace * Introduce close(), closed and simplify read() * Simplify _open_body * Further simplify _open_body and amend docstring * Avoid calling _open_body when end of file is reached * Ensure expected_position is always defined * Ensure proper handling of InvalidRange * Account for negative seek on empty file * Amend docstring * Typo * Don't request beyond known content length --------- Co-authored-by: Max Bolingbroke <maxb@anthropic.com>
1 parent 7a36a8f commit 804e8a3

3 files changed

Lines changed: 391 additions & 31 deletions

File tree

help.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,21 @@ FUNCTIONS
233233
If set to `True` on a file opened for reading, GetObject will not be
234234
called until the first seek() or read().
235235
Avoids redundant API queries when seeking before reading.
236+
range_chunk_size: int, optional
237+
Default: `None`
238+
Maximum byte range per S3 GET request when reading.
239+
When None (default), a single GET request is made for the entire file,
240+
and data is streamed from that single botocore.response.StreamingBody
241+
in buffer_size chunks.
242+
When set to a positive integer, multiple GET requests are made, each
243+
limited to at most this many bytes via HTTP Range headers. Each GET
244+
returns a new StreamingBody that is streamed in buffer_size chunks.
245+
Useful for reading small portions of large files without forcing
246+
S3-compatible systems like SeaweedFS/Ceph to load the entire file.
247+
Larger values mean fewer billable GET requests but higher load on S3
248+
servers. Smaller values mean more GET requests but less server load per request.
249+
Values larger than the file size result in a single GET for the whole file.
250+
Affects reading only. Does not affect memory usage (controlled by buffer_size).
236251
client: object, optional
237252
The S3 client to use when working with boto3.
238253
If you don't specify this, then smart_open will create a new client for you.

smart_open/s3.py

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import logging
1515
import time
1616
import warnings
17+
from math import inf
1718

1819
from typing import (
1920
Callable,
@@ -320,6 +321,7 @@ def open(
320321
client=None,
321322
client_kwargs=None,
322323
writebuffer=None,
324+
range_chunk_size=None,
323325
):
324326
"""Open an S3 object for reading or writing.
325327
@@ -366,6 +368,21 @@ def open(
366368
If set to `True` on a file opened for reading, GetObject will not be
367369
called until the first seek() or read().
368370
Avoids redundant API queries when seeking before reading.
371+
range_chunk_size: int, optional
372+
Default: `None`
373+
Maximum byte range per S3 GET request when reading.
374+
When None (default), a single GET request is made for the entire file,
375+
and data is streamed from that single botocore.response.StreamingBody
376+
in buffer_size chunks.
377+
When set to a positive integer, multiple GET requests are made, each
378+
limited to at most this many bytes via HTTP Range headers. Each GET
379+
returns a new StreamingBody that is streamed in buffer_size chunks.
380+
Useful for reading small portions of large files without forcing
381+
S3-compatible systems like SeaweedFS/Ceph to load the entire file.
382+
Larger values mean fewer billable GET requests but higher load on S3
383+
servers. Smaller values mean more GET requests but less server load per request.
384+
Values larger than the file size result in a single GET for the whole file.
385+
Affects reading only. Does not affect memory usage (controlled by buffer_size).
369386
client: object, optional
370387
The S3 client to use when working with boto3.
371388
If you don't specify this, then smart_open will create a new client for you.
@@ -397,6 +414,7 @@ def open(
397414
defer_seek=defer_seek,
398415
client=client,
399416
client_kwargs=client_kwargs,
417+
range_chunk_size=range_chunk_size,
400418
)
401419
elif mode == constants.WRITE_BINARY:
402420
if multipart_upload:
@@ -462,11 +480,13 @@ def __init__(
462480
bucket,
463481
key,
464482
version_id=None,
483+
range_chunk_size=None,
465484
):
466485
self._client = client
467486
self._bucket = bucket
468487
self._key = key
469488
self._version_id = version_id
489+
self._range_chunk_size = range_chunk_size
470490

471491
self._content_length = None
472492
self._position = 0
@@ -536,11 +556,23 @@ def _open_body(self, start=None, stop=None):
536556
start and stop follow the semantics of the http range header,
537557
so a stop without a start will read bytes beginning at stop.
538558
559+
If self._range_chunk_size is set, the S3 server is protected from open range
560+
headers and stop will be set such that at most self._range_chunk_size bytes
561+
are returned in a single GET request.
562+
539563
As a side effect, set self._content_length. Set self._position
540564
to self._content_length if start is past end of file.
541565
"""
542566
if start is None and stop is None:
543567
start = self._position
568+
569+
# Apply chunking: limit the stop position if range_chunk_size is set
570+
if stop is None and self._range_chunk_size is not None:
571+
stop = start + self._range_chunk_size - 1
572+
# Don't request beyond known content length
573+
if self._content_length is not None:
574+
stop = min(stop, self._content_length - 1)
575+
544576
range_string = smart_open.utils.make_range_string(start, stop)
545577

546578
try:
@@ -620,11 +652,13 @@ def _open_body(self, start=None, stop=None):
620652

621653
def read(self, size=-1):
622654
"""Read from the continuous connection with the remote peer."""
623-
if self.closed:
624-
# This is necessary for the very first read() after __init__().
625-
self._open_body()
626-
if self._position >= self._content_length:
627-
return b''
655+
if size < -1:
656+
raise ValueError(f'size must be >= -1, got {size}')
657+
658+
if size == -1:
659+
size = inf # makes for a simple while-condition below
660+
661+
binary_collected = io.BytesIO()
628662

629663
#
630664
# Boto3 has built-in error handling and retry mechanisms:
@@ -639,31 +673,47 @@ def read(self, size=-1):
639673
# HTTP connection and try again. Usually, a single retry attempt is
640674
# enough to recover, but we try multiple times "just in case".
641675
#
642-
for attempt, seconds in enumerate([1, 2, 4, 8, 16], 1):
643-
try:
644-
if size == -1:
645-
binary = self._body.read()
646-
else:
647-
binary = self._body.read(size)
648-
except (
649-
ConnectionResetError,
650-
botocore.exceptions.BotoCoreError,
651-
urllib3.exceptions.HTTPError,
652-
) as err:
653-
logger.warning(
654-
'%s: caught %r while reading %d bytes, sleeping %ds before retry',
655-
self,
656-
err,
657-
size,
658-
seconds,
659-
)
660-
time.sleep(seconds)
661-
self._open_body()
662-
else:
663-
self._position += len(binary)
664-
return binary
676+
def retry_read(attempts=(1, 2, 4, 8, 16)) -> bytes:
677+
for seconds in attempts:
678+
if self.closed:
679+
self._open_body()
680+
try:
681+
if size == inf:
682+
return self._body.read()
683+
return self._body.read(size - binary_collected.tell())
684+
except (
685+
ConnectionResetError,
686+
botocore.exceptions.BotoCoreError,
687+
urllib3.exceptions.HTTPError,
688+
) as err:
689+
logger.warning(
690+
'%s: caught %r while reading %d bytes, sleeping %ds before retry',
691+
self,
692+
err,
693+
-1 if size == inf else size,
694+
seconds,
695+
)
696+
self.close()
697+
time.sleep(seconds)
698+
raise IOError(
699+
'%s: failed to read %d bytes after %d attempts' %
700+
(self, -1 if size == inf else size, len(attempts)),
701+
)
702+
703+
while (
704+
self._content_length is None # very first read call
705+
or (
706+
self._position < self._content_length # not yet end of file
707+
and binary_collected.tell() < size # not yet read enough
708+
)
709+
):
710+
binary = retry_read()
711+
self._position += len(binary)
712+
binary_collected.write(binary)
713+
if not binary: # end of stream
714+
self.close()
665715

666-
raise IOError('%s: failed to read %d bytes after %d attempts' % (self, size, attempt))
716+
return binary_collected.getvalue()
667717

668718
def __str__(self):
669719
return 'smart_open.s3._SeekableReader(%r, %r)' % (self._bucket, self._key)
@@ -710,6 +760,7 @@ def __init__(
710760
defer_seek=False,
711761
client=None,
712762
client_kwargs=None,
763+
range_chunk_size=None,
713764
):
714765
self._version_id = version_id
715766
self._buffer_size = buffer_size
@@ -721,6 +772,7 @@ def __init__(
721772
bucket,
722773
key,
723774
self._version_id,
775+
range_chunk_size=range_chunk_size,
724776
)
725777
self._current_pos = 0
726778
self._buffer = smart_open.bytebuffer.ByteBuffer(buffer_size)

0 commit comments

Comments
 (0)