Skip to content

Commit 8588b2e

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

3 files changed

Lines changed: 255 additions & 34 deletions

File tree

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

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -299,21 +299,9 @@ 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(self, retry_policy: Optional[AsyncRetry] = None) -> AsyncRetry:
315303
if retry_policy is None:
316-
retry_policy = AsyncRetry(
304+
return AsyncRetry(
317305
predicate=_is_write_retryable, on_error=self._on_open_error
318306
)
319307
else:
@@ -324,7 +312,7 @@ def combined_on_error(exc):
324312
if original_on_error:
325313
original_on_error(exc)
326314

327-
retry_policy = AsyncRetry(
315+
return AsyncRetry(
328316
predicate=_is_write_retryable,
329317
initial=retry_policy._initial,
330318
maximum=retry_policy._maximum,
@@ -333,6 +321,21 @@ def combined_on_error(exc):
333321
on_error=combined_on_error,
334322
)
335323

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

@@ -560,6 +563,7 @@ async def close(
560563
self,
561564
finalize_on_close=False,
562565
full_object_checksum: Optional[int] = None,
566+
retry_policy: Optional[AsyncRetry] = None,
563567
) -> Union[int, _storage_v2.Object]:
564568
"""Closes the underlying bidi-gRPC stream.
565569
@@ -581,6 +585,9 @@ async def close(
581585
crc32c_int = google_crc32c.value(data)
582586
print(crc32c_int)
583587
588+
:type retry_policy: :class:`~google.api_core.retry_async.AsyncRetry`
589+
:param retry_policy: (Optional) The retry policy to use for the operation.
590+
584591
rtype: Union[int, _storage_v2.Object]
585592
returns: Updated `self.persisted_size` by default after closing the
586593
bidi-gRPC stream. However, if `finalize_on_close=True` is passed,
@@ -604,15 +611,44 @@ async def close(
604611
)
605612

606613
if finalize_on_close:
607-
return await self.finalize(full_object_checksum=full_object_checksum)
614+
return await self.finalize(
615+
full_object_checksum=full_object_checksum,
616+
retry_policy=retry_policy,
617+
)
608618

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

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

614648
async def finalize(
615-
self, full_object_checksum: Optional[int] = None
649+
self,
650+
full_object_checksum: Optional[int] = None,
651+
retry_policy: Optional[AsyncRetry] = None,
616652
) -> _storage_v2.Object:
617653
"""Finalizes the Appendable Object.
618654
@@ -638,6 +674,9 @@ async def finalize(
638674
crc32c_int = google_crc32c.value(data)
639675
print(crc32c_int)
640676
677+
:type retry_policy: :class:`~google.api_core.retry_async.AsyncRetry`
678+
:param retry_policy: (Optional) The retry policy to use for the operation.
679+
641680
rtype: google.cloud.storage_v2.types.Object
642681
returns: The finalized object resource.
643682
@@ -666,14 +705,40 @@ async def finalize(
666705
),
667706
)
668707

669-
try:
708+
retry_policy = self._merge_retry_policy(retry_policy)
709+
710+
attempt_count = 0
711+
712+
async def _do_finalize():
713+
nonlocal attempt_count
714+
attempt_count += 1
715+
716+
if attempt_count > 1:
717+
logger.info(
718+
f"Re-opening the stream for finalize retry attempt: {attempt_count}"
719+
)
720+
expected_offset = self.offset
721+
self._is_stream_open = False
722+
await self.open()
723+
if self.offset is not None and expected_offset is not None and self.offset < expected_offset:
724+
raise exceptions.InternalServerError(
725+
f"Unrecoverable data loss during reconnect. Expected offset {expected_offset}, got {self.offset}"
726+
)
727+
670728
await self.write_obj_stream.send(finalize_req)
671729
response = await self.write_obj_stream.recv()
672730
self.object_resource = response.resource
673731
self.persisted_size = self.object_resource.size
674732
return self.object_resource
733+
734+
try:
735+
return await retry_policy(_do_finalize)()
675736
finally:
676-
await self.write_obj_stream.close()
737+
if self.write_obj_stream:
738+
try:
739+
await self.write_obj_stream.close()
740+
except Exception as e:
741+
logger.debug(f"Stream close during finalize cleanup resulted in: {e}")
677742
self._is_stream_open = False
678743
self.offset = None
679744

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)