Skip to content

Commit 5f513fb

Browse files
committed
feat: Wire BatchWriteRecord and ListRecords into ingest_dataframe
- Add use_batch_write_record=False flag to ingest_dataframe() (Proposal C) - Implement _ingest_batch_write() with 25 records per API call - Map partial failures (response.errors) back to specific row indices - Add list_records() utility function with pagination support - Export list_records from feature_store __init__.py Tested: 50 integration tests + 17 unit tests
1 parent 45985d4 commit 5f513fb

7 files changed

Lines changed: 1464 additions & 18 deletions

File tree

‎sagemaker-mlops/src/sagemaker/mlops/feature_store/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
create_athena_query,
5555
get_session_from_role,
5656
ingest_dataframe,
57+
list_records,
5758
load_feature_definitions_from_dataframe,
5859
)
5960

@@ -116,6 +117,7 @@
116117
"create_athena_query",
117118
"get_session_from_role",
118119
"ingest_dataframe",
120+
"list_records",
119121
"load_feature_definitions_from_dataframe",
120122
# Classes
121123
"AthenaQuery",

‎sagemaker-mlops/src/sagemaker/mlops/feature_store/feature_utils.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ def ingest_dataframe(
478478
max_processes: int = 1,
479479
wait: bool = True,
480480
timeout: Union[int, float] = None,
481+
use_batch_write_record: bool = False,
481482
):
482483
"""Ingest a pandas DataFrame to a FeatureGroup.
483484
@@ -488,6 +489,10 @@ def ingest_dataframe(
488489
max_processes: Number of processes (default: 1).
489490
wait: Wait for ingestion to complete (default: True).
490491
timeout: Timeout in seconds (default: None).
492+
use_batch_write_record: If True, use BatchWriteRecord API (25 records per
493+
call) instead of PutRecord (1 record per call) for significantly better
494+
throughput. Requires both ``sagemaker:BatchWriteRecord`` AND
495+
``sagemaker:PutRecord`` IAM permissions. Default: False.
491496
492497
Returns:
493498
IngestionManagerPandas instance.
@@ -518,10 +523,50 @@ def ingest_dataframe(
518523
feature_definitions=feature_definitions,
519524
max_workers=max_workers,
520525
max_processes=max_processes,
526+
use_batch_write_record=use_batch_write_record,
521527
)
522528
manager.run(data_frame=data_frame, wait=wait, timeout=timeout)
523529
return manager
524530

531+
532+
def list_records(
533+
feature_group_name: str,
534+
max_results: int = None,
535+
next_token: str = None,
536+
include_soft_deleted_records: bool = False,
537+
region: str = None,
538+
):
539+
"""List record identifiers from a FeatureGroup's OnlineStore.
540+
541+
Returns a single page of results. Use ``next_token`` from the response
542+
to fetch subsequent pages.
543+
544+
Args:
545+
feature_group_name: Name of the FeatureGroup.
546+
max_results: Maximum number of record identifiers per page (1-100).
547+
next_token: Pagination token from a previous response.
548+
include_soft_deleted_records: If True, include soft-deleted records.
549+
region: AWS region name.
550+
551+
Returns:
552+
ListRecordsResponse with ``record_identifiers`` (List[str]) and
553+
``next_token`` (str or None).
554+
"""
555+
fg = CoreFeatureGroup.get(feature_group_name=feature_group_name, region=region)
556+
557+
kwargs = {}
558+
if max_results is not None:
559+
kwargs["max_results"] = max_results
560+
if next_token is not None:
561+
kwargs["next_token"] = next_token
562+
if include_soft_deleted_records:
563+
kwargs["include_soft_deleted_records"] = include_soft_deleted_records
564+
if region is not None:
565+
kwargs["region"] = region
566+
567+
return fg.list_records(**kwargs)
568+
569+
525570
@_telemetry_emitter(Feature.FEATURE_STORE, "get_feature_group_as_dataframe")
526571
def get_feature_group_as_dataframe(
527572
feature_group_name: str,

‎sagemaker-mlops/src/sagemaker/mlops/feature_store/ingestion_manager_pandas.py‎

Lines changed: 196 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414
from pandas.api.types import is_list_like
1515

1616
from sagemaker.core.resources import FeatureGroup as CoreFeatureGroup
17-
from sagemaker.core.shapes import FeatureValue
17+
from sagemaker.core.shapes import BatchWriteRecordEntry, FeatureValue
1818
from sagemaker.core.utils.utils import Unassigned
1919
from sagemaker.core.telemetry import Feature, _telemetry_emitter
2020

2121
logger = logging.getLogger(__name__)
2222

23+
# Maximum number of records per BatchWriteRecord API call
24+
BATCH_WRITE_MAX_ENTRIES = 25
25+
2326

2427
class 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

Comments
 (0)