Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Commit cd2cc45

Browse files
committed
feat: Mutations Batcher shim
1 parent 4621e30 commit cd2cc45

5 files changed

Lines changed: 344 additions & 404 deletions

File tree

google/cloud/bigtable/batcher.py

Lines changed: 35 additions & 248 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,12 @@
1313
# limitations under the License.
1414

1515
"""User friendly container for Google Cloud Bigtable MutationBatcher."""
16-
import threading
1716
import queue
18-
import concurrent.futures
1917
import atexit
2018

2119

22-
from google.api_core.exceptions import from_grpc_status
23-
from dataclasses import dataclass
20+
from google.cloud.bigtable.data.exceptions import MutationsExceptionGroup
21+
from google.cloud.bigtable.data.mutations import RowMutationEntry
2422

2523

2624
FLUSH_COUNT = 100 # after this many elements, send out the batch
@@ -41,131 +39,6 @@ def __init__(self, message, exc):
4139
super().__init__(self.message)
4240

4341

44-
class _MutationsBatchQueue(object):
45-
"""Private Threadsafe Queue to hold rows for batching."""
46-
47-
def __init__(self, max_mutation_bytes=MAX_MUTATION_SIZE, flush_count=FLUSH_COUNT):
48-
"""Specify the queue constraints"""
49-
self._queue = queue.Queue()
50-
self.total_mutation_count = 0
51-
self.total_size = 0
52-
self.max_mutation_bytes = max_mutation_bytes
53-
self.flush_count = flush_count
54-
55-
def get(self):
56-
"""
57-
Retrieve an item from the queue. Recalculate queue size.
58-
59-
If the queue is empty, return None.
60-
"""
61-
try:
62-
row = self._queue.get_nowait()
63-
mutation_size = row.get_mutations_size()
64-
self.total_mutation_count -= len(row._get_mutations())
65-
self.total_size -= mutation_size
66-
return row
67-
except queue.Empty:
68-
return None
69-
70-
def put(self, item):
71-
"""Insert an item to the queue. Recalculate queue size."""
72-
73-
mutation_count = len(item._get_mutations())
74-
75-
self._queue.put(item)
76-
77-
self.total_size += item.get_mutations_size()
78-
self.total_mutation_count += mutation_count
79-
80-
def full(self):
81-
"""Check if the queue is full."""
82-
if (
83-
self.total_mutation_count >= self.flush_count
84-
or self.total_size >= self.max_mutation_bytes
85-
):
86-
return True
87-
return False
88-
89-
90-
@dataclass
91-
class _BatchInfo:
92-
"""Keeping track of size of a batch"""
93-
94-
mutations_count: int = 0
95-
rows_count: int = 0
96-
mutations_size: int = 0
97-
98-
99-
class _FlowControl(object):
100-
def __init__(
101-
self,
102-
max_mutations=MAX_OUTSTANDING_ELEMENTS,
103-
max_mutation_bytes=MAX_OUTSTANDING_BYTES,
104-
):
105-
"""Control the inflight requests. Keep track of the mutations, row bytes and row counts.
106-
As requests to backend are being made, adjust the number of mutations being processed.
107-
108-
If threshold is reached, block the flow.
109-
Reopen the flow as requests are finished.
110-
"""
111-
self.max_mutations = max_mutations
112-
self.max_mutation_bytes = max_mutation_bytes
113-
self.inflight_mutations = 0
114-
self.inflight_size = 0
115-
self.event = threading.Event()
116-
self.event.set()
117-
self._lock = threading.Lock()
118-
119-
def is_blocked(self):
120-
"""Returns True if:
121-
122-
- inflight mutations >= max_mutations, or
123-
- inflight bytes size >= max_mutation_bytes, or
124-
"""
125-
126-
return (
127-
self.inflight_mutations >= self.max_mutations
128-
or self.inflight_size >= self.max_mutation_bytes
129-
)
130-
131-
def control_flow(self, batch_info):
132-
"""
133-
Calculate the resources used by this batch
134-
"""
135-
136-
with self._lock:
137-
self.inflight_mutations += batch_info.mutations_count
138-
self.inflight_size += batch_info.mutations_size
139-
self.set_flow_control_status()
140-
141-
def wait(self):
142-
"""
143-
Wait until flow control pushback has been released.
144-
It awakens as soon as `event` is set.
145-
"""
146-
self.event.wait()
147-
148-
def set_flow_control_status(self):
149-
"""Check the inflight mutations and size.
150-
151-
If values exceed the allowed threshold, block the event.
152-
"""
153-
if self.is_blocked():
154-
self.event.clear() # sleep
155-
else:
156-
self.event.set() # awaken the threads
157-
158-
def release(self, batch_info):
159-
"""
160-
Release the resources.
161-
Decrement the row size to allow enqueued mutations to be run.
162-
"""
163-
with self._lock:
164-
self.inflight_mutations -= batch_info.mutations_count
165-
self.inflight_size -= batch_info.mutations_size
166-
self.set_flow_control_status()
167-
168-
16942
class MutationsBatcher(object):
17043
"""A MutationsBatcher is used in batch cases where the number of mutations
17144
is large or unknown. It will store :class:`DirectRow` in memory until one of the
@@ -214,29 +87,41 @@ def __init__(
21487
flush_interval=1,
21588
batch_completed_callback=None,
21689
):
217-
self._rows = _MutationsBatchQueue(
218-
max_mutation_bytes=max_row_bytes, flush_count=flush_count
219-
)
22090
self.table = table
221-
self._executor = concurrent.futures.ThreadPoolExecutor()
222-
atexit.register(self.close)
223-
self._timer = threading.Timer(flush_interval, self.flush)
224-
self._timer.start()
225-
self.flow_control = _FlowControl(
226-
max_mutations=MAX_OUTSTANDING_ELEMENTS,
227-
max_mutation_bytes=MAX_OUTSTANDING_BYTES,
228-
)
229-
self.futures_mapping = {}
230-
self.exceptions = queue.Queue()
91+
self._flush_count = flush_count
92+
self._max_row_bytes = max_row_bytes
93+
self._flush_interval = flush_interval
23194
self._user_batch_completed_callback = batch_completed_callback
95+
self._init_batcher()
96+
atexit.register(self.close)
97+
self._exceptions = queue.Queue()
23298

23399
@property
234100
def flush_count(self):
235-
return self._rows.flush_count
101+
return self._flush_count
236102

237103
@property
238104
def max_row_bytes(self):
239-
return self._rows.max_mutation_bytes
105+
return self._max_row_bytes
106+
107+
def _init_batcher(self):
108+
self._batcher = self.table._table_impl.mutations_batcher(
109+
flush_interval=self._flush_interval,
110+
flush_limit_mutation_count=self._flush_count,
111+
flush_limit_bytes=self._max_row_bytes,
112+
)
113+
self._batcher._user_batch_completed_callback = (
114+
self._user_batch_completed_callback
115+
)
116+
117+
def _close_batcher(self):
118+
try:
119+
self._batcher.close()
120+
except MutationsExceptionGroup as exc_group:
121+
for error in exc_group.exceptions:
122+
# Return the cause of the FailedMutationEntryError to the user,
123+
# as this might be more what they're expecting.
124+
self._exceptions.put(error.__cause__)
240125

241126
def __enter__(self):
242127
"""Starting the MutationsBatcher as a context manager"""
@@ -260,10 +145,7 @@ def mutate(self, row):
260145
* :exc:`~.table._BigtableRetryableError` if any row returned a transient error.
261146
* :exc:`RuntimeError` if the number of responses doesn't match the number of rows that were retried
262147
"""
263-
self._rows.put(row)
264-
265-
if self._rows.full():
266-
self._flush_async()
148+
self._batcher.append(RowMutationEntry(row.row_key, row._get_mutations()))
267149

268150
def mutate_rows(self, rows):
269151
"""Add multiple rows to the batch. If the current batch meets one of the size
@@ -298,102 +180,8 @@ def flush(self):
298180
:raises:
299181
* :exc:`.batcherMutationsBatchError` if there's any error in the mutations.
300182
"""
301-
rows_to_flush = []
302-
row = self._rows.get()
303-
while row is not None:
304-
rows_to_flush.append(row)
305-
row = self._rows.get()
306-
response = self._flush_rows(rows_to_flush)
307-
return response
308-
309-
def _flush_async(self):
310-
"""Sends the current batch to Cloud Bigtable asynchronously.
311-
312-
:raises:
313-
* :exc:`.batcherMutationsBatchError` if there's any error in the mutations.
314-
"""
315-
next_row = self._rows.get()
316-
while next_row is not None:
317-
# start a new batch
318-
rows_to_flush = [next_row]
319-
batch_info = _BatchInfo(
320-
mutations_count=len(next_row._get_mutations()),
321-
rows_count=1,
322-
mutations_size=next_row.get_mutations_size(),
323-
)
324-
# fill up batch with rows
325-
next_row = self._rows.get()
326-
while next_row is not None and self._row_fits_in_batch(
327-
next_row, batch_info
328-
):
329-
rows_to_flush.append(next_row)
330-
batch_info.mutations_count += len(next_row._get_mutations())
331-
batch_info.rows_count += 1
332-
batch_info.mutations_size += next_row.get_mutations_size()
333-
next_row = self._rows.get()
334-
# send batch over network
335-
# wait for resources to become available
336-
self.flow_control.wait()
337-
# once unblocked, submit the batch
338-
# event flag will be set by control_flow to block subsequent thread, but not blocking this one
339-
self.flow_control.control_flow(batch_info)
340-
future = self._executor.submit(self._flush_rows, rows_to_flush)
341-
# schedule release of resources from flow control
342-
self.futures_mapping[future] = batch_info
343-
future.add_done_callback(self._batch_completed_callback)
344-
345-
def _batch_completed_callback(self, future):
346-
"""Callback for when the mutation has finished to clean up the current batch
347-
and release items from the flow controller.
348-
Raise exceptions if there's any.
349-
Release the resources locked by the flow control and allow enqueued tasks to be run.
350-
"""
351-
processed_rows = self.futures_mapping[future]
352-
self.flow_control.release(processed_rows)
353-
del self.futures_mapping[future]
354-
355-
def _row_fits_in_batch(self, row, batch_info):
356-
"""Checks if a row can fit in the current batch.
357-
358-
:type row: class
359-
:param row: :class:`~google.cloud.bigtable.row.DirectRow`.
360-
361-
:type batch_info: :class:`_BatchInfo`
362-
:param batch_info: Information about the current batch.
363-
364-
:rtype: bool
365-
:returns: True if the row can fit in the current batch.
366-
"""
367-
new_rows_count = batch_info.rows_count + 1
368-
new_mutations_count = batch_info.mutations_count + len(row._get_mutations())
369-
new_mutations_size = batch_info.mutations_size + row.get_mutations_size()
370-
return (
371-
new_rows_count <= self.flush_count
372-
and new_mutations_size <= self.max_row_bytes
373-
and new_mutations_count <= self.flow_control.max_mutations
374-
and new_mutations_size <= self.flow_control.max_mutation_bytes
375-
)
376-
377-
def _flush_rows(self, rows_to_flush):
378-
"""Mutate the specified rows.
379-
380-
:raises:
381-
* :exc:`.batcherMutationsBatchError` if there's any error in the mutations.
382-
"""
383-
responses = []
384-
if len(rows_to_flush) > 0:
385-
response = self.table.mutate_rows(rows_to_flush)
386-
387-
if self._user_batch_completed_callback:
388-
self._user_batch_completed_callback(response)
389-
390-
for result in response:
391-
if result.code != 0:
392-
exc = from_grpc_status(result.code, result.message)
393-
self.exceptions.put(exc)
394-
responses.append(result)
395-
396-
return responses
183+
self._close_batcher()
184+
self._init_batcher()
397185

398186
def __exit__(self, exc_type, exc_value, exc_traceback):
399187
"""Clean up resources. Flush and shutdown the ThreadPoolExecutor."""
@@ -406,9 +194,8 @@ def close(self):
406194
:raises:
407195
* :exc:`.batcherMutationsBatchError` if there's any error in the mutations.
408196
"""
409-
self.flush()
410-
self._executor.shutdown(wait=True)
197+
self._close_batcher()
411198
atexit.unregister(self.close)
412-
if self.exceptions.qsize() > 0:
413-
exc = list(self.exceptions.queue)
199+
if self._exceptions.qsize() > 0:
200+
exc = list(self._exceptions.queue)
414201
raise MutationsBatchError("Errors in batch mutations.", exc=exc)

google/cloud/bigtable/data/_async/client.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from __future__ import annotations
1717

1818
from typing import (
19-
Callable,
2019
cast,
2120
Any,
2221
AsyncIterable,
@@ -116,7 +115,6 @@
116115
if TYPE_CHECKING:
117116
from google.cloud.bigtable.data._helpers import RowKeySamples
118117
from google.cloud.bigtable.data._helpers import ShardedQuery
119-
from google.rpc import status_pb2
120118

121119
if CrossSync.is_async:
122120
from google.cloud.bigtable.data._async.mutations_batcher import (

google/cloud/bigtable/data/_async/mutations_batcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
#
1515
from __future__ import annotations
1616

17-
from typing import Callable, Optional, Sequence, TYPE_CHECKING, cast
17+
from typing import Sequence, TYPE_CHECKING, cast
1818
import atexit
1919
import warnings
2020
from collections import deque

0 commit comments

Comments
 (0)