Skip to content

Commit e08d5ca

Browse files
authored
feat(storage): support full_object_checksum in AsyncAppendableObjectWriter (#17658)
Add support for verifying `full_object_checksum` on finalising/closing appendable object uploads.
1 parent c6538bc commit e08d5ca

3 files changed

Lines changed: 173 additions & 6 deletions

File tree

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

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -547,12 +547,30 @@ async def flush(self) -> int:
547547
self.bytes_appended_since_last_flush = 0
548548
return self.persisted_size
549549

550-
async def close(self, finalize_on_close=False) -> Union[int, _storage_v2.Object]:
550+
async def close(
551+
self,
552+
finalize_on_close=False,
553+
full_object_checksum: Optional[int] = None,
554+
) -> Union[int, _storage_v2.Object]:
551555
"""Closes the underlying bidi-gRPC stream.
552556
553557
:type finalize_on_close: bool
554558
:param finalize_on_close: Finalizes the Appendable Object. No more data
555559
can be appended.
560+
:type full_object_checksum: int
561+
:param full_object_checksum: (Optional) This should be the CRC32C checksum of
562+
the entire contents of the object as a 32-bit integer.
563+
Used only when finalize_on_close is True.
564+
565+
It can be obtained by running:
566+
567+
.. code-block:: python
568+
569+
import google_crc32c
570+
571+
data = b"Hello, world!"
572+
crc32c_int = google_crc32c.value(data)
573+
print(crc32c_int)
556574
557575
rtype: Union[int, _storage_v2.Object]
558576
returns: Updated `self.persisted_size` by default after closing the
@@ -561,20 +579,32 @@ async def close(self, finalize_on_close=False) -> Union[int, _storage_v2.Object]
561579
562580
:raises ValueError: If the stream is not open (i.e., `open()` has not
563581
been called).
582+
:raises ValueError: If full_object_checksum is provided but
583+
finalize_on_close is False.
584+
:raises google.api_core.exceptions.InvalidArgument: If the provided
585+
full_object_checksum does not match the checksum computed by the
586+
server.
564587
565588
"""
566589
if not self._is_stream_open:
567590
raise ValueError("Stream is not open. Call open() before close().")
568591

592+
if full_object_checksum is not None and not finalize_on_close:
593+
raise ValueError(
594+
"full_object_checksum can only be provided when finalize_on_close is True."
595+
)
596+
569597
if finalize_on_close:
570-
return await self.finalize()
598+
return await self.finalize(full_object_checksum=full_object_checksum)
571599

572600
await self.write_obj_stream.close()
573601

574602
self._is_stream_open = False
575603
return self.persisted_size
576604

577-
async def finalize(self) -> _storage_v2.Object:
605+
async def finalize(
606+
self, full_object_checksum: Optional[int] = None
607+
) -> _storage_v2.Object:
578608
"""Finalizes the Appendable Object.
579609
580610
Note: Once finalized no more data can be appended.
@@ -585,18 +615,40 @@ async def finalize(self) -> _storage_v2.Object:
585615
However if `.finalize()` is called no more data can be appended to the
586616
object.
587617
618+
:type full_object_checksum: int
619+
:param full_object_checksum: (Optional) This should be the CRC32C checksum of
620+
the entire contents of the object as a 32-bit integer.
621+
622+
It can be obtained by running:
623+
624+
.. code-block:: python
625+
626+
import google_crc32c
627+
628+
data = b"Hello, world!"
629+
crc32c_int = google_crc32c.value(data)
630+
print(crc32c_int)
631+
588632
rtype: google.cloud.storage_v2.types.Object
589633
returns: The finalized object resource.
590634
591635
:raises ValueError: If the stream is not open (i.e., `open()` has not
592636
been called).
637+
:raises google.api_core.exceptions.InvalidArgument: If the provided
638+
full_object_checksum does not match the checksum computed by the
639+
server.
593640
"""
594641
if not self._is_stream_open:
595642
raise ValueError("Stream is not open. Call open() before finalize().")
596643

597-
await self.write_obj_stream.send(
598-
_storage_v2.BidiWriteObjectRequest(finish_write=True)
599-
)
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
649+
)
650+
651+
await self.write_obj_stream.send(finalize_req)
600652
response = await self.write_obj_stream.recv()
601653
self.object_resource = response.resource
602654
self.persisted_size = self.object_resource.size

packages/google-cloud-storage/tests/system/test_zonal.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# python additional imports
1111
import google_crc32c
1212
import pytest
13+
from google.api_core import exceptions
1314
from google.api_core.exceptions import FailedPrecondition, NotFound, OutOfRange
1415

1516
# current library imports
@@ -1046,3 +1047,68 @@ async def _run():
10461047
blobs_to_delete.append(storage_client.bucket(_ZONAL_BUCKET).blob(object_name))
10471048

10481049
event_loop.run_until_complete(_run())
1050+
1051+
1052+
def test_finalize_with_correct_checksum(
1053+
storage_client,
1054+
blobs_to_delete,
1055+
event_loop,
1056+
grpc_client_direct,
1057+
):
1058+
object_name = f"appendable_checksum_success_{uuid.uuid4()}"
1059+
object_data = b"Hello, appendable object with correct checksum!"
1060+
object_checksum = google_crc32c.value(object_data)
1061+
1062+
async def _run():
1063+
writer = AsyncAppendableObjectWriter(
1064+
grpc_client_direct, _ZONAL_BUCKET, object_name
1065+
)
1066+
await writer.open()
1067+
await writer.append(object_data)
1068+
object_metadata = await writer.finalize(full_object_checksum=object_checksum)
1069+
1070+
assert object_metadata.size == len(object_data)
1071+
assert int(object_metadata.checksums.crc32c) == object_checksum
1072+
1073+
# clean up
1074+
blobs_to_delete.append(storage_client.bucket(_ZONAL_BUCKET).blob(object_name))
1075+
del writer
1076+
gc.collect()
1077+
1078+
event_loop.run_until_complete(_run())
1079+
1080+
1081+
def test_finalize_with_incorrect_checksum_fails(
1082+
storage_client,
1083+
blobs_to_delete,
1084+
event_loop,
1085+
grpc_client_direct,
1086+
):
1087+
object_name = f"appendable_checksum_fail_{uuid.uuid4()}"
1088+
object_data = b"Hello, appendable object with incorrect checksum!"
1089+
object_checksum = google_crc32c.value(object_data)
1090+
incorrect_checksum = object_checksum + 1
1091+
1092+
async def _run():
1093+
writer = AsyncAppendableObjectWriter(
1094+
grpc_client_direct, _ZONAL_BUCKET, object_name
1095+
)
1096+
await writer.open()
1097+
await writer.append(object_data)
1098+
1099+
# Finalization should fail with an exception due to mismatch
1100+
with pytest.raises(exceptions.InvalidArgument) as excinfo:
1101+
await writer.finalize(full_object_checksum=incorrect_checksum)
1102+
1103+
# Assert that the error relates to checksum verification/mismatch
1104+
error_msg = str(excinfo.value).lower()
1105+
assert (
1106+
"crc32c" in error_msg or "checksum" in error_msg or "mismatch" in error_msg
1107+
)
1108+
1109+
# clean up
1110+
blobs_to_delete.append(storage_client.bucket(_ZONAL_BUCKET).blob(object_name))
1111+
del writer
1112+
gc.collect()
1113+
1114+
event_loop.run_until_complete(_run())

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,52 @@ async def test_methods_require_open_stream_raises(self, mock_appendable_writer):
503503
for coro in methods:
504504
with pytest.raises(ValueError, match="Stream is not open"):
505505
await coro
506+
507+
@pytest.mark.asyncio
508+
async def test_finalize_with_checksum(self, mock_appendable_writer):
509+
writer = self._make_one(mock_appendable_writer["mock_client"])
510+
writer._is_stream_open = True
511+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
512+
513+
resource = storage_type.Object(size=999)
514+
mock_appendable_writer[
515+
"mock_stream"
516+
].recv.return_value = storage_type.BidiWriteObjectResponse(resource=resource)
517+
518+
checksum = 12345678
519+
res = await writer.finalize(full_object_checksum=checksum)
520+
521+
assert res == resource
522+
assert writer.persisted_size == 999
523+
mock_appendable_writer["mock_stream"].send.assert_awaited_with(
524+
storage_type.BidiWriteObjectRequest(
525+
finish_write=True,
526+
object_checksums=storage_type.ObjectChecksums(crc32c=checksum),
527+
)
528+
)
529+
mock_appendable_writer["mock_stream"].close.assert_awaited()
530+
assert not writer._is_stream_open
531+
532+
@pytest.mark.asyncio
533+
async def test_close_with_checksum_and_finalize(self, mock_appendable_writer):
534+
writer = self._make_one(mock_appendable_writer["mock_client"])
535+
writer._is_stream_open = True
536+
writer.finalize = AsyncMock()
537+
538+
checksum = 12345678
539+
await writer.close(finalize_on_close=True, full_object_checksum=checksum)
540+
writer.finalize.assert_awaited_once_with(full_object_checksum=checksum)
541+
542+
@pytest.mark.asyncio
543+
async def test_close_with_checksum_without_finalize_raises(
544+
self, mock_appendable_writer
545+
):
546+
writer = self._make_one(mock_appendable_writer["mock_client"])
547+
writer._is_stream_open = True
548+
549+
checksum = 12345678
550+
with pytest.raises(
551+
ValueError,
552+
match="full_object_checksum can only be provided when finalize_on_close is True",
553+
):
554+
await writer.close(finalize_on_close=False, full_object_checksum=checksum)

0 commit comments

Comments
 (0)