Skip to content

Commit b68d882

Browse files
authored
Merge branch 'master' into remove-ic-sdkt-hack
2 parents e161c32 + 368a1a9 commit b68d882

7 files changed

Lines changed: 438 additions & 9 deletions

File tree

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

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
"""Dataset Builder for FeatureStore."""
44
from dataclasses import dataclass, field
55
from enum import Enum
6-
from typing import Any, Dict, List, Union
6+
from typing import Any, Dict, List, Optional, Union
77
import datetime
8+
import logging
89

910
import pandas as pd
1011

@@ -18,6 +19,8 @@
1819
run_athena_query,
1920
)
2021

22+
logger = logging.getLogger(__name__)
23+
2124
_DEFAULT_CATALOG = "AwsDataCatalog"
2225
_DEFAULT_DATABASE = "sagemaker_featurestore"
2326

@@ -258,6 +261,8 @@ class DatasetBuilder:
258261
_event_time_starting_timestamp: datetime.datetime = field(default=None, init=False)
259262
_event_time_ending_timestamp: datetime.datetime = field(default=None, init=False)
260263
_feature_groups_to_be_merged: List[FeatureGroupToBeMerged] = field(default_factory=list, init=False)
264+
_register_as_dataset: bool = False
265+
_source_feature_groups: List = field(default_factory=list)
261266

262267
@classmethod
263268
def create(
@@ -269,6 +274,7 @@ def create(
269274
event_time_identifier_feature_name: str = None,
270275
included_feature_names: List[str] = None,
271276
kms_key_id: str = None,
277+
register_as_dataset: bool = False,
272278
) -> "DatasetBuilder":
273279
"""Create a DatasetBuilder for generating a Dataset.
274280
@@ -298,6 +304,7 @@ def create(
298304
_event_time_identifier_feature_name=event_time_identifier_feature_name,
299305
_included_feature_names=included_feature_names,
300306
_kms_key_id=kms_key_id,
307+
_register_as_dataset=register_as_dataset,
301308
)
302309

303310
def with_feature_group(
@@ -336,6 +343,7 @@ def with_feature_group(
336343
feature_name_in_target, join_comparator, join_type,
337344
)
338345
)
346+
self._source_feature_groups.append(feature_group)
339347
return self
340348

341349
def point_in_time_accurate_join(self) -> "DatasetBuilder":
@@ -779,3 +787,86 @@ def _construct_join_condition(self, fg: FeatureGroupToBeMerged, suffix: str) ->
779787
)
780788

781789
return join
790+
791+
def _collect_source_feature_group_arns(self) -> List[str]:
792+
"""Collect and deduplicate Feature Group ARNs from base and merged FGs."""
793+
arns = []
794+
# Base FG
795+
if isinstance(self._base, FeatureGroup):
796+
base_arn = getattr(self._base, "feature_group_arn", None)
797+
if base_arn:
798+
arns.append(base_arn)
799+
# Merged FGs
800+
for fg in self._source_feature_groups:
801+
fg_arn = getattr(fg, "feature_group_arn", None)
802+
if fg_arn and fg_arn not in arns:
803+
arns.append(fg_arn)
804+
return arns
805+
806+
def _register_as_hub_content_dataset(
807+
self, csv_path: str, query_execution_id: Optional[str] = None
808+
) -> None:
809+
"""Register the output CSV as a SM Dataset (HubContent) for lineage tracking.
810+
811+
This is a best-effort operation: if it fails due to missing permissions
812+
(AccessDeniedException), a warning is logged and the method returns without
813+
raising — the primary workflow (returning the CSV) is not affected.
814+
815+
Args:
816+
csv_path: S3 path of the generated CSV file.
817+
query_execution_id: Athena query execution ID (for provenance tracking).
818+
"""
819+
source_fg_arns = self._collect_source_feature_group_arns()
820+
if not source_fg_arns:
821+
logger.warning(
822+
"register_as_dataset=True but no Feature Group ARNs found. "
823+
"Skipping dataset registration."
824+
)
825+
return
826+
827+
# Generate a dataset name from the base FG name + timestamp
828+
base_name = ""
829+
if isinstance(self._base, FeatureGroup):
830+
base_name = getattr(self._base, "feature_group_name", "dataset")
831+
else:
832+
base_name = "dataframe-dataset"
833+
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
834+
dataset_name = f"fs-{base_name}-{timestamp}"
835+
836+
try:
837+
from sagemaker.ai_registry.dataset import DataSet
838+
839+
DataSet.create(
840+
name=dataset_name,
841+
source=csv_path,
842+
content_metadata={
843+
"SourceFeatureGroups": source_fg_arns,
844+
"ExtractionMethod": "FeatureStoreDatasetBuilder",
845+
"AthenaQueryExecutionId": query_execution_id or "",
846+
},
847+
description=f"Dataset extracted from Feature Groups: {', '.join(source_fg_arns)}",
848+
sagemaker_session=self._sagemaker_session,
849+
wait=False,
850+
)
851+
logger.info(
852+
"Registered dataset '%s' as SM Dataset (HubContent) with source FGs: %s",
853+
dataset_name,
854+
source_fg_arns,
855+
)
856+
except Exception as e:
857+
# Graceful fallback: log warning, don't block the primary workflow
858+
error_msg = str(e)
859+
if "AccessDenied" in error_msg or "not authorized" in error_msg.lower():
860+
logger.warning(
861+
"Unable to register dataset as HubContent due to missing permissions "
862+
"(sagemaker:ImportHubContent). Lineage will not be created for this "
863+
"dataset extraction. To enable lineage, add sagemaker:ImportHubContent "
864+
"permission to your execution role. Error: %s",
865+
error_msg,
866+
)
867+
else:
868+
logger.warning(
869+
"Failed to register dataset as HubContent. Lineage will not be created. "
870+
"Error: %s",
871+
error_msg,
872+
)

‎sagemaker-mlops/tests/integ/feature_store/feature_processor/test_feature_processor_integ.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -799,11 +799,12 @@ def transform(raw_s3_data_as_df):
799799
# sys.version_info[:2] not in [(3, 9), (3, 12)],
800800
# reason=f"SageMaker Spark image only supports Python 3.9 and 3.12, got {sys.version_info[:2]}",
801801
# )
802-
# @pytest.mark.skip(
803-
# reason="Lake Formation credential vending (GetTemporaryGlueTableCredentials) requires "
804-
# "full LF environment setup (resource registration, trust policy, data location grants) "
805-
# "that is not configured in CI. See quip-amazon.com/S3FEAMMMuKm0 for details."
806-
# )
802+
@pytest.mark.skip(
803+
reason="Lake Formation credential vending (GetTemporaryGlueTableCredentials) requires "
804+
"full LF environment setup (resource registration, trust policy, data location grants) "
805+
"that is not configured in CI. Test role permissions were modified from console. "
806+
"See quip-amazon.com/S3FEAMMMuKm0 for details."
807+
)
807808
@pytest.mark.spark_py312
808809
@pytest.mark.slow_test
809810
def test_to_pipeline_and_execute_with_lake_formation(

‎sagemaker-mlops/tests/unit/sagemaker/mlops/feature_store/test_dataset_builder.py‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,178 @@ def test_to_csv_raises_for_invalid_base(self, mock_session):
345345

346346
with pytest.raises(ValueError, match="must be either"):
347347
builder.to_csv_file()
348+
349+
350+
class TestDatasetBuilderRegisterAsDataset:
351+
"""Tests for the register_as_dataset lineage feature."""
352+
353+
@pytest.fixture
354+
def mock_session(self):
355+
return Mock()
356+
357+
@pytest.fixture
358+
def mock_feature_group(self):
359+
fg = MagicMock(spec=FeatureGroup)
360+
fg.feature_group_name = "customers-fg"
361+
fg.feature_group_arn = "arn:aws:sagemaker:us-west-2:123456789012:feature-group/customers-fg"
362+
return fg
363+
364+
def test_register_as_dataset_default_false(self, mock_session):
365+
"""Default register_as_dataset is False."""
366+
builder = DatasetBuilder.create(
367+
base=MagicMock(spec=FeatureGroup),
368+
output_path="s3://bucket/output",
369+
session=mock_session,
370+
)
371+
assert builder._register_as_dataset is False
372+
373+
def test_register_as_dataset_true_sets_flag(self, mock_session):
374+
"""register_as_dataset=True is stored correctly."""
375+
builder = DatasetBuilder.create(
376+
base=MagicMock(spec=FeatureGroup),
377+
output_path="s3://bucket/output",
378+
session=mock_session,
379+
register_as_dataset=True,
380+
)
381+
assert builder._register_as_dataset is True
382+
383+
def test_collect_source_fg_arns_from_base(self, mock_session, mock_feature_group):
384+
"""Collects base FG ARN."""
385+
builder = DatasetBuilder(
386+
_sagemaker_session=mock_session,
387+
_base=mock_feature_group,
388+
_output_path="s3://bucket/output",
389+
_register_as_dataset=True,
390+
)
391+
arns = builder._collect_source_feature_group_arns()
392+
assert arns == ["arn:aws:sagemaker:us-west-2:123456789012:feature-group/customers-fg"]
393+
394+
def test_collect_source_fg_arns_with_merged_fg(self, mock_session, mock_feature_group):
395+
"""Collects base + merged FG ARNs."""
396+
merged_fg = MagicMock(spec=FeatureGroup)
397+
merged_fg.feature_group_arn = "arn:aws:sagemaker:us-west-2:123456789012:feature-group/orders-fg"
398+
399+
builder = DatasetBuilder(
400+
_sagemaker_session=mock_session,
401+
_base=mock_feature_group,
402+
_output_path="s3://bucket/output",
403+
_register_as_dataset=True,
404+
)
405+
builder._source_feature_groups.append(merged_fg)
406+
407+
arns = builder._collect_source_feature_group_arns()
408+
assert len(arns) == 2
409+
assert "arn:aws:sagemaker:us-west-2:123456789012:feature-group/customers-fg" in arns
410+
assert "arn:aws:sagemaker:us-west-2:123456789012:feature-group/orders-fg" in arns
411+
412+
def test_collect_source_fg_arns_deduplicates(self, mock_session, mock_feature_group):
413+
"""Doesn't duplicate ARNs if same FG used twice."""
414+
builder = DatasetBuilder(
415+
_sagemaker_session=mock_session,
416+
_base=mock_feature_group,
417+
_output_path="s3://bucket/output",
418+
_register_as_dataset=True,
419+
)
420+
builder._source_feature_groups.append(mock_feature_group)
421+
422+
arns = builder._collect_source_feature_group_arns()
423+
assert len(arns) == 1
424+
425+
def test_collect_source_fg_arns_dataframe_base_empty(self, mock_session):
426+
"""DataFrame base has no FG ARN."""
427+
df = pd.DataFrame({"id": [1], "event_time": ["2024-01-01"]})
428+
builder = DatasetBuilder(
429+
_sagemaker_session=mock_session,
430+
_base=df,
431+
_output_path="s3://bucket/output",
432+
_record_identifier_feature_name="id",
433+
_event_time_identifier_feature_name="event_time",
434+
_register_as_dataset=True,
435+
)
436+
arns = builder._collect_source_feature_group_arns()
437+
assert arns == []
438+
439+
def test_register_hub_content_called_on_success(self, mock_session, mock_feature_group):
440+
"""DataSet.create is called with correct params."""
441+
builder = DatasetBuilder(
442+
_sagemaker_session=mock_session,
443+
_base=mock_feature_group,
444+
_output_path="s3://bucket/output",
445+
_register_as_dataset=True,
446+
)
447+
448+
with patch(
449+
"sagemaker.ai_registry.dataset.DataSet.create",
450+
) as mock_create:
451+
builder._register_as_hub_content_dataset(
452+
csv_path="s3://bucket/output/result.csv",
453+
query_execution_id="abc-123",
454+
)
455+
mock_create.assert_called_once()
456+
call_kwargs = mock_create.call_args[1]
457+
assert "customers-fg" in call_kwargs["name"]
458+
assert call_kwargs["source"] == "s3://bucket/output/result.csv"
459+
assert call_kwargs["content_metadata"]["SourceFeatureGroups"] == [
460+
"arn:aws:sagemaker:us-west-2:123456789012:feature-group/customers-fg"
461+
]
462+
assert call_kwargs["content_metadata"]["ExtractionMethod"] == "FeatureStoreDatasetBuilder"
463+
assert call_kwargs["content_metadata"]["AthenaQueryExecutionId"] == "abc-123"
464+
assert call_kwargs["sagemaker_session"] == mock_session
465+
assert call_kwargs["wait"] is False
466+
467+
def test_register_graceful_on_access_denied(self, mock_session, mock_feature_group, caplog):
468+
"""AccessDeniedException logs warning, doesn't raise."""
469+
builder = DatasetBuilder(
470+
_sagemaker_session=mock_session,
471+
_base=mock_feature_group,
472+
_output_path="s3://bucket/output",
473+
_register_as_dataset=True,
474+
)
475+
476+
with patch(
477+
"sagemaker.ai_registry.dataset.DataSet.create",
478+
) as mock_create:
479+
mock_create.side_effect = Exception("AccessDenied: not authorized")
480+
# Should NOT raise
481+
builder._register_as_hub_content_dataset(
482+
csv_path="s3://bucket/output/result.csv",
483+
)
484+
485+
def test_register_graceful_on_generic_error(self, mock_session, mock_feature_group):
486+
"""Generic errors log warning, don't raise."""
487+
builder = DatasetBuilder(
488+
_sagemaker_session=mock_session,
489+
_base=mock_feature_group,
490+
_output_path="s3://bucket/output",
491+
_register_as_dataset=True,
492+
)
493+
494+
with patch(
495+
"sagemaker.ai_registry.dataset.DataSet.create",
496+
) as mock_create:
497+
mock_create.side_effect = Exception("Some service error")
498+
# Should NOT raise
499+
builder._register_as_hub_content_dataset(
500+
csv_path="s3://bucket/output/result.csv",
501+
)
502+
503+
def test_register_skipped_when_no_fg_arns(self, mock_session):
504+
"""Skips registration when no FG ARNs available (DataFrame base, no merged FGs)."""
505+
df = pd.DataFrame({"id": [1], "event_time": ["2024-01-01"]})
506+
builder = DatasetBuilder(
507+
_sagemaker_session=mock_session,
508+
_base=df,
509+
_output_path="s3://bucket/output",
510+
_record_identifier_feature_name="id",
511+
_event_time_identifier_feature_name="event_time",
512+
_register_as_dataset=True,
513+
)
514+
515+
with patch(
516+
"sagemaker.ai_registry.dataset.DataSet.create",
517+
) as mock_create:
518+
builder._register_as_hub_content_dataset(
519+
csv_path="s3://bucket/output/result.csv",
520+
)
521+
# Should NOT call DataSet.create since no FG ARNs
522+
mock_create.assert_not_called()

‎sagemaker-train/src/sagemaker/ai_registry/dataset.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def create(
232232
role: Optional[str] = None,
233233
domain_id: Optional[str] = None,
234234
sagemaker_session: Optional[Session] = None,
235+
content_metadata: Optional[dict] = None,
235236
) -> "DataSet":
236237
"""Create a new DataSet Hub AIR entity.
237238
@@ -251,6 +252,9 @@ def create(
251252
environment; supply it explicitly when creating datasets outside Studio
252253
(e.g. from a laptop or CI) so they still appear in the target domain.
253254
sagemaker_session: Optional SageMaker session. If not provided, uses default session.
255+
content_metadata: Optional metadata dict (PascalCase keys) to include in the
256+
HubContent document. Used by Feature Store lineage to pass source FG ARNs.
257+
When provided, file format validation is skipped.
254258
255259
Returns:
256260
DataSet: The created dataset instance
@@ -268,13 +272,27 @@ def create(
268272
if domain_id is None:
269273
domain_id = _get_current_domain_id(sagemaker_session)
270274

271-
# Validate dataset file
272-
cls._validate_dataset_file(source)
275+
# Validate dataset file (skip for Feature Store metadata-only datasets)
276+
if content_metadata is None:
277+
cls._validate_dataset_file(source)
273278
sagemaker_session = TrainDefaults.get_sagemaker_session(sagemaker_session=sagemaker_session)
274279
role = TrainDefaults.get_role(role=role, sagemaker_session=sagemaker_session)
275280

276281
# Parse S3 URL to extract bucket and prefix
277-
if source.startswith("s3://"):
282+
if content_metadata is not None:
283+
# Feature Store datasets are CSVs, not LLM training formats — skip format validation
284+
if source.startswith("s3://"):
285+
parsed = urlparse(source)
286+
bucket_name = parsed.netloc
287+
s3_key = parsed.path.lstrip("/")
288+
s3_prefix = s3_key
289+
method = DataSetMethod.GENERATED
290+
else:
291+
bucket_name = _get_default_bucket()
292+
s3_prefix = _get_default_s3_prefix(name)
293+
method = DataSetMethod.UPLOADED
294+
AIRHub.upload_to_s3(bucket_name, s3_prefix, source)
295+
elif source.startswith("s3://"):
278296
parsed = urlparse(source)
279297
bucket_name = parsed.netloc
280298
s3_key = parsed.path.lstrip("/")
@@ -313,6 +331,7 @@ def create(
313331
conversation_id=DATASET_DEFAULT_CONVERSATION_ID, # Required for now, needs cleanup
314332
conversation_checkpoint_id=DATASET_DEFAULT_CHECKPOINT_ID,
315333
dependencies=[],
334+
content_metadata=content_metadata,
316335
)
317336

318337
document_str = hub_content_document.to_json()

0 commit comments

Comments
 (0)