1313# limitations under the License.
1414
1515"""User friendly container for Google Cloud Bigtable MutationBatcher."""
16- import threading
1716import queue
18- import concurrent .futures
1917import 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
2624FLUSH_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-
16942class 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 ._batcher_kwargs = {
92+ "flush_interval" : flush_interval ,
93+ "flush_limit_mutation_count" : flush_count ,
94+ "flush_limit_bytes" : max_row_bytes ,
95+ "flow_control_max_mutation_count" : MAX_OUTSTANDING_ELEMENTS ,
96+ "flow_control_max_bytes" : MAX_OUTSTANDING_BYTES ,
97+ }
23198 self ._user_batch_completed_callback = batch_completed_callback
99+ self ._init_batcher ()
100+ atexit .register (self .close )
101+ self ._exceptions = queue .Queue ()
232102
233103 @property
234104 def flush_count (self ):
235- return self ._rows . flush_count
105+ return self ._flush_count
236106
237107 @property
238108 def max_row_bytes (self ):
239- return self ._rows .max_mutation_bytes
109+ return self ._max_row_bytes
110+
111+ def _init_batcher (self ):
112+ self ._batcher = self .table ._table_impl .mutations_batcher (** self ._batcher_kwargs )
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+ # Unpack the root cause of the FailedMutationEntryError
123+ # and return that error to the user.
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
@@ -296,104 +178,10 @@ def flush(self):
296178 :dedent: 4
297179
298180 :raises:
299- * :exc:`.batcherMutationsBatchError` if there's any error in the mutations.
300- """
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.
181+ * :exc:`~batcher.MutationsBatchError` if there's any error in the mutations.
366182 """
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."""
@@ -404,11 +192,10 @@ def close(self):
404192 Any errors will be raised.
405193
406194 :raises:
407- * :exc:`.batcherMutationsBatchError ` if there's any error in the mutations.
195+ * :exc:`~batcher.MutationsBatchError ` 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 )
0 commit comments