Skip to content

Commit e7f6a34

Browse files
authored
fix(bigtable): data client should acknowledge all mutations in batch (#18124)
fail V3 mutate_rows entries the server never acknowledged
1 parent ac0dfd1 commit e7f6a34

6 files changed

Lines changed: 155 additions & 9 deletions

File tree

packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/_mutate_rows.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,18 @@ async def _run_attempt(self):
214214
self._handle_entry_error(idx, exc)
215215
# bubble up exception to be handled by retry wrapper
216216
raise
217+
# Any entries that were sent but never received a response entry (a
218+
# successfully-closed but incomplete stream) must not be treated as
219+
# successful. Record a retryable error so idempotent entries are retried
220+
# and non-idempotent entries surface as failures instead of being
221+
# silently dropped.
222+
for idx in active_request_indices.values():
223+
self._handle_entry_error(
224+
idx,
225+
bt_exceptions._MutateRowsIncomplete(
226+
"no response entry received for mutation"
227+
),
228+
)
217229
# check if attempt succeeded, or needs to be retried
218230
if self.remaining_indices:
219231
# unfinished work; raise exception to trigger retry

packages/google-cloud-bigtable/google/cloud/bigtable/data/_sync_autogen/_mutate_rows.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ def _run_attempt(self):
173173
for idx in active_request_indices.values():
174174
self._handle_entry_error(idx, exc)
175175
raise
176+
for idx in active_request_indices.values():
177+
self._handle_entry_error(
178+
idx,
179+
bt_exceptions._MutateRowsIncomplete(
180+
"no response entry received for mutation"
181+
),
182+
)
176183
if self.remaining_indices:
177184
raise bt_exceptions._MutateRowsIncomplete
178185

packages/google-cloud-bigtable/tests/unit/data/_async/test__mutate_rows.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,13 @@ def _make_mutation(self, count=1, size=1):
5353
return mutation
5454

5555
@CrossSync.convert
56-
async def _mock_stream(self, mutation_list, error_dict):
56+
async def _mock_stream(self, mutation_list, error_dict, omit_indices=None):
57+
omit_indices = omit_indices or set()
5758
for idx, entry in enumerate(mutation_list):
59+
if idx in omit_indices:
60+
# simulate a server that closes the stream OK without returning
61+
# a response entry for this mutation
62+
continue
5863
code = error_dict.get(idx, 0)
5964
yield MutateRowsResponse(
6065
entries=[
@@ -64,12 +69,12 @@ async def _mock_stream(self, mutation_list, error_dict):
6469
]
6570
)
6671

67-
def _make_mock_gapic(self, mutation_list, error_dict=None):
72+
def _make_mock_gapic(self, mutation_list, error_dict=None, omit_indices=None):
6873
mock_fn = CrossSync.Mock()
6974
if error_dict is None:
7075
error_dict = {}
7176
mock_fn.side_effect = lambda *args, **kwargs: self._mock_stream(
72-
mutation_list, error_dict
77+
mutation_list, error_dict, omit_indices
7378
)
7479
return mock_fn
7580

@@ -374,3 +379,47 @@ async def test_run_attempt_partial_success_non_retryable(self):
374379
assert len(instance.errors[1]) == 1
375380
assert instance.errors[1][0].grpc_status_code == 300
376381
assert 2 not in instance.errors
382+
383+
@CrossSync.pytest
384+
async def test_run_attempt_missing_entry_retryable(self):
385+
"""If the server closes the stream successfully but omits a response
386+
entry, the unanswered mutation must not be treated as successful. It
387+
should be recorded as a retryable _MutateRowsIncomplete error so
388+
idempotent entries are retried."""
389+
from google.cloud.bigtable.data.exceptions import _MutateRowsIncomplete
390+
391+
mutations = [
392+
self._make_mutation(),
393+
self._make_mutation(),
394+
self._make_mutation(),
395+
]
396+
# server omits the response entry for index 1
397+
mock_gapic_fn = self._make_mock_gapic(mutations, omit_indices={1})
398+
instance = self._make_one(mutation_entries=mutations)
399+
instance.is_retryable = lambda x: True
400+
with mock.patch.object(instance, "_gapic_fn", mock_gapic_fn):
401+
with pytest.raises(_MutateRowsIncomplete):
402+
await instance._run_attempt()
403+
assert instance.remaining_indices == [1]
404+
assert 0 not in instance.errors
405+
assert len(instance.errors[1]) == 1
406+
assert isinstance(instance.errors[1][0], _MutateRowsIncomplete)
407+
assert 2 not in instance.errors
408+
409+
@CrossSync.pytest
410+
async def test_run_attempt_missing_entry_non_retryable(self):
411+
"""A missing response entry for a non-retryable mutation is surfaced as
412+
a failure rather than being silently dropped."""
413+
from google.cloud.bigtable.data.exceptions import _MutateRowsIncomplete
414+
415+
mutations = [self._make_mutation(), self._make_mutation()]
416+
# server omits the response entry for index 0
417+
mock_gapic_fn = self._make_mock_gapic(mutations, omit_indices={0})
418+
instance = self._make_one(mutation_entries=mutations)
419+
instance.is_retryable = lambda x: False
420+
with mock.patch.object(instance, "_gapic_fn", mock_gapic_fn):
421+
await instance._run_attempt()
422+
assert instance.remaining_indices == []
423+
assert len(instance.errors[0]) == 1
424+
assert isinstance(instance.errors[0][0], _MutateRowsIncomplete)
425+
assert 1 not in instance.errors

packages/google-cloud-bigtable/tests/unit/data/_async/test_client.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,25 @@ async def test_call_metadata(self, include_app_profile, fn_name, fn_args, gapic_
15911591
transport_mock = mock.MagicMock()
15921592
rpc_mock = CrossSync.Mock()
15931593
transport_mock._wrapped_methods.__getitem__.return_value = rpc_mock
1594+
if gapic_fn == "mutate_rows":
1595+
# An unacknowledged MutateRows entry is now treated as a retryable
1596+
# incomplete mutation, so an empty mock stream would retry until the
1597+
# operation timeout. Return a success entry for the single mutation
1598+
# so the operation completes after a single attempt.
1599+
from google.cloud.bigtable_v2.types import MutateRowsResponse
1600+
from google.rpc import status_pb2
1601+
1602+
@CrossSync.convert
1603+
async def mutate_rows_stream(*args, **kwargs):
1604+
yield MutateRowsResponse(
1605+
entries=[
1606+
MutateRowsResponse.Entry(
1607+
index=0, status=status_pb2.Status(code=0)
1608+
)
1609+
]
1610+
)
1611+
1612+
rpc_mock.side_effect = mutate_rows_stream
15941613
gapic_client = client._gapic_client
15951614
if CrossSync.is_async:
15961615
# inner BigtableClient is held as ._client for BigtableAsyncClient
@@ -3492,9 +3511,10 @@ async def test_bulk_mutate_error_recovery(self):
34923511
async with self._make_client(project="project") as client:
34933512
table = client.get_table("instance", "table")
34943513
with mock.patch.object(client._gapic_client, "mutate_rows") as mock_gapic:
3495-
# fail with a retryable error, then a non-retryable one
3514+
# first entry fails with a retryable error (other two succeed),
3515+
# then the retried entry succeeds
34963516
mock_gapic.side_effect = [
3497-
self._mock_response([DeadlineExceeded("mock")]),
3517+
self._mock_response([DeadlineExceeded("mock"), None, None]),
34983518
self._mock_response([None]),
34993519
]
35003520
mutation = mutations.SetCell(

packages/google-cloud-bigtable/tests/unit/data/_sync_autogen/test__mutate_rows.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ def _make_mutation(self, count=1, size=1):
5252
mutation.size = lambda: size
5353
return mutation
5454

55-
def _mock_stream(self, mutation_list, error_dict):
55+
def _mock_stream(self, mutation_list, error_dict, omit_indices=None):
56+
omit_indices = omit_indices or set()
5657
for idx, entry in enumerate(mutation_list):
58+
if idx in omit_indices:
59+
continue
5760
code = error_dict.get(idx, 0)
5861
yield MutateRowsResponse(
5962
entries=[
@@ -63,12 +66,12 @@ def _mock_stream(self, mutation_list, error_dict):
6366
]
6467
)
6568

66-
def _make_mock_gapic(self, mutation_list, error_dict=None):
69+
def _make_mock_gapic(self, mutation_list, error_dict=None, omit_indices=None):
6770
mock_fn = CrossSync._Sync_Impl.Mock()
6871
if error_dict is None:
6972
error_dict = {}
7073
mock_fn.side_effect = lambda *args, **kwargs: self._mock_stream(
71-
mutation_list, error_dict
74+
mutation_list, error_dict, omit_indices
7275
)
7376
return mock_fn
7477

@@ -323,3 +326,43 @@ def test_run_attempt_partial_success_non_retryable(self):
323326
assert len(instance.errors[1]) == 1
324327
assert instance.errors[1][0].grpc_status_code == 300
325328
assert 2 not in instance.errors
329+
330+
def test_run_attempt_missing_entry_retryable(self):
331+
"""If the server closes the stream successfully but omits a response
332+
entry, the unanswered mutation must not be treated as successful. It
333+
should be recorded as a retryable _MutateRowsIncomplete error so
334+
idempotent entries are retried."""
335+
from google.cloud.bigtable.data.exceptions import _MutateRowsIncomplete
336+
337+
mutations = [
338+
self._make_mutation(),
339+
self._make_mutation(),
340+
self._make_mutation(),
341+
]
342+
mock_gapic_fn = self._make_mock_gapic(mutations, omit_indices={1})
343+
instance = self._make_one(mutation_entries=mutations)
344+
instance.is_retryable = lambda x: True
345+
with mock.patch.object(instance, "_gapic_fn", mock_gapic_fn):
346+
with pytest.raises(_MutateRowsIncomplete):
347+
instance._run_attempt()
348+
assert instance.remaining_indices == [1]
349+
assert 0 not in instance.errors
350+
assert len(instance.errors[1]) == 1
351+
assert isinstance(instance.errors[1][0], _MutateRowsIncomplete)
352+
assert 2 not in instance.errors
353+
354+
def test_run_attempt_missing_entry_non_retryable(self):
355+
"""A missing response entry for a non-retryable mutation is surfaced as
356+
a failure rather than being silently dropped."""
357+
from google.cloud.bigtable.data.exceptions import _MutateRowsIncomplete
358+
359+
mutations = [self._make_mutation(), self._make_mutation()]
360+
mock_gapic_fn = self._make_mock_gapic(mutations, omit_indices={0})
361+
instance = self._make_one(mutation_entries=mutations)
362+
instance.is_retryable = lambda x: False
363+
with mock.patch.object(instance, "_gapic_fn", mock_gapic_fn):
364+
instance._run_attempt()
365+
assert instance.remaining_indices == []
366+
assert len(instance.errors[0]) == 1
367+
assert isinstance(instance.errors[0][0], _MutateRowsIncomplete)
368+
assert 1 not in instance.errors

packages/google-cloud-bigtable/tests/unit/data/_sync_autogen/test_client.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,21 @@ def test_call_metadata(self, include_app_profile, fn_name, fn_args, gapic_fn):
13071307
transport_mock = mock.MagicMock()
13081308
rpc_mock = CrossSync._Sync_Impl.Mock()
13091309
transport_mock._wrapped_methods.__getitem__.return_value = rpc_mock
1310+
if gapic_fn == "mutate_rows":
1311+
from google.rpc import status_pb2
1312+
1313+
from google.cloud.bigtable_v2.types import MutateRowsResponse
1314+
1315+
def mutate_rows_stream(*args, **kwargs):
1316+
yield MutateRowsResponse(
1317+
entries=[
1318+
MutateRowsResponse.Entry(
1319+
index=0, status=status_pb2.Status(code=0)
1320+
)
1321+
]
1322+
)
1323+
1324+
rpc_mock.side_effect = mutate_rows_stream
13101325
gapic_client = client._gapic_client
13111326
gapic_client._transport = transport_mock
13121327
gapic_client._is_universe_domain_valid = True
@@ -2959,7 +2974,7 @@ def test_bulk_mutate_error_recovery(self):
29592974
table = client.get_table("instance", "table")
29602975
with mock.patch.object(client._gapic_client, "mutate_rows") as mock_gapic:
29612976
mock_gapic.side_effect = [
2962-
self._mock_response([DeadlineExceeded("mock")]),
2977+
self._mock_response([DeadlineExceeded("mock"), None, None]),
29632978
self._mock_response([None]),
29642979
]
29652980
mutation = mutations.SetCell(

0 commit comments

Comments
 (0)