Skip to content

Commit fac536e

Browse files
fix(bigtable): surface batcher flush errors and disable timer (#18145)
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 - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes #<issue_number_goes_here> 🦕 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 7f8a552 commit fac536e

2 files changed

Lines changed: 71 additions & 19 deletions

File tree

packages/google-cloud-bigtable/google/cloud/bigtable/batcher.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,15 @@ class MutationsBatcher(object):
174174
request.
175175
176176
This class is not suited for usage in systems where each mutation
177-
must be guaranteed to be sent, since calling mutate may only result in an
178-
in-memory change. In a case of a system crash, any :class:`DirectRow` remaining in
179-
memory will not necessarily be sent to the service, even after the
180-
completion of the :func:`mutate()` method.
177+
must be guaranteed to be sent, since calling :func:`mutate()` may only
178+
result in an in-memory change. Rows are only sent to the service when a size
179+
limit is reached, when :func:`flush()` is called explicitly, or when the
180+
batcher is closed (:func:`close()` is also registered to run at interpreter
181+
exit). There is no time-based background flush. As a result, if the process
182+
terminates abruptly -- e.g. a crash, ``SIGKILL``, or ``os._exit`` where the
183+
``atexit`` handler never runs -- any :class:`DirectRow` still buffered in
184+
memory is silently dropped and never sent, even after :func:`mutate()`
185+
returned.
181186
182187
Note on thread safety: The same :class:`MutationBatcher` cannot be shared by multiple end-user threads.
183188
@@ -196,8 +201,9 @@ class MutationsBatcher(object):
196201
(5 MB).
197202
198203
:type flush_interval: float
199-
:param flush_interval: (Optional) The interval (in seconds) between asynchronous flush.
200-
Default is 1 second.
204+
:param flush_interval: (Deprecated) No longer used. Retained only for
205+
backwards compatibility. There is no time-based background flush; see the
206+
class docstring for when rows are sent.
201207
202208
:type batch_completed_callback: Callable[list:[`~google.rpc.status_pb2.Status`]] = None
203209
:param batch_completed_callback: (Optional) A callable for handling responses
@@ -219,8 +225,12 @@ def __init__(
219225
self.table = table
220226
self._executor = concurrent.futures.ThreadPoolExecutor()
221227
atexit.register(self.close)
222-
self._timer = threading.Timer(flush_interval, self.flush)
223-
self._timer.start()
228+
# ``flush_interval`` is retained for backwards compatibility but is no
229+
# longer used: the previous background ``threading.Timer`` was one-shot
230+
# (never re-armed), so it fired at most once and could silently drop the
231+
# rows it dequeued if that single flush raised on the timer thread.
232+
# Flushing now happens only on size thresholds, explicit ``flush()``,
233+
# or ``close()`` (also registered via ``atexit``).
224234
self.flow_control = _FlowControl(
225235
max_mutations=MAX_OUTSTANDING_ELEMENTS,
226236
max_mutation_bytes=MAX_OUTSTANDING_BYTES,
@@ -425,7 +435,19 @@ def close(self):
425435
:raises:
426436
* :exc:`.batcherMutationsBatchError` if there's any error in the mutations.
427437
"""
428-
self.flush()
438+
try:
439+
self.flush()
440+
except MutationsBatchError as exc:
441+
for e in exc.exc:
442+
self.exceptions.put(e)
443+
except Exception as exc:
444+
# A failure in this final synchronous flush must not abort cleanup.
445+
# If it propagated here it would skip the executor shutdown (leaving
446+
# in-flight async flushes un-awaited) and skip draining
447+
# self.exceptions, masking every error already captured from
448+
# earlier async flushes -- silently discarding those failures.
449+
# Record it like any other batch failure and continue.
450+
self.exceptions.put(exc)
429451
self._executor.shutdown(wait=True)
430452
atexit.unregister(self.close)
431453
if self.exceptions.qsize() > 0:

packages/google-cloud-bigtable/tests/unit/v2_client/test_batcher.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,20 +175,18 @@ def test_mutations_batcher_context_manager_flushed_when_closed():
175175

176176
@mock.patch("google.cloud.bigtable.batcher.threading.Timer")
177177
@mock.patch("google.cloud.bigtable.batcher.MutationsBatcher.flush")
178-
def test_mutations_batcher_flush_interval(mocked_flush, mocked_timer):
178+
def test_mutations_batcher_flush_interval_does_not_start_timer(
179+
mocked_flush, mocked_timer
180+
):
181+
# ``flush_interval`` is accepted for backwards compatibility but no longer
182+
# starts a background timer. Constructing the batcher must not create a
183+
# timer or trigger a flush.
179184
table = _Table(TABLE_NAME)
180-
flush_interval = 0.5
181-
mutation_batcher = MutationsBatcher(table=table, flush_interval=flush_interval)
185+
MutationsBatcher(table=table, flush_interval=0.5)
182186

183-
mocked_timer.assert_called_once_with(flush_interval, mutation_batcher.flush)
184-
mocked_timer.return_value.start.assert_called_once_with()
187+
mocked_timer.assert_not_called()
185188
mocked_flush.assert_not_called()
186189

187-
# Manually invoke the timer callback to verify it calls flush
188-
timer_callback = mocked_timer.call_args[0][1]
189-
timer_callback()
190-
mocked_flush.assert_called_once_with()
191-
192190

193191
def test_mutations_batcher_response_with_error_codes():
194192
from google.rpc.status_pb2 import Status
@@ -267,6 +265,38 @@ def test_batch_completed_callback_ignores_cancelled_future():
267265
assert mutation_batcher.exceptions.qsize() == 0
268266

269267

268+
def test_mutations_batcher_close_surfaces_errors_when_final_flush_raises():
269+
"""If the final flush in ``close()`` raises, ``close()`` must still shut
270+
down the executor and surface every accumulated error -- including ones
271+
already captured from async flushes -- instead of letting the flush
272+
exception mask them and abort cleanup (silent data loss)."""
273+
from google.api_core.exceptions import PermissionDenied, ServiceUnavailable
274+
275+
with mock.patch("tests.unit.v2_client.test_batcher._Table") as mocked_table:
276+
table = mocked_table.return_value
277+
mutation_batcher = MutationsBatcher(table=table)
278+
279+
# Simulate an error already captured earlier (e.g. from an async flush).
280+
prior_error = ServiceUnavailable("earlier async failure")
281+
mutation_batcher.exceptions.put(prior_error)
282+
283+
# The row stays queued (below flush_count), so it is only flushed by
284+
# close(); make that final flush raise.
285+
row = DirectRow(row_key=b"row_key")
286+
row.set_cell("cf1", b"c1", b"1")
287+
mutation_batcher.mutate(row)
288+
table.mutate_rows.side_effect = PermissionDenied("denied")
289+
290+
with pytest.raises(MutationsBatchError) as exc:
291+
mutation_batcher.close()
292+
293+
# both the pre-existing and the flush-time errors are reported
294+
assert prior_error in exc.value.exc
295+
assert any(isinstance(e, PermissionDenied) for e in exc.value.exc)
296+
# cleanup still ran despite the flush raising
297+
assert mutation_batcher._executor._shutdown is True
298+
299+
270300
def test_flow_control_event_is_set_when_not_blocked():
271301
flow_control = _FlowControl()
272302

0 commit comments

Comments
 (0)