Skip to content

Commit 94c3c1b

Browse files
committed
fix(storage): track EOF in BlobReader to handle doubly-gzipped blobs (#18423)
Fixes #18423. When reading doubly-gzipped blobs (or objects with Content-Encoding: gzip), out-of-bounds byte range requests may return full payload bytes instead of HTTP 416 RequestRangeNotSatisfiable. This change adds client-side _eof tracking to BlobReader. When a range download returns fewer bytes than requested (or raises 416), BlobReader sets _eof = True so subsequent read() calls immediately return b"" without initiating redundant HTTP range requests.
1 parent 47b2845 commit 94c3c1b

2 files changed

Lines changed: 44 additions & 7 deletions

File tree

packages/google-cloud-storage/google/cloud/storage/fileio.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,15 @@ def __init__(self, blob, chunk_size=None, retry=DEFAULT_RETRY, **download_kwargs
118118
self._chunk_size = chunk_size or blob.chunk_size or DEFAULT_CHUNK_SIZE
119119
self._retry = retry
120120
self._download_kwargs = download_kwargs
121+
self._eof = False
121122

122123
def read(self, size=-1):
123124
self._checkClosed() # Raises ValueError if closed.
124125

125126
result = self._buffer.read(size)
126127
# If the read request demands more bytes than are buffered, fetch more.
127128
remaining_size = size - len(result)
128-
if remaining_size > 0 or size < 0:
129+
if (remaining_size > 0 or size < 0) and not self._eof:
129130
self._pos += self._buffer.tell()
130131
read_size = len(result)
131132

@@ -142,17 +143,31 @@ def read(self, size=-1):
142143
# chunked downloads, and the server only knows the checksum of the
143144
# entire file.
144145
try:
145-
result += self._blob.download_as_bytes(
146+
downloaded = self._blob.download_as_bytes(
146147
start=fetch_start,
147148
end=fetch_end,
148149
checksum=None,
149150
retry=self._retry,
150151
**self._download_kwargs,
151152
)
153+
# If fewer bytes were returned than requested for a range (len < fetch_end - fetch_start),
154+
# or 0 bytes were returned, we have reached EOF. Tracking EOF client-side prevents
155+
# infinite read loops for objects (such as doubly-gzipped files) where out-of-bounds
156+
# range requests re-send body content instead of raising RequestRangeNotSatisfiable.
157+
if (
158+
fetch_end is None
159+
or len(downloaded) == 0
160+
or (
161+
fetch_end is not None
162+
and len(downloaded) < (fetch_end - fetch_start)
163+
)
164+
):
165+
self._eof = True
166+
result += downloaded
152167
except RequestRangeNotSatisfiable:
153168
# We've reached the end of the file. Python file objects should
154169
# return an empty response in this case, not raise an error.
155-
pass
170+
self._eof = True
156171

157172
# If more bytes were read than is immediately needed, buffer the
158173
# remainder and then trim the result.
@@ -175,6 +190,7 @@ def seek(self, pos, whence=0):
175190
If the blob size is not already known it will call blob.reload().
176191
"""
177192
self._checkClosed() # Raises ValueError if closed.
193+
self._eof = False
178194

179195
if self._blob.size is None:
180196
reload_kwargs = {

packages/google-cloud-storage/tests/unit/test_fileio.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,27 @@ def test_416_error_handled(self):
159159
reader = self._make_blob_reader(blob)
160160
self.assertEqual(reader.read(), b"")
161161

162+
def test_read_doubly_gzipped_eof(self):
163+
blob = mock.Mock()
164+
fake_data = b"x" * 100 # 100 bytes of test data
165+
166+
def download_side_effect(start=0, end=None, **_):
167+
# For doubly-gzipped blobs (Content-Encoding: gzip), out-of-bounds
168+
# range requests return HTTP 200 with the full transcoded body
169+
# rather than HTTP 416. Simulate this GCS server behavior.
170+
return fake_data
171+
172+
blob.download_as_bytes = mock.Mock(side_effect=download_side_effect)
173+
reader = self._make_blob_reader(blob, chunk_size=1024)
174+
175+
# First read fetches 100 bytes (< 1024 chunk_size), setting client-side EOF
176+
data1 = reader.read(1024)
177+
self.assertEqual(data1, fake_data)
178+
179+
# Second read must return empty bytes without making additional HTTP requests
180+
data2 = reader.read(1024)
181+
self.assertEqual(data2, b"")
182+
162183
def test_readline(self):
163184
blob = mock.Mock()
164185

@@ -185,15 +206,15 @@ def read_from_fake_data(start=0, end=None, **_):
185206
blob.size = len(TEST_BINARY_DATA)
186207
reader.seek(0)
187208

188-
# Read all lines. The readlines algorithm will attempt to read past the end of the last line once to verify there is no more to read.
209+
# Read all lines. With client-side EOF detection on short reads (chunk 6 returned 4 bytes < 10 requested), no extra call past EOF is made.
189210
self.assertEqual(b"".join(reader.readlines()), TEST_BINARY_DATA)
190211
blob.download_as_bytes.assert_called_with(
191-
start=len(TEST_BINARY_DATA),
192-
end=len(TEST_BINARY_DATA) + 10,
212+
start=50,
213+
end=60,
193214
checksum=None,
194215
retry=DEFAULT_RETRY,
195216
)
196-
self.assertEqual(blob.download_as_bytes.call_count, 13)
217+
self.assertEqual(blob.download_as_bytes.call_count, 12)
197218

198219
reader.close()
199220

0 commit comments

Comments
 (0)