Repository navigation
feat(zonal): pass Preconditions to AsyncAppendableObjectWriter #1131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| import asyncio | ||
| import collections | ||
| import concurrent.futures | ||
| import dataclasses | ||
| import logging | ||
| from typing import Optional | ||
| from unittest import mock | ||
|
|
||
| import pytest | ||
|
|
@@ -99,18 +101,35 @@ async def test_init_aaow(): | |
| assert result is mock_writer_instance | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Because the fake is always patched in, CI never builds the real |
||
| class _FakePreconditions: | ||
| if_generation_match: Optional[int] = None | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "if_generation_match, expected_kwargs", | ||
| [(0, {"generation": 0}), (7, {"generation": None, "if_generation_match": 7})], | ||
| [ | ||
| (0, {"generation": 0}), | ||
| ( | ||
| 7, | ||
| { | ||
| "generation": None, | ||
| "preconditions": _FakePreconditions(if_generation_match=7), | ||
| }, | ||
| ), | ||
| ], | ||
| ) | ||
| async def test_init_aaow_if_generation_match(if_generation_match, expected_kwargs): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This covers |
||
| mock_writer_instance = mock.AsyncMock() | ||
| with mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| new_callable=mock.Mock, | ||
| return_value=mock_writer_instance, | ||
| ) as mock_writer_class: | ||
| with ( | ||
| mock.patch.object(zb_hns_utils, "Preconditions", _FakePreconditions), | ||
| mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| new_callable=mock.Mock, | ||
| return_value=mock_writer_instance, | ||
| ) as mock_writer_class, | ||
| ): | ||
| await zb_hns_utils.init_aaow( | ||
| mock_grpc_client, | ||
| bucket_name, | ||
|
|
@@ -149,26 +168,41 @@ async def test_init_aaow_rejects_generation_with_if_generation_match( | |
|
|
||
| @pytest.mark.asyncio | ||
| async def test_init_aaow_if_generation_match_unsupported_sdk(): | ||
| with mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| side_effect=TypeError("unexpected keyword argument 'if_generation_match'"), | ||
| with ( | ||
| mock.patch.object(zb_hns_utils, "Preconditions", None), | ||
| mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| new_callable=mock.Mock, | ||
| ) as mock_writer_class, | ||
| ): | ||
| with pytest.raises(NotImplementedError): | ||
| await zb_hns_utils.init_aaow( | ||
| mock_grpc_client, bucket_name, object_name, if_generation_match=7 | ||
| ) | ||
| mock_writer_class.assert_not_called() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_init_aaow_unrelated_type_error_propagates(): | ||
| with mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| side_effect=TypeError("boom"), | ||
| async def test_init_aaow_if_generation_match_zero_without_preconditions(): | ||
| mock_writer_instance = mock.AsyncMock() | ||
| with ( | ||
| mock.patch.object(zb_hns_utils, "Preconditions", None), | ||
| mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| new_callable=mock.Mock, | ||
| return_value=mock_writer_instance, | ||
| ) as mock_writer_class, | ||
| ): | ||
| with pytest.raises(TypeError, match="boom"): | ||
| await zb_hns_utils.init_aaow( | ||
| mock_grpc_client, bucket_name, object_name, if_generation_match=7 | ||
| ) | ||
| await zb_hns_utils.init_aaow( | ||
| mock_grpc_client, bucket_name, object_name, if_generation_match=0 | ||
| ) | ||
| mock_writer_class.assert_called_once_with( | ||
| client=mock_grpc_client, | ||
| bucket_name=bucket_name, | ||
| object_name=object_name, | ||
| generation=0, | ||
| writer_options={}, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
|
|
@@ -179,10 +213,13 @@ async def test_init_aaow_unrelated_type_error_propagates(): | |
| async def test_init_aaow_failed_precondition(if_generation_match, expected): | ||
| mock_writer_instance = mock.AsyncMock() | ||
| mock_writer_instance.open.side_effect = FailedPrecondition("exists") | ||
| with mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| new_callable=mock.Mock, | ||
| return_value=mock_writer_instance, | ||
| with ( | ||
| mock.patch.object(zb_hns_utils, "Preconditions", _FakePreconditions), | ||
| mock.patch( | ||
| "gcsfs.zb_hns_utils.AsyncAppendableObjectWriter", | ||
| new_callable=mock.Mock, | ||
| return_value=mock_writer_instance, | ||
| ), | ||
| ): | ||
| with pytest.raises(expected) as excinfo: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. optional: for the |
||
| await zb_hns_utils.init_aaow( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,13 @@ | |
|
|
||
| from gcsfs.retry import GenerationMismatchError | ||
|
|
||
| try: | ||
| from google.cloud.storage.asyncio.async_appendable_object_writer import ( | ||
| Preconditions, | ||
| ) | ||
| except ImportError: # releases before googleapis/google-cloud-python#18641 | ||
| Preconditions = None | ||
|
|
||
| MRD_MAX_RANGES = 1000 # MRD supports up to 1000 ranges per request | ||
| try: | ||
| DEFAULT_CONCURRENCY = int(os.environ.get("DEFAULT_GCSFS_CONCURRENCY", "4")) | ||
|
|
@@ -175,7 +182,7 @@ async def init_aaow( | |
|
|
||
| ``if_generation_match=0`` creates the object only if it does not exist; | ||
| a positive value overwrites only that generation (requires a | ||
| google-cloud-storage release whose writer accepts ``if_generation_match``). | ||
| google-cloud-storage release whose writer accepts ``preconditions``). | ||
| A failed precondition raises ``FileExistsError`` for ``0`` and | ||
| ``GenerationMismatchError`` otherwise; without ``if_generation_match`` the | ||
| SDK's ``FailedPrecondition`` propagates unchanged. | ||
|
|
@@ -187,32 +194,30 @@ async def init_aaow( | |
| writer_options["FLUSH_INTERVAL_BYTES"] = flush_interval_bytes | ||
| kwargs = {} | ||
| if generation and if_generation_match is not None: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| # generation > 0 appends to an existing object (AppendObjectSpec), which | ||
| # carries no precondition, so the SDK would silently ignore it. | ||
| # generation > 0 appends to an existing object, so a generation | ||
| # precondition cannot apply. Check here because if_generation_match=0 | ||
| # is mapped to generation=0 below, which would hide the conflict. | ||
| raise ValueError( | ||
| "if_generation_match cannot be combined with an explicit generation" | ||
| ) | ||
| if if_generation_match == 0: | ||
| # Every SDK release maps generation=0 to WriteObjectSpec(if_generation_match=0). | ||
| generation = 0 | ||
| elif if_generation_match is not None: | ||
| kwargs["if_generation_match"] = if_generation_match | ||
| try: | ||
| writer = AsyncAppendableObjectWriter( | ||
| client=grpc_client, | ||
| bucket_name=bucket_name, | ||
| object_name=object_name, | ||
| generation=generation, | ||
| writer_options=writer_options, | ||
| **kwargs, | ||
| ) | ||
| except TypeError as e: | ||
| if "if_generation_match" not in str(e): | ||
| raise | ||
| raise NotImplementedError( | ||
| "if_generation_match > 0 on zonal buckets requires a google-cloud-storage " | ||
| "release whose AsyncAppendableObjectWriter accepts if_generation_match" | ||
| ) from e | ||
| if Preconditions is None: | ||
| raise NotImplementedError( | ||
| "if_generation_match > 0 on zonal buckets requires a google-cloud-storage " | ||
| "release whose AsyncAppendableObjectWriter accepts Preconditions" | ||
| ) | ||
| kwargs["preconditions"] = Preconditions(if_generation_match=if_generation_match) | ||
| writer = AsyncAppendableObjectWriter( | ||
| client=grpc_client, | ||
| bucket_name=bucket_name, | ||
| object_name=object_name, | ||
| generation=generation, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-existing, fine as a follow-up: |
||
| writer_options=writer_options, | ||
| **kwargs, | ||
| ) | ||
| try: | ||
| await writer.open() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-existing, fine as a follow-up: opening the writer replaces the object right away. |
||
| except FailedPrecondition as e: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking. The errors raised here ( On a real zonal bucket, with both google-cloud-storage 3.12.0 and googleapis/google-cloud-python#18641:
def _ensure_aaow(self):
if self.aaow is None:
try:
self.aaow = asyn.sync(self.gcsfs.loop, self._init_aaow, self.bucket,
self.key, self.generation, self.flush_interval_bytes)
except Exception:
self.closed = True # same as AbstractBufferedFile.flush()
raiseWith this change each case makes 1 RPC and no object is created, and the existing zonal unit suites still pass. Test idea: set A related follow-up on the same close path (pre-existing, not blocking): |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test_zonal_file_open_write_mode_with_if_generation_matchneeds the same update. Its real-GCS branch still says "SDK releases whose writer lacks the parameter" and usespytest.raises((NotImplementedError, GenerationMismatchError)). With 3.12.0 it reports PASSED without exercising CAS, whereas this test is SKIPPED. Suggestion: skip onNotImplementedErrorwith this same message, expect onlyGenerationMismatchError, and add a positive write withif_generation_match=<current gen>.