| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 593a8b0 commit 2fe5bdc
2 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -350,6 +350,26 @@ def _batch_completed_callback(self, future): | |||
| 350 | 350 | processed_rows = self.futures_mapping[future] | |
| 351 | 351 | self.flow_control.release(processed_rows) | |
| 352 | 352 | del self.futures_mapping[future] | |
| 353 | + # Surface any exception raised inside the async flush. Without this, an | ||
| 354 | + # exception raised by ``_flush_rows`` (e.g. a non-retryable RPC error, a | ||
| 355 | + # retry deadline, or a response-count mismatch) would be stored on the | ||
| 356 | + # future and silently discarded, so the failed mutations would never be | ||
| 357 | + # reported to the user -- effectively silent data loss. Per-row errors | ||
| 358 | + # from a successful RPC are already recorded in ``self.exceptions`` by | ||
| 359 | + # ``_flush_rows``; here the whole batch failed with a single exception, | ||
| 360 | + # so record it once per row in the batch to keep the reported error | ||
| 361 | + # count aligned with the number of affected mutations. | ||
| 362 | + # | ||
| 363 | + # A cancelled future is "done", so this callback still runs for it, but | ||
| 364 | + # ``future.exception()`` would raise ``CancelledError``. Nothing here | ||
| 365 | + # cancels futures today, but guard against it so the callback stays | ||
| 366 | + # correct if cancellation is ever introduced. | ||
| 367 | + if future.cancelled(): | ||
| 368 | + return | ||
| 369 | + exc = future.exception() | ||
| 370 | + if exc is not None: | ||
| 371 | + for _ in range(processed_rows.rows_count): | ||
| 372 | + self.exceptions.put(exc) | ||
| 353 | 373 | ||
| 354 | 374 | def _row_fits_in_batch(self, row, batch_info): | |
| 355 | 375 | """Checks if a row can fit in the current batch. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -213,6 +213,60 @@ def test_mutations_batcher_response_with_error_codes(): | |||
| 213 | 213 | assert exc.value.exc[1].message == mocked_response[1].message | |
| 214 | 214 | ||
| 215 | 215 | ||
| 216 | + def test_mutations_batcher_asynchronous_flush_exception_is_surfaced(): | ||
| 217 | + """An exception raised by the underlying ``mutate_rows`` call (e.g. a | ||
| 218 | + non-retryable RPC error or a response-count mismatch) is raised inside the | ||
| 219 | + async flush task. It must be captured and re-raised at ``close()`` rather | ||
| 220 | + than being silently swallowed by the executor -- otherwise the failed | ||
| 221 | + mutations are never reported to the user (silent data loss).""" | ||
| 222 | + from google.api_core.exceptions import PermissionDenied | ||
| 223 | + | ||
| 224 | + with mock.patch("tests.unit.v2_client.test_batcher._Table") as mocked_table: | ||
| 225 | + table = mocked_table.return_value | ||
| 226 | + # flush_count=2 forces the batch to flush asynchronously (through the | ||
| 227 | + # executor) as soon as the second row is added | ||
| 228 | + mutation_batcher = MutationsBatcher(table=table, flush_count=2) | ||
| 229 | + | ||
| 230 | + row1 = DirectRow(row_key=b"row_key") | ||
| 231 | + row1.set_cell("cf1", b"c1", b"1") | ||
| 232 | + row2 = DirectRow(row_key=b"row_key") | ||
| 233 | + row2.set_cell("cf1", b"c1", b"2") | ||
| 234 | + table.mutate_rows.side_effect = PermissionDenied("denied") | ||
| 235 | + | ||
| 236 | + mutation_batcher.mutate_rows([row1, row2]) | ||
| 237 | + with pytest.raises(MutationsBatchError) as exc: | ||
| 238 | + mutation_batcher.close() | ||
| 239 | + assert exc.value.message == "Errors in batch mutations." | ||
| 240 | + # the whole batch (both rows) failed, so both are reported -- the error | ||
| 241 | + # count stays aligned with the number of affected mutations | ||
| 242 | + assert len(exc.value.exc) == 2 | ||
| 243 | + assert all(isinstance(e, PermissionDenied) for e in exc.value.exc) | ||
| 244 | + | ||
| 245 | + | ||
| 246 | + def test_batch_completed_callback_ignores_cancelled_future(): | ||
| 247 | + """A cancelled future is still "done", so the completion callback runs for | ||
| 248 | + it, but ``future.exception()`` would raise ``CancelledError``. The callback | ||
| 249 | + must short-circuit on a cancelled future instead of letting that propagate.""" | ||
| 250 | + from google.cloud.bigtable.batcher import _BatchInfo | ||
| 251 | + | ||
| 252 | + table = _Table(TABLE_NAME) | ||
| 253 | + with MutationsBatcher(table=table) as mutation_batcher: | ||
| 254 | + batch_info = _BatchInfo(rows_count=2, mutations_count=2, mutations_size=0) | ||
| 255 | + | ||
| 256 | + cancelled_future = mock.Mock() | ||
| 257 | + cancelled_future.cancelled.return_value = True | ||
| 258 | + cancelled_future.exception.side_effect = AssertionError( | ||
| 259 | + "exception() must not be called on a cancelled future" | ||
| 260 | + ) | ||
| 261 | + mutation_batcher.futures_mapping[cancelled_future] = batch_info | ||
| 262 | + | ||
| 263 | + # Should not raise, should not record any exceptions | ||
| 264 | + mutation_batcher._batch_completed_callback(cancelled_future) | ||
| 265 | + | ||
| 266 | + assert cancelled_future not in mutation_batcher.futures_mapping | ||
| 267 | + assert mutation_batcher.exceptions.qsize() == 0 | ||
| 268 | + | ||
| 269 | + | ||
| 216 | 270 | def test_flow_control_event_is_set_when_not_blocked(): | |
| 217 | 271 | flow_control = _FlowControl() | |
| 218 | 272 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments