|
2 | 2 | Registry lineage generation for Feast objects. |
3 | 3 |
|
4 | 4 | This module provides functionality to generate relationship graphs between |
5 | | -Feast objects (entities, feature views, data sources, feature services) |
6 | | -for lineage visualization. |
| 5 | +Feast objects (entities, feature views, data sources, feature services, |
| 6 | +saved datasets) for lineage visualization. |
7 | 7 | """ |
8 | 8 |
|
9 | 9 | from dataclasses import dataclass |
10 | 10 | from enum import Enum |
11 | | -from typing import Dict, List, Tuple |
| 11 | +from typing import Dict, List, Set, Tuple |
12 | 12 |
|
13 | 13 | from feast.protos.feast.core.Registry_pb2 import Registry |
14 | 14 |
|
15 | 15 |
|
| 16 | +def _extract_storage_identifiers(storage) -> Set[str]: |
| 17 | + """Extract physical location identifiers from a SavedDatasetStorage proto. |
| 18 | +
|
| 19 | + Returns a set of non-empty strings (URIs, table names, paths) that can |
| 20 | + be matched against DataSource options. |
| 21 | + """ |
| 22 | + ids: Set[str] = set() |
| 23 | + if hasattr(storage, "file_storage") and storage.HasField("file_storage"): |
| 24 | + if storage.file_storage.uri: |
| 25 | + ids.add(storage.file_storage.uri) |
| 26 | + if hasattr(storage, "bigquery_storage") and storage.HasField("bigquery_storage"): |
| 27 | + if storage.bigquery_storage.table: |
| 28 | + ids.add(storage.bigquery_storage.table) |
| 29 | + if hasattr(storage, "redshift_storage") and storage.HasField("redshift_storage"): |
| 30 | + if storage.redshift_storage.table: |
| 31 | + ids.add(storage.redshift_storage.table) |
| 32 | + if hasattr(storage, "snowflake_storage") and storage.HasField("snowflake_storage"): |
| 33 | + if storage.snowflake_storage.table: |
| 34 | + ids.add(storage.snowflake_storage.table) |
| 35 | + if hasattr(storage, "spark_storage") and storage.HasField("spark_storage"): |
| 36 | + if storage.spark_storage.path: |
| 37 | + ids.add(storage.spark_storage.path) |
| 38 | + if storage.spark_storage.table: |
| 39 | + ids.add(storage.spark_storage.table) |
| 40 | + if hasattr(storage, "trino_storage") and storage.HasField("trino_storage"): |
| 41 | + if storage.trino_storage.table: |
| 42 | + ids.add(storage.trino_storage.table) |
| 43 | + if hasattr(storage, "athena_storage") and storage.HasField("athena_storage"): |
| 44 | + if storage.athena_storage.table: |
| 45 | + ids.add(storage.athena_storage.table) |
| 46 | + return ids |
| 47 | + |
| 48 | + |
| 49 | +def _extract_datasource_identifiers(data_source) -> Set[str]: |
| 50 | + """Extract physical location identifiers from a DataSource proto. |
| 51 | +
|
| 52 | + Returns a set of non-empty strings (URIs, table names, paths) that can |
| 53 | + be compared against SavedDatasetStorage identifiers. |
| 54 | + """ |
| 55 | + ids: Set[str] = set() |
| 56 | + opts = ( |
| 57 | + data_source.WhichOneof("options") |
| 58 | + if hasattr(data_source, "WhichOneof") |
| 59 | + else None |
| 60 | + ) |
| 61 | + if opts == "file_options" and data_source.file_options.uri: |
| 62 | + ids.add(data_source.file_options.uri) |
| 63 | + elif opts == "bigquery_options" and data_source.bigquery_options.table: |
| 64 | + ids.add(data_source.bigquery_options.table) |
| 65 | + elif opts == "redshift_options" and data_source.redshift_options.table: |
| 66 | + ids.add(data_source.redshift_options.table) |
| 67 | + elif opts == "snowflake_options" and data_source.snowflake_options.table: |
| 68 | + ids.add(data_source.snowflake_options.table) |
| 69 | + elif opts == "spark_options": |
| 70 | + if data_source.spark_options.path: |
| 71 | + ids.add(data_source.spark_options.path) |
| 72 | + if data_source.spark_options.table: |
| 73 | + ids.add(data_source.spark_options.table) |
| 74 | + elif opts == "trino_options" and data_source.trino_options.table: |
| 75 | + ids.add(data_source.trino_options.table) |
| 76 | + elif opts == "athena_options" and data_source.athena_options.table: |
| 77 | + ids.add(data_source.athena_options.table) |
| 78 | + |
| 79 | + # Also check batch_source if present (FeatureView's embedded source) |
| 80 | + if hasattr(data_source, "batch_source") and data_source.HasField("batch_source"): |
| 81 | + ids.update(_extract_datasource_identifiers(data_source.batch_source)) |
| 82 | + |
| 83 | + return ids |
| 84 | + |
| 85 | + |
| 86 | +def _build_datasource_location_index(registry: Registry) -> Dict[str, str]: |
| 87 | + """Build a reverse index: physical location → DataSource name. |
| 88 | +
|
| 89 | + Scans all DataSources in the registry and maps each physical identifier |
| 90 | + (URI, table, path) to the DataSource's name. |
| 91 | + """ |
| 92 | + location_to_name: Dict[str, str] = {} |
| 93 | + for ds in registry.data_sources: |
| 94 | + if not (hasattr(ds, "name") and ds.name): |
| 95 | + continue |
| 96 | + for loc_id in _extract_datasource_identifiers(ds): |
| 97 | + location_to_name[loc_id] = ds.name |
| 98 | + return location_to_name |
| 99 | + |
| 100 | + |
16 | 101 | class FeastObjectType(Enum): |
17 | 102 | DATA_SOURCE = "dataSource" |
18 | 103 | ENTITY = "entity" |
19 | 104 | FEATURE_VIEW = "featureView" |
20 | 105 | LABEL_VIEW = "labelView" |
21 | 106 | FEATURE_SERVICE = "featureService" |
22 | 107 | FEATURE = "feature" |
| 108 | + SAVED_DATASET = "savedDataset" |
23 | 109 |
|
24 | 110 |
|
25 | 111 | @dataclass |
@@ -390,6 +476,83 @@ def _parse_direct_relationships(self, registry: Registry) -> List[EntityRelation |
390 | 476 | ) |
391 | 477 | ) |
392 | 478 |
|
| 479 | + # SavedDataset relationships |
| 480 | + ds_location_index = _build_datasource_location_index(registry) |
| 481 | + |
| 482 | + for saved_dataset in registry.saved_datasets: |
| 483 | + if hasattr(saved_dataset, "spec") and saved_dataset.spec: |
| 484 | + # FeatureService -> SavedDataset (when created via a feature service) |
| 485 | + if ( |
| 486 | + hasattr(saved_dataset.spec, "feature_service_name") |
| 487 | + and saved_dataset.spec.feature_service_name |
| 488 | + ): |
| 489 | + relationships.append( |
| 490 | + EntityRelation( |
| 491 | + source=EntityReference( |
| 492 | + FeastObjectType.FEATURE_SERVICE, |
| 493 | + saved_dataset.spec.feature_service_name, |
| 494 | + ), |
| 495 | + target=EntityReference( |
| 496 | + FeastObjectType.SAVED_DATASET, |
| 497 | + saved_dataset.spec.name, |
| 498 | + ), |
| 499 | + ) |
| 500 | + ) |
| 501 | + |
| 502 | + # FeatureView -> SavedDataset (derived from feature refs "view:feat") |
| 503 | + if ( |
| 504 | + hasattr(saved_dataset.spec, "features") |
| 505 | + and saved_dataset.spec.features |
| 506 | + ): |
| 507 | + from feast.utils import _parse_feature_ref |
| 508 | + |
| 509 | + seen_views: set = set() |
| 510 | + for feat_ref in saved_dataset.spec.features: |
| 511 | + try: |
| 512 | + view_name, _, _ = _parse_feature_ref(feat_ref) |
| 513 | + except ValueError: |
| 514 | + continue |
| 515 | + if view_name and view_name not in seen_views: |
| 516 | + seen_views.add(view_name) |
| 517 | + relationships.append( |
| 518 | + EntityRelation( |
| 519 | + source=EntityReference( |
| 520 | + FeastObjectType.FEATURE_VIEW, |
| 521 | + view_name, |
| 522 | + ), |
| 523 | + target=EntityReference( |
| 524 | + FeastObjectType.SAVED_DATASET, |
| 525 | + saved_dataset.spec.name, |
| 526 | + ), |
| 527 | + ) |
| 528 | + ) |
| 529 | + |
| 530 | + # DataSource -> SavedDataset (matched via storage location) |
| 531 | + if ( |
| 532 | + hasattr(saved_dataset.spec, "storage") |
| 533 | + and saved_dataset.spec.storage |
| 534 | + ): |
| 535 | + storage_ids = _extract_storage_identifiers( |
| 536 | + saved_dataset.spec.storage |
| 537 | + ) |
| 538 | + matched_ds_names: set = set() |
| 539 | + for loc_id in storage_ids: |
| 540 | + ds_name = ds_location_index.get(loc_id) |
| 541 | + if ds_name and ds_name not in matched_ds_names: |
| 542 | + matched_ds_names.add(ds_name) |
| 543 | + relationships.append( |
| 544 | + EntityRelation( |
| 545 | + source=EntityReference( |
| 546 | + FeastObjectType.DATA_SOURCE, |
| 547 | + ds_name, |
| 548 | + ), |
| 549 | + target=EntityReference( |
| 550 | + FeastObjectType.SAVED_DATASET, |
| 551 | + saved_dataset.spec.name, |
| 552 | + ), |
| 553 | + ) |
| 554 | + ) |
| 555 | + |
393 | 556 | return relationships |
394 | 557 |
|
395 | 558 | def _parse_indirect_relationships( |
|
0 commit comments