Skip to content

Commit d7961c4

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

3 files changed

Lines changed: 266 additions & 34 deletions

File tree

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

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -299,21 +299,11 @@ def _on_open_error(self, exc):
299299
if redirect_proto.generation:
300300
self.generation = redirect_proto.generation
301301

302-
async def open(
303-
self,
304-
retry_policy: Optional[AsyncRetry] = None,
305-
metadata: Optional[List[Tuple[str, str]]] = None,
306-
) -> None:
307-
"""Opens the underlying bidi-gRPC stream.
308-
309-
:raises ValueError: If the stream is already open.
310-
311-
"""
312-
if self._is_stream_open:
313-
raise ValueError("Underlying bidi-gRPC stream is already open")
314-
302+
def _merge_retry_policy(
303+
self, retry_policy: Optional[AsyncRetry] = None
304+
) -> AsyncRetry:
315305
if retry_policy is None:
316-
retry_policy = AsyncRetry(
306+
return AsyncRetry(
317307
predicate=_is_write_retryable, on_error=self._on_open_error
318308
)
319309
else:
@@ -324,7 +314,7 @@ def combined_on_error(exc):
324314
if original_on_error:
325315
original_on_error(exc)
326316

327-
retry_policy = AsyncRetry(
317+
return AsyncRetry(
328318
predicate=_is_write_retryable,
329319
initial=retry_policy._initial,
330320
maximum=retry_policy._maximum,
@@ -333,6 +323,21 @@ def combined_on_error(exc):
333323
on_error=combined_on_error,
334324
)
335325

326+
async def open(
327+
self,
328+
retry_policy: Optional[AsyncRetry] = None,
329+
metadata: Optional[List[Tuple[str, str]]] = None,
330+
) -> None:
331+
"""Opens the underlying bidi-gRPC stream.
332+
333+
:raises ValueError: If the stream is already open.
334+
335+
"""
336+
if self._is_stream_open:
337+
raise ValueError("Underlying bidi-gRPC stream is already open")
338+
339+
retry_policy = self._merge_retry_policy(retry_policy)
340+
336341
async def _do_open():
337342
current_metadata = list(metadata) if metadata else []
338343

@@ -560,6 +565,7 @@ async def close(
560565
self,
561566
finalize_on_close=False,
562567
full_object_checksum: Optional[int] = None,
568+
retry_policy: Optional[AsyncRetry] = None,
563569
) -> Union[int, _storage_v2.Object]:
564570
"""Closes the underlying bidi-gRPC stream.
565571
@@ -581,6 +587,9 @@ async def close(
581587
crc32c_int = google_crc32c.value(data)
582588
print(crc32c_int)
583589
590+
:type retry_policy: :class:`~google.api_core.retry_async.AsyncRetry`
591+
:param retry_policy: (Optional) The retry policy to use for the operation.
592+
584593
rtype: Union[int, _storage_v2.Object]
585594
returns: Updated `self.persisted_size` by default after closing the
586595
bidi-gRPC stream. However, if `finalize_on_close=True` is passed,
@@ -604,15 +613,47 @@ async def close(
604613
)
605614

606615
if finalize_on_close:
607-
return await self.finalize(full_object_checksum=full_object_checksum)
616+
return await self.finalize(
617+
full_object_checksum=full_object_checksum,
618+
retry_policy=retry_policy,
619+
)
608620

609-
await self.write_obj_stream.close()
621+
retry_policy = self._merge_retry_policy(retry_policy)
610622

611-
self._is_stream_open = False
612-
return self.persisted_size
623+
attempt_count = 0
624+
625+
async def _do_close():
626+
nonlocal attempt_count
627+
attempt_count += 1
628+
629+
if attempt_count > 1:
630+
logger.info(
631+
f"Re-opening the stream for close retry attempt: {attempt_count}"
632+
)
633+
expected_offset = self.offset
634+
self._is_stream_open = False
635+
await self.open()
636+
if (
637+
self.offset is not None
638+
and expected_offset is not None
639+
and self.offset < expected_offset
640+
):
641+
raise exceptions.InternalServerError(
642+
f"Unrecoverable data loss during reconnect. Expected offset {expected_offset}, got {self.offset}"
643+
)
644+
645+
await self.write_obj_stream.close()
646+
return self.persisted_size
647+
648+
try:
649+
return await retry_policy(_do_close)()
650+
finally:
651+
self._is_stream_open = False
613652

614653
async def finalize(
615-
self, full_object_checksum: Optional[int] = None
654+
self,
655+
full_object_checksum: Optional[int] = None,
656+
retry_policy: Optional[AsyncRetry] = None,
616657
) -> _storage_v2.Object:
617658
"""Finalizes the Appendable Object.
618659
@@ -638,6 +679,9 @@ async def finalize(
638679
crc32c_int = google_crc32c.value(data)
639680
print(crc32c_int)
640681
682+
:type retry_policy: :class:`~google.api_core.retry_async.AsyncRetry`
683+
:param retry_policy: (Optional) The retry policy to use for the operation.
684+
641685
rtype: google.cloud.storage_v2.types.Object
642686
returns: The finalized object resource.
643687
@@ -666,14 +710,46 @@ async def finalize(
666710
),
667711
)
668712

669-
try:
713+
retry_policy = self._merge_retry_policy(retry_policy)
714+
715+
attempt_count = 0
716+
717+
async def _do_finalize():
718+
nonlocal attempt_count
719+
attempt_count += 1
720+
721+
if attempt_count > 1:
722+
logger.info(
723+
f"Re-opening the stream for finalize retry attempt: {attempt_count}"
724+
)
725+
expected_offset = self.offset
726+
self._is_stream_open = False
727+
await self.open()
728+
if (
729+
self.offset is not None
730+
and expected_offset is not None
731+
and self.offset < expected_offset
732+
):
733+
raise exceptions.InternalServerError(
734+
f"Unrecoverable data loss during reconnect. Expected offset {expected_offset}, got {self.offset}"
735+
)
736+
670737
await self.write_obj_stream.send(finalize_req)
671738
response = await self.write_obj_stream.recv()
672739
self.object_resource = response.resource
673740
self.persisted_size = self.object_resource.size
674741
return self.object_resource
742+
743+
try:
744+
return await retry_policy(_do_finalize)()
675745
finally:
676-
await self.write_obj_stream.close()
746+
if self.write_obj_stream:
747+
try:
748+
await self.write_obj_stream.close()
749+
except Exception as e:
750+
logger.debug(
751+
f"Stream close during finalize cleanup resulted in: {e}"
752+
)
677753
self._is_stream_open = False
678754
self.offset = None
679755

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)