3 files changed
@@ -32,9 +32,13 @@ offline_store: | |||
| 32 | 32 | spark.sql.session.timeZone: "UTC" | |
| 33 | 33 | spark.sql.execution.arrow.fallback.enabled: "true" | |
| 34 | 34 | spark.sql.execution.arrow.pyspark.enabled: "true" | |
| 35 | + # Optional: spill large materializations to the staging location instead of collecting in the driver | ||
| 36 | + staging_location: "s3://my-bucket/tmp/feast" | ||
| 35 | 37 | online_store: | |
| 36 | 38 | path: data/online_store.db | |
| 37 | 39 | ``` | |
| 40 | + | ||
| 41 | + > The `staging_location` can point to object storage (like S3, GCS, or Azure blobs) or a local filesystem directory (e.g., `/tmp/feast/staging`) to spill large materialization outputs before reading them back into Feast. | ||
| 38 | 42 | {% endcode %} | |
| 39 | 43 | ||
| 40 | 44 | The full set of configuration options is available in [SparkOfflineStoreConfig](https://rtd.feast.dev/en/master/#feast.infra.offline_stores.contrib.spark_offline_store.spark.SparkOfflineStoreConfig). | |
@@ -60,7 +64,7 @@ Below is a matrix indicating which functionality is supported by `SparkRetrieval | |||
| 60 | 64 | | export to arrow table | yes | | |
| 61 | 65 | | export to arrow batches | no | | |
| 62 | 66 | | export to SQL | no | | |
| 63 | - | export to data lake (S3, GCS, etc.) | no | | ||
| 67 | + | export to data lake (S3, GCS, etc.) | yes | | ||
| 64 | 68 | | export to data warehouse | no | | |
| 65 | 69 | | export as Spark dataframe | yes | | |
| 66 | 70 | | local execution of Python-based on-demand transforms | no | | |
@@ -16,6 +16,7 @@ | |||
| 16 | 16 | Union, | |
| 17 | 17 | cast, | |
| 18 | 18 | ) | |
| 19 | + from urllib.parse import urlparse | ||
| 19 | 20 | ||
| 20 | 21 | if TYPE_CHECKING: | |
| 21 | 22 | from feast.saved_dataset import ValidationReference | |
@@ -24,6 +25,7 @@ | |||
| 24 | 25 | import pandas | |
| 25 | 26 | import pandas as pd | |
| 26 | 27 | import pyarrow | |
| 28 | + import pyarrow.dataset as ds | ||
| 27 | 29 | import pyarrow.parquet as pq | |
| 28 | 30 | import pyspark | |
| 29 | 31 | from pydantic import StrictStr | |
@@ -445,8 +447,43 @@ def _to_df_internal(self, timeout: Optional[int] = None) -> pd.DataFrame: | |||
| 445 | 447 | ||
| 446 | 448 | def _to_arrow_internal(self, timeout: Optional[int] = None) -> pyarrow.Table: | |
| 447 | 449 | """Return dataset as pyarrow Table synchronously""" | |
| 450 | + if self._should_use_staging_for_arrow(): | ||
| 451 | + return self._to_arrow_via_staging() | ||
| 452 | + | ||
| 448 | 453 | return pyarrow.Table.from_pandas(self._to_df_internal(timeout=timeout)) | |
| 449 | 454 | ||
| 455 | + def _should_use_staging_for_arrow(self) -> bool: | ||
| 456 | + offline_store = getattr(self._config, "offline_store", None) | ||
| 457 | + return bool( | ||
| 458 | + isinstance(offline_store, SparkOfflineStoreConfig) | ||
| 459 | + and getattr(offline_store, "staging_location", None) | ||
| 460 | + ) | ||
| 461 | + | ||
| 462 | + def _to_arrow_via_staging(self) -> pyarrow.Table: | ||
| 463 | + paths = self.to_remote_storage() | ||
| 464 | + if not paths: | ||
| 465 | + return pyarrow.table({}) | ||
| 466 | + | ||
| 467 | + parquet_paths = _filter_parquet_files(paths) | ||
| 468 | + if not parquet_paths: | ||
| 469 | + return pyarrow.table({}) | ||
| 470 | + | ||
| 471 | + normalized_paths = self._normalize_staging_paths(parquet_paths) | ||
| 472 | + dataset = ds.dataset(normalized_paths, format="parquet") | ||
| 473 | + return dataset.to_table() | ||
| 474 | + | ||
| 475 | + def _normalize_staging_paths(self, paths: List[str]) -> List[str]: | ||
| 476 | + """Normalize staging paths for PyArrow datasets.""" | ||
| 477 | + normalized = [] | ||
| 478 | + for path in paths: | ||
| 479 | + if path.startswith("file://"): | ||
| 480 | + normalized.append(path[len("file://") :]) | ||
| 481 | + elif "://" in path: | ||
| 482 | + normalized.append(path) | ||
| 483 | + else: | ||
| 484 | + normalized.append(path) | ||
| 485 | + return normalized | ||
| 486 | + | ||
| 450 | 487 | def to_feast_df( | |
| 451 | 488 | self, | |
| 452 | 489 | validation_reference: Optional["ValidationReference"] = None, | |
@@ -508,55 +545,53 @@ def supports_remote_storage_export(self) -> bool: | |||
| 508 | 545 | ||
| 509 | 546 | def to_remote_storage(self) -> List[str]: | |
| 510 | 547 | """Currently only works for local and s3-based staging locations""" | |
| 511 | - if self.supports_remote_storage_export(): | ||
| 512 | - sdf: pyspark.sql.DataFrame = self.to_spark_df() | ||
| 513 | - | ||
| 514 | - if self._config.offline_store.staging_location.startswith("/"): | ||
| 515 | - local_file_staging_location = os.path.abspath( | ||
| 516 | - self._config.offline_store.staging_location | ||
| 517 | - ) | ||
| 518 | - | ||
| 519 | - # write to staging location | ||
| 520 | - output_uri = os.path.join( | ||
| 521 | - str(local_file_staging_location), str(uuid.uuid4()) | ||
| 522 | - ) | ||
| 523 | - sdf.write.parquet(output_uri) | ||
| 524 | - | ||
| 525 | - return _list_files_in_folder(output_uri) | ||
| 526 | - elif self._config.offline_store.staging_location.startswith("s3://"): | ||
| 527 | - from feast.infra.utils import aws_utils | ||
| 528 | - | ||
| 529 | - spark_compatible_s3_staging_location = ( | ||
| 530 | - self._config.offline_store.staging_location.replace( | ||
| 531 | - "s3://", "s3a://" | ||
| 532 | - ) | ||
| 533 | - ) | ||
| 534 | - | ||
| 535 | - # write to staging location | ||
| 536 | - output_uri = os.path.join( | ||
| 537 | - str(spark_compatible_s3_staging_location), str(uuid.uuid4()) | ||
| 538 | - ) | ||
| 539 | - sdf.write.parquet(output_uri) | ||
| 540 | - | ||
| 541 | - return aws_utils.list_s3_files( | ||
| 542 | - self._config.offline_store.region, output_uri | ||
| 543 | - ) | ||
| 544 | - elif self._config.offline_store.staging_location.startswith("hdfs://"): | ||
| 545 | - output_uri = os.path.join( | ||
| 546 | - self._config.offline_store.staging_location, str(uuid.uuid4()) | ||
| 547 | - ) | ||
| 548 | - sdf.write.parquet(output_uri) | ||
| 549 | - spark_session = get_spark_session_or_start_new_with_repoconfig( | ||
| 550 | - store_config=self._config.offline_store | ||
| 551 | - ) | ||
| 552 | - return _list_hdfs_files(spark_session, output_uri) | ||
| 553 | - else: | ||
| 554 | - raise NotImplementedError( | ||
| 555 | - "to_remote_storage is only implemented for file://, s3:// and hdfs:// uri schemes" | ||
| 556 | - ) | ||
| 548 | + if not self.supports_remote_storage_export(): | ||
| 549 | + raise NotImplementedError() | ||
| 550 | + | ||
| 551 | + sdf: pyspark.sql.DataFrame = self.to_spark_df() | ||
| 552 | + staging_location = self._config.offline_store.staging_location | ||
| 553 | + | ||
| 554 | + if staging_location.startswith("/"): | ||
| 555 | + local_file_staging_location = os.path.abspath(staging_location) | ||
| 556 | + output_uri = os.path.join(local_file_staging_location, str(uuid.uuid4())) | ||
| 557 | + sdf.write.parquet(output_uri) | ||
| 558 | + return _list_files_in_folder(output_uri) | ||
| 559 | + elif staging_location.startswith("s3://"): | ||
| 560 | + from feast.infra.utils import aws_utils | ||
| 557 | 561 | ||
| 562 | + spark_compatible_s3_staging_location = staging_location.replace( | ||
| 563 | + "s3://", "s3a://" | ||
| 564 | + ) | ||
| 565 | + output_uri = os.path.join( | ||
| 566 | + spark_compatible_s3_staging_location, str(uuid.uuid4()) | ||
| 567 | + ) | ||
| 568 | + sdf.write.parquet(output_uri) | ||
| 569 | + s3_uri_for_listing = output_uri.replace("s3a://", "s3://", 1) | ||
| 570 | + return aws_utils.list_s3_files( | ||
| 571 | + self._config.offline_store.region, s3_uri_for_listing | ||
| 572 | + ) | ||
| 573 | + elif staging_location.startswith("gs://"): | ||
| 574 | + output_uri = os.path.join(staging_location, str(uuid.uuid4())) | ||
| 575 | + sdf.write.parquet(output_uri) | ||
| 576 | + return _list_gcs_files(output_uri) | ||
| 577 | + elif staging_location.startswith(("wasbs://", "abfs://", "abfss://")) or ( | ||
| 578 | + staging_location.startswith("https://") | ||
| 579 | + and ".blob.core.windows.net" in staging_location | ||
| 580 | + ): | ||
| 581 | + output_uri = os.path.join(staging_location, str(uuid.uuid4())) | ||
| 582 | + sdf.write.parquet(output_uri) | ||
| 583 | + return _list_azure_files(output_uri) | ||
| 584 | + elif staging_location.startswith("hdfs://"): | ||
| 585 | + output_uri = os.path.join(staging_location, str(uuid.uuid4())) | ||
| 586 | + sdf.write.parquet(output_uri) | ||
| 587 | + spark_session = get_spark_session_or_start_new_with_repoconfig( | ||
| 588 | + store_config=self._config.offline_store | ||
| 589 | + ) | ||
| 590 | + return _list_hdfs_files(spark_session, output_uri) | ||
| 558 | 591 | else: | |
| 559 | - raise NotImplementedError() | ||
| 592 | + raise NotImplementedError( | ||
| 593 | + "to_remote_storage is only implemented for file://, s3://, gs://, azure, and hdfs uri schemes" | ||
| 594 | + ) | ||
| 560 | 595 | ||
| 561 | 596 | @property | |
| 562 | 597 | def metadata(self) -> Optional[RetrievalMetadata]: | |
@@ -789,6 +824,10 @@ def _list_files_in_folder(folder): | |||
| 789 | 824 | return files | |
| 790 | 825 | ||
| 791 | 826 | ||
| 827 | + def _filter_parquet_files(paths: List[str]) -> List[str]: | ||
| 828 | + return [path for path in paths if path.endswith(".parquet")] | ||
| 829 | + | ||
| 830 | + | ||
| 792 | 831 | def _list_hdfs_files(spark_session: SparkSession, uri: str) -> List[str]: | |
| 793 | 832 | jvm = spark_session._jvm | |
| 794 | 833 | jsc = spark_session._jsc | |
@@ -805,6 +844,81 @@ def _list_hdfs_files(spark_session: SparkSession, uri: str) -> List[str]: | |||
| 805 | 844 | return files | |
| 806 | 845 | ||
| 807 | 846 | ||
| 847 | + def _list_gcs_files(path: str) -> List[str]: | ||
| 848 | + try: | ||
| 849 | + from google.cloud import storage | ||
| 850 | + except ImportError as e: | ||
| 851 | + from feast.errors import FeastExtrasDependencyImportError | ||
| 852 | + | ||
| 853 | + raise FeastExtrasDependencyImportError("gcp", str(e)) | ||
| 854 | + | ||
| 855 | + assert path.startswith("gs://"), "GCS path must start with gs://" | ||
| 856 | + bucket_path = path[len("gs://") :] | ||
| 857 | + if "/" in bucket_path: | ||
| 858 | + bucket, prefix = bucket_path.split("/", 1) | ||
| 859 | + else: | ||
| 860 | + bucket, prefix = bucket_path, "" | ||
| 861 | + | ||
| 862 | + client = storage.Client() | ||
| 863 | + bucket_obj = client.bucket(bucket) | ||
| 864 | + blobs = bucket_obj.list_blobs(prefix=prefix) | ||
| 865 | + | ||
| 866 | + files = [] | ||
| 867 | + for blob in blobs: | ||
| 868 | + if not blob.name.endswith("/"): | ||
| 869 | + files.append(f"gs://{bucket}/{blob.name}") | ||
| 870 | + return files | ||
| 871 | + | ||
| 872 | + | ||
| 873 | + def _list_azure_files(path: str) -> List[str]: | ||
| 874 | + try: | ||
| 875 | + from azure.identity import DefaultAzureCredential | ||
| 876 | + from azure.storage.blob import BlobServiceClient | ||
| 877 | + except ImportError as e: | ||
| 878 | + from feast.errors import FeastExtrasDependencyImportError | ||
| 879 | + | ||
| 880 | + raise FeastExtrasDependencyImportError("azure", str(e)) | ||
| 881 | + | ||
| 882 | + parsed = urlparse(path) | ||
| 883 | + scheme = parsed.scheme | ||
| 884 | + | ||
| 885 | + if scheme in ("wasbs", "abfs", "abfss"): | ||
| 886 | + if "@" not in parsed.netloc: | ||
| 887 | + raise ValueError("Azure staging URI must include container@account host") | ||
| 888 | + container, account_host = parsed.netloc.split("@", 1) | ||
| 889 | + account_url = f"https://{account_host}" | ||
| 890 | + base = f"{scheme}://{container}@{account_host}" | ||
| 891 | + prefix = parsed.path.lstrip("/") | ||
| 892 | + else: | ||
| 893 | + account_url = f"{parsed.scheme}://{parsed.netloc}" | ||
| 894 | + container_and_prefix = parsed.path.lstrip("/").split("/", 1) | ||
| 895 | + container = container_and_prefix[0] | ||
| 896 | + base = f"{account_url}/{container}" | ||
| 897 | + prefix = container_and_prefix[1] if len(container_and_prefix) > 1 else "" | ||
| 898 | + | ||
| 899 | + credential = os.environ.get("AZURE_STORAGE_KEY") or os.environ.get( | ||
| 900 | + "AZURE_STORAGE_ACCOUNT_KEY" | ||
| 901 | + ) | ||
| 902 | + if credential: | ||
| 903 | + client = BlobServiceClient(account_url=account_url, credential=credential) | ||
| 904 | + else: | ||
| 905 | + default_credential = DefaultAzureCredential( | ||
| 906 | + exclude_shared_token_cache_credential=True | ||
| 907 | + ) | ||
| 908 | + client = BlobServiceClient( | ||
| 909 | + account_url=account_url, credential=default_credential | ||
| 910 | + ) | ||
| 911 | + | ||
| 912 | + container_client = client.get_container_client(container) | ||
| 913 | + blobs = container_client.list_blobs(name_starts_with=prefix if prefix else None) | ||
| 914 | + | ||
| 915 | + files = [] | ||
| 916 | + for blob in blobs: | ||
| 917 | + if not blob.name.endswith("/"): | ||
| 918 | + files.append(f"{base}/{blob.name}") | ||
| 919 | + return files | ||
| 920 | + | ||
| 921 | + | ||
| 808 | 922 | def _cast_data_frame( | |
| 809 | 923 | df_new: pyspark.sql.DataFrame, df_existing: pyspark.sql.DataFrame | |
| 810 | 924 | ) -> pyspark.sql.DataFrame: | |
0 commit comments