Skip to content

Commit ce980a5

Browse files
committed
fix(storage): add retry support for finalize and close in AsyncAppendableObjectWriter
Fixes: b/532527637
1 parent e52b015 commit ce980a5

3 files changed

Lines changed: 273 additions & 19 deletions

File tree

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

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ async def close(
560560
self,
561561
finalize_on_close=False,
562562
full_object_checksum: Optional[int] = None,
563+
retry_policy: Optional[AsyncRetry] = None,
563564
) -> Union[int, _storage_v2.Object]:
564565
"""Closes the underlying bidi-gRPC stream.
565566
@@ -581,6 +582,9 @@ async def close(
581582
crc32c_int = google_crc32c.value(data)
582583
print(crc32c_int)
583584
585+
:type retry_policy: :class:`~google.api_core.retry_async.AsyncRetry`
586+
:param retry_policy: (Optional) The retry policy to use for the operation.
587+
584588
rtype: Union[int, _storage_v2.Object]
585589
returns: Updated `self.persisted_size` by default after closing the
586590
bidi-gRPC stream. However, if `finalize_on_close=True` is passed,
@@ -604,15 +608,58 @@ async def close(
604608
)
605609

606610
if finalize_on_close:
607-
return await self.finalize(full_object_checksum=full_object_checksum)
611+
return await self.finalize(
612+
full_object_checksum=full_object_checksum,
613+
retry_policy=retry_policy,
614+
)
608615

609-
await self.write_obj_stream.close()
616+
if retry_policy is None:
617+
retry_policy = AsyncRetry(
618+
predicate=_is_write_retryable, on_error=self._on_open_error
619+
)
620+
else:
621+
original_on_error = retry_policy._on_error
622+
623+
def combined_on_error(exc):
624+
self._on_open_error(exc)
625+
if original_on_error:
626+
original_on_error(exc)
627+
628+
retry_policy = AsyncRetry(
629+
predicate=_is_write_retryable,
630+
initial=retry_policy._initial,
631+
maximum=retry_policy._maximum,
632+
multiplier=retry_policy._multiplier,
633+
deadline=retry_policy._deadline,
634+
on_error=combined_on_error,
635+
)
636+
637+
attempt_count = 0
638+
639+
async def _do_close():
640+
nonlocal attempt_count
641+
attempt_count += 1
642+
643+
if attempt_count > 1:
644+
logger.info(
645+
f"Re-opening the stream for close retry attempt: {attempt_count}"
646+
)
647+
self._is_stream_open = False
648+
await self.open()
649+
650+
await self.write_obj_stream.close()
651+
return self.persisted_size
652+
653+
try:
654+
return await retry_policy(_do_close)()
655+
finally:
656+
self._is_stream_open = False
610657

611-
self._is_stream_open = False
612-
return self.persisted_size
613658

614659
async def finalize(
615-
self, full_object_checksum: Optional[int] = None
660+
self,
661+
full_object_checksum: Optional[int] = None,
662+
retry_policy: Optional[AsyncRetry] = None,
616663
) -> _storage_v2.Object:
617664
"""Finalizes the Appendable Object.
618665
@@ -638,6 +685,9 @@ async def finalize(
638685
crc32c_int = google_crc32c.value(data)
639686
print(crc32c_int)
640687
688+
:type retry_policy: :class:`~google.api_core.retry_async.AsyncRetry`
689+
:param retry_policy: (Optional) The retry policy to use for the operation.
690+
641691
rtype: google.cloud.storage_v2.types.Object
642692
returns: The finalized object resource.
643693
@@ -666,14 +716,62 @@ async def finalize(
666716
),
667717
)
668718

669-
try:
719+
if retry_policy is None:
720+
retry_policy = AsyncRetry(
721+
predicate=_is_write_retryable, on_error=self._on_open_error
722+
)
723+
else:
724+
original_on_error = retry_policy._on_error
725+
726+
def combined_on_error(exc):
727+
self._on_open_error(exc)
728+
if original_on_error:
729+
original_on_error(exc)
730+
731+
retry_policy = AsyncRetry(
732+
predicate=_is_write_retryable,
733+
initial=retry_policy._initial,
734+
maximum=retry_policy._maximum,
735+
multiplier=retry_policy._multiplier,
736+
deadline=retry_policy._deadline,
737+
on_error=combined_on_error,
738+
)
739+
740+
attempt_count = 0
741+
742+
async def _do_finalize():
743+
nonlocal attempt_count
744+
attempt_count += 1
745+
746+
if attempt_count > 1:
747+
logger.info(
748+
f"Re-opening the stream for finalize retry attempt: {attempt_count}"
749+
)
750+
self._is_stream_open = False
751+
await self.open()
752+
670753
await self.write_obj_stream.send(finalize_req)
671754
response = await self.write_obj_stream.recv()
672755
self.object_resource = response.resource
673756
self.persisted_size = self.object_resource.size
674757
return self.object_resource
758+
759+
try:
760+
return await retry_policy(_do_finalize)()
675761
finally:
676-
await self.write_obj_stream.close()
762+
if self.write_obj_stream:
763+
if self.write_obj_stream.is_stream_open:
764+
try:
765+
await self.write_obj_stream.close()
766+
except Exception as e:
767+
logger.warning(
768+
f"Error closing write stream during finalize cleanup. Got exception: {e}"
769+
)
770+
else:
771+
try:
772+
await self.write_obj_stream.close()
773+
except Exception:
774+
pass
677775
self._is_stream_open = False
678776
self.offset = None
679777

packages/google-cloud-storage/tests/conformance/test_bidi_writes.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -136,24 +136,27 @@ def on_retry_error(exc):
136136
CONTENT, metadata=fault_injection_metadata, retry_policy=policy_to_pass
137137
)
138138
# await writer.finalize()
139-
await writer.close(finalize_on_close=True)
139+
f_o_c = scenario.get("finalize_on_close", True)
140+
await writer.close(finalize_on_close=f_o_c, retry_policy=policy_to_pass)
140141

141142
# If an exception was expected, this line should not be reached.
142143
if scenario["expected_error"] is not None:
143144
raise AssertionError(
144145
f"Expected exception {scenario['expected_error']} was not raised."
145146
)
146147

147-
# 4. Verify the object content.
148-
read_request = storage_v2.ReadObjectRequest(
149-
bucket=f"projects/_/buckets/{bucket_name}",
150-
object=object_name,
151-
)
152-
read_stream = await gapic_client.read_object(request=read_request)
153-
data = b""
154-
async for chunk in read_stream:
155-
data += chunk.checksummed_data.content
156-
assert data == CONTENT
148+
# 4. Verify the object content if applicable.
149+
if not scenario.get("skip_verification"):
150+
read_request = storage_v2.ReadObjectRequest(
151+
bucket=f"projects/_/buckets/{bucket_name}",
152+
object=object_name,
153+
)
154+
read_stream = await gapic_client.read_object(request=read_request)
155+
data = b""
156+
async for chunk in read_stream:
157+
data += chunk.checksummed_data.content
158+
assert data == CONTENT
159+
157160
if scenario["expected_error"] is None:
158161
# Scenarios like 503, 500, smarter resumption, and redirects
159162
# SHOULD trigger at least one retry attempt.
@@ -235,6 +238,26 @@ async def test_bidi_writes(testbench):
235238
"instruction": "redirect-send-handle-and-token-tokenval",
236239
"expected_error": None,
237240
},
241+
{
242+
"name": "Retry exactly on finalize/close (Redirect Error)",
243+
"method": "storage.objects.insert",
244+
"instruction": "redirect-send-handle-and-token-mytoken-on-finish-write",
245+
"expected_error": None,
246+
},
247+
{
248+
"name": "Retry exactly on finalize/close (503)",
249+
"method": "storage.objects.insert",
250+
"instruction": "return-503-on-finish-write",
251+
"expected_error": None,
252+
},
253+
{
254+
"name": "Retry exactly on close (finalize_on_close=False) (503)",
255+
"method": "storage.objects.insert",
256+
"instruction": "return-503-on-half-close",
257+
"expected_error": None,
258+
"finalize_on_close": False,
259+
"skip_verification": True,
260+
},
238261
]
239262

240263
try:

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

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,9 @@ async def test_close_with_checksum_and_finalize(self, mock_appendable_writer):
560560

561561
checksum = 12345678
562562
await writer.close(finalize_on_close=True, full_object_checksum=checksum)
563-
writer.finalize.assert_awaited_once_with(full_object_checksum=checksum)
563+
writer.finalize.assert_awaited_once_with(
564+
full_object_checksum=checksum, retry_policy=None
565+
)
564566

565567
@pytest.mark.asyncio
566568
async def test_close_with_checksum_without_finalize_raises(
@@ -625,3 +627,134 @@ async def test_finalize_mismatch_closes_stream(self, mock_appendable_writer):
625627
# Assert stream was closed and local state reset despite exception
626628
mock_appendable_writer["mock_stream"].close.assert_awaited()
627629
assert not writer._is_stream_open
630+
631+
@pytest.mark.asyncio
632+
async def test_finalize_retry_on_transient_error(self, mock_appendable_writer):
633+
writer = self._make_one(mock_appendable_writer["mock_client"])
634+
writer._is_stream_open = True
635+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
636+
637+
resource = storage_type.Object(size=999)
638+
mock_appendable_writer["mock_stream"].recv.side_effect = [
639+
exceptions.InternalServerError("500 Transient Error"),
640+
storage_type.BidiWriteObjectResponse(resource=resource),
641+
]
642+
643+
res = await writer.finalize()
644+
645+
assert res == resource
646+
assert writer.persisted_size == 999
647+
assert mock_appendable_writer["mock_stream"].send.await_count == 2
648+
assert not writer._is_stream_open
649+
650+
@pytest.mark.asyncio
651+
async def test_finalize_custom_retry_policy(self, mock_appendable_writer):
652+
from google.api_core.retry_async import AsyncRetry
653+
654+
writer = self._make_one(mock_appendable_writer["mock_client"])
655+
writer._is_stream_open = True
656+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
657+
658+
custom_policy = AsyncRetry(
659+
predicate=lambda exc: isinstance(exc, exceptions.InternalServerError)
660+
)
661+
resource = storage_type.Object(size=999)
662+
mock_appendable_writer[
663+
"mock_stream"
664+
].recv.return_value = storage_type.BidiWriteObjectResponse(resource=resource)
665+
666+
res = await writer.finalize(retry_policy=custom_policy)
667+
assert res == resource
668+
669+
@pytest.mark.asyncio
670+
async def test_close_with_finalize_and_custom_retry_policy(
671+
self, mock_appendable_writer
672+
):
673+
from google.api_core.retry_async import AsyncRetry
674+
675+
writer = self._make_one(mock_appendable_writer["mock_client"])
676+
writer._is_stream_open = True
677+
writer.finalize = AsyncMock()
678+
679+
custom_policy = AsyncRetry(predicate=lambda exc: False)
680+
await writer.close(finalize_on_close=True, retry_policy=custom_policy)
681+
writer.finalize.assert_awaited_once_with(
682+
full_object_checksum=None,
683+
retry_policy=custom_policy,
684+
)
685+
686+
@pytest.mark.asyncio
687+
async def test_close_retry_on_transient_error(self, mock_appendable_writer):
688+
writer = self._make_one(mock_appendable_writer["mock_client"])
689+
writer._is_stream_open = True
690+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
691+
692+
resource = storage_type.Object(size=999)
693+
mock_appendable_writer["mock_stream"].recv.side_effect = [
694+
exceptions.InternalServerError("500 Transient Error"),
695+
storage_type.BidiWriteObjectResponse(resource=resource),
696+
]
697+
698+
res = await writer.close(finalize_on_close=True)
699+
700+
assert res == resource
701+
assert writer.persisted_size == 999
702+
assert mock_appendable_writer["mock_stream"].send.await_count == 2
703+
assert not writer._is_stream_open
704+
705+
@pytest.mark.asyncio
706+
async def test_finalize_retry_on_redirect_error(self, mock_appendable_writer):
707+
writer = self._make_one(mock_appendable_writer["mock_client"])
708+
writer._is_stream_open = True
709+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
710+
711+
redirect = BidiWriteObjectRedirectedError(
712+
routing_token="rt1",
713+
write_handle=storage_type.BidiWriteHandle(handle=b"h1"),
714+
)
715+
exc = exceptions.Aborted("aborted", errors=[redirect])
716+
717+
resource = storage_type.Object(size=999)
718+
mock_appendable_writer["mock_stream"].recv.side_effect = [
719+
exc,
720+
storage_type.BidiWriteObjectResponse(resource=resource),
721+
]
722+
723+
writer.open = mock.AsyncMock()
724+
725+
res = await writer.finalize()
726+
727+
assert res == resource
728+
assert writer.persisted_size == 999
729+
assert mock_appendable_writer["mock_stream"].send.await_count == 2
730+
assert writer._routing_token == "rt1"
731+
assert writer.write_handle.handle == b"h1"
732+
writer.open.assert_awaited_once()
733+
734+
@pytest.mark.asyncio
735+
async def test_close_retry_on_redirect_error(self, mock_appendable_writer):
736+
writer = self._make_one(mock_appendable_writer["mock_client"])
737+
writer._is_stream_open = True
738+
writer.write_obj_stream = mock_appendable_writer["mock_stream"]
739+
740+
redirect = BidiWriteObjectRedirectedError(
741+
routing_token="rt2",
742+
write_handle=storage_type.BidiWriteHandle(handle=b"h2"),
743+
)
744+
exc = exceptions.Aborted("aborted", errors=[redirect])
745+
746+
mock_appendable_writer["mock_stream"].close.side_effect = [
747+
exc,
748+
None,
749+
]
750+
751+
writer.open = mock.AsyncMock()
752+
writer.persisted_size = 999
753+
754+
res = await writer.close()
755+
756+
assert res == 999
757+
assert mock_appendable_writer["mock_stream"].close.await_count == 2
758+
assert writer._routing_token == "rt2"
759+
assert writer.write_handle.handle == b"h2"
760+
writer.open.assert_awaited_once()

0 commit comments

Comments
 (0)