1414from pandas .api .types import is_list_like
1515
1616from sagemaker .core .resources import FeatureGroup as CoreFeatureGroup
17- from sagemaker .core .shapes import FeatureValue
17+ from sagemaker .core .shapes import BatchWriteRecordEntry , FeatureValue
1818from sagemaker .core .utils .utils import Unassigned
1919from sagemaker .core .telemetry import Feature , _telemetry_emitter
2020
2121logger = logging .getLogger (__name__ )
2222
23+ # Maximum number of records per BatchWriteRecord API call
24+ BATCH_WRITE_MAX_ENTRIES = 25
25+
2326
2427class IngestionError (Exception ):
2528 """Exception raised for errors during ingestion.
@@ -55,6 +58,7 @@ class IngestionManagerPandas:
5558 feature_definitions : Dict [str , Dict [Any , Any ]]
5659 max_workers : int = 1
5760 max_processes : int = 1
61+ use_batch_write_record : bool = False
5862 _async_result : Any = field (default = None , init = False )
5963 _processing_pool : Pool = field (default = None , init = False )
6064 _failed_indices : List [int ] = field (default_factory = list , init = False )
@@ -133,19 +137,33 @@ def _run_single_process_single_thread(
133137 ):
134138 """Ingest utilizing a single process and a single thread."""
135139 logger .info ("Started single-threaded ingestion for %d rows" , len (data_frame ))
136- failed_rows = []
137-
138- fg = CoreFeatureGroup (feature_group_name = self .feature_group_name )
139140
140- for row in data_frame .itertuples ():
141- self ._ingest_row (
141+ if self .use_batch_write_record :
142+ logger .info (
143+ "Using BatchWriteRecord API (batch size=%d). "
144+ "Requires sagemaker:BatchWriteRecord AND sagemaker:PutRecord IAM permissions." ,
145+ BATCH_WRITE_MAX_ENTRIES ,
146+ )
147+ failed_rows = IngestionManagerPandas ._ingest_batch_write (
142148 data_frame = data_frame ,
143- row = row ,
144- feature_group = fg ,
149+ feature_group_name = self .feature_group_name ,
145150 feature_definitions = self .feature_definitions ,
146- failed_rows = failed_rows ,
151+ start_index = 0 ,
152+ end_index = len (data_frame ),
147153 target_stores = target_stores ,
148154 )
155+ else :
156+ failed_rows = []
157+ fg = CoreFeatureGroup (feature_group_name = self .feature_group_name )
158+ for row in data_frame .itertuples ():
159+ self ._ingest_row (
160+ data_frame = data_frame ,
161+ row = row ,
162+ feature_group = fg ,
163+ feature_definitions = self .feature_definitions ,
164+ failed_rows = failed_rows ,
165+ target_stores = target_stores ,
166+ )
149167
150168 self ._failed_indices = failed_rows
151169 if self ._failed_indices :
@@ -176,6 +194,7 @@ def _run_multi_process(
176194 target_stores ,
177195 start_index ,
178196 timeout ,
197+ self .use_batch_write_record ,
179198 ))
180199
181200 def init_worker ():
@@ -200,6 +219,7 @@ def _run_multi_threaded(
200219 target_stores : List [str ] = None ,
201220 row_offset : int = 0 ,
202221 timeout : Union [int , float ] = None ,
222+ use_batch_write_record : bool = False ,
203223 ) -> List [int ]:
204224 """Start multi-threaded ingestion within a single process."""
205225 executor = ThreadPoolExecutor (max_workers = max_workers )
@@ -209,15 +229,26 @@ def _run_multi_threaded(
209229 for i in range (max_workers ):
210230 start_index = min (i * batch_size , data_frame .shape [0 ])
211231 end_index = min (i * batch_size + batch_size , data_frame .shape [0 ])
212- future = executor .submit (
213- IngestionManagerPandas ._ingest_single_batch ,
214- data_frame = data_frame ,
215- feature_group_name = feature_group_name ,
216- feature_definitions = feature_definitions ,
217- start_index = start_index ,
218- end_index = end_index ,
219- target_stores = target_stores ,
220- )
232+ if use_batch_write_record :
233+ future = executor .submit (
234+ IngestionManagerPandas ._ingest_batch_write ,
235+ data_frame = data_frame ,
236+ feature_group_name = feature_group_name ,
237+ feature_definitions = feature_definitions ,
238+ start_index = start_index ,
239+ end_index = end_index ,
240+ target_stores = target_stores ,
241+ )
242+ else :
243+ future = executor .submit (
244+ IngestionManagerPandas ._ingest_single_batch ,
245+ data_frame = data_frame ,
246+ feature_group_name = feature_group_name ,
247+ feature_definitions = feature_definitions ,
248+ start_index = start_index ,
249+ end_index = end_index ,
250+ target_stores = target_stores ,
251+ )
221252 futures [future ] = (start_index + row_offset , end_index + row_offset )
222253
223254 failed_indices = []
@@ -332,3 +363,150 @@ def _convert_to_string_list(feature_value: List[Any]) -> List[str]:
332363 f"must be an Array, but was { type (feature_value )} "
333364 )
334365 return [str (v ) if v is not None else None for v in feature_value ]
366+
367+ @staticmethod
368+ def _build_record (
369+ data_frame : DataFrame ,
370+ row : Iterable ,
371+ feature_definitions : Dict [str , Dict [Any , Any ]],
372+ ) -> List [FeatureValue ]:
373+ """Build a list of FeatureValue from a DataFrame row.
374+
375+ Args:
376+ data_frame: Source DataFrame (for column names).
377+ row: A single row from itertuples().
378+ feature_definitions: Feature definition metadata.
379+
380+ Returns:
381+ List of FeatureValue objects for the row.
382+ """
383+ record = []
384+ for index in range (1 , len (row )):
385+ feature_name = data_frame .columns [index - 1 ]
386+ feature_value = row [index ]
387+
388+ if not IngestionManagerPandas ._feature_value_is_not_none (feature_value ):
389+ continue
390+
391+ if IngestionManagerPandas ._is_feature_collection_type (feature_name , feature_definitions ):
392+ record .append (FeatureValue (
393+ feature_name = feature_name ,
394+ value_as_string_list = IngestionManagerPandas ._convert_to_string_list (feature_value ),
395+ ))
396+ else :
397+ record .append (FeatureValue (
398+ feature_name = feature_name ,
399+ value_as_string = str (feature_value ),
400+ ))
401+ return record
402+
403+ @staticmethod
404+ def _ingest_batch_write (
405+ data_frame : DataFrame ,
406+ feature_group_name : str ,
407+ feature_definitions : Dict [str , Dict [Any , Any ]],
408+ start_index : int ,
409+ end_index : int ,
410+ target_stores : List [str ] = None ,
411+ ) -> List [int ]:
412+ """Ingest records using BatchWriteRecord API (up to 25 per call).
413+
414+ Args:
415+ data_frame: Source DataFrame.
416+ feature_group_name: Name of the FeatureGroup.
417+ feature_definitions: Feature definition metadata.
418+ start_index: Start index in the DataFrame slice.
419+ end_index: End index in the DataFrame slice.
420+ target_stores: Target stores for ingestion.
421+
422+ Returns:
423+ List of row indices that failed to ingest.
424+ """
425+ logger .info (
426+ "Started batch write ingestion index %d to %d (batch_size=%d)" ,
427+ start_index , end_index , BATCH_WRITE_MAX_ENTRIES ,
428+ )
429+ failed_rows = []
430+ rows = list (data_frame [start_index :end_index ].itertuples ())
431+
432+ for batch_start in range (0 , len (rows ), BATCH_WRITE_MAX_ENTRIES ):
433+ batch = rows [batch_start :batch_start + BATCH_WRITE_MAX_ENTRIES ]
434+ entries = []
435+ row_indices = []
436+
437+ for row in batch :
438+ try :
439+ record = IngestionManagerPandas ._build_record (
440+ data_frame = data_frame ,
441+ row = row ,
442+ feature_definitions = feature_definitions ,
443+ )
444+ entry_kwargs = {
445+ "feature_group_name" : feature_group_name ,
446+ "record" : record ,
447+ }
448+ if target_stores is not None :
449+ entry_kwargs ["target_stores" ] = target_stores
450+ entry = BatchWriteRecordEntry (** entry_kwargs )
451+ entries .append (entry )
452+ row_indices .append (row [0 ])
453+ except Exception as e :
454+ logger .error ("Failed to build record for row %d: %s" , row [0 ], e )
455+ failed_rows .append (row [0 ])
456+
457+ if not entries :
458+ continue
459+
460+ try :
461+ fg = CoreFeatureGroup (feature_group_name = feature_group_name )
462+ response = fg .batch_write_record (entries = entries )
463+
464+ # Handle partial failures from unprocessed entries
465+ if response .unprocessed_entries :
466+ for entry in response .unprocessed_entries :
467+ # unprocessed_entries are BatchWriteRecordEntry objects;
468+ # find their position in the original entries list
469+ try :
470+ idx = entries .index (entry )
471+ failed_rows .append (row_indices [idx ])
472+ except ValueError :
473+ # Can't match entry back — mark all rows in batch as failed
474+ logger .warning (
475+ "Unprocessed entry could not be mapped to a specific row. "
476+ "Marking all rows in this batch as failed."
477+ )
478+ failed_rows .extend (row_indices )
479+ break
480+
481+ # Handle errors (BatchWriteRecordError with entry object)
482+ if response .errors :
483+ for error in response .errors :
484+ # BatchWriteRecordError has .entry (the failed entry), .error_code, .error_message
485+ error_entry = getattr (error , "entry" , None )
486+ if error_entry is not None :
487+ try :
488+ idx = entries .index (error_entry )
489+ failed_rows .append (row_indices [idx ])
490+ except ValueError :
491+ # Can't match entry back — mark all rows in batch as failed
492+ logger .warning (
493+ "BatchWriteRecord error could not be mapped to row: %s - %s" ,
494+ getattr (error , "error_code" , "" ),
495+ getattr (error , "error_message" , "" ),
496+ )
497+ failed_rows .extend (row_indices )
498+ break
499+ else :
500+ # Fallback: no entry object, mark all rows as failed
501+ logger .warning ("BatchWriteRecord error without entry: %s" , error )
502+ failed_rows .extend (row_indices )
503+ break
504+
505+ except Exception as e :
506+ logger .error (
507+ "BatchWriteRecord call failed for batch starting at row %d: %s" ,
508+ row_indices [0 ] if row_indices else start_index , e ,
509+ )
510+ failed_rows .extend (row_indices )
511+
512+ return failed_rows
0 commit comments