Skip to content

Commit a5a717d

Browse files
authored
feat(storage): add option to disable checksums and improve robustness of full_object_checksum validation (#17665)
This PR introduces performance options and robustness improvements to the asynchronous append operations in `AsyncAppendableObjectWriter`. ### Key Changes: 1. **Disable Checksums for Performance**: - Added the `enable_checksum` optional parameter (default: `True`) to `AsyncAppendableObjectWriter.append()`. - When set to `False`, the writer skips calculation of chunk-level CRC32C checksums in `_WriteResumptionStrategy`, improving append throughput. - Updated unit tests to verify that chunk-level checksums are omitted when disabled. 2. **Robustness of Finalization Checksum Validation**: - Implemented type checking (`isinstance(int)`) and range checking (`[0, 2**32-1]`) for `full_object_checksum` in `finalize()`. - Wrapped stream receiving and response processing inside a `try...finally` block. This guarantees that the underlying stream is closed and local writer state variables are reset on mismatch exceptions/errors, preventing stream leaks. - Simplified the finalization request building code into a clean `if-elif-else` format. - Added corresponding unit tests verifying validation error exceptions and proper stream cleanup.
1 parent fc423c8 commit a5a717d

5 files changed

Lines changed: 133 additions & 16 deletions

File tree

packages/google-cloud-storage/google/cloud/storage/asyncio/async_appendable_object_writer.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ async def append(
386386
data: bytes,
387387
retry_policy: Optional[AsyncRetry] = None,
388388
metadata: Optional[List[Tuple[str, str]]] = None,
389+
enable_checksum: bool = True,
389390
) -> None:
390391
"""Appends data to the Appendable object with automatic retries.
391392
@@ -406,6 +407,9 @@ async def append(
406407
:type metadata: List[Tuple[str, str]]
407408
:param metadata: (Optional) The metadata to be sent with the request.
408409
410+
:type enable_checksum: bool
411+
:param enable_checksum: (Optional) If True, calculates and checks checksums for each chunk. Defaults to True.
412+
409413
:raises ValueError: If the stream is not open.
410414
"""
411415
if not self._is_stream_open:
@@ -487,7 +491,12 @@ async def generator():
487491
return generator()
488492

489493
# State initialization
490-
write_state = _WriteState(_MAX_CHUNK_SIZE_BYTES, buffer, self.flush_interval)
494+
write_state = _WriteState(
495+
_MAX_CHUNK_SIZE_BYTES,
496+
buffer,
497+
self.flush_interval,
498+
enable_checksum=enable_checksum,
499+
)
491500
write_state.write_handle = self.write_handle
492501
write_state.persisted_size = self.persisted_size
493502
# offset is set during `open()` call.
@@ -641,22 +650,32 @@ async def finalize(
641650
if not self._is_stream_open:
642651
raise ValueError("Stream is not open. Call open() before finalize().")
643652

644-
finalize_req = _storage_v2.BidiWriteObjectRequest(finish_write=True)
645-
646-
if full_object_checksum is not None:
647-
finalize_req.object_checksums = _storage_v2.ObjectChecksums(
648-
crc32c=full_object_checksum
653+
if full_object_checksum is None:
654+
finalize_req = _storage_v2.BidiWriteObjectRequest(finish_write=True)
655+
elif isinstance(full_object_checksum, bool) or not isinstance(
656+
full_object_checksum, int
657+
):
658+
raise TypeError("full_object_checksum must be an integer.")
659+
elif not (0 <= full_object_checksum <= 0xFFFFFFFF):
660+
raise ValueError("full_object_checksum must be a 32-bit unsigned integer.")
661+
else:
662+
finalize_req = _storage_v2.BidiWriteObjectRequest(
663+
finish_write=True,
664+
object_checksums=_storage_v2.ObjectChecksums(
665+
crc32c=full_object_checksum
666+
),
649667
)
650668

651-
await self.write_obj_stream.send(finalize_req)
652-
response = await self.write_obj_stream.recv()
653-
self.object_resource = response.resource
654-
self.persisted_size = self.object_resource.size
655-
await self.write_obj_stream.close()
656-
657-
self._is_stream_open = False
658-
self.offset = None
659-
return self.object_resource
669+
try:
670+
await self.write_obj_stream.send(finalize_req)
671+
response = await self.write_obj_stream.recv()
672+
self.object_resource = response.resource
673+
self.persisted_size = self.object_resource.size
674+
return self.object_resource
675+
finally:
676+
await self.write_obj_stream.close()
677+
self._is_stream_open = False
678+
self.offset = None
660679

661680
@property
662681
def is_stream_open(self) -> bool:

packages/google-cloud-storage/google/cloud/storage/asyncio/async_multi_range_downloader.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,12 @@ async def download_ranges(
407407
:type retry_policy: :class:`~google.api_core.retry_async.AsyncRetry`
408408
:param retry_policy: (Optional) The retry policy to use for the operation.
409409
410+
:type metadata: List[Tuple[str, str]]
411+
:param metadata: (Optional) The metadata to be sent with the request.
412+
413+
:type enable_checksum: bool
414+
:param enable_checksum: (Optional) If True, checksums are verified for downloaded data. Defaults to True.
415+
410416
:raises ValueError: if the underlying bidi-GRPC stream is not open.
411417
:raises ValueError: if the length of read_ranges is more than 1000.
412418
:raises DataCorruption: if a checksum mismatch is detected while reading data.

packages/google-cloud-storage/google/cloud/storage/asyncio/retry/writes_resumption_strategy.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def __init__(
4444
chunk_size: int,
4545
user_buffer: IO[bytes],
4646
flush_interval: int,
47+
enable_checksum: bool = True,
4748
):
4849
self.chunk_size = chunk_size
4950
self.user_buffer = user_buffer
@@ -59,6 +60,7 @@ def __init__(
5960
self.write_handle: Union[bytes, storage_type.BidiWriteHandle, None] = None
6061
self.routing_token: Optional[str] = None
6162
self.is_finalized: bool = False
63+
self.enable_checksum: bool = enable_checksum
6264

6365

6466
class _WriteResumptionStrategy(_BaseResumptionStrategy):
@@ -84,7 +86,8 @@ def generate_requests(
8486
break
8587

8688
checksummed_data = storage_type.ChecksummedData(content=chunk)
87-
checksummed_data.crc32c = google_crc32c.value(chunk)
89+
if write_state.enable_checksum:
90+
checksummed_data.crc32c = google_crc32c.value(chunk)
8891

8992
request = storage_type.BidiWriteObjectRequest(
9093
write_offset=write_state.bytes_sent,

packages/google-cloud-storage/tests/unit/asyncio/retry/test_writes_resumption_strategy.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ def test_generate_requests_checksum_verification(self, strategy):
128128
expected_int = google_crc32c.value(chunk_data)
129129
assert requests[0].checksummed_data.crc32c == expected_int
130130

131+
def test_generate_requests_checksum_disabled(self, strategy):
132+
"""Verify CRC32C is not calculated if enable_checksum is False."""
133+
chunk_data = b"test_data"
134+
mock_buffer = io.BytesIO(chunk_data)
135+
write_state = _WriteState(
136+
chunk_size=10,
137+
user_buffer=mock_buffer,
138+
flush_interval=10,
139+
enable_checksum=False,
140+
)
141+
state = {"write_state": write_state}
142+
143+
requests = strategy.generate_requests(state)
144+
145+
assert not requests[0].checksummed_data.crc32c
146+
131147
def test_generate_requests_flush_logic_exact_interval(self, strategy):
132148
"""Verify the flush bit is set exactly when the interval is reached."""
133149
mock_buffer = io.BytesIO(b"A" * 12)

packages/google-cloud-storage/tests/unit/asyncio/test_async_appendable_object_writer.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,29 @@ async def test_append_data_less_than_flush_interval(self, mock_appendable_writer
299299
assert writer.offset == data_len
300300
assert writer.bytes_appended_since_last_flush == data_len
301301

302+
@pytest.mark.asyncio
303+
async def test_append_with_checksum_disabled(self, mock_appendable_writer):
304+
"""Verify append propagates enable_checksum=False to the _WriteState."""
305+
writer = self._make_one(mock_appendable_writer["mock_client"])
306+
writer._is_stream_open = True
307+
writer.persisted_size = 0
308+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
309+
writer.write_obj_stream.send = AsyncMock()
310+
311+
with mock.patch(
312+
"google.cloud.storage.asyncio.async_appendable_object_writer._BidiStreamRetryManager"
313+
) as MockManager:
314+
mock_execute = AsyncMock()
315+
MockManager.return_value.execute = mock_execute
316+
317+
await writer.append(DATA_LESS_THAN_FLUSH_INTERVAL, enable_checksum=False)
318+
319+
# Check that execute was called with a state dictionary containing write_state having enable_checksum=False
320+
mock_execute.assert_called_once()
321+
state_arg = mock_execute.call_args[0][0]
322+
assert "write_state" in state_arg
323+
assert state_arg["write_state"].enable_checksum is False
324+
302325
@pytest.mark.parametrize(
303326
"data_len",
304327
[
@@ -552,3 +575,53 @@ async def test_close_with_checksum_without_finalize_raises(
552575
match="full_object_checksum can only be provided when finalize_on_close is True",
553576
):
554577
await writer.close(finalize_on_close=False, full_object_checksum=checksum)
578+
579+
@pytest.mark.asyncio
580+
async def test_finalize_invalid_checksum_type(self, mock_appendable_writer):
581+
writer = self._make_one(mock_appendable_writer["mock_client"])
582+
writer._is_stream_open = True
583+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
584+
585+
with pytest.raises(TypeError, match="full_object_checksum must be an integer"):
586+
await writer.finalize(full_object_checksum="not-an-int")
587+
588+
with pytest.raises(TypeError, match="full_object_checksum must be an integer"):
589+
await writer.finalize(full_object_checksum=True)
590+
591+
@pytest.mark.asyncio
592+
async def test_finalize_invalid_checksum_range(self, mock_appendable_writer):
593+
writer = self._make_one(mock_appendable_writer["mock_client"])
594+
writer._is_stream_open = True
595+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
596+
597+
# negative
598+
with pytest.raises(
599+
ValueError, match="full_object_checksum must be a 32-bit unsigned integer"
600+
):
601+
await writer.finalize(full_object_checksum=-1)
602+
603+
# overflow
604+
with pytest.raises(
605+
ValueError, match="full_object_checksum must be a 32-bit unsigned integer"
606+
):
607+
await writer.finalize(full_object_checksum=0x100000000)
608+
609+
@pytest.mark.asyncio
610+
async def test_finalize_mismatch_closes_stream(self, mock_appendable_writer):
611+
writer = self._make_one(mock_appendable_writer["mock_client"])
612+
writer._is_stream_open = True
613+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
614+
615+
# Mock recv to raise an exception (like server rejecting checksum mismatch)
616+
from google.api_core.exceptions import InvalidArgument
617+
618+
mock_appendable_writer["mock_stream"].recv.side_effect = InvalidArgument(
619+
"checksum mismatch"
620+
)
621+
622+
with pytest.raises(InvalidArgument):
623+
await writer.finalize(full_object_checksum=12345)
624+
625+
# Assert stream was closed and local state reset despite exception
626+
mock_appendable_writer["mock_stream"].close.assert_awaited()
627+
assert not writer._is_stream_open

0 commit comments

Comments
 (0)