Skip to content

Commit ac0dfd1

Browse files
fix(storage): ensure bidi-gRPC stream is cleaned up on open failure and close (#18119)
- Ensure socket_like_rpc is closed if open() or the initial recv() fails or is cancelled in _AsyncReadObjectStream.open() - Add _close_socket_like_rpc helper to safely handle RPC closure - Only call requests_done() in close() if socket_like_rpc is active, and ensure stream state and RPC are closed even if requests_done() fails - Add unit tests for open failures/cancellations and close behavior on inactive or failing streams Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/google-cloud-python/issues) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [x] Ensure the tests and linter pass - [x] Code coverage does not decrease (if any source code was changed) - [x] Appropriate docs were updated (if necessary) Fixes b/547471318 🦕 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 2fe5bdc commit ac0dfd1

2 files changed

Lines changed: 163 additions & 27 deletions

File tree

‎packages/google-cloud-storage/google/cloud/storage/asyncio/async_read_object_stream.py‎

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
16+
import logging
1517
from typing import List, Optional, Tuple
1618

1719
from google.api_core.bidi_async import AsyncBidiRpc
@@ -22,6 +24,8 @@
2224
)
2325
from google.cloud.storage.asyncio.async_grpc_client import AsyncGrpcClient
2426

27+
logger = logging.getLogger(__name__)
28+
2529

2630
class _AsyncReadObjectStream(_AsyncAbstractObjectStream):
2731
"""Class representing a gRPC bidi-stream for reading data from a GCS ``Object``.
@@ -126,40 +130,53 @@ async def open(self, metadata: Optional[List[Tuple[str, str]]] = None) -> None:
126130
initial_request=self.first_bidi_read_req,
127131
metadata=current_metadata,
128132
)
129-
await self.socket_like_rpc.open() # this is actually 1 send
130-
response = await self.socket_like_rpc.recv()
131-
# populated only in the first response of bidi-stream and when opened
132-
# without using `read_handle`
133-
if hasattr(response, "metadata") and response.metadata:
134-
if self.generation_number is None:
135-
self.generation_number = response.metadata.generation
136-
# update persisted size
137-
self.persisted_size = response.metadata.size
138-
self.object_metadata = response.metadata
139-
if (
140-
hasattr(response.metadata, "finalize_time")
141-
and response.metadata.finalize_time
142-
and response.metadata.finalize_time.second > 0
143-
):
144-
self.is_finalized = True
133+
try:
134+
await self.socket_like_rpc.open() # this is actually 1 send
135+
response = await self.socket_like_rpc.recv()
136+
# populated only in the first response of bidi-stream and when opened
137+
# without using `read_handle`
138+
if hasattr(response, "metadata") and response.metadata:
139+
if self.generation_number is None:
140+
self.generation_number = response.metadata.generation
141+
# update persisted size
142+
self.persisted_size = response.metadata.size
143+
self.object_metadata = response.metadata
145144
if (
146-
hasattr(response.metadata, "checksums")
147-
and response.metadata.checksums
145+
hasattr(response.metadata, "finalize_time")
146+
and response.metadata.finalize_time
147+
and response.metadata.finalize_time.second > 0
148148
):
149-
self.full_obj_server_crc32c = response.metadata.checksums.crc32c
150-
151-
if response and response.read_handle:
152-
self.read_handle = response.read_handle
153-
154-
self._is_stream_open = True
149+
self.is_finalized = True
150+
if (
151+
hasattr(response.metadata, "checksums")
152+
and response.metadata.checksums
153+
):
154+
self.full_obj_server_crc32c = response.metadata.checksums.crc32c
155+
156+
if response and response.read_handle:
157+
self.read_handle = response.read_handle
158+
159+
self._is_stream_open = True
160+
except (asyncio.CancelledError, Exception):
161+
await self._close_socket_like_rpc()
162+
raise
163+
164+
async def _close_socket_like_rpc(self) -> None:
165+
try:
166+
await self.socket_like_rpc.close()
167+
except Exception as exc:
168+
logger.debug("Error while closing the read bidi-gRPC stream: %s", exc)
155169

156170
async def close(self) -> None:
157171
"""Closes the bidi-gRPC connection."""
158172
if not self._is_stream_open:
159173
raise ValueError("Stream is not open")
160-
await self.requests_done()
161-
await self.socket_like_rpc.close()
162-
self._is_stream_open = False
174+
try:
175+
if self.socket_like_rpc.is_active:
176+
await self.requests_done()
177+
finally:
178+
self._is_stream_open = False
179+
await self._close_socket_like_rpc()
163180

164181
async def requests_done(self):
165182
"""Signals that all requests have been sent."""

‎packages/google-cloud-storage/tests/unit/asyncio/test_async_read_object_stream.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
1516
from unittest import mock
1617
from unittest.mock import AsyncMock
1718

1819
import pytest
20+
from google.api_core.exceptions import Aborted
1921

2022
from google.cloud import _storage_v2
2123
from google.cloud.storage.asyncio import async_read_object_stream
@@ -203,6 +205,123 @@ async def test_close(mock_client, mock_cls_async_bidi_rpc):
203205
assert not read_obj_stream.is_stream_open
204206

205207

208+
@mock.patch("google.cloud.storage.asyncio.async_read_object_stream.AsyncBidiRpc")
209+
@mock.patch(
210+
"google.cloud.storage.asyncio.async_grpc_client.AsyncGrpcClient.grpc_client"
211+
)
212+
@pytest.mark.asyncio
213+
async def test_open_closes_rpc_when_first_recv_fails(
214+
mock_client, mock_cls_async_bidi_rpc
215+
):
216+
read_obj_stream = await instantiate_read_obj_stream(
217+
mock_client, mock_cls_async_bidi_rpc, open=False
218+
)
219+
socket_like_rpc = mock_cls_async_bidi_rpc.return_value
220+
socket_like_rpc.recv = AsyncMock(
221+
side_effect=Aborted("Idle stream has been closed.")
222+
)
223+
224+
with pytest.raises(Aborted):
225+
await read_obj_stream.open()
226+
227+
socket_like_rpc.open.assert_awaited_once()
228+
socket_like_rpc.close.assert_awaited_once()
229+
assert not read_obj_stream.is_stream_open
230+
231+
232+
@mock.patch("google.cloud.storage.asyncio.async_read_object_stream.AsyncBidiRpc")
233+
@mock.patch(
234+
"google.cloud.storage.asyncio.async_grpc_client.AsyncGrpcClient.grpc_client"
235+
)
236+
@pytest.mark.parametrize("cancelled_await", ["open", "recv"])
237+
@pytest.mark.asyncio
238+
async def test_open_closes_rpc_when_cancelled(
239+
mock_client, mock_cls_async_bidi_rpc, cancelled_await
240+
):
241+
read_obj_stream = await instantiate_read_obj_stream(
242+
mock_client, mock_cls_async_bidi_rpc, open=False
243+
)
244+
socket_like_rpc = mock_cls_async_bidi_rpc.return_value
245+
setattr(
246+
socket_like_rpc,
247+
cancelled_await,
248+
AsyncMock(side_effect=asyncio.CancelledError),
249+
)
250+
251+
with pytest.raises(asyncio.CancelledError):
252+
await read_obj_stream.open()
253+
254+
socket_like_rpc.close.assert_awaited_once()
255+
assert not read_obj_stream.is_stream_open
256+
257+
258+
@mock.patch("google.cloud.storage.asyncio.async_read_object_stream.AsyncBidiRpc")
259+
@mock.patch(
260+
"google.cloud.storage.asyncio.async_grpc_client.AsyncGrpcClient.grpc_client"
261+
)
262+
@pytest.mark.asyncio
263+
async def test_open_propagates_close_failure_from_failed_open(
264+
mock_client, mock_cls_async_bidi_rpc
265+
):
266+
read_obj_stream = await instantiate_read_obj_stream(
267+
mock_client, mock_cls_async_bidi_rpc, open=False
268+
)
269+
socket_like_rpc = mock_cls_async_bidi_rpc.return_value
270+
socket_like_rpc.recv = AsyncMock(
271+
side_effect=Aborted("Idle stream has been closed.")
272+
)
273+
socket_like_rpc.close = AsyncMock(side_effect=RuntimeError("close blew up"))
274+
275+
with pytest.raises(Aborted):
276+
await read_obj_stream.open()
277+
278+
assert not read_obj_stream.is_stream_open
279+
280+
281+
@mock.patch("google.cloud.storage.asyncio.async_read_object_stream.AsyncBidiRpc")
282+
@mock.patch(
283+
"google.cloud.storage.asyncio.async_grpc_client.AsyncGrpcClient.grpc_client"
284+
)
285+
@pytest.mark.asyncio
286+
async def test_close_closes_rpc_when_requests_done_fails(
287+
mock_client, mock_cls_async_bidi_rpc
288+
):
289+
read_obj_stream = await instantiate_read_obj_stream(
290+
mock_client, mock_cls_async_bidi_rpc, open=True
291+
)
292+
socket_like_rpc = read_obj_stream.socket_like_rpc
293+
read_obj_stream.requests_done = AsyncMock(
294+
side_effect=Aborted("Idle stream has been closed.")
295+
)
296+
297+
with pytest.raises(Aborted):
298+
await read_obj_stream.close()
299+
300+
socket_like_rpc.close.assert_awaited_once()
301+
assert not read_obj_stream.is_stream_open
302+
303+
304+
@mock.patch("google.cloud.storage.asyncio.async_read_object_stream.AsyncBidiRpc")
305+
@mock.patch(
306+
"google.cloud.storage.asyncio.async_grpc_client.AsyncGrpcClient.grpc_client"
307+
)
308+
@pytest.mark.asyncio
309+
async def test_close_skips_requests_done_on_inactive_rpc(
310+
mock_client, mock_cls_async_bidi_rpc
311+
):
312+
read_obj_stream = await instantiate_read_obj_stream(
313+
mock_client, mock_cls_async_bidi_rpc, open=True
314+
)
315+
read_obj_stream.socket_like_rpc.is_active = False
316+
read_obj_stream.requests_done = AsyncMock()
317+
318+
await read_obj_stream.close()
319+
320+
read_obj_stream.requests_done.assert_not_called()
321+
read_obj_stream.socket_like_rpc.close.assert_awaited_once()
322+
assert not read_obj_stream.is_stream_open
323+
324+
206325
@mock.patch("google.cloud.storage.asyncio.async_read_object_stream.AsyncBidiRpc")
207326
@mock.patch(
208327
"google.cloud.storage.asyncio.async_grpc_client.AsyncGrpcClient.grpc_client"

0 commit comments

Comments
 (0)