From e1e210d275b4472d407cc69b95a4ce7ee689d1db Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Fri, 5 Aug 2022 13:01:14 -0700 Subject: [PATCH 01/51] Broken state Signed-off-by: Kevin Zhang --- sdk/python/feast/inference.py | 6 +- .../feast/infra/contrib/azure_provider.py | 252 +++++++ .../contrib/mssql_offline_store/mssql.py | 647 ++++++++++++++++++ .../mssql_offline_store/mssqlserver_source.py | 220 ++++++ .../mssql_offline_store/tests/data_source.py | 68 ++ sdk/python/feast/infra/provider.py | 3 +- .../contrib/azure/registry_store.py | 97 +++ sdk/python/feast/repo_config.py | 5 + setup.py | 14 + 9 files changed, 1310 insertions(+), 2 deletions(-) create mode 100644 sdk/python/feast/infra/contrib/azure_provider.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py create mode 100644 sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py diff --git a/sdk/python/feast/inference.py b/sdk/python/feast/inference.py index 84e7a1373f..446978a174 100644 --- a/sdk/python/feast/inference.py +++ b/sdk/python/feast/inference.py @@ -7,6 +7,7 @@ from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_NAME, FeatureView from feast.field import Field, from_value_type from feast.infra.offline_stores.bigquery_source import BigQuerySource +from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import MsSqlServerSource from feast.infra.offline_stores.file_source import FileSource from feast.infra.offline_stores.redshift_source import RedshiftSource from feast.infra.offline_stores.snowflake_source import SnowflakeSource @@ -40,12 +41,14 @@ def update_data_sources_with_inferred_event_timestamp_col( ts_column_type_regex_pattern = "TIMESTAMP[A-Z]*" elif isinstance(data_source, SnowflakeSource): ts_column_type_regex_pattern = "TIMESTAMP_[A-Z]*" + elif isinstance(data_source, MsSqlServerSource): + ts_column_type_regex_pattern = "TIMESTAMP|DATETIME" else: raise RegistryInferenceFailure( "DataSource", f""" DataSource inferencing of timestamp_field is currently only supported - for FileSource, SparkSource, BigQuerySource, RedshiftSource, and SnowflakeSource. + for FileSource, SparkSource, BigQuerySource, RedshiftSource, SnowflakeSource, MsSqlSource. Attempting to infer from {data_source}. """, ) @@ -55,6 +58,7 @@ def update_data_sources_with_inferred_event_timestamp_col( or isinstance(data_source, BigQuerySource) or isinstance(data_source, RedshiftSource) or isinstance(data_source, SnowflakeSource) + or isinstance(data_source, MsSqlServerSource) or "SparkSource" == data_source.__class__.__name__ ) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py new file mode 100644 index 0000000000..6106200a2e --- /dev/null +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -0,0 +1,252 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from datetime import datetime, timedelta +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union + +import pandas +import pyarrow as pa +from tqdm import tqdm + +from feast import FeatureService +from feast.entity import Entity +from feast.feature_logging import FeatureServiceLoggingSource +from feast.feature_view import FeatureView +from feast.infra.offline_stores.offline_store import RetrievalJob +from feast.infra.offline_stores.offline_utils import get_offline_store_from_config +from feast.infra.online_stores.helpers import get_online_store_from_config +from feast.infra.provider import ( + Provider, +) +from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto +from feast.protos.feast.types.Value_pb2 import Value as ValueProto +from feast.registry import Registry +from feast.repo_config import RepoConfig +from feast.saved_dataset import SavedDataset +from feast.usage import RatioSampler, log_exceptions_and_usage, set_usage_attribute +from feast.utils import make_tzaware, _convert_arrow_to_proto, _get_column_names, _run_pyarrow_field_mapping + +DEFAULT_BATCH_SIZE = 10_000 + +class AzureProvider(Provider): + def __init__(self, config: RepoConfig): + self.repo_config = config + self.offline_store = get_offline_store_from_config(config.offline_store) + self.online_store = ( + get_online_store_from_config(config.online_store) + if config.online_store + else None + ) + + @log_exceptions_and_usage(registry="az") + def update_infra( + self, + project: str, + tables_to_delete: Sequence[FeatureView], + tables_to_keep: Sequence[FeatureView], + entities_to_delete: Sequence[Entity], + entities_to_keep: Sequence[Entity], + partial: bool, + ): + # Call update only if there is an online store + if self.online_store: + self.online_store.update( + config=self.repo_config, + tables_to_delete=tables_to_delete, + tables_to_keep=tables_to_keep, + entities_to_keep=entities_to_keep, + entities_to_delete=entities_to_delete, + partial=partial, + ) + + @log_exceptions_and_usage(registry="az") + def teardown_infra( + self, + project: str, + tables: Sequence[FeatureView], + entities: Sequence[Entity], + ) -> None: + if self.online_store: + self.online_store.teardown(self.repo_config, tables, entities) + + @log_exceptions_and_usage(registry="az") + def online_write_batch( + self, + config: RepoConfig, + table: FeatureView, + data: List[ + Tuple[EntityKeyProto, Dict[str, ValueProto], datetime, Optional[datetime]] + ], + progress: Optional[Callable[[int], Any]], + ) -> None: + if self.online_store: + self.online_store.online_write_batch(config, table, data, progress) + + @log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001), registry="az") + def online_read( + self, + config: RepoConfig, + table: FeatureView, + entity_keys: List[EntityKeyProto], + requested_features: List[str] = None, + ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: + result = [] + if self.online_store: + result = self.online_store.online_read(config, table, entity_keys, requested_features) + return result + + def ingest_df( + self, feature_view: FeatureView, entities: List[Entity], df: pandas.DataFrame, + ): + table = pa.Table.from_pandas(df) + + if feature_view.batch_source.field_mapping is not None: + table = _run_pyarrow_field_mapping(table, feature_view.batch_source.field_mapping) + + join_keys = {entity.join_key: entity.value_type for entity in entities} + rows_to_write = _convert_arrow_to_proto(table, feature_view, join_keys) + + self.online_write_batch( + self.repo_config, feature_view, rows_to_write, progress=None + ) + + def materialize_single_feature_view( + self, + config: RepoConfig, + feature_view: FeatureView, + start_date: datetime, + end_date: datetime, + registry: Registry, + project: str, + tqdm_builder: Callable[[int], tqdm], + ) -> None: + entities = [] + for entity_name in feature_view.entities: + entities.append(registry.get_entity(entity_name, project)) + + ( + join_key_columns, + feature_name_columns, + event_timestamp_column, + created_timestamp_column, + ) = _get_column_names(feature_view, entities) + + offline_job = self.offline_store.pull_latest_from_table_or_query( + config=config, + data_source=feature_view.batch_source, + join_key_columns=join_key_columns, + feature_name_columns=feature_name_columns, + event_timestamp_column=event_timestamp_column, + created_timestamp_column=created_timestamp_column, + start_date=start_date, + end_date=end_date, + ) + + table = offline_job.to_arrow() + + if feature_view.batch_source.field_mapping is not None: + table = _run_pyarrow_field_mapping(table, feature_view.batch_source.field_mapping) + + join_keys = {entity.join_key: entity.value_type for entity in entities} + + with tqdm_builder(table.num_rows) as pbar: + for batch in table.to_batches(DEFAULT_BATCH_SIZE): + rows_to_write = _convert_arrow_to_proto(batch, feature_view, join_keys) + self.online_write_batch( + self.repo_config, + feature_view, + rows_to_write, + lambda x: pbar.update(x), + ) + + @log_exceptions_and_usage(registry="az") + def get_historical_features( + self, + config: RepoConfig, + feature_views: List[FeatureView], + feature_refs: List[str], + entity_df: Union[pandas.DataFrame, str], + registry: Registry, + project: str, + full_feature_names: bool, + ) -> RetrievalJob: + job = self.offline_store.get_historical_features( + config=config, + feature_views=feature_views, + feature_refs=feature_refs, + entity_df=entity_df, + registry=registry, + project=project, + full_feature_names=full_feature_names, + ) + return job + + def retrieve_saved_dataset( + self, + config: RepoConfig, + dataset: SavedDataset + ) -> RetrievalJob: + feature_name_columns = [ + ref.replace(":", "__") if dataset.full_feature_names else ref.split(":")[1] + for ref in dataset.features + ] + + # ToDo: replace hardcoded value + event_ts_column = "event_timestamp" + + return self.offline_store.pull_all_from_table_or_query( + config=config, + data_source=dataset.storage.to_data_source(), + join_key_columns=dataset.join_keys, + feature_name_columns=feature_name_columns, + event_timestamp_column=event_ts_column, + start_date=make_tzaware(dataset.min_event_timestamp), # type: ignore + end_date=make_tzaware(dataset.max_event_timestamp + timedelta(seconds=1)), # type: ignore + ) + + def write_feature_service_logs( + self, + feature_service: FeatureService, + logs: Union[pa.Table, str], + config: RepoConfig, + registry: Registry, + ): + assert ( + feature_service.logging_config is not None + ), "Logging should be configured for the feature service before calling this function" + + self.offline_store.write_logged_features( + config=config, + data=logs, + source=FeatureServiceLoggingSource(feature_service, config.project), + logging_config=feature_service.logging_config, + registry=registry, + ) + + def retrieve_feature_service_logs( + self, + feature_service: FeatureService, + start_date: datetime, + end_date: datetime, + config: RepoConfig, + registry: Registry, + ) -> RetrievalJob: + assert ( + feature_service.logging_config is not None + ), "Logging should be configured for the feature service before calling this function" + + logging_source = FeatureServiceLoggingSource(feature_service, config.project) + schema = logging_source.get_schema(registry) + logging_config = feature_service.logging_config + ts_column = logging_source.get_log_timestamp_column() + columns = list(set(schema.names) - {ts_column}) + + return self.offline_store.pull_all_from_table_or_query( + config=config, + data_source=logging_config.destination.to_data_source(), + join_key_columns=[], + feature_name_columns=columns, + timestamp_field=ts_column, + start_date=make_tzaware(start_date), + end_date=make_tzaware(end_date), + ) \ No newline at end of file diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py new file mode 100644 index 0000000000..ebef9fc635 --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -0,0 +1,647 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from typing import ( + Dict, + List, + Optional, + Set, + Union, +) +from feast.infra.offline_stores import offline_utils +from feast.on_demand_feature_view import OnDemandFeatureView + +import numpy as np +import pandas +import pyarrow +from jinja2 import BaseLoader, Environment +from pydantic.types import StrictStr +from pydantic.typing import Literal +from sqlalchemy import create_engine +import sqlalchemy +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from feast import errors +from feast.data_source import DataSource + +from .mssqlserver_source import MsSqlServerSource +from feast.feature_view import FeatureView +from feast.infra.offline_stores.file_source import SavedDatasetFileStorage +from feast.infra.offline_stores.offline_store import ( + OfflineStore, + RetrievalJob, + RetrievalMetadata, +) +from feast.infra.offline_stores.offline_utils import ( + DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL, +) +from feast.infra.provider import ( + RetrievalJob, +) +from feast.registry import Registry +from feast.repo_config import FeastBaseModel, RepoConfig +from feast.saved_dataset import SavedDatasetStorage +from feast import FileSource +from feast.usage import log_exceptions_and_usage +from feast.utils import _get_requested_feature_views_to_features_dict + +EntitySchema = Dict[str, np.dtype] + + +class MsSqlServerOfflineStoreConfig(FeastBaseModel): + """Offline store config for SQL Server""" + + type: Literal[ + "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" + ] = "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" + """ Offline store type selector""" + + connection_string: StrictStr = "mssql+pyodbc://sa:yourStrong(!)Password@localhost:1433/feast_test?driver=ODBC+Driver+17+for+SQL+Server" + """Connection string containing the host, port, and configuration parameters for SQL Server + format: SQLAlchemy connection string, e.g. mssql+pyodbc://sa:yourStrong(!)Password@localhost:1433/feast_test?driver=ODBC+Driver+17+for+SQL+Server""" + + +class MsSqlServerOfflineStore(OfflineStore): + def __init__(self): + self._engine = None + + def _make_engine(self, config: RepoConfig = None) -> Session: + if self._engine is None: + self._engine = create_engine(config.connection_string) + return self._engine + + @staticmethod + @log_exceptions_and_usage(offline_store="mssql") + def pull_latest_from_table_or_query( + self, + config: RepoConfig, + data_source: DataSource, + join_key_columns: List[str], + feature_name_columns: List[str], + event_timestamp_column: str, + created_timestamp_column: Optional[str], + start_date: datetime, + end_date: datetime, + ) -> RetrievalJob: + assert type(data_source).__name__ == "MsSqlServerSource" + assert ( + config.offline_store.type + == "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" + ) + from_expression = data_source.get_table_query_string().replace("`", "") + + partition_by_join_key_string = ", ".join(join_key_columns) + if partition_by_join_key_string != "": + partition_by_join_key_string = ( + "PARTITION BY " + partition_by_join_key_string + ) + timestamps = [event_timestamp_column] + if created_timestamp_column: + timestamps.append(created_timestamp_column) + timestamp_desc_string = " DESC, ".join(timestamps) + " DESC" + field_string = ", ".join(join_key_columns + feature_name_columns + timestamps) + + query = f""" + SELECT {field_string} + FROM ( + SELECT {field_string}, + ROW_NUMBER() OVER({partition_by_join_key_string} ORDER BY {timestamp_desc_string}) AS _feast_row + FROM {from_expression} inner_t + WHERE {event_timestamp_column} BETWEEN CONVERT(DATETIMEOFFSET, '{start_date}', 120) AND CONVERT(DATETIMEOFFSET, '{end_date}', 120) + ) outer_t + WHERE outer_t._feast_row = 1 + """ + self._make_engine(config.offline_store) + + return MsSqlServerRetrievalJob( + query=query, + engine=self._engine, + config=config, + full_feature_names=False, + on_demand_feature_views=None, + ) + + @staticmethod + @log_exceptions_and_usage(offline_store="mssql") + def pull_all_from_table_or_query( + self, + config: RepoConfig, + data_source: DataSource, + join_key_columns: List[str], + feature_name_columns: List[str], + event_timestamp_column: str, + start_date: datetime, + end_date: datetime, + ) -> RetrievalJob: + assert type(data_source).__name__ == "MsSqlServerSource" + assert ( + config.offline_store.type + == "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" + ) + from_expression = data_source.get_table_query_string().replace("`", "") + timestamps = [event_timestamp_column] + field_string = ", ".join(join_key_columns + feature_name_columns + timestamps) + + query = f""" + SELECT {field_string} + FROM ( + SELECT {field_string} + FROM {from_expression} + WHERE {event_timestamp_column} BETWEEN TIMESTAMP '{start_date}' AND TIMESTAMP '{end_date}' + ) + """ + self._make_engine(config.offline_store) + + return MsSqlServerRetrievalJob( + query=query, + engine=self._engine, + config=config, + full_feature_names=False, + ) + + @staticmethod + @log_exceptions_and_usage(offline_store="mssql") + def get_historical_features( + self, + config: RepoConfig, + feature_views: List[FeatureView], + feature_refs: List[str], + entity_df: Union[pandas.DataFrame, str], + registry: Registry, + project: str, + full_feature_names: bool = False, + ) -> RetrievalJob: + expected_join_keys = _get_join_keys(project, feature_views, registry) + + assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) + engine = self._make_engine(config.offline_store) + + ( + table_schema, + table_name, + ) = _upload_entity_df_into_sqlserver_and_get_entity_schema( + engine, config, entity_df + ) + + entity_df_event_timestamp_col = ( + offline_utils.infer_event_timestamp_from_entity_df(table_schema) + ) + _assert_expected_columns_in_sqlserver( + expected_join_keys, + entity_df_event_timestamp_col, + table_schema, + ) + + # Build a query context containing all information required to template the SQL query + query_context = get_feature_view_query_context( + feature_refs, feature_views, registry, project + ) + + # TODO: Infer min_timestamp and max_timestamp from entity_df + # Generate the SQL query from the query context + query = build_point_in_time_query( + query_context, + min_timestamp=datetime.now() - timedelta(days=365), + max_timestamp=datetime.now() + timedelta(days=1), + left_table_query_string=table_name, + entity_df_event_timestamp_col=entity_df_event_timestamp_col, + full_feature_names=full_feature_names, + ) + + job = MsSqlServerRetrievalJob( + query=query, + engine=self._engine, + config=config.offline_store, + full_feature_names=full_feature_names, + on_demand_feature_views=registry.list_on_demand_feature_views(project), + ) + return job + + +def _assert_expected_columns_in_dataframe( + join_keys: Set[str], entity_df_event_timestamp_col: str, entity_df: pandas.DataFrame +): + entity_df_columns = set(entity_df.columns.values) + expected_columns = join_keys.copy() + expected_columns.add(entity_df_event_timestamp_col) + + missing_keys = expected_columns - entity_df_columns + + if len(missing_keys) != 0: + raise errors.FeastEntityDFMissingColumnsError(expected_columns, missing_keys) + + +def _assert_expected_columns_in_sqlserver( + join_keys: Set[str], entity_df_event_timestamp_col: str, table_schema: EntitySchema +): + entity_columns = set(table_schema.keys()) + + expected_columns = join_keys.copy() + expected_columns.add(entity_df_event_timestamp_col) + + missing_keys = expected_columns - entity_columns + + if len(missing_keys) != 0: + raise errors.FeastEntityDFMissingColumnsError(expected_columns, missing_keys) + + +def _get_join_keys( + project: str, feature_views: List[FeatureView], registry: Registry +) -> Set[str]: + join_keys = set() + for feature_view in feature_views: + entities = feature_view.entities + for entity_name in entities: + entity = registry.get_entity(entity_name, project) + join_keys.add(entity.join_key) + return join_keys + + +def _infer_event_timestamp_from_sqlserver_schema(table_schema) -> str: + if any( + schema_field["COLUMN_NAME"] == DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL + for schema_field in table_schema + ): + return DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL + else: + datetime_columns = list( + filter( + lambda schema_field: schema_field["DATA_TYPE"] == "DATETIMEOFFSET", + table_schema, + ) + ) + if len(datetime_columns) == 1: + print( + f"Using {datetime_columns[0]['COLUMN_NAME']} as the event timestamp. To specify a column explicitly, please name it {DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL}." + ) + return datetime_columns[0].name + else: + raise ValueError( + f"Please provide an entity_df with a column named {DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL} representing the time of events." + ) + + +class MsSqlServerRetrievalJob(RetrievalJob): + def __init__( + self, + query: str, + engine: Engine, + config: RepoConfig, + full_feature_names: bool, + on_demand_feature_views: Optional[List[OnDemandFeatureView]], + metadata: Optional[RetrievalMetadata] = None, + drop_columns: Optional[List[str]] = None, + ): + self.query = query + self.engine = engine + self._config = config + self._full_feature_names = full_feature_names + self._on_demand_feature_views = on_demand_feature_views + self._drop_columns = drop_columns + self._metadata = metadata + + @property + def full_feature_names(self) -> bool: + return self._full_feature_names + + @property + def on_demand_feature_views(self) -> Optional[List[OnDemandFeatureView]]: + return self._on_demand_feature_views + + def _to_df_internal(self) -> pandas.DataFrame: + return pandas.read_sql(self.query, con=self.engine).fillna(value=np.nan) + + def _to_arrow_internal(self) -> pyarrow.Table: + result = pandas.read_sql(self.query, con=self.engine).fillna(value=np.nan) + return pyarrow.Table.from_pandas(result) + + ## Implements persist in Feast 0.18 - This persists to filestorage + ## ToDo: Persist to Azure Storage + def persist(self, storage: SavedDatasetStorage): + assert isinstance(storage, SavedDatasetFileStorage) + + filesystem, path = FileSource.create_filesystem_and_path( + storage.file_options.file_url, storage.file_options.s3_endpoint_override, + ) + + if path.endswith(".parquet"): + pyarrow.parquet.write_table( + self.to_arrow(), where=path, filesystem=filesystem + ) + else: + # otherwise assume destination is directory + pyarrow.parquet.write_to_dataset( + self.to_arrow(), root_path=path, filesystem=filesystem + ) + + @property + def metadata(self) -> Optional[RetrievalMetadata]: + return self._metadata + + +@dataclass(frozen=True) +class FeatureViewQueryContext: + """Context object used to template a point-in-time SQL query""" + + name: str + ttl: int + entities: List[str] + features: List[str] # feature reference format + table_ref: str + event_timestamp_column: str + created_timestamp_column: Optional[str] + table_subquery: str + entity_selections: List[str] + + +def _upload_entity_df_into_sqlserver_and_get_entity_schema( + engine: sqlalchemy.engine.Engine, + config: RepoConfig, + entity_df: Union[pandas.DataFrame, str], +) -> EntitySchema: + """ + Uploads a Pandas entity dataframe into a SQL Server table and constructs the + schema from the original entity_df dataframe. + """ + table_id = offline_utils.get_temp_entity_table_name() + session = sessionmaker(bind=engine)() + + if type(entity_df) is str: + # TODO: This should be a temporary table, right? + session.execute(f"SELECT * INTO {table_id} FROM ({entity_df}) t") + + session.commit() + + limited_entity_df = MsSqlServerRetrievalJob( + f"SELECT TOP 1 * FROM {table_id}", + engine, + config, + full_feature_names=False, + on_demand_feature_views=None, + ).to_df() + + entity_schema = dict(zip(limited_entity_df.columns, limited_entity_df.dtypes)), table_id + + elif isinstance(entity_df, pandas.DataFrame): + # Drop the index so that we don't have unnecessary columns + entity_df.to_sql( + name=table_id, + con=engine, + index=False + ) + entity_schema = dict(zip(entity_df.columns, entity_df.dtypes)), table_id + else: + raise ValueError( + f"The entity dataframe you have provided must be a SQL Server SQL query," + f" or a Pandas dataframe. But we found: {type(entity_df)} " + ) + + return entity_schema + + +def get_feature_view_query_context( + feature_refs: List[str], + feature_views: List[FeatureView], + registry: Registry, + project: str, +) -> List[FeatureViewQueryContext]: + """Build a query context containing all information required to template a point-in-time SQL query""" + + ( + feature_views_to_feature_map, + on_demand_feature_views_to_features, + ) = _get_requested_feature_views_to_features_dict( + feature_refs, feature_views, registry.list_on_demand_feature_views(project) + ) + + query_context = [] + for feature_view, features in feature_views_to_feature_map.items(): + join_keys = [] + entity_selections = [] + reverse_field_mapping = { + v: k for k, v in feature_view.source.field_mapping.items() + } + for entity_name in feature_view.entities: + entity = registry.get_entity(entity_name, project) + join_keys.append(entity.join_key) + join_key_column = reverse_field_mapping.get( + entity.join_key, entity.join_key + ) + entity_selections.append(f"{join_key_column} AS {entity.join_key}") + + if isinstance(feature_view.ttl, timedelta): + ttl_seconds = int(feature_view.ttl.total_seconds()) + else: + ttl_seconds = 0 + + assert isinstance(feature_view.source, MsSqlServerSource) + + event_timestamp_column = feature_view.source.event_timestamp_column + created_timestamp_column = feature_view.source.created_timestamp_column + + context = FeatureViewQueryContext( + name=feature_view.name, + ttl=ttl_seconds, + entities=join_keys, + features=features, + table_ref=feature_view.source.table_ref, + event_timestamp_column=reverse_field_mapping.get( + event_timestamp_column, event_timestamp_column + ), + created_timestamp_column=reverse_field_mapping.get( + created_timestamp_column, created_timestamp_column + ), + # TODO: Make created column optional and not hardcoded + table_subquery=feature_view.source.get_table_query_string().replace("`", ""), + entity_selections=entity_selections, + ) + query_context.append(context) + return query_context + + +def build_point_in_time_query( + feature_view_query_contexts: List[FeatureViewQueryContext], + min_timestamp: datetime, + max_timestamp: datetime, + left_table_query_string: str, + entity_df_event_timestamp_col: str, + full_feature_names: bool = False, +): + + """Build point-in-time query between each feature view table and the entity dataframe""" + template = Environment(loader=BaseLoader()).from_string( + source=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN + ) + + # Add additional fields to dict + template_context = { + "min_timestamp": min_timestamp, + "max_timestamp": max_timestamp, + "left_table_query_string": left_table_query_string, + "entity_df_event_timestamp_col": entity_df_event_timestamp_col, + "unique_entity_keys": set( + [entity for fv in feature_view_query_contexts for entity in fv.entities] + ), + "featureviews": [asdict(context) for context in feature_view_query_contexts], + "full_feature_names": full_feature_names, + } + + query = template.render(template_context) + return query + + +# TODO: Optimizations +# * Use NEWID() instead of ROW_NUMBER(), or join on entity columns directly +# * Precompute ROW_NUMBER() so that it doesn't have to be recomputed for every query on entity_dataframe +# * Create temporary tables instead of keeping all tables in memory + +MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN = """ +/* + Compute a deterministic hash for the `left_table_query_string` that will be used throughout + all the logic as the field to GROUP BY the data +*/ +WITH entity_dataframe AS ( + SELECT *, + {{entity_df_event_timestamp_col}} AS entity_timestamp + {% for featureview in featureviews %} + ,CONCAT( + {% for entity_key in unique_entity_keys %} + {{entity_key}}, + {% endfor %} + {{entity_df_event_timestamp_col}} + ) AS {{featureview.name}}__entity_row_unique_id + {% endfor %} + FROM {{ left_table_query_string }} +), +{% for featureview in featureviews %} +{{ featureview.name }}__entity_dataframe AS ( + SELECT + {{ featureview.entities | join(', ')}}, + entity_timestamp, + {{featureview.name}}__entity_row_unique_id + FROM entity_dataframe + GROUP BY {{ featureview.entities | join(', ')}}, entity_timestamp, {{featureview.name}}__entity_row_unique_id +), +/* + This query template performs the point-in-time correctness join for a single feature set table + to the provided entity table. + 1. We first join the current feature_view to the entity dataframe that has been passed. + This JOIN has the following logic: + - For each row of the entity dataframe, only keep the rows where the `event_timestamp_column` + is less than the one provided in the entity dataframe + - If there a TTL for the current feature_view, also keep the rows where the `event_timestamp_column` + is higher the the one provided minus the TTL + - For each row, Join on the entity key and retrieve the `entity_row_unique_id` that has been + computed previously + The output of this CTE will contain all the necessary information and already filtered out most + of the data that is not relevant. +*/ +{{ featureview.name }}__subquery AS ( + SELECT + t.{{ featureview.event_timestamp_column }} as event_timestamp, + {{ 't.' + featureview.created_timestamp_column ~ ' as created_timestamp,' if featureview.created_timestamp_column else '' }} + t.{{ featureview.entity_selections | join(', ')}}, + {% for feature in featureview.features %} + {{ feature }} as {% if full_feature_names %}{{ featureview.name }}__{{feature}}{% else %}{{ feature }}{% endif %}{% if loop.last %}{% else %}, {% endif %} + {% endfor %} + FROM {{ featureview.table_subquery }} t + WHERE {{ featureview.event_timestamp_column }} <= (SELECT MAX(entity_timestamp) FROM entity_dataframe) + {% if featureview.ttl == 0 %}{% else %} + AND {{ featureview.ttl }} >= DATEDIFF(SECOND, {{ featureview.event_timestamp_column }}, (SELECT MIN(entity_timestamp) FROM entity_dataframe) ) + {% endif %} +), +{{ featureview.name }}__base AS ( + SELECT + subquery.*, + entity_dataframe.{{entity_df_event_timestamp_col}} AS entity_timestamp, + entity_dataframe.{{featureview.name}}__entity_row_unique_id + FROM {{ featureview.name }}__subquery AS subquery + INNER JOIN entity_dataframe + ON 1=1 + AND subquery.event_timestamp <= entity_dataframe.{{entity_df_event_timestamp_col}} + {% if featureview.ttl == 0 %}{% else %} + AND {{ featureview.ttl }} > = DATEDIFF(SECOND, subquery.event_timestamp, entity_dataframe.{{entity_df_event_timestamp_col}}) + {% endif %} + {% for entity in featureview.entities %} + AND subquery.{{ entity }} = entity_dataframe.{{ entity }} + {% endfor %} +), +/* + 2. If the `created_timestamp_column` has been set, we need to + deduplicate the data first. This is done by calculating the + `MAX(created_at_timestamp)` for each event_timestamp. + + We then join the data on the next CTE +*/ +{% if featureview.created_timestamp_column %} +{{ featureview.name }}__dedup AS ( + SELECT + {{featureview.name}}__entity_row_unique_id, + event_timestamp, + MAX(created_timestamp) as created_timestamp + FROM {{ featureview.name }}__base + GROUP BY {{featureview.name}}__entity_row_unique_id, event_timestamp +), +{% endif %} +/* + 3. The data has been filtered during the first CTE "*__base" + Thus we only need to compute the latest timestamp of each feature. +*/ +{{ featureview.name }}__latest AS ( + SELECT + {{ featureview.name }}__base.{{ featureview.name }}__entity_row_unique_id, + MAX({{ featureview.name }}__base.event_timestamp) AS event_timestamp + {% if featureview.created_timestamp_column %} + ,MAX({{ featureview.name }}__base.created_timestamp) AS created_timestamp + {% endif %} + FROM {{ featureview.name }}__base + {% if featureview.created_timestamp_column %} + INNER JOIN {{ featureview.name }}__dedup + ON {{ featureview.name }}__dedup.{{ featureview.name }}__entity_row_unique_id = {{ featureview.name }}__base.{{ featureview.name }}__entity_row_unique_id + AND {{ featureview.name }}__dedup.event_timestamp = {{ featureview.name }}__base.event_timestamp + AND {{ featureview.name }}__dedup.created_timestamp = {{ featureview.name }}__base.created_timestamp + {% endif %} + GROUP BY {{ featureview.name }}__base.{{ featureview.name }}__entity_row_unique_id +), +/* + 4. Once we know the latest value of each feature for a given timestamp, + we can join again the data back to the original "base" dataset +*/ +{{ featureview.name }}__cleaned AS ( + SELECT base.* + FROM {{ featureview.name }}__base as base + INNER JOIN {{ featureview.name }}__latest + ON base.{{ featureview.name }}__entity_row_unique_id = {{ featureview.name }}__latest.{{ featureview.name }}__entity_row_unique_id + AND base.event_timestamp = {{ featureview.name }}__latest.event_timestamp + {% if featureview.created_timestamp_column %} + AND base.created_timestamp = {{ featureview.name }}__latest.created_timestamp + {% endif %} +){% if loop.last %}{% else %}, {% endif %} +{% endfor %} +/* + Joins the outputs of multiple time travel joins to a single table. + The entity_dataframe dataset being our source of truth here. + */ +SELECT entity_dataframe.* +{% for featureview in featureviews %} + {% for feature in featureview.features %} + ,{% if full_feature_names %}{{ featureview.name }}__{{feature}}{% else %}{{ feature }}{% endif %} + {% endfor %} +{% endfor %} +FROM entity_dataframe +{% for featureview in featureviews %} +LEFT JOIN ( + SELECT + {{featureview.name}}__entity_row_unique_id + {% for feature in featureview.features %} + ,{% if full_feature_names %}{{ featureview.name }}__{{feature}}{% else %}{{ feature }}{% endif %} + {% endfor %} + FROM {{ featureview.name }}__cleaned +) {{ featureview.name }}__cleaned +ON +{{ featureview.name }}__cleaned.{{ featureview.name }}__entity_row_unique_id = entity_dataframe.{{ featureview.name }}__entity_row_unique_id +{% endfor %} +""" \ No newline at end of file diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py new file mode 100644 index 0000000000..e5602eddf6 --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -0,0 +1,220 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from typing import Callable, Dict, Iterable, Optional, Tuple +import json + +import pandas +from sqlalchemy import create_engine + +from feast import type_map +from feast.data_source import DataSource +from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto +from feast.value_type import ValueType +from feast.repo_config import RepoConfig + + +class MsSqlServerOptions: + """ + DataSource MsSqlServer options used to source features from MsSqlServer query + """ + + def __init__( + self, connection_str: Optional[str], table_ref: Optional[str], + ): + self._connection_str = connection_str + self._table_ref = table_ref + + @property + def table_ref(self): + """ + Returns the table ref of this SQL Server source + """ + return self._table_ref + + @table_ref.setter + def table_ref(self, table_ref): + """ + Sets the table ref of this SQL Server source + """ + self._table_ref = table_ref + + @property + def connection_str(self): + """ + Returns the SqlServer SQL connection string referenced by this source + """ + return self._connection_str + + @connection_str.setter + def connection_str(self, connection_str): + """ + Sets the SqlServer SQL connection string referenced by this source + """ + self._connection_str = connection_str + + @classmethod + def from_proto( + cls, sqlserver_options_proto: DataSourceProto.CustomSourceOptions + ) -> "MsSqlServerOptions": + """ + Creates an MsSqlServerOptions from a protobuf representation of a SqlServer option + Args: + sqlserver_options_proto: A protobuf representation of a DataSource + Returns: + Returns a SqlServerOptions object based on the sqlserver_options protobuf + """ + options = json.loads(sqlserver_options_proto.configuration) + + sqlserver_options = cls( + table_ref=options["table_ref"], connection_str=options["connection_str"], + ) + + return sqlserver_options + + def to_proto(self) -> DataSourceProto.CustomSourceOptions: + """ + Converts a MsSqlServerOptions object to a protobuf representation. + Returns: + CustomSourceOptions protobuf + """ + + sqlserver_options_proto = DataSourceProto.CustomSourceOptions( + configuration=json.dumps( + { + "table_ref": self._table_ref, + "connection_string": self._connection_str, + } + ).encode("utf-8") + ) + + return sqlserver_options_proto + + +class MsSqlServerSource(DataSource): + def __init__( + self, + table_ref: Optional[str] = None, + event_timestamp_column: Optional[str] = None, + created_timestamp_column: Optional[str] = "", + field_mapping: Optional[Dict[str, str]] = None, + date_partition_column: Optional[str] = "", + connection_str: Optional[str] = "", + description: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + owner: Optional[str] = None, + name: Optional[str] = None + ): + self._mssqlserver_options = MsSqlServerOptions( + connection_str=connection_str, table_ref=table_ref + ) + self._connection_str = connection_str + + super().__init__( + created_timestamp_column = created_timestamp_column, + field_mapping = field_mapping, + date_partition_column = date_partition_column, + description = description, + tags = tags, + owner = owner, + name = name, + timestamp_field = event_timestamp_column, + ) + + def __eq__(self, other): + if not isinstance(other, MsSqlServerSource): + raise TypeError( + "Comparisons should only involve SqlServerSource class objects." + ) + + return ( + self.name == other.name + and self.mssqlserver_options.connection_str + == other.mssqlserver_options.connection_str + and self.timestamp_field == other.timestamp_field + and self.created_timestamp_column == other.created_timestamp_column + and self.field_mapping == other.field_mapping + ) + + def __hash__(self): + return hash(( + self.name, + self.mssqlserver_options.connection_str, + self.timestamp_field, + self.created_timestamp_column)) + + @property + def table_ref(self): + return self._mssqlserver_options.table_ref + + @property + def mssqlserver_options(self): + """ + Returns the SQL Server options of this data source + """ + return self._mssqlserver_options + + @mssqlserver_options.setter + def mssqlserver_options(self, sqlserver_options): + """ + Sets the SQL Server options of this data source + """ + self._mssqlserver_options = sqlserver_options + + @staticmethod + def from_proto(data_source: DataSourceProto): + options = json.loads(data_source.custom_options.configuration) + return MsSqlServerSource( + field_mapping=dict(data_source.field_mapping), + table_ref=options["table_ref"], + connection_str=options["connection_string"], + event_timestamp_column=data_source.timestamp_field, + created_timestamp_column=data_source.created_timestamp_column, + date_partition_column=data_source.date_partition_column, + ) + + def to_proto(self) -> DataSourceProto: + data_source_proto = DataSourceProto( + type=DataSourceProto.CUSTOM_SOURCE, + field_mapping=self.field_mapping, + custom_options=self.mssqlserver_options.to_proto(), + ) + + data_source_proto.timestamp_field = self.timestamp_field + data_source_proto.created_timestamp_column = self.created_timestamp_column + data_source_proto.date_partition_column = self.date_partition_column + + return data_source_proto + + def get_table_query_string(self) -> str: + """Returns a string that can directly be used to reference this table in SQL""" + return f"`{self.table_ref}`" + + def validate(self, config: RepoConfig): + # As long as the query gets successfully executed, or the table exists, + # the data source is validated. We don't need the results though. + # self.get_table_column_names_and_types() + return None + + @staticmethod + def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: + return type_map.mssqlserver_to_feast_value_type + + def get_table_column_names_and_types(self) -> Iterable[Tuple[str, str]]: + conn = create_engine(self._connection_str) + name_type_pairs = [] + database, table_name = self.table_ref.split(".") + columns_query = f""" + SELECT COLUMN_NAME, DATA_TYPE FROM {database}.INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '{table_name}' + """ + table_schema = pandas.read_sql(columns_query, conn) + name_type_pairs.extend( + list( + zip( + table_schema["COLUMN_NAME"].to_list(), + table_schema["DATA_TYPE"].to_list(), + ) + ) + ) + return name_type_pairs \ No newline at end of file diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py new file mode 100644 index 0000000000..d04ec35afb --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -0,0 +1,68 @@ +import uuid +import os +from typing import Dict, List +from venv import create + +import pandas as pd +from pyspark import SparkConf +from pyspark.sql import SparkSession + +from feast.data_source import DataSource +from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import MsSqlServerOfflineStoreConfig +from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( + MsSqlServerSource, +) +from tests.integration.feature_repos.universal.data_source_creator import ( + DataSourceCreator, +) + +class MsqlDataSourceCreator(DataSourceCreator): + mssql_offline_store_config: MsSqlServerOfflineStoreConfig + def __init__(self, project_name: str, *args, **kwargs): + super().__init__(project_name) + if not self.mssql_offline_store_config: + self.create_offline_store_config() + + def create_offline_store_config(self) -> MsSqlServerOfflineStoreConfig: + #TODO: Fill in connection string + connection_string = os.getenv("AZURE_CONNECTION_STRING", "") + self.mssql_offline_store_config = MsSqlServerOfflineStoreConfig() + self.mssql_offline_store_config.connection_string = connection_string + return self.mssql_offline_store_config + + def create_data_source( + self, + df: pd.DataFrame, + destination_name: str, + timestamp_field="ts", + created_timestamp_column="created_ts", + field_mapping: Dict[str, str] = None, + **kwargs, + ) -> DataSource: + if timestamp_field in df: + df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True) + # Make sure the field mapping is correct and convert the datetime datasources. + + if field_mapping: + timestamp_mapping = {value: key for key, value in field_mapping.items()} + if ( + timestamp_field in timestamp_mapping + and timestamp_mapping[timestamp_field] in df + ): + col = timestamp_mapping[timestamp_field] + df[col] = pd.to_datetime(df[col], utc=True) + # Upload dataframe to azure table + destination_name = self.get_prefixed_table_name(destination_name) + return MsSqlServerSource( + connection_str=self.mssql_offline_store_config.connection_string, + table_ref=destination_name, + event_timestamp_column=timestamp_field, + created_timestamp_column=created_timestamp_column, + field_mapping=field_mapping, + ) + def get_prefixed_table_name(self, destination_name: str) -> str: + # TODO fix this + return f"{self.project_name}_{destination_name}" + + def teardown(self): + pass diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index bf2a4ec7bb..8df6be103a 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -2,7 +2,7 @@ from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union - +from collections import defaultdict import pandas as pd import pyarrow from tqdm import tqdm @@ -10,6 +10,7 @@ from feast import FeatureService, errors from feast.entity import Entity from feast.feature_view import FeatureView +from feast.on_demand_feature_view import OnDemandFeatureView from feast.importer import import_class from feast.infra.infra_object import Infra from feast.infra.offline_stores.offline_store import RetrievalJob diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py new file mode 100644 index 0000000000..2a120ce70b --- /dev/null +++ b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import os +import uuid + +from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto +from feast.registry import RegistryConfig +from feast.registry_store import RegistryStore +from pathlib import Path +from datetime import datetime +from urllib.parse import urlparse +from tempfile import TemporaryFile + +REGISTRY_SCHEMA_VERSION = "1" + + +class AzBlobRegistryStore(RegistryStore): + def __init__(self, registry_config: RegistryConfig, repo_path: Path): + try: + from azure.storage.blob import BlobClient + from azure.identity import DefaultAzureCredential + from azure.storage.blob import BlobServiceClient + import logging + except ImportError as e: + from feast.errors import FeastExtrasDependencyImportError + + raise FeastExtrasDependencyImportError("az", str(e)) + + self._uri = urlparse(registry_config.path) + self._account_url = self._uri.scheme + "://" + self._uri.netloc + container_path = self._uri.path.lstrip("/").split("/") + self._container = container_path.pop(0) + self._path = "/".join(container_path) + + try: + # turn the verbosity of the blob client to warning and above (this reduces verbosity) + logger = logging.getLogger("azure") + logger.setLevel(logging.ERROR) + + # Attempt to use shared account key to login first + if 'REGISTRY_BLOB_KEY' in os.environ: + client = BlobServiceClient( + account_url=self._account_url, credential=os.environ['REGISTRY_BLOB_KEY'] + ) + self.blob = client.get_blob_client( + container=self._container, blob=self._path + ) + return + + default_credential = DefaultAzureCredential( + exclude_shared_token_cache_credential=True + ) + + client = BlobServiceClient( + account_url=self._account_url, credential=default_credential + ) + self.blob = client.get_blob_client( + container=self._container, blob=self._path + ) + except: + print( + "Could not connect to blob. Check the following\nIs the URL specified correctly?\nIs you IAM role set to Storage Blob Data Contributor?\n" + ) + + return + + def get_registry_proto(self): + file_obj = TemporaryFile() + registry_proto = RegistryProto() + + if self.blob.exists(): + download_stream = self.blob.download_blob() + file_obj.write(download_stream.readall()) + + file_obj.seek(0) + registry_proto.ParseFromString(file_obj.read()) + return registry_proto + raise FileNotFoundError( + f'Registry not found at path "{self._uri.geturl()}". Have you run "feast apply"?' + ) + + def update_registry_proto(self, registry_proto: RegistryProto): + self._write_registry(registry_proto) + + def teardown(self): + self.blob.delete_blob() + + def _write_registry(self, registry_proto: RegistryProto): + registry_proto.version_id = str(uuid.uuid4()) + registry_proto.last_updated.FromDatetime(datetime.utcnow()) + + file_obj = TemporaryFile() + file_obj.write(registry_proto.SerializeToString()) + file_obj.seek(0) + self.blob.upload_blob(file_obj, overwrite=True) + return \ No newline at end of file diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 118c1ca872..10127e8487 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -61,6 +61,7 @@ "trino": "feast.infra.offline_stores.contrib.trino_offline_store.trino.TrinoOfflineStore", "postgres": "feast.infra.offline_stores.contrib.postgres_offline_store.postgres.PostgreSQLOfflineStore", "athena": "feast.infra.offline_stores.contrib.athena_offline_store.athena.AthenaOfflineStore", + "mssql": "feast.infra.offline_stores.contrib.mssql_offline_store.msql.MsSqlServerOfflineStore" } FEATURE_SERVER_CONFIG_CLASS_FOR_TYPE = { @@ -174,6 +175,8 @@ def __init__(self, **data: Any): self._offline_config = "bigquery" elif data["provider"] == "aws": self._offline_config = "redshift" + elif data["provider"] == "azure": + self._offline_config = "mssql" self._online_store = None if "online_store" in data: @@ -334,6 +337,8 @@ def _validate_offline_store_config(cls, values): values["offline_store"]["type"] = "bigquery" elif values["provider"] == "aws": values["offline_store"]["type"] = "redshift" + if values["provider"] == "azure": + values["offline_store"]["type"] = "mssql" offline_store_type = values["offline_store"]["type"] diff --git a/setup.py b/setup.py index 5a770b8a6a..1f235f70fa 100644 --- a/setup.py +++ b/setup.py @@ -129,6 +129,18 @@ "cffi==1.15.*,<2", ] +AZURE_REQUIRED = ( + [ + "redis==4.2.2", + "hiredis>=2.0.0,<3", + "azure-storage-blob>=0.37.0", + "azure-identity>=1.6.1", + "SQLAlchemy>=1.4.19", + "dill==0.3.4", + "pyodbc>=4.0.30" + ] +) + CI_REQUIRED = ( [ "build", @@ -185,6 +197,7 @@ + GE_REQUIRED + HBASE_REQUIRED + CASSANDRA_REQUIRED + + AZURE_REQUIRED ) @@ -515,6 +528,7 @@ def copy_extensions_to_source(self): "spark": SPARK_REQUIRED, "trino": TRINO_REQUIRED, "postgres": POSTGRES_REQUIRED, + "azure": AZURE_REQUIRED, "mysql": MYSQL_REQUIRED, "ge": GE_REQUIRED, "hbase": HBASE_REQUIRED, From 011d1e0750c55d464067f26a3b7d6b515edac5d5 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 10 Aug 2022 14:24:00 -0700 Subject: [PATCH 02/51] working state Signed-off-by: Kevin Zhang --- sdk/python/feast/data_source.py | 1 + sdk/python/feast/feature_store.py | 2 -- sdk/python/feast/feature_view.py | 1 + .../feast/infra/contrib/azure_provider.py | 10 ++++----- .../contrib/mssql_offline_store/mssql.py | 22 +++++-------------- .../mssql_offline_store/mssqlserver_source.py | 17 ++++++++------ sdk/python/feast/infra/provider.py | 1 + sdk/python/feast/infra/registry/registry.py | 10 ++++----- sdk/python/feast/repo_config.py | 2 +- 9 files changed, 29 insertions(+), 37 deletions(-) diff --git a/sdk/python/feast/data_source.py b/sdk/python/feast/data_source.py index 76b012e585..ccf1e8095f 100644 --- a/sdk/python/feast/data_source.py +++ b/sdk/python/feast/data_source.py @@ -297,6 +297,7 @@ def from_proto(data_source: DataSourceProto) -> Any: raise ValueError("Could not identify the source type being added.") if data_source_type == DataSourceProto.SourceType.CUSTOM_SOURCE: + data_source.data_source_class_type = "feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source.MsSqlServerSource" cls = get_data_source_class_from_type(data_source.data_source_class_type) return cls.from_proto(data_source) diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index a2178d9b28..23600e7c64 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -564,9 +564,7 @@ def _validate_all_feature_views( "This API is stable, but the functionality does not scale well for offline retrieval", RuntimeWarning, ) - set_usage_attribute("odfv", bool(odfvs_to_update)) - _validate_feature_views( [ *views_to_update, diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 41bad5828a..3003a76cf4 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -351,6 +351,7 @@ def from_proto(cls, feature_view_proto: FeatureViewProto): Returns: A FeatureViewProto object based on the feature view protobuf. """ + print(feature_view_proto.spec.batch_source) batch_source = DataSource.from_proto(feature_view_proto.spec.batch_source) stream_source = ( DataSource.from_proto(feature_view_proto.spec.stream_source) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 6106200a2e..57ce1c639f 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -38,7 +38,7 @@ def __init__(self, config: RepoConfig): else None ) - @log_exceptions_and_usage(registry="az") + #@log_exceptions_and_usage(registry="az") def update_infra( self, project: str, @@ -59,7 +59,7 @@ def update_infra( partial=partial, ) - @log_exceptions_and_usage(registry="az") + #@log_exceptions_and_usage(registry="az") def teardown_infra( self, project: str, @@ -69,7 +69,7 @@ def teardown_infra( if self.online_store: self.online_store.teardown(self.repo_config, tables, entities) - @log_exceptions_and_usage(registry="az") + #@log_exceptions_and_usage(registry="az") def online_write_batch( self, config: RepoConfig, @@ -82,7 +82,7 @@ def online_write_batch( if self.online_store: self.online_store.online_write_batch(config, table, data, progress) - @log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001), registry="az") + #@log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001), registry="az") def online_read( self, config: RepoConfig, @@ -159,7 +159,7 @@ def materialize_single_feature_view( lambda x: pbar.update(x), ) - @log_exceptions_and_usage(registry="az") + #@log_exceptions_and_usage(registry="az") def get_historical_features( self, config: RepoConfig, diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index ebef9fc635..83d585b917 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -27,7 +27,6 @@ from feast import errors from feast.data_source import DataSource -from .mssqlserver_source import MsSqlServerSource from feast.feature_view import FeatureView from feast.infra.offline_stores.file_source import SavedDatasetFileStorage from feast.infra.offline_stores.offline_store import ( @@ -45,7 +44,6 @@ from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage from feast import FileSource -from feast.usage import log_exceptions_and_usage from feast.utils import _get_requested_feature_views_to_features_dict EntitySchema = Dict[str, np.dtype] @@ -55,8 +53,8 @@ class MsSqlServerOfflineStoreConfig(FeastBaseModel): """Offline store config for SQL Server""" type: Literal[ - "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" - ] = "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" + "mssql" + ] = "mssql" """ Offline store type selector""" connection_string: StrictStr = "mssql+pyodbc://sa:yourStrong(!)Password@localhost:1433/feast_test?driver=ODBC+Driver+17+for+SQL+Server" @@ -74,7 +72,7 @@ def _make_engine(self, config: RepoConfig = None) -> Session: return self._engine @staticmethod - @log_exceptions_and_usage(offline_store="mssql") + #@log_exceptions_and_usage(offline_store="mssql") def pull_latest_from_table_or_query( self, config: RepoConfig, @@ -87,10 +85,6 @@ def pull_latest_from_table_or_query( end_date: datetime, ) -> RetrievalJob: assert type(data_source).__name__ == "MsSqlServerSource" - assert ( - config.offline_store.type - == "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" - ) from_expression = data_source.get_table_query_string().replace("`", "") partition_by_join_key_string = ", ".join(join_key_columns) @@ -125,7 +119,7 @@ def pull_latest_from_table_or_query( ) @staticmethod - @log_exceptions_and_usage(offline_store="mssql") + #@log_exceptions_and_usage(offline_store="mssql") def pull_all_from_table_or_query( self, config: RepoConfig, @@ -137,10 +131,6 @@ def pull_all_from_table_or_query( end_date: datetime, ) -> RetrievalJob: assert type(data_source).__name__ == "MsSqlServerSource" - assert ( - config.offline_store.type - == "feast_azure_provider.mssqlserver.MsSqlServerOfflineStore" - ) from_expression = data_source.get_table_query_string().replace("`", "") timestamps = [event_timestamp_column] field_string = ", ".join(join_key_columns + feature_name_columns + timestamps) @@ -163,7 +153,7 @@ def pull_all_from_table_or_query( ) @staticmethod - @log_exceptions_and_usage(offline_store="mssql") + #@log_exceptions_and_usage(offline_store="mssql") def get_historical_features( self, config: RepoConfig, @@ -437,7 +427,7 @@ def get_feature_view_query_context( else: ttl_seconds = 0 - assert isinstance(feature_view.source, MsSqlServerSource) + #assert isinstance(feature_view.source, MsSqlServerSource) event_timestamp_column = feature_view.source.event_timestamp_column created_timestamp_column = feature_view.source.created_timestamp_column diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index e5602eddf6..41a1ea7792 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -12,7 +12,7 @@ from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto from feast.value_type import ValueType from feast.repo_config import RepoConfig - +from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import MsSqlServerOfflineStoreConfig class MsSqlServerOptions: """ @@ -165,6 +165,7 @@ def mssqlserver_options(self, sqlserver_options): def from_proto(data_source: DataSourceProto): options = json.loads(data_source.custom_options.configuration) return MsSqlServerSource( + name=data_source.name, field_mapping=dict(data_source.field_mapping), table_ref=options["table_ref"], connection_str=options["connection_string"], @@ -183,7 +184,7 @@ def to_proto(self) -> DataSourceProto: data_source_proto.timestamp_field = self.timestamp_field data_source_proto.created_timestamp_column = self.created_timestamp_column data_source_proto.date_partition_column = self.date_partition_column - + data_source_proto.name = self.name return data_source_proto def get_table_query_string(self) -> str: @@ -193,20 +194,22 @@ def get_table_query_string(self) -> str: def validate(self, config: RepoConfig): # As long as the query gets successfully executed, or the table exists, # the data source is validated. We don't need the results though. - # self.get_table_column_names_and_types() + self.get_table_column_names_and_types(config) return None @staticmethod def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: return type_map.mssqlserver_to_feast_value_type - def get_table_column_names_and_types(self) -> Iterable[Tuple[str, str]]: - conn = create_engine(self._connection_str) + def get_table_column_names_and_types(self, config: RepoConfig) -> Iterable[Tuple[str, str]]: + assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) + conn = create_engine(config.offline_store.connection_string) + self._mssqlserver_options.connection_str = config.offline_store.connection_string name_type_pairs = [] database, table_name = self.table_ref.split(".") columns_query = f""" - SELECT COLUMN_NAME, DATA_TYPE FROM {database}.INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = '{table_name}' + SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '{table_name} and table_schema = {database}' """ table_schema = pandas.read_sql(columns_query, conn) name_type_pairs.extend( diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index 8df6be103a..5d329ae213 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -25,6 +25,7 @@ "gcp": "feast.infra.gcp.GcpProvider", "aws": "feast.infra.aws.AwsProvider", "local": "feast.infra.local.LocalProvider", + "azure": "feast.infra.contrib.azure_provider.AzureProvider", } diff --git a/sdk/python/feast/infra/registry/registry.py b/sdk/python/feast/infra/registry/registry.py index 221b44141a..f6171aed2a 100644 --- a/sdk/python/feast/infra/registry/registry.py +++ b/sdk/python/feast/infra/registry/registry.py @@ -57,10 +57,11 @@ REGISTRY_SCHEMA_VERSION = "1" REGISTRY_STORE_CLASS_FOR_TYPE = { - "GCSRegistryStore": "feast.infra.registry.gcs.GCSRegistryStore", - "S3RegistryStore": "feast.infra.registry.s3.S3RegistryStore", + "GCSRegistryStore": "feast.infra.gcp.GCSRegistryStore", + "S3RegistryStore": "feast.infra.aws.S3RegistryStore", "FileRegistryStore": "feast.infra.registry.file.FileRegistryStore", - "PostgreSQLRegistryStore": "feast.infra.registry.contrib.postgres.postgres_registry_store.PostgreSQLRegistryStore", + "PostgreSQLRegistryStore": "feast.infra.registry_stores.contrib.postgres.registry_store.PostgreSQLRegistryStore", + "AzureRegistryStore": "feast.infra.registry_stores.contrib.azure.registry_store.AzBlobRegistryStore" } REGISTRY_STORE_CLASS_FOR_SCHEME = { @@ -322,9 +323,6 @@ def apply_data_source( f"{data_source.__class__.__module__}.{data_source.__class__.__name__}" ) data_source_proto.project = project - data_source_proto.data_source_class_type = ( - f"{data_source.__class__.__module__}.{data_source.__class__.__name__}" - ) registry.data_sources.append(data_source_proto) if commit: self.commit() diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 10127e8487..0fef47ae4d 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -61,7 +61,7 @@ "trino": "feast.infra.offline_stores.contrib.trino_offline_store.trino.TrinoOfflineStore", "postgres": "feast.infra.offline_stores.contrib.postgres_offline_store.postgres.PostgreSQLOfflineStore", "athena": "feast.infra.offline_stores.contrib.athena_offline_store.athena.AthenaOfflineStore", - "mssql": "feast.infra.offline_stores.contrib.mssql_offline_store.msql.MsSqlServerOfflineStore" + "mssql": "feast.infra.offline_stores.contrib.mssql_offline_store.mssql.MsSqlServerOfflineStore" } FEATURE_SERVER_CONFIG_CLASS_FOR_TYPE = { From a6a2fce9a5895074ebd36ae8aacb3fb73b05e447 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 10 Aug 2022 14:55:21 -0700 Subject: [PATCH 03/51] Fix the lint issues Signed-off-by: Kevin Zhang --- sdk/python/feast/inference.py | 4 +- .../feast/infra/contrib/azure_provider.py | 57 ++++---- .../contrib/mssql_offline_store/mssql.py | 129 ++++++++---------- .../mssql_offline_store/mssqlserver_source.py | 60 ++++---- .../mssql_offline_store/tests/data_source.py | 11 +- sdk/python/feast/infra/provider.py | 4 +- sdk/python/feast/infra/registry/registry.py | 2 +- .../registry_stores/contrib/azure/__init__.py | 0 .../contrib/azure/registry_store.py | 21 +-- sdk/python/feast/repo_config.py | 2 +- 10 files changed, 156 insertions(+), 134 deletions(-) create mode 100644 sdk/python/feast/infra/registry_stores/contrib/azure/__init__.py diff --git a/sdk/python/feast/inference.py b/sdk/python/feast/inference.py index 446978a174..eefc466bf2 100644 --- a/sdk/python/feast/inference.py +++ b/sdk/python/feast/inference.py @@ -7,7 +7,9 @@ from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_NAME, FeatureView from feast.field import Field, from_value_type from feast.infra.offline_stores.bigquery_source import BigQuerySource -from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import MsSqlServerSource +from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( + MsSqlServerSource, +) from feast.infra.offline_stores.file_source import FileSource from feast.infra.offline_stores.redshift_source import RedshiftSource from feast.infra.offline_stores.snowflake_source import SnowflakeSource diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 57ce1c639f..405d85a357 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -15,19 +15,23 @@ from feast.infra.offline_stores.offline_store import RetrievalJob from feast.infra.offline_stores.offline_utils import get_offline_store_from_config from feast.infra.online_stores.helpers import get_online_store_from_config -from feast.infra.provider import ( - Provider, -) +from feast.infra.provider import Provider from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto -from feast.registry import Registry +from feast.registry import BaseRegistry from feast.repo_config import RepoConfig from feast.saved_dataset import SavedDataset from feast.usage import RatioSampler, log_exceptions_and_usage, set_usage_attribute -from feast.utils import make_tzaware, _convert_arrow_to_proto, _get_column_names, _run_pyarrow_field_mapping +from feast.utils import ( + _convert_arrow_to_proto, + _get_column_names, + _run_pyarrow_field_mapping, + make_tzaware, +) DEFAULT_BATCH_SIZE = 10_000 + class AzureProvider(Provider): def __init__(self, config: RepoConfig): self.repo_config = config @@ -38,7 +42,7 @@ def __init__(self, config: RepoConfig): else None ) - #@log_exceptions_and_usage(registry="az") + # @log_exceptions_and_usage(registry="az") def update_infra( self, project: str, @@ -59,7 +63,7 @@ def update_infra( partial=partial, ) - #@log_exceptions_and_usage(registry="az") + # @log_exceptions_and_usage(registry="az") def teardown_infra( self, project: str, @@ -69,7 +73,7 @@ def teardown_infra( if self.online_store: self.online_store.teardown(self.repo_config, tables, entities) - #@log_exceptions_and_usage(registry="az") + # @log_exceptions_and_usage(registry="az") def online_write_batch( self, config: RepoConfig, @@ -82,7 +86,7 @@ def online_write_batch( if self.online_store: self.online_store.online_write_batch(config, table, data, progress) - #@log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001), registry="az") + # @log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001), registry="az") def online_read( self, config: RepoConfig, @@ -92,16 +96,23 @@ def online_read( ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: result = [] if self.online_store: - result = self.online_store.online_read(config, table, entity_keys, requested_features) + result = self.online_store.online_read( + config, table, entity_keys, requested_features + ) return result def ingest_df( - self, feature_view: FeatureView, entities: List[Entity], df: pandas.DataFrame, + self, + feature_view: FeatureView, + entities: List[Entity], + df: pandas.DataFrame, ): table = pa.Table.from_pandas(df) if feature_view.batch_source.field_mapping is not None: - table = _run_pyarrow_field_mapping(table, feature_view.batch_source.field_mapping) + table = _run_pyarrow_field_mapping( + table, feature_view.batch_source.field_mapping + ) join_keys = {entity.join_key: entity.value_type for entity in entities} rows_to_write = _convert_arrow_to_proto(table, feature_view, join_keys) @@ -116,7 +127,7 @@ def materialize_single_feature_view( feature_view: FeatureView, start_date: datetime, end_date: datetime, - registry: Registry, + registry: BaseRegistry, project: str, tqdm_builder: Callable[[int], tqdm], ) -> None: @@ -136,7 +147,7 @@ def materialize_single_feature_view( data_source=feature_view.batch_source, join_key_columns=join_key_columns, feature_name_columns=feature_name_columns, - event_timestamp_column=event_timestamp_column, + timestamp_field=event_timestamp_column, created_timestamp_column=created_timestamp_column, start_date=start_date, end_date=end_date, @@ -145,7 +156,9 @@ def materialize_single_feature_view( table = offline_job.to_arrow() if feature_view.batch_source.field_mapping is not None: - table = _run_pyarrow_field_mapping(table, feature_view.batch_source.field_mapping) + table = _run_pyarrow_field_mapping( + table, feature_view.batch_source.field_mapping + ) join_keys = {entity.join_key: entity.value_type for entity in entities} @@ -159,14 +172,14 @@ def materialize_single_feature_view( lambda x: pbar.update(x), ) - #@log_exceptions_and_usage(registry="az") + # @log_exceptions_and_usage(registry="az") def get_historical_features( self, config: RepoConfig, feature_views: List[FeatureView], feature_refs: List[str], entity_df: Union[pandas.DataFrame, str], - registry: Registry, + registry: BaseRegistry, project: str, full_feature_names: bool, ) -> RetrievalJob: @@ -182,9 +195,7 @@ def get_historical_features( return job def retrieve_saved_dataset( - self, - config: RepoConfig, - dataset: SavedDataset + self, config: RepoConfig, dataset: SavedDataset ) -> RetrievalJob: feature_name_columns = [ ref.replace(":", "__") if dataset.full_feature_names else ref.split(":")[1] @@ -209,7 +220,7 @@ def write_feature_service_logs( feature_service: FeatureService, logs: Union[pa.Table, str], config: RepoConfig, - registry: Registry, + registry: BaseRegistry, ): assert ( feature_service.logging_config is not None @@ -229,7 +240,7 @@ def retrieve_feature_service_logs( start_date: datetime, end_date: datetime, config: RepoConfig, - registry: Registry, + registry: BaseRegistry, ) -> RetrievalJob: assert ( feature_service.logging_config is not None @@ -249,4 +260,4 @@ def retrieve_feature_service_logs( timestamp_field=ts_column, start_date=make_tzaware(start_date), end_date=make_tzaware(end_date), - ) \ No newline at end of file + ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 83d585b917..66a7b23155 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -3,31 +3,23 @@ from dataclasses import asdict, dataclass from datetime import datetime, timedelta -from typing import ( - Dict, - List, - Optional, - Set, - Union, -) -from feast.infra.offline_stores import offline_utils -from feast.on_demand_feature_view import OnDemandFeatureView +from typing import Any, Dict, List, Optional, Set, Tuple, Union import numpy as np import pandas import pyarrow +import sqlalchemy from jinja2 import BaseLoader, Environment from pydantic.types import StrictStr from pydantic.typing import Literal from sqlalchemy import create_engine -import sqlalchemy from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker -from feast import errors +from feast import FileSource, errors from feast.data_source import DataSource - from feast.feature_view import FeatureView +from feast.infra.offline_stores import offline_utils from feast.infra.offline_stores.file_source import SavedDatasetFileStorage from feast.infra.offline_stores.offline_store import ( OfflineStore, @@ -37,13 +29,11 @@ from feast.infra.offline_stores.offline_utils import ( DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL, ) -from feast.infra.provider import ( - RetrievalJob, -) -from feast.registry import Registry +from feast.infra.provider import RetrievalJob +from feast.on_demand_feature_view import OnDemandFeatureView +from feast.registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage -from feast import FileSource from feast.utils import _get_requested_feature_views_to_features_dict EntitySchema = Dict[str, np.dtype] @@ -52,9 +42,7 @@ class MsSqlServerOfflineStoreConfig(FeastBaseModel): """Offline store config for SQL Server""" - type: Literal[ - "mssql" - ] = "mssql" + type: Literal["mssql"] = "mssql" """ Offline store type selector""" connection_string: StrictStr = "mssql+pyodbc://sa:yourStrong(!)Password@localhost:1433/feast_test?driver=ODBC+Driver+17+for+SQL+Server" @@ -62,24 +50,24 @@ class MsSqlServerOfflineStoreConfig(FeastBaseModel): format: SQLAlchemy connection string, e.g. mssql+pyodbc://sa:yourStrong(!)Password@localhost:1433/feast_test?driver=ODBC+Driver+17+for+SQL+Server""" -class MsSqlServerOfflineStore(OfflineStore): - def __init__(self): - self._engine = None +ENGINE = None + - def _make_engine(self, config: RepoConfig = None) -> Session: - if self._engine is None: - self._engine = create_engine(config.connection_string) - return self._engine +def _make_engine(config: MsSqlServerOfflineStoreConfig) -> Engine: + if ENGINE is None: + ENGINE = create_engine(config.connection_string) + return ENGINE + +class MsSqlServerOfflineStore(OfflineStore): @staticmethod - #@log_exceptions_and_usage(offline_store="mssql") + # @log_exceptions_and_usage(offline_store="mssql") def pull_latest_from_table_or_query( - self, config: RepoConfig, data_source: DataSource, join_key_columns: List[str], feature_name_columns: List[str], - event_timestamp_column: str, + timestamp_field: str, created_timestamp_column: Optional[str], start_date: datetime, end_date: datetime, @@ -92,7 +80,7 @@ def pull_latest_from_table_or_query( partition_by_join_key_string = ( "PARTITION BY " + partition_by_join_key_string ) - timestamps = [event_timestamp_column] + timestamps = [timestamp_field] if created_timestamp_column: timestamps.append(created_timestamp_column) timestamp_desc_string = " DESC, ".join(timestamps) + " DESC" @@ -104,35 +92,34 @@ def pull_latest_from_table_or_query( SELECT {field_string}, ROW_NUMBER() OVER({partition_by_join_key_string} ORDER BY {timestamp_desc_string}) AS _feast_row FROM {from_expression} inner_t - WHERE {event_timestamp_column} BETWEEN CONVERT(DATETIMEOFFSET, '{start_date}', 120) AND CONVERT(DATETIMEOFFSET, '{end_date}', 120) + WHERE {timestamp_field} BETWEEN CONVERT(DATETIMEOFFSET, '{start_date}', 120) AND CONVERT(DATETIMEOFFSET, '{end_date}', 120) ) outer_t WHERE outer_t._feast_row = 1 """ - self._make_engine(config.offline_store) + engine = _make_engine(config.offline_store) return MsSqlServerRetrievalJob( query=query, - engine=self._engine, - config=config, + engine=engine, + config=config.offline_store, full_feature_names=False, on_demand_feature_views=None, ) @staticmethod - #@log_exceptions_and_usage(offline_store="mssql") + # @log_exceptions_and_usage(offline_store="mssql") def pull_all_from_table_or_query( - self, config: RepoConfig, data_source: DataSource, join_key_columns: List[str], feature_name_columns: List[str], - event_timestamp_column: str, + timestamp_field: str, start_date: datetime, end_date: datetime, ) -> RetrievalJob: assert type(data_source).__name__ == "MsSqlServerSource" from_expression = data_source.get_table_query_string().replace("`", "") - timestamps = [event_timestamp_column] + timestamps = [timestamp_field] field_string = ", ".join(join_key_columns + feature_name_columns + timestamps) query = f""" @@ -140,34 +127,34 @@ def pull_all_from_table_or_query( FROM ( SELECT {field_string} FROM {from_expression} - WHERE {event_timestamp_column} BETWEEN TIMESTAMP '{start_date}' AND TIMESTAMP '{end_date}' + WHERE {timestamp_field} BETWEEN TIMESTAMP '{start_date}' AND TIMESTAMP '{end_date}' ) """ - self._make_engine(config.offline_store) + engine = _make_engine(config.offline_store) return MsSqlServerRetrievalJob( query=query, - engine=self._engine, - config=config, + engine=engine, + config=config.offline_store, full_feature_names=False, + on_demand_feature_views=None, ) @staticmethod - #@log_exceptions_and_usage(offline_store="mssql") + # @log_exceptions_and_usage(offline_store="mssql") def get_historical_features( - self, config: RepoConfig, feature_views: List[FeatureView], feature_refs: List[str], entity_df: Union[pandas.DataFrame, str], - registry: Registry, + registry: BaseRegistry, project: str, full_feature_names: bool = False, ) -> RetrievalJob: expected_join_keys = _get_join_keys(project, feature_views, registry) assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) - engine = self._make_engine(config.offline_store) + engine = _make_engine(config.offline_store) ( table_schema, @@ -203,7 +190,7 @@ def get_historical_features( job = MsSqlServerRetrievalJob( query=query, - engine=self._engine, + engine=engine, config=config.offline_store, full_feature_names=full_feature_names, on_demand_feature_views=registry.list_on_demand_feature_views(project), @@ -239,7 +226,7 @@ def _assert_expected_columns_in_sqlserver( def _get_join_keys( - project: str, feature_views: List[FeatureView], registry: Registry + project: str, feature_views: List[FeatureView], registry: BaseRegistry ) -> Set[str]: join_keys = set() for feature_view in feature_views: @@ -279,7 +266,7 @@ def __init__( self, query: str, engine: Engine, - config: RepoConfig, + config: MsSqlServerOfflineStoreConfig, full_feature_names: bool, on_demand_feature_views: Optional[List[OnDemandFeatureView]], metadata: Optional[RetrievalMetadata] = None, @@ -314,7 +301,8 @@ def persist(self, storage: SavedDatasetStorage): assert isinstance(storage, SavedDatasetFileStorage) filesystem, path = FileSource.create_filesystem_and_path( - storage.file_options.file_url, storage.file_options.s3_endpoint_override, + storage.file_options.uri, + storage.file_options.s3_endpoint_override, ) if path.endswith(".parquet"): @@ -341,7 +329,7 @@ class FeatureViewQueryContext: entities: List[str] features: List[str] # feature reference format table_ref: str - event_timestamp_column: str + timestamp_field: str created_timestamp_column: Optional[str] table_subquery: str entity_selections: List[str] @@ -351,7 +339,7 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( engine: sqlalchemy.engine.Engine, config: RepoConfig, entity_df: Union[pandas.DataFrame, str], -) -> EntitySchema: +) -> Tuple[Dict[Any, Any], str]: """ Uploads a Pandas entity dataframe into a SQL Server table and constructs the schema from the original entity_df dataframe. @@ -368,20 +356,19 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( limited_entity_df = MsSqlServerRetrievalJob( f"SELECT TOP 1 * FROM {table_id}", engine, - config, + config.offline_store, full_feature_names=False, on_demand_feature_views=None, ).to_df() - entity_schema = dict(zip(limited_entity_df.columns, limited_entity_df.dtypes)), table_id + entity_schema = ( + dict(zip(limited_entity_df.columns, limited_entity_df.dtypes)), + table_id, + ) elif isinstance(entity_df, pandas.DataFrame): # Drop the index so that we don't have unnecessary columns - entity_df.to_sql( - name=table_id, - con=engine, - index=False - ) + entity_df.to_sql(name=table_id, con=engine, index=False) entity_schema = dict(zip(entity_df.columns, entity_df.dtypes)), table_id else: raise ValueError( @@ -395,7 +382,7 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( def get_feature_view_query_context( feature_refs: List[str], feature_views: List[FeatureView], - registry: Registry, + registry: BaseRegistry, project: str, ) -> List[FeatureViewQueryContext]: """Build a query context containing all information required to template a point-in-time SQL query""" @@ -412,7 +399,7 @@ def get_feature_view_query_context( join_keys = [] entity_selections = [] reverse_field_mapping = { - v: k for k, v in feature_view.source.field_mapping.items() + v: k for k, v in feature_view.batch_source.field_mapping.items() } for entity_name in feature_view.entities: entity = registry.get_entity(entity_name, project) @@ -427,25 +414,27 @@ def get_feature_view_query_context( else: ttl_seconds = 0 - #assert isinstance(feature_view.source, MsSqlServerSource) + # assert isinstance(feature_view.source, MsSqlServerSource) - event_timestamp_column = feature_view.source.event_timestamp_column - created_timestamp_column = feature_view.source.created_timestamp_column + timestamp_field = feature_view.batch_source.timestamp_field + created_timestamp_column = feature_view.batch_source.created_timestamp_column context = FeatureViewQueryContext( name=feature_view.name, ttl=ttl_seconds, entities=join_keys, features=features, - table_ref=feature_view.source.table_ref, - event_timestamp_column=reverse_field_mapping.get( - event_timestamp_column, event_timestamp_column + table_ref=feature_view.batch_source.get_table_query_string().replace( + "`", "" ), + timestamp_field=reverse_field_mapping.get(timestamp_field, timestamp_field), created_timestamp_column=reverse_field_mapping.get( created_timestamp_column, created_timestamp_column ), # TODO: Make created column optional and not hardcoded - table_subquery=feature_view.source.get_table_query_string().replace("`", ""), + table_subquery=feature_view.batch_source.get_table_query_string().replace( + "`", "" + ), entity_selections=entity_selections, ) query_context.append(context) @@ -634,4 +623,4 @@ def build_point_in_time_query( ON {{ featureview.name }}__cleaned.{{ featureview.name }}__entity_row_unique_id = entity_dataframe.{{ featureview.name }}__entity_row_unique_id {% endfor %} -""" \ No newline at end of file +""" diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 41a1ea7792..dc3e6e9204 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -1,18 +1,21 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from typing import Callable, Dict, Iterable, Optional, Tuple import json +from typing import Callable, Dict, Iterable, Optional, Tuple import pandas from sqlalchemy import create_engine from feast import type_map from feast.data_source import DataSource +from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import ( + MsSqlServerOfflineStoreConfig, +) from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto -from feast.value_type import ValueType from feast.repo_config import RepoConfig -from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import MsSqlServerOfflineStoreConfig +from feast.value_type import ValueType + class MsSqlServerOptions: """ @@ -20,7 +23,9 @@ class MsSqlServerOptions: """ def __init__( - self, connection_str: Optional[str], table_ref: Optional[str], + self, + connection_str: Optional[str], + table_ref: Optional[str], ): self._connection_str = connection_str self._table_ref = table_ref @@ -67,7 +72,8 @@ def from_proto( options = json.loads(sqlserver_options_proto.configuration) sqlserver_options = cls( - table_ref=options["table_ref"], connection_str=options["connection_str"], + table_ref=options["table_ref"], + connection_str=options["connection_str"], ) return sqlserver_options @@ -94,6 +100,7 @@ def to_proto(self) -> DataSourceProto.CustomSourceOptions: class MsSqlServerSource(DataSource): def __init__( self, + name: str, table_ref: Optional[str] = None, event_timestamp_column: Optional[str] = None, created_timestamp_column: Optional[str] = "", @@ -103,7 +110,6 @@ def __init__( description: Optional[str] = None, tags: Optional[Dict[str, str]] = None, owner: Optional[str] = None, - name: Optional[str] = None ): self._mssqlserver_options = MsSqlServerOptions( connection_str=connection_str, table_ref=table_ref @@ -111,14 +117,14 @@ def __init__( self._connection_str = connection_str super().__init__( - created_timestamp_column = created_timestamp_column, - field_mapping = field_mapping, - date_partition_column = date_partition_column, - description = description, - tags = tags, - owner = owner, - name = name, - timestamp_field = event_timestamp_column, + created_timestamp_column=created_timestamp_column, + field_mapping=field_mapping, + date_partition_column=date_partition_column, + description=description, + tags=tags, + owner=owner, + name=name, + timestamp_field=event_timestamp_column, ) def __eq__(self, other): @@ -137,11 +143,14 @@ def __eq__(self, other): ) def __hash__(self): - return hash(( - self.name, - self.mssqlserver_options.connection_str, - self.timestamp_field, - self.created_timestamp_column)) + return hash( + ( + self.name, + self.mssqlserver_options.connection_str, + self.timestamp_field, + self.created_timestamp_column, + ) + ) @property def table_ref(self): @@ -199,12 +208,17 @@ def validate(self, config: RepoConfig): @staticmethod def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: - return type_map.mssqlserver_to_feast_value_type + raise NotImplementedError() + # return type_map.mssqlserver_to_feast_value_type - def get_table_column_names_and_types(self, config: RepoConfig) -> Iterable[Tuple[str, str]]: + def get_table_column_names_and_types( + self, config: RepoConfig + ) -> Iterable[Tuple[str, str]]: assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) conn = create_engine(config.offline_store.connection_string) - self._mssqlserver_options.connection_str = config.offline_store.connection_string + self._mssqlserver_options.connection_str = ( + config.offline_store.connection_string + ) name_type_pairs = [] database, table_name = self.table_ref.split(".") columns_query = f""" @@ -220,4 +234,4 @@ def get_table_column_names_and_types(self, config: RepoConfig) -> Iterable[Tuple ) ) ) - return name_type_pairs \ No newline at end of file + return name_type_pairs diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index d04ec35afb..669121a2ba 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -1,5 +1,5 @@ -import uuid import os +import uuid from typing import Dict, List from venv import create @@ -8,7 +8,9 @@ from pyspark.sql import SparkSession from feast.data_source import DataSource -from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import MsSqlServerOfflineStoreConfig +from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import ( + MsSqlServerOfflineStoreConfig, +) from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( MsSqlServerSource, ) @@ -16,15 +18,17 @@ DataSourceCreator, ) + class MsqlDataSourceCreator(DataSourceCreator): mssql_offline_store_config: MsSqlServerOfflineStoreConfig + def __init__(self, project_name: str, *args, **kwargs): super().__init__(project_name) if not self.mssql_offline_store_config: self.create_offline_store_config() def create_offline_store_config(self) -> MsSqlServerOfflineStoreConfig: - #TODO: Fill in connection string + # TODO: Fill in connection string connection_string = os.getenv("AZURE_CONNECTION_STRING", "") self.mssql_offline_store_config = MsSqlServerOfflineStoreConfig() self.mssql_offline_store_config.connection_string = connection_string @@ -60,6 +64,7 @@ def create_data_source( created_timestamp_column=created_timestamp_column, field_mapping=field_mapping, ) + def get_prefixed_table_name(self, destination_name: str) -> str: # TODO fix this return f"{self.project_name}_{destination_name}" diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index 5d329ae213..be22a2307f 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -1,8 +1,9 @@ from abc import ABC, abstractmethod +from collections import defaultdict from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union -from collections import defaultdict + import pandas as pd import pyarrow from tqdm import tqdm @@ -10,7 +11,6 @@ from feast import FeatureService, errors from feast.entity import Entity from feast.feature_view import FeatureView -from feast.on_demand_feature_view import OnDemandFeatureView from feast.importer import import_class from feast.infra.infra_object import Infra from feast.infra.offline_stores.offline_store import RetrievalJob diff --git a/sdk/python/feast/infra/registry/registry.py b/sdk/python/feast/infra/registry/registry.py index f6171aed2a..27c4ed9c5f 100644 --- a/sdk/python/feast/infra/registry/registry.py +++ b/sdk/python/feast/infra/registry/registry.py @@ -61,7 +61,7 @@ "S3RegistryStore": "feast.infra.aws.S3RegistryStore", "FileRegistryStore": "feast.infra.registry.file.FileRegistryStore", "PostgreSQLRegistryStore": "feast.infra.registry_stores.contrib.postgres.registry_store.PostgreSQLRegistryStore", - "AzureRegistryStore": "feast.infra.registry_stores.contrib.azure.registry_store.AzBlobRegistryStore" + "AzureRegistryStore": "feast.infra.registry_stores.contrib.azure.registry_store.AzBlobRegistryStore", } REGISTRY_STORE_CLASS_FOR_SCHEME = { diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/__init__.py b/sdk/python/feast/infra/registry_stores/contrib/azure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py index 2a120ce70b..79c59825d8 100644 --- a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py +++ b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py @@ -3,14 +3,14 @@ import os import uuid +from datetime import datetime +from pathlib import Path +from tempfile import TemporaryFile +from urllib.parse import urlparse from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.registry import RegistryConfig from feast.registry_store import RegistryStore -from pathlib import Path -from datetime import datetime -from urllib.parse import urlparse -from tempfile import TemporaryFile REGISTRY_SCHEMA_VERSION = "1" @@ -18,10 +18,10 @@ class AzBlobRegistryStore(RegistryStore): def __init__(self, registry_config: RegistryConfig, repo_path: Path): try: - from azure.storage.blob import BlobClient - from azure.identity import DefaultAzureCredential - from azure.storage.blob import BlobServiceClient import logging + + from azure.identity import DefaultAzureCredential + from azure.storage.blob import BlobClient, BlobServiceClient except ImportError as e: from feast.errors import FeastExtrasDependencyImportError @@ -39,9 +39,10 @@ def __init__(self, registry_config: RegistryConfig, repo_path: Path): logger.setLevel(logging.ERROR) # Attempt to use shared account key to login first - if 'REGISTRY_BLOB_KEY' in os.environ: + if "REGISTRY_BLOB_KEY" in os.environ: client = BlobServiceClient( - account_url=self._account_url, credential=os.environ['REGISTRY_BLOB_KEY'] + account_url=self._account_url, + credential=os.environ["REGISTRY_BLOB_KEY"], ) self.blob = client.get_blob_client( container=self._container, blob=self._path @@ -94,4 +95,4 @@ def _write_registry(self, registry_proto: RegistryProto): file_obj.write(registry_proto.SerializeToString()) file_obj.seek(0) self.blob.upload_blob(file_obj, overwrite=True) - return \ No newline at end of file + return diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 0fef47ae4d..9ab2556367 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -61,7 +61,7 @@ "trino": "feast.infra.offline_stores.contrib.trino_offline_store.trino.TrinoOfflineStore", "postgres": "feast.infra.offline_stores.contrib.postgres_offline_store.postgres.PostgreSQLOfflineStore", "athena": "feast.infra.offline_stores.contrib.athena_offline_store.athena.AthenaOfflineStore", - "mssql": "feast.infra.offline_stores.contrib.mssql_offline_store.mssql.MsSqlServerOfflineStore" + "mssql": "feast.infra.offline_stores.contrib.mssql_offline_store.mssql.MsSqlServerOfflineStore", } FEATURE_SERVER_CONFIG_CLASS_FOR_TYPE = { From 57b63bb3ce9e00531b0b5405e34457b54a2a7344 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 10 Aug 2022 16:22:39 -0700 Subject: [PATCH 04/51] Semi working state Signed-off-by: Kevin Zhang --- .../contrib/mssql_offline_store/mssql.py | 291 ++++++++++-------- .../mssql_offline_store/mssqlserver_source.py | 20 +- .../infra/offline_stores/offline_utils.py | 3 +- 3 files changed, 175 insertions(+), 139 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 66a7b23155..5f27236cf1 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -15,6 +15,7 @@ from sqlalchemy import create_engine from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker +from feast.errors import InvalidEntityType from feast import FileSource, errors from feast.data_source import DataSource @@ -34,8 +35,8 @@ from feast.registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage -from feast.utils import _get_requested_feature_views_to_features_dict - +from feast.utils import _get_requested_feature_views_to_features_dict, to_naive_utc +from feast.infra.offline_stores.offline_utils import FeatureViewQueryContext, build_point_in_time_query EntitySchema = Dict[str, np.dtype] @@ -50,13 +51,9 @@ class MsSqlServerOfflineStoreConfig(FeastBaseModel): format: SQLAlchemy connection string, e.g. mssql+pyodbc://sa:yourStrong(!)Password@localhost:1433/feast_test?driver=ODBC+Driver+17+for+SQL+Server""" -ENGINE = None - -def _make_engine(config: MsSqlServerOfflineStoreConfig) -> Engine: - if ENGINE is None: - ENGINE = create_engine(config.connection_string) - return ENGINE +def make_engine(config: MsSqlServerOfflineStoreConfig) -> Engine: + return create_engine(config.connection_string) class MsSqlServerOfflineStore(OfflineStore): @@ -96,7 +93,7 @@ def pull_latest_from_table_or_query( ) outer_t WHERE outer_t._feast_row = 1 """ - engine = _make_engine(config.offline_store) + engine = make_engine(config.offline_store) return MsSqlServerRetrievalJob( query=query, @@ -130,7 +127,7 @@ def pull_all_from_table_or_query( WHERE {timestamp_field} BETWEEN TIMESTAMP '{start_date}' AND TIMESTAMP '{end_date}' ) """ - engine = _make_engine(config.offline_store) + engine = make_engine(config.offline_store) return MsSqlServerRetrievalJob( query=query, @@ -154,7 +151,7 @@ def get_historical_features( expected_join_keys = _get_join_keys(project, feature_views, registry) assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) - engine = _make_engine(config.offline_store) + engine = make_engine(config.offline_store) ( table_schema, @@ -171,22 +168,29 @@ def get_historical_features( entity_df_event_timestamp_col, table_schema, ) + entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( + entity_df, + entity_df_event_timestamp_col, + engine, + ) # Build a query context containing all information required to template the SQL query query_context = get_feature_view_query_context( - feature_refs, feature_views, registry, project + feature_refs, feature_views, registry, project, entity_df_timestamp_range=entity_df_event_timestamp_range, ) # TODO: Infer min_timestamp and max_timestamp from entity_df # Generate the SQL query from the query context query = build_point_in_time_query( query_context, - min_timestamp=datetime.now() - timedelta(days=365), - max_timestamp=datetime.now() + timedelta(days=1), left_table_query_string=table_name, entity_df_event_timestamp_col=entity_df_event_timestamp_col, + entity_df_columns=table_schema.keys(), full_feature_names=full_feature_names, + query_template=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN, + ) + query = query.replace("`", "") job = MsSqlServerRetrievalJob( query=query, @@ -198,6 +202,82 @@ def get_historical_features( return job +def get_feature_view_query_context( + feature_refs: List[str], + feature_views: List[FeatureView], + registry: BaseRegistry, + project: str, + entity_df_timestamp_range: Tuple[datetime, datetime], +) -> List[FeatureViewQueryContext]: + """Build a query context containing all information required to template a point-in-time SQL query""" + + ( + feature_views_to_feature_map, + on_demand_feature_views_to_features, + ) = _get_requested_feature_views_to_features_dict( + feature_refs, feature_views, registry.list_on_demand_feature_views(project) + ) + + query_context = [] + for feature_view, features in feature_views_to_feature_map.items(): + join_keys = [] + entity_selections = [] + reverse_field_mapping = { + v: k for k, v in feature_view.batch_source.field_mapping.items() + } + for entity_name in feature_view.entities: + entity = registry.get_entity(entity_name, project) + join_keys.append(entity.join_key) + join_key_column = reverse_field_mapping.get( + entity.join_key, entity.join_key + ) + entity_selections.append(f"{join_key_column} AS {entity.join_key}") + + if isinstance(feature_view.ttl, timedelta): + ttl_seconds = int(feature_view.ttl.total_seconds()) + else: + ttl_seconds = 0 + + + timestamp_field = reverse_field_mapping.get( + feature_view.batch_source.timestamp_field, + feature_view.batch_source.timestamp_field, + ) + created_timestamp_column = reverse_field_mapping.get( + feature_view.batch_source.created_timestamp_column, + feature_view.batch_source.created_timestamp_column, + ) + + date_partition_column = reverse_field_mapping.get( + feature_view.batch_source.date_partition_column, + feature_view.batch_source.date_partition_column, + ) + max_event_timestamp = to_naive_utc(entity_df_timestamp_range[1]).isoformat() + min_event_timestamp = None + if feature_view.ttl: + min_event_timestamp = to_naive_utc( + entity_df_timestamp_range[0] - feature_view.ttl + ).isoformat() + + context = FeatureViewQueryContext( + name=feature_view.projection.name_to_use(), + ttl=ttl_seconds, + entities=join_keys, + features=features, + field_mapping=feature_view.batch_source.field_mapping, + timestamp_field=timestamp_field, + created_timestamp_column=created_timestamp_column, + # TODO: Make created column optional and not hardcoded + table_subquery=feature_view.batch_source.get_table_query_string(), + entity_selections=entity_selections, + min_event_timestamp=min_event_timestamp, + max_event_timestamp=max_event_timestamp, + date_partition_column=date_partition_column, + ) + query_context.append(context) + + return query_context + def _assert_expected_columns_in_dataframe( join_keys: Set[str], entity_df_event_timestamp_col: str, entity_df: pandas.DataFrame ): @@ -320,21 +400,6 @@ def metadata(self) -> Optional[RetrievalMetadata]: return self._metadata -@dataclass(frozen=True) -class FeatureViewQueryContext: - """Context object used to template a point-in-time SQL query""" - - name: str - ttl: int - entities: List[str] - features: List[str] # feature reference format - table_ref: str - timestamp_field: str - created_timestamp_column: Optional[str] - table_subquery: str - entity_selections: List[str] - - def _upload_entity_df_into_sqlserver_and_get_entity_schema( engine: sqlalchemy.engine.Engine, config: RepoConfig, @@ -378,99 +443,44 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( return entity_schema - -def get_feature_view_query_context( - feature_refs: List[str], - feature_views: List[FeatureView], - registry: BaseRegistry, - project: str, -) -> List[FeatureViewQueryContext]: - """Build a query context containing all information required to template a point-in-time SQL query""" - - ( - feature_views_to_feature_map, - on_demand_feature_views_to_features, - ) = _get_requested_feature_views_to_features_dict( - feature_refs, feature_views, registry.list_on_demand_feature_views(project) - ) - - query_context = [] - for feature_view, features in feature_views_to_feature_map.items(): - join_keys = [] - entity_selections = [] - reverse_field_mapping = { - v: k for k, v in feature_view.batch_source.field_mapping.items() - } - for entity_name in feature_view.entities: - entity = registry.get_entity(entity_name, project) - join_keys.append(entity.join_key) - join_key_column = reverse_field_mapping.get( - entity.join_key, entity.join_key +def _get_entity_df_event_timestamp_range( + entity_df: Union[pandas.DataFrame, str], + entity_df_event_timestamp_col: str, + engine: Session, +) -> Tuple[datetime, datetime]: + if isinstance(entity_df, pandas.DataFrame): + entity_df_event_timestamp = entity_df.loc[ + :, entity_df_event_timestamp_col + ].infer_objects() + if pandas.api.types.is_string_dtype(entity_df_event_timestamp): + entity_df_event_timestamp = pandas.to_datetime( + entity_df_event_timestamp, utc=True ) - entity_selections.append(f"{join_key_column} AS {entity.join_key}") - - if isinstance(feature_view.ttl, timedelta): - ttl_seconds = int(feature_view.ttl.total_seconds()) - else: - ttl_seconds = 0 - - # assert isinstance(feature_view.source, MsSqlServerSource) - - timestamp_field = feature_view.batch_source.timestamp_field - created_timestamp_column = feature_view.batch_source.created_timestamp_column - - context = FeatureViewQueryContext( - name=feature_view.name, - ttl=ttl_seconds, - entities=join_keys, - features=features, - table_ref=feature_view.batch_source.get_table_query_string().replace( - "`", "" - ), - timestamp_field=reverse_field_mapping.get(timestamp_field, timestamp_field), - created_timestamp_column=reverse_field_mapping.get( - created_timestamp_column, created_timestamp_column - ), - # TODO: Make created column optional and not hardcoded - table_subquery=feature_view.batch_source.get_table_query_string().replace( - "`", "" - ), - entity_selections=entity_selections, + entity_df_event_timestamp_range = ( + entity_df_event_timestamp.min().to_pydatetime(), + entity_df_event_timestamp.max().to_pydatetime(), ) - query_context.append(context) - return query_context - - -def build_point_in_time_query( - feature_view_query_contexts: List[FeatureViewQueryContext], - min_timestamp: datetime, - max_timestamp: datetime, - left_table_query_string: str, - entity_df_event_timestamp_col: str, - full_feature_names: bool = False, -): - - """Build point-in-time query between each feature view table and the entity dataframe""" - template = Environment(loader=BaseLoader()).from_string( - source=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN - ) - - # Add additional fields to dict - template_context = { - "min_timestamp": min_timestamp, - "max_timestamp": max_timestamp, - "left_table_query_string": left_table_query_string, - "entity_df_event_timestamp_col": entity_df_event_timestamp_col, - "unique_entity_keys": set( - [entity for fv in feature_view_query_contexts for entity in fv.entities] - ), - "featureviews": [asdict(context) for context in feature_view_query_contexts], - "full_feature_names": full_feature_names, - } - - query = template.render(template_context) - return query + elif isinstance(entity_df, str): + # If the entity_df is a string (SQL query), determine range + # from table + df = pandas.read_sql(entity_df, con=engine).fillna(value=np.nan) + entity_df_event_timestamp = df.loc[ + :, entity_df_event_timestamp_col + ].infer_objects() + if pandas.api.types.is_string_dtype(entity_df_event_timestamp): + entity_df_event_timestamp = pandas.to_datetime( + entity_df_event_timestamp, utc=True + ) + entity_df_event_timestamp_range = ( + entity_df_event_timestamp.min().to_pydatetime(), + entity_df_event_timestamp.max().to_pydatetime(), + ) + print(entity_df) + pass + else: + raise InvalidEntityType(type(entity_df)) + return entity_df_event_timestamp_range # TODO: Optimizations # * Use NEWID() instead of ROW_NUMBER(), or join on entity columns directly @@ -495,43 +505,53 @@ def build_point_in_time_query( {% endfor %} FROM {{ left_table_query_string }} ), + {% for featureview in featureviews %} + {{ featureview.name }}__entity_dataframe AS ( SELECT - {{ featureview.entities | join(', ')}}, + {{ featureview.entities | join(', ')}}{% if featureview.entities %},{% else %}{% endif %} entity_timestamp, {{featureview.name}}__entity_row_unique_id FROM entity_dataframe - GROUP BY {{ featureview.entities | join(', ')}}, entity_timestamp, {{featureview.name}}__entity_row_unique_id + GROUP BY + {{ featureview.entities | join(', ')}}{% if featureview.entities %},{% else %}{% endif %} + entity_timestamp, + {{featureview.name}}__entity_row_unique_id ), + /* This query template performs the point-in-time correctness join for a single feature set table to the provided entity table. + 1. We first join the current feature_view to the entity dataframe that has been passed. This JOIN has the following logic: - - For each row of the entity dataframe, only keep the rows where the `event_timestamp_column` + - For each row of the entity dataframe, only keep the rows where the timestamp_field` is less than the one provided in the entity dataframe - - If there a TTL for the current feature_view, also keep the rows where the `event_timestamp_column` + - If there a TTL for the current feature_view, also keep the rows where the `timestamp_field` is higher the the one provided minus the TTL - For each row, Join on the entity key and retrieve the `entity_row_unique_id` that has been computed previously + The output of this CTE will contain all the necessary information and already filtered out most of the data that is not relevant. */ + {{ featureview.name }}__subquery AS ( SELECT - t.{{ featureview.event_timestamp_column }} as event_timestamp, - {{ 't.' + featureview.created_timestamp_column ~ ' as created_timestamp,' if featureview.created_timestamp_column else '' }} - t.{{ featureview.entity_selections | join(', ')}}, + {{ featureview.timestamp_field }} as event_timestamp, + {{ featureview.created_timestamp_column ~ ' as created_timestamp,' if featureview.created_timestamp_column else '' }} + {{ featureview.entity_selections | join(', ')}}{% if featureview.entity_selections %},{% else %}{% endif %} {% for feature in featureview.features %} - {{ feature }} as {% if full_feature_names %}{{ featureview.name }}__{{feature}}{% else %}{{ feature }}{% endif %}{% if loop.last %}{% else %}, {% endif %} + {{ feature }} as {% if full_feature_names %}{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}{% else %}{{ featureview.field_mapping.get(feature, feature) }}{% endif %}{% if loop.last %}{% else %}, {% endif %} {% endfor %} - FROM {{ featureview.table_subquery }} t - WHERE {{ featureview.event_timestamp_column }} <= (SELECT MAX(entity_timestamp) FROM entity_dataframe) + FROM {{ featureview.table_subquery }} + WHERE {{ featureview.timestamp_field }} <= '{{ featureview.max_event_timestamp }}' {% if featureview.ttl == 0 %}{% else %} - AND {{ featureview.ttl }} >= DATEDIFF(SECOND, {{ featureview.event_timestamp_column }}, (SELECT MIN(entity_timestamp) FROM entity_dataframe) ) + AND {{ featureview.timestamp_field }} >= '{{ featureview.min_event_timestamp }}' {% endif %} ), + {{ featureview.name }}__base AS ( SELECT subquery.*, @@ -541,18 +561,20 @@ def build_point_in_time_query( INNER JOIN entity_dataframe ON 1=1 AND subquery.event_timestamp <= entity_dataframe.{{entity_df_event_timestamp_col}} + {% if featureview.ttl == 0 %}{% else %} AND {{ featureview.ttl }} > = DATEDIFF(SECOND, subquery.event_timestamp, entity_dataframe.{{entity_df_event_timestamp_col}}) {% endif %} + {% for entity in featureview.entities %} AND subquery.{{ entity }} = entity_dataframe.{{ entity }} {% endfor %} ), + /* 2. If the `created_timestamp_column` has been set, we need to deduplicate the data first. This is done by calculating the `MAX(created_at_timestamp)` for each event_timestamp. - We then join the data on the next CTE */ {% if featureview.created_timestamp_column %} @@ -565,6 +587,7 @@ def build_point_in_time_query( GROUP BY {{featureview.name}}__entity_row_unique_id, event_timestamp ), {% endif %} + /* 3. The data has been filtered during the first CTE "*__base" Thus we only need to compute the latest timestamp of each feature. @@ -576,6 +599,7 @@ def build_point_in_time_query( {% if featureview.created_timestamp_column %} ,MAX({{ featureview.name }}__base.created_timestamp) AS created_timestamp {% endif %} + FROM {{ featureview.name }}__base {% if featureview.created_timestamp_column %} INNER JOIN {{ featureview.name }}__dedup @@ -583,8 +607,10 @@ def build_point_in_time_query( AND {{ featureview.name }}__dedup.event_timestamp = {{ featureview.name }}__base.event_timestamp AND {{ featureview.name }}__dedup.created_timestamp = {{ featureview.name }}__base.created_timestamp {% endif %} + GROUP BY {{ featureview.name }}__base.{{ featureview.name }}__entity_row_unique_id ), + /* 4. Once we know the latest value of each feature for a given timestamp, we can join again the data back to the original "base" dataset @@ -599,11 +625,14 @@ def build_point_in_time_query( AND base.created_timestamp = {{ featureview.name }}__latest.created_timestamp {% endif %} ){% if loop.last %}{% else %}, {% endif %} + {% endfor %} + /* Joins the outputs of multiple time travel joins to a single table. The entity_dataframe dataset being our source of truth here. */ + SELECT entity_dataframe.* {% for featureview in featureviews %} {% for feature in featureview.features %} diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index dc3e6e9204..9fe38ce3d9 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -208,8 +208,7 @@ def validate(self, config: RepoConfig): @staticmethod def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: - raise NotImplementedError() - # return type_map.mssqlserver_to_feast_value_type + return lambda x: x def get_table_column_names_and_types( self, config: RepoConfig @@ -220,11 +219,18 @@ def get_table_column_names_and_types( config.offline_store.connection_string ) name_type_pairs = [] - database, table_name = self.table_ref.split(".") - columns_query = f""" - SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = '{table_name} and table_schema = {database}' - """ + if (len(self.table_ref.split(".")) == 2): + database, table_name = self.table_ref.split(".") + columns_query = f""" + SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '{table_name}' and table_schema = '{database}' + """ + else: + columns_query = f""" + SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '{self.table_ref}' + """ + table_schema = pandas.read_sql(columns_query, conn) name_type_pairs.extend( list( diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index 42b8f8497a..12cbe7bcc1 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -212,7 +212,8 @@ def build_point_in_time_query( "full_feature_names": full_feature_names, "final_output_feature_names": final_output_feature_names, } - + print("asdfasdf") + print(template_context["unique_entity_keys"]) query = template.render(template_context) return query From ae7ed8a2c4cbe4f1c2668666adab64d686465d77 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 10 Aug 2022 16:40:06 -0700 Subject: [PATCH 05/51] Fix Signed-off-by: Kevin Zhang --- .../contrib/mssql_offline_store/mssql.py | 82 +------------------ .../mssql_offline_store/mssqlserver_source.py | 2 +- .../infra/offline_stores/offline_utils.py | 2 - sdk/python/feast/type_map.py | 25 ++++++ sdk/python/feast/types.py | 1 + 5 files changed, 28 insertions(+), 84 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 5f27236cf1..8421f933cd 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -35,8 +35,7 @@ from feast.registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage -from feast.utils import _get_requested_feature_views_to_features_dict, to_naive_utc -from feast.infra.offline_stores.offline_utils import FeatureViewQueryContext, build_point_in_time_query +from feast.infra.offline_stores.offline_utils import build_point_in_time_query, get_feature_view_query_context EntitySchema = Dict[str, np.dtype] @@ -201,83 +200,6 @@ def get_historical_features( ) return job - -def get_feature_view_query_context( - feature_refs: List[str], - feature_views: List[FeatureView], - registry: BaseRegistry, - project: str, - entity_df_timestamp_range: Tuple[datetime, datetime], -) -> List[FeatureViewQueryContext]: - """Build a query context containing all information required to template a point-in-time SQL query""" - - ( - feature_views_to_feature_map, - on_demand_feature_views_to_features, - ) = _get_requested_feature_views_to_features_dict( - feature_refs, feature_views, registry.list_on_demand_feature_views(project) - ) - - query_context = [] - for feature_view, features in feature_views_to_feature_map.items(): - join_keys = [] - entity_selections = [] - reverse_field_mapping = { - v: k for k, v in feature_view.batch_source.field_mapping.items() - } - for entity_name in feature_view.entities: - entity = registry.get_entity(entity_name, project) - join_keys.append(entity.join_key) - join_key_column = reverse_field_mapping.get( - entity.join_key, entity.join_key - ) - entity_selections.append(f"{join_key_column} AS {entity.join_key}") - - if isinstance(feature_view.ttl, timedelta): - ttl_seconds = int(feature_view.ttl.total_seconds()) - else: - ttl_seconds = 0 - - - timestamp_field = reverse_field_mapping.get( - feature_view.batch_source.timestamp_field, - feature_view.batch_source.timestamp_field, - ) - created_timestamp_column = reverse_field_mapping.get( - feature_view.batch_source.created_timestamp_column, - feature_view.batch_source.created_timestamp_column, - ) - - date_partition_column = reverse_field_mapping.get( - feature_view.batch_source.date_partition_column, - feature_view.batch_source.date_partition_column, - ) - max_event_timestamp = to_naive_utc(entity_df_timestamp_range[1]).isoformat() - min_event_timestamp = None - if feature_view.ttl: - min_event_timestamp = to_naive_utc( - entity_df_timestamp_range[0] - feature_view.ttl - ).isoformat() - - context = FeatureViewQueryContext( - name=feature_view.projection.name_to_use(), - ttl=ttl_seconds, - entities=join_keys, - features=features, - field_mapping=feature_view.batch_source.field_mapping, - timestamp_field=timestamp_field, - created_timestamp_column=created_timestamp_column, - # TODO: Make created column optional and not hardcoded - table_subquery=feature_view.batch_source.get_table_query_string(), - entity_selections=entity_selections, - min_event_timestamp=min_event_timestamp, - max_event_timestamp=max_event_timestamp, - date_partition_column=date_partition_column, - ) - query_context.append(context) - - return query_context - def _assert_expected_columns_in_dataframe( join_keys: Set[str], entity_df_event_timestamp_col: str, entity_df: pandas.DataFrame ): @@ -475,8 +397,6 @@ def _get_entity_df_event_timestamp_range( entity_df_event_timestamp.min().to_pydatetime(), entity_df_event_timestamp.max().to_pydatetime(), ) - print(entity_df) - pass else: raise InvalidEntityType(type(entity_df)) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 9fe38ce3d9..98d129bb3a 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -208,7 +208,7 @@ def validate(self, config: RepoConfig): @staticmethod def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: - return lambda x: x + return type_map.mssql_to_feast_value_type def get_table_column_names_and_types( self, config: RepoConfig diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index 12cbe7bcc1..a1ceb607a3 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -212,8 +212,6 @@ def build_point_in_time_query( "full_feature_names": full_feature_names, "final_output_feature_names": final_output_feature_names, } - print("asdfasdf") - print(template_context["unique_entity_keys"]) query = template.render(template_context) return query diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index f8292b9c0d..d385590f97 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -532,6 +532,31 @@ def bq_to_feast_value_type(bq_type_as_str: str) -> ValueType: return value_type +def mssql_to_feast_value_type(mssql_type_as_str: str) -> ValueType: + type_map = { + "bigint": ValueType.FLOAT, + "binary": ValueType.BYTES, + "bit": ValueType.BOOL, + "char": ValueType.STRING, + "date": ValueType.UNIX_TIMESTAMP, + "datetime": ValueType.UNIX_TIMESTAMP, + "float": ValueType.FLOAT, + "nchar": ValueType.STRING, + "nvarchar": ValueType.STRING, + "nvarchar(max)": ValueType.STRING, + "real": ValueType.FLOAT, + "smallint": ValueType.INT32, + "tinyint": ValueType.INT32, + "varbinary": ValueType.BYTES, + "varchar": ValueType.STRING, + "None": ValueType.NULL, + # skip date, geometry, hllsketch, time, timetz + } + if mssql_type_as_str.lower() not in type_map: + raise ValueError(f"Mssql type not supported by feast {mssql_type_as_str}") + return type_map[mssql_type_as_str.lower()] + + def redshift_to_feast_value_type(redshift_type_as_str: str) -> ValueType: # Type names from https://docs.aws.amazon.com/redshift/latest/dg/c_Supported_data_types.html diff --git a/sdk/python/feast/types.py b/sdk/python/feast/types.py index 0ba1725f17..249871e883 100644 --- a/sdk/python/feast/types.py +++ b/sdk/python/feast/types.py @@ -13,6 +13,7 @@ # limitations under the License. from abc import ABC, abstractmethod from enum import Enum +from multiprocessing.sharedctypes import Value from typing import Dict, Union from feast.value_type import ValueType From 421645b6755e8bf6da0c1a23f19e87152216c77e Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 10 Aug 2022 16:41:10 -0700 Subject: [PATCH 06/51] Fremove print Signed-off-by: Kevin Zhang --- sdk/python/feast/feature_view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 3003a76cf4..41bad5828a 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -351,7 +351,6 @@ def from_proto(cls, feature_view_proto: FeatureViewProto): Returns: A FeatureViewProto object based on the feature view protobuf. """ - print(feature_view_proto.spec.batch_source) batch_source = DataSource.from_proto(feature_view_proto.spec.batch_source) stream_source = ( DataSource.from_proto(feature_view_proto.spec.stream_source) From 07fece53e3ba2bd7088d76b42342065c9de1aacb Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 11 Aug 2022 10:24:18 -0700 Subject: [PATCH 07/51] Fix lint Signed-off-by: Kevin Zhang --- .../feast/infra/contrib/azure_provider.py | 1 - .../contrib/mssql_offline_store/mssql.py | 35 ++++++++++--------- .../mssql_offline_store/mssqlserver_source.py | 2 +- .../mssql_offline_store/tests/data_source.py | 6 +--- sdk/python/feast/infra/provider.py | 1 - .../contrib/azure/registry_store.py | 8 ++--- sdk/python/feast/type_map.py | 2 +- sdk/python/feast/types.py | 1 - 8 files changed, 25 insertions(+), 31 deletions(-) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 405d85a357..4a7aba8090 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -21,7 +21,6 @@ from feast.registry import BaseRegistry from feast.repo_config import RepoConfig from feast.saved_dataset import SavedDataset -from feast.usage import RatioSampler, log_exceptions_and_usage, set_usage_attribute from feast.utils import ( _convert_arrow_to_proto, _get_column_names, diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 8421f933cd..a557385f2a 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -1,41 +1,37 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from dataclasses import asdict, dataclass -from datetime import datetime, timedelta +from datetime import datetime from typing import Any, Dict, List, Optional, Set, Tuple, Union import numpy as np import pandas import pyarrow import sqlalchemy -from jinja2 import BaseLoader, Environment from pydantic.types import StrictStr from pydantic.typing import Literal from sqlalchemy import create_engine from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session, sessionmaker -from feast.errors import InvalidEntityType +from sqlalchemy.orm import sessionmaker from feast import FileSource, errors from feast.data_source import DataSource +from feast.errors import InvalidEntityType from feast.feature_view import FeatureView from feast.infra.offline_stores import offline_utils from feast.infra.offline_stores.file_source import SavedDatasetFileStorage -from feast.infra.offline_stores.offline_store import ( - OfflineStore, - RetrievalJob, - RetrievalMetadata, -) +from feast.infra.offline_stores.offline_store import OfflineStore, RetrievalMetadata from feast.infra.offline_stores.offline_utils import ( DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL, + build_point_in_time_query, + get_feature_view_query_context, ) from feast.infra.provider import RetrievalJob from feast.on_demand_feature_view import OnDemandFeatureView from feast.registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage -from feast.infra.offline_stores.offline_utils import build_point_in_time_query, get_feature_view_query_context + EntitySchema = Dict[str, np.dtype] @@ -50,7 +46,6 @@ class MsSqlServerOfflineStoreConfig(FeastBaseModel): format: SQLAlchemy connection string, e.g. mssql+pyodbc://sa:yourStrong(!)Password@localhost:1433/feast_test?driver=ODBC+Driver+17+for+SQL+Server""" - def make_engine(config: MsSqlServerOfflineStoreConfig) -> Engine: return create_engine(config.connection_string) @@ -175,7 +170,11 @@ def get_historical_features( # Build a query context containing all information required to template the SQL query query_context = get_feature_view_query_context( - feature_refs, feature_views, registry, project, entity_df_timestamp_range=entity_df_event_timestamp_range, + feature_refs, + feature_views, + registry, + project, + entity_df_timestamp_range=entity_df_event_timestamp_range, ) # TODO: Infer min_timestamp and max_timestamp from entity_df @@ -187,7 +186,6 @@ def get_historical_features( entity_df_columns=table_schema.keys(), full_feature_names=full_feature_names, query_template=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN, - ) query = query.replace("`", "") @@ -200,6 +198,7 @@ def get_historical_features( ) return job + def _assert_expected_columns_in_dataframe( join_keys: Set[str], entity_df_event_timestamp_col: str, entity_df: pandas.DataFrame ): @@ -336,7 +335,7 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( if type(entity_df) is str: # TODO: This should be a temporary table, right? - session.execute(f"SELECT * INTO {table_id} FROM ({entity_df}) t") + session.execute(f"SELECT * INTO {table_id} FROM ({entity_df}) t") # type: ignore session.commit() @@ -365,10 +364,11 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( return entity_schema + def _get_entity_df_event_timestamp_range( entity_df: Union[pandas.DataFrame, str], entity_df_event_timestamp_col: str, - engine: Session, + engine: Engine, ) -> Tuple[datetime, datetime]: if isinstance(entity_df, pandas.DataFrame): entity_df_event_timestamp = entity_df.loc[ @@ -398,10 +398,11 @@ def _get_entity_df_event_timestamp_range( entity_df_event_timestamp.max().to_pydatetime(), ) else: - raise InvalidEntityType(type(entity_df)) + raise InvalidEntityType(type(entity_df)) return entity_df_event_timestamp_range + # TODO: Optimizations # * Use NEWID() instead of ROW_NUMBER(), or join on entity columns directly # * Precompute ROW_NUMBER() so that it doesn't have to be recomputed for every query on entity_dataframe diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 98d129bb3a..42cea809e5 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -219,7 +219,7 @@ def get_table_column_names_and_types( config.offline_store.connection_string ) name_type_pairs = [] - if (len(self.table_ref.split(".")) == 2): + if len(self.table_ref.split(".")) == 2: database, table_name = self.table_ref.split(".") columns_query = f""" SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index 669121a2ba..eaa6e871e6 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -1,11 +1,7 @@ import os -import uuid -from typing import Dict, List -from venv import create +from typing import Dict import pandas as pd -from pyspark import SparkConf -from pyspark.sql import SparkSession from feast.data_source import DataSource from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import ( diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index be22a2307f..7d3c37e4c2 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from collections import defaultdict from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py index 79c59825d8..d5cdbb00bb 100644 --- a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py +++ b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py @@ -21,7 +21,7 @@ def __init__(self, registry_config: RegistryConfig, repo_path: Path): import logging from azure.identity import DefaultAzureCredential - from azure.storage.blob import BlobClient, BlobServiceClient + from azure.storage.blob import BlobServiceClient except ImportError as e: from feast.errors import FeastExtrasDependencyImportError @@ -59,9 +59,9 @@ def __init__(self, registry_config: RegistryConfig, repo_path: Path): self.blob = client.get_blob_client( container=self._container, blob=self._path ) - except: + except Exception as e: print( - "Could not connect to blob. Check the following\nIs the URL specified correctly?\nIs you IAM role set to Storage Blob Data Contributor?\n" + f"Could not connect to blob. Check the following\nIs the URL specified correctly?\nIs you IAM role set to Storage Blob Data Contributor? \n Errored out with exception {e}" ) return @@ -94,5 +94,5 @@ def _write_registry(self, registry_proto: RegistryProto): file_obj = TemporaryFile() file_obj.write(registry_proto.SerializeToString()) file_obj.seek(0) - self.blob.upload_blob(file_obj, overwrite=True) + self.blob.upload_blob(file_obj, overwrite=True) # type: ignore return diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index d385590f97..02e142da64 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -532,6 +532,7 @@ def bq_to_feast_value_type(bq_type_as_str: str) -> ValueType: return value_type + def mssql_to_feast_value_type(mssql_type_as_str: str) -> ValueType: type_map = { "bigint": ValueType.FLOAT, @@ -557,7 +558,6 @@ def mssql_to_feast_value_type(mssql_type_as_str: str) -> ValueType: return type_map[mssql_type_as_str.lower()] - def redshift_to_feast_value_type(redshift_type_as_str: str) -> ValueType: # Type names from https://docs.aws.amazon.com/redshift/latest/dg/c_Supported_data_types.html type_map = { diff --git a/sdk/python/feast/types.py b/sdk/python/feast/types.py index 249871e883..0ba1725f17 100644 --- a/sdk/python/feast/types.py +++ b/sdk/python/feast/types.py @@ -13,7 +13,6 @@ # limitations under the License. from abc import ABC, abstractmethod from enum import Enum -from multiprocessing.sharedctypes import Value from typing import Dict, Union from feast.value_type import ValueType From 406203189054954d11bd6fb46ed1d1db93f7de7f Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 11 Aug 2022 10:26:05 -0700 Subject: [PATCH 08/51] Run build-sphinx Signed-off-by: Kevin Zhang --- .../docs/source/feast.protos.feast.serving.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sdk/python/docs/source/feast.protos.feast.serving.rst b/sdk/python/docs/source/feast.protos.feast.serving.rst index 792335b189..bffb7c8a9f 100644 --- a/sdk/python/docs/source/feast.protos.feast.serving.rst +++ b/sdk/python/docs/source/feast.protos.feast.serving.rst @@ -20,6 +20,22 @@ feast.protos.feast.serving.Connector\_pb2\_grpc module :undoc-members: :show-inheritance: +feast.protos.feast.serving.LoggingService\_pb2 module +----------------------------------------------------- + +.. automodule:: feast.protos.feast.serving.LoggingService_pb2 + :members: + :undoc-members: + :show-inheritance: + +feast.protos.feast.serving.LoggingService\_pb2\_grpc module +----------------------------------------------------------- + +.. automodule:: feast.protos.feast.serving.LoggingService_pb2_grpc + :members: + :undoc-members: + :show-inheritance: + feast.protos.feast.serving.ServingService\_pb2 module ----------------------------------------------------- From cb393293839b27cbe7ab7a15686246686206486e Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 11 Aug 2022 11:09:11 -0700 Subject: [PATCH 09/51] Add tutorials Signed-off-by: Kevin Zhang --- docs/tutorials/azure/README.md | 88 ++++ docs/tutorials/azure/data/data_generator.py | 260 +++++++++++ .../deployment/fs_sqldb_azuredeploy.json | 340 ++++++++++++++ .../deployment/fs_synapse_azuredeploy.json | 413 +++++++++++++++++ docs/tutorials/azure/media/arch.png | Bin 0 -> 113721 bytes docs/tutorials/azure/media/ci-kernel.png | Bin 0 -> 2873 bytes docs/tutorials/azure/media/ci.png | Bin 0 -> 40476 bytes docs/tutorials/azure/media/feast-overview.png | Bin 0 -> 84942 bytes .../azure/media/feast-tutorial-arch.png | Bin 0 -> 68733 bytes .../notebooks/feature_repo/feature_store.yaml | 11 + .../azure/notebooks/part1-load-data.ipynb | 224 ++++++++++ .../notebooks/part2-register-features.ipynb | 270 +++++++++++ .../part3-train-and-deploy-with-feast.ipynb | 420 ++++++++++++++++++ docs/tutorials/azure/notebooks/src/score.py | 75 ++++ .../azure/sql/create_cx_profile_table.sql | 14 + .../azure/sql/create_drivers_table.sql | 14 + .../azure/sql/create_orders_table.sql | 13 + .../azure/sql/load_cx_profile_data.sql | 8 + .../tutorials/azure/sql/load_drivers_data.sql | 8 + docs/tutorials/azure/sql/load_orders_data.sql | 8 + 20 files changed, 2166 insertions(+) create mode 100644 docs/tutorials/azure/README.md create mode 100644 docs/tutorials/azure/data/data_generator.py create mode 100644 docs/tutorials/azure/deployment/fs_sqldb_azuredeploy.json create mode 100644 docs/tutorials/azure/deployment/fs_synapse_azuredeploy.json create mode 100644 docs/tutorials/azure/media/arch.png create mode 100644 docs/tutorials/azure/media/ci-kernel.png create mode 100644 docs/tutorials/azure/media/ci.png create mode 100644 docs/tutorials/azure/media/feast-overview.png create mode 100644 docs/tutorials/azure/media/feast-tutorial-arch.png create mode 100644 docs/tutorials/azure/notebooks/feature_repo/feature_store.yaml create mode 100644 docs/tutorials/azure/notebooks/part1-load-data.ipynb create mode 100644 docs/tutorials/azure/notebooks/part2-register-features.ipynb create mode 100644 docs/tutorials/azure/notebooks/part3-train-and-deploy-with-feast.ipynb create mode 100644 docs/tutorials/azure/notebooks/src/score.py create mode 100644 docs/tutorials/azure/sql/create_cx_profile_table.sql create mode 100644 docs/tutorials/azure/sql/create_drivers_table.sql create mode 100644 docs/tutorials/azure/sql/create_orders_table.sql create mode 100644 docs/tutorials/azure/sql/load_cx_profile_data.sql create mode 100644 docs/tutorials/azure/sql/load_drivers_data.sql create mode 100644 docs/tutorials/azure/sql/load_orders_data.sql diff --git a/docs/tutorials/azure/README.md b/docs/tutorials/azure/README.md new file mode 100644 index 0000000000..65c086a565 --- /dev/null +++ b/docs/tutorials/azure/README.md @@ -0,0 +1,88 @@ +# Getting started with Feast on Azure + +The objective of this tutorial is to build a model that predicts if a driver will complete a trip based on a number of features ingested into Feast. During this tutorial you will: + +1. Deploy the infrastructure for a feature store (using an ARM template) +1. Register features into a central feature registry hosted on Blob Storage +1. Consume features from the feature store for training and inference + +## Prerequisites + +For this tutorial you will require: + +1. An Azure subscription. +1. Working knowledge of Python and ML concepts. +1. Basic understanding of Azure Machine Learning - using notebooks, etc. + +## 1. Deploy Infrastructure + +We have created an ARM template that deploys and configures all the infrastructure required to run feast in Azure. This makes the set-up very simple - select the **Deploy to Azure** button below. + +The only 2 required parameters during the set-up are: + +- **Admin Password** for the the Dedicated SQL Pool being deployed. +- **Principal ID** this is to set the storage permissions for the feast registry store. You can find the value for this by opening **Cloud Shell** and run the following command: + +```bash +# If you are using Azure portal CLI or Azure CLI 2.37.0 or above +az ad signed-in-user show --query id -o tsv + +# If you are using Azure CLI below 2.37.0 +az ad signed-in-user show --query objectId -o tsv +``` + +> You may want to first make sure your subscription has registered `Microsoft.Synapse`, `Microsoft.SQL`, `Microsoft.Network` and `Microsoft.Compute` providers before running the template below, as some of them may require explicit registration. If you are on a Free Subscription, you will not be able to deploy the workspace part of this tutorial. + +[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Ffeast-dev%2Ffeast%2Fmaster%2Fdocs%2Ftutorials%2Fazure%2Fdeployment%2Ffs_synapse_azuredeploy.json) + +![feast architecture](media/arch.png) + +The ARM template will not only deploy the infrastructure but it will also: + +- install feast with the azure provider on the compute instance +- set the Registry Blob path, Dedicated SQL Pool and Redis cache connection strings in the Azure ML default Keyvault. + +> **☕ It can take up to 20 minutes for the Redis cache to be provisioned.** + +## 2. Git clone this repo to your compute instance + +In the [Azure Machine Learning Studio](https://ml.azure.com), navigate to the left-hand menu and select **Compute**. You should see your compute instance running, select **Terminal** + +![compute instance terminal](media/ci.png) + +In the terminal you need to clone this GitHub repo: + +```bash +git clone https://github.com/feast-dev/feast +``` + +### 3. Load feature values into Feature Store + +In the Azure ML Studio, select *Notebooks* from the left-hand menu and then open the [Loading feature values into feature store notebook](./notebooks/part1-load-data.ipynb).Work through this notebook. + +> __💁Ensure the Jupyter kernel is set to Python 3.8 - AzureML__ + +![compute instance kernel](media/ci-kernel.png) + + +## 4. Register features in Feature store + +In the Azure ML Studio, select *Notebooks* from the left-hand menu and then open the [register features into your feature registry notebook](notebooks/part2-register-features.ipynb). Work through this notebook. + +> __💁Ensure the Jupyter kernel is set to Python 3.8 - AzureML__ + +## 5.Train and Deploy a model using the Feature Store + +In the Azure ML Studio, select *Notebooks* from the left-hand menu and then open the [train and deploy a model using feast notebook](notebooks/part3-train-and-deploy-with-feast.ipynb). Work through this notebook. + +> __💁Ensure the Jupyter kernel is set to Python 3.8 - AzureML__ +> +> If problems are encountered during model training stage, create a new cell and rexecute `!pip install scikit-learn==0.22.1`. Upon completion, restart the Kernel and start over. + +## 6. Running Feast Azure Tutorials locally without Azure workspace + +* If you are on a free tier instance, you will not be able to deploy the azure deployment because the azure workspace requires VCPUs and the free trial subscription does not have a quota. +* The workaround is to remove the `Microsoft.MachineLearningServices/workspaces/computes` resource from `fs_snapse_azure_deploy.json` and setting up the environment locally. + 1. After deployment, find your `Azure SQL Pool` secrets by going to `Subscriptions->->Resource Group->Key Vault` and giving your account admin permissions to the keyvault. Retrieve the `FEAST-REGISTRY-PATH`, `FEAST-OFFLINE-STORE-CONN`, and `FEAST-ONLINE-STORE-CONN` secrets to use in your local environment. + 2. In your local environment, you will need to install the azure cli and login to the cli using `az login`. + 3. After everything is setup, you should be able to work through the first 2 tutorial notebooks without any errors (The 3rd notebook requires Azure workspace resources). \ No newline at end of file diff --git a/docs/tutorials/azure/data/data_generator.py b/docs/tutorials/azure/data/data_generator.py new file mode 100644 index 0000000000..77fec08296 --- /dev/null +++ b/docs/tutorials/azure/data/data_generator.py @@ -0,0 +1,260 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import numpy as np +import pandas as pd +from datetime import datetime, timedelta +from pytz import FixedOffset, timezone, utc +from random import randint +from enum import Enum +from sqlalchemy import create_engine, DateTime +from datetime import datetime + +DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL = "event_timestamp" + + +class EventTimestampType(Enum): + TZ_NAIVE = 0 + TZ_AWARE_UTC = 1 + TZ_AWARE_FIXED_OFFSET = 2 + TZ_AWARE_US_PACIFIC = 3 + + +def _convert_event_timestamp(event_timestamp: pd.Timestamp, t: EventTimestampType): + if t == EventTimestampType.TZ_NAIVE: + return event_timestamp + elif t == EventTimestampType.TZ_AWARE_UTC: + return event_timestamp.replace(tzinfo=utc) + elif t == EventTimestampType.TZ_AWARE_FIXED_OFFSET: + return event_timestamp.replace(tzinfo=utc).astimezone(FixedOffset(60)) + elif t == EventTimestampType.TZ_AWARE_US_PACIFIC: + return event_timestamp.replace(tzinfo=utc).astimezone(timezone("US/Pacific")) + + +def create_orders_df( + customers, + drivers, + start_date, + end_date, + order_count, + infer_event_timestamp_col=False, +) -> pd.DataFrame: + """ + Example df generated by this function: + | order_id | driver_id | customer_id | order_is_success | event_timestamp | + +----------+-----------+-------------+------------------+---------------------+ + | 100 | 5004 | 1007 | 0 | 2021-03-10 19:31:15 | + | 101 | 5003 | 1006 | 0 | 2021-03-11 22:02:50 | + | 102 | 5010 | 1005 | 0 | 2021-03-13 00:34:24 | + | 103 | 5010 | 1001 | 1 | 2021-03-14 03:05:59 | + """ + df = pd.DataFrame() + df["order_id"] = [order_id for order_id in range(100, 100 + order_count)] + df["driver_id"] = np.random.choice(drivers, order_count) + df["customer_id"] = np.random.choice(customers, order_count) + df["order_is_success"] = np.random.randint(0, 2, size=order_count).astype(np.int32) + + if infer_event_timestamp_col: + df["e_ts"] = [ + _convert_event_timestamp( + pd.Timestamp(dt, unit="ms", tz="UTC").round("ms"), + EventTimestampType(3), + ) + for idx, dt in enumerate( + pd.date_range(start=start_date, end=end_date, periods=order_count) + ) + ] + df.sort_values( + by=["e_ts", "order_id", "driver_id", "customer_id"], inplace=True, + ) + else: + df[DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL] = [ + _convert_event_timestamp( + pd.Timestamp(dt, unit="ms", tz="UTC").round("ms"), + EventTimestampType(idx % 4), + ) + for idx, dt in enumerate( + pd.date_range(start=start_date, end=end_date, periods=order_count) + ) + ] + df.sort_values( + by=[ + DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL, + "order_id", + "driver_id", + "customer_id", + ], + inplace=True, + ) + return df + + +def create_driver_hourly_stats_df(drivers, start_date, end_date) -> pd.DataFrame: + """ + Example df generated by this function: + | datetime | driver_id | conv_rate | acc_rate | avg_daily_trips | created | + |------------------+-----------+-----------+----------+-----------------+------------------| + | 2021-03-17 19:31 | 5010 | 0.229297 | 0.685843 | 861 | 2021-03-24 19:34 | + | 2021-03-17 20:31 | 5010 | 0.781655 | 0.861280 | 769 | 2021-03-24 19:34 | + | 2021-03-17 21:31 | 5010 | 0.150333 | 0.525581 | 778 | 2021-03-24 19:34 | + | 2021-03-17 22:31 | 5010 | 0.951701 | 0.228883 | 570 | 2021-03-24 19:34 | + | 2021-03-17 23:31 | 5010 | 0.819598 | 0.262503 | 473 | 2021-03-24 19:34 | + | | ... | ... | ... | ... | | + | 2021-03-24 16:31 | 5001 | 0.061585 | 0.658140 | 477 | 2021-03-24 19:34 | + | 2021-03-24 17:31 | 5001 | 0.088949 | 0.303897 | 618 | 2021-03-24 19:34 | + | 2021-03-24 18:31 | 5001 | 0.096652 | 0.747421 | 480 | 2021-03-24 19:34 | + | 2021-03-17 19:31 | 5005 | 0.142936 | 0.707596 | 466 | 2021-03-24 19:34 | + | 2021-03-17 19:31 | 5005 | 0.142936 | 0.707596 | 466 | 2021-03-24 19:34 | + """ + df_hourly = pd.DataFrame( + { + "datetime": [ + pd.Timestamp(dt, unit="ms", tz="UTC").round("ms") + for dt in pd.date_range( + start=start_date, end=end_date, freq="1H", closed="left" + ) + ] + # include a fixed timestamp for get_historical_features in the quickstart + # + [ + # pd.Timestamp( + # year=2021, month=4, day=12, hour=7, minute=0, second=0, tz="UTC" + # ) + # ] + } + ) + df_all_drivers = pd.DataFrame() + dates = df_hourly["datetime"].map(pd.Timestamp.date).unique() + + for driver in drivers: + df_hourly_copy = df_hourly.copy() + df_hourly_copy["driver_id"] = driver + for date in dates: + df_hourly_copy.loc[ + df_hourly_copy["datetime"].map(pd.Timestamp.date) == date, + "avg_daily_trips", + ] = randint(10, 30) + df_all_drivers = pd.concat([df_hourly_copy, df_all_drivers]) + + df_all_drivers.reset_index(drop=True, inplace=True) + rows = df_all_drivers["datetime"].count() + + df_all_drivers["conv_rate"] = np.random.random(size=rows).astype(np.float32) + df_all_drivers["acc_rate"] = np.random.random(size=rows).astype(np.float32) + + df_all_drivers["created"] = pd.to_datetime(pd.Timestamp.now(tz=None).round("ms")) + + # Create duplicate rows that should be filtered by created timestamp + # TODO: These duplicate rows area indirectly being filtered out by the point in time join already. We need to + # inject a bad row at a timestamp where we know it will get joined to the entity dataframe, and then test that + # we are actually filtering it with the created timestamp + late_row = df_all_drivers.iloc[int(rows / 2)] + df_all_drivers = df_all_drivers.append(late_row).append(late_row) + + return df_all_drivers + + +def create_customer_daily_profile_df(customers, start_date, end_date) -> pd.DataFrame: + """ + Example df generated by this function: + | datetime | customer_id | current_balance | avg_passenger_count | lifetime_trip_count | created | + |------------------+-------------+-----------------+---------------------+---------------------+------------------| + | 2021-03-17 19:31 | 1010 | 0.889188 | 0.049057 | 412 | 2021-03-24 19:38 | + | 2021-03-18 19:31 | 1010 | 0.979273 | 0.212630 | 639 | 2021-03-24 19:38 | + | 2021-03-19 19:31 | 1010 | 0.976549 | 0.176881 | 70 | 2021-03-24 19:38 | + | 2021-03-20 19:31 | 1010 | 0.273697 | 0.325012 | 68 | 2021-03-24 19:38 | + | 2021-03-21 19:31 | 1010 | 0.438262 | 0.313009 | 192 | 2021-03-24 19:38 | + | | ... | ... | ... | ... | | + | 2021-03-19 19:31 | 1001 | 0.738860 | 0.857422 | 344 | 2021-03-24 19:38 | + | 2021-03-20 19:31 | 1001 | 0.848397 | 0.745989 | 106 | 2021-03-24 19:38 | + | 2021-03-21 19:31 | 1001 | 0.301552 | 0.185873 | 812 | 2021-03-24 19:38 | + | 2021-03-22 19:31 | 1001 | 0.943030 | 0.561219 | 322 | 2021-03-24 19:38 | + | 2021-03-23 19:31 | 1001 | 0.354919 | 0.810093 | 273 | 2021-03-24 19:38 | + """ + df_daily = pd.DataFrame( + { + "datetime": [ + pd.Timestamp(dt, unit="ms", tz="UTC").round("ms") + for dt in pd.date_range( + start=start_date, end=end_date, freq="1D", closed="left" + ) + ] + } + ) + df_all_customers = pd.DataFrame() + + for customer in customers: + df_daily_copy = df_daily.copy() + rows = df_daily_copy["datetime"].count() + df_daily_copy["customer_id"] = customer + df_daily_copy["current_balance"] = np.random.uniform( + low=10.0, high=50.0, size=rows + ).astype(np.float32) + df_daily_copy["lifetime_trip_count"] = np.linspace( + start=randint(10, 20), stop=randint(40, 50), num=rows + ).astype(np.int32) + df_daily_copy["avg_passenger_count"] = np.random.uniform( + low=1, high=3, size=rows + ).astype(np.float32) + df_all_customers = pd.concat([df_daily_copy, df_all_customers]) + + df_all_customers.reset_index(drop=True, inplace=True) + + rows = df_all_customers["datetime"].count() + + # TODO: Remove created timestamp in order to test whether its really optional + df_all_customers["created"] = pd.to_datetime(pd.Timestamp.now(tz=None).round("ms")) + return df_all_customers + + +def generate_entities(date, n_customers, n_drivers, order_count): + end_date = date + before_start_date = end_date - timedelta(days=365) + start_date = end_date - timedelta(days=7) + after_end_date = end_date + timedelta(days=365) + customer_entities = [20000 + c_id for c_id in range(n_customers)] + driver_entities = [50000 + d_id for d_id in range(n_drivers)] + orders_df = create_orders_df( + customers=customer_entities, + drivers=driver_entities, + start_date=start_date, + end_date=end_date, + order_count=order_count, + infer_event_timestamp_col=False, + ) + return customer_entities, driver_entities, end_date, orders_df, start_date + + +def save_df_to_csv(df, table_name, dtype): + df.to_csv(table_name+".csv", index=False) + + +if __name__ == "__main__": + start_date = datetime.now().replace(microsecond=0, second=0, minute=0) + ( + customer_entities, + driver_entities, + end_date, + orders_df, + start_date, + ) = generate_entities(start_date, 1000, 1000, 20000) + + customer_df = create_customer_daily_profile_df( + customer_entities, start_date, end_date + ) + print(customer_df.head()) + + drivers_df = create_driver_hourly_stats_df(driver_entities, start_date, end_date) + + print(drivers_df.head()) + + + orders_table = "orders" + driver_hourly_table = "driver_hourly" + customer_profile_table = "customer_profile" + + print("uploading orders") + save_df_to_csv(orders_df, orders_table, dtype={"event_timestamp": DateTime()}) + print("uploading drivers") + save_df_to_csv(drivers_df, driver_hourly_table, dtype={"datetime": DateTime()}) + print("uploading customers") + save_df_to_csv(customer_df, customer_profile_table, dtype={"datetime": DateTime()}) \ No newline at end of file diff --git a/docs/tutorials/azure/deployment/fs_sqldb_azuredeploy.json b/docs/tutorials/azure/deployment/fs_sqldb_azuredeploy.json new file mode 100644 index 0000000000..2846a5341d --- /dev/null +++ b/docs/tutorials/azure/deployment/fs_sqldb_azuredeploy.json @@ -0,0 +1,340 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "administratorLoginPassword": { + "type": "securestring", + "metadata": { + "description": "The administrator password of the SQL logical server." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Specifies the principal ID assigned to the role. You can find in cloud shell using 'az ad signed-in-user show --query id -o tsv'" + } + }, + "administratorLogin": { + "type": "string", + "metadata": { + "description": "The administrator username of the SQL logical server." + }, + "defaultValue": "azureuser" + }, + "location": { + "type": "string", + "metadata": { + "description": "description" + }, + "defaultValue": "[resourceGroup().location]" + }, + "registryBlobStore": { + "type": "string", + "metadata": { + "description": "Storage account to host the feast registry db" + }, + "defaultValue": "[concat('fsregistry',uniqueString(resourceGroup().id))]" + }, + "sqlServerName": { + "type": "string", + "metadata": { + "description": "The SQL Server Name" + }, + "defaultValue": "[concat('fssqlsvr',uniqueString(resourceGroup().id))]" + }, + "sqlDbName": { + "type": "string", + "metadata": { + "description": "SQL DB Name" + }, + "defaultValue": "[concat('fsoffline',uniqueString(resourceGroup().id))]" + }, + "redisCacheName": { + "type": "string", + "metadata": { + "description": "Redis Cache Name" + }, + "defaultValue": "[concat('fsonline',uniqueString(resourceGroup().id))]" + }, + "amlWorkspaceName": { + "type": "string", + "metadata": { + "description": "description" + }, + "defaultValue": "[concat('mlws',uniqueString(resourceGroup().id))]" + }, + "vmSize": { + "type": "string", + "metadata": { + "description": "description" + }, + "defaultValue": "Standard_DS3_v2" + }, + "roleDefinitionID": { + "type": "string", + "metadata": { + "description": "Specifies the role definition ID used in the role assignment." + }, + "defaultValue": "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + } + }, + "functions": [], + "variables": { + "tenantId": "[subscription().tenantId]", + "storageAccountName": "[concat('st', uniqueString(resourceGroup().id))]", + "keyVaultName": "[concat('kv-', uniqueString(resourceGroup().id))]", + "applicationInsightsName": "[concat('appi-', uniqueString(resourceGroup().id))]", + "containerRegistryName": "[concat('cr', uniqueString(resourceGroup().id))]", + "storageAccount": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]", + "registryAccount": "[resourceId('Microsoft.Storage/storageAccounts', parameters('registryBlobStore'))]", + "keyVault": "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", + "applicationInsights": "[resourceId('Microsoft.Insights/components', variables('applicationInsightsName'))]", + "containerRegistry": "[resourceId('Microsoft.ContainerRegistry/registries', variables('containerRegistryName'))]", + "redisCache": "[resourceId('Microsoft.Cache/redis', parameters('redisCacheName'))]", + "roleAssignmentName": "[guid(parameters('principalId'), parameters('roleDefinitionID'), resourceGroup().id)]" + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-01-01", + "name": "[variables('storageAccountName')]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard_RAGRS" + }, + "kind": "StorageV2", + "properties": { + "encryption": { + "services": { + "blob": { + "enabled": true + }, + "file": { + "enabled": true + } + }, + "keySource": "Microsoft.Storage" + }, + "supportsHttpsTrafficOnly": true + } + }, + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2021-04-01-preview", + "name": "[variables('keyVaultName')]", + "location": "[parameters('location')]", + "properties": { + "tenantId": "[variables('tenantId')]", + "sku": { + "name": "standard", + "family": "A" + }, + "accessPolicies": [], + "enableSoftDelete": true + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/FEAST-OFFLINE-STORE-CONN')]", + "apiVersion": "2019-09-01", + "location": "[resourceGroup().location]", + "properties": { + "value": "[concat('mssql+pyodbc://',parameters('administratorLogin'),':',parameters('administratorLoginPassword'),'@', parameters('sqlServerName'),'.database.windows.net:1433/', parameters('sqlDbName'), '?driver=ODBC+Driver+17+for+SQL+Server&autocommit=True')]" + }, + "dependsOn": [ + "[variables('keyVault')]" + ] + } + ] + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[variables('applicationInsightsName')]", + "location": "[if(or(equals(parameters('location'),'eastus2'), equals(parameters('location'),'westcentralus')),'southcentralus',parameters('location'))]", + "kind": "web", + "properties": { + "Application_Type": "web" + } + }, + { + "type": "Microsoft.ContainerRegistry/registries", + "sku": { + "name": "Standard", + "tier": "Standard" + }, + "name": "[variables('containerRegistryName')]", + "apiVersion": "2019-12-01-preview", + "location": "[parameters('location')]", + "properties": { + "adminUserEnabled": true + } + }, + { + "type": "Microsoft.MachineLearningServices/workspaces", + "apiVersion": "2021-04-01", + "name": "[parameters('amlWorkspaceName')]", + "location": "[resourceGroup().location]", + "identity": { + "type": "SystemAssigned" + }, + "tags": { + "displayName": "Azure ML Workspace" + }, + "dependsOn": [ + "[variables('storageAccount')]", + "[variables('keyVault')]", + "[variables('applicationInsights')]", + "[variables('containerRegistry')]" + ], + "properties": { + "storageAccount": "[variables('storageAccount')]", + "keyVault": "[variables('keyVault')]", + "applicationInsights": "[variables('applicationInsights')]", + "containerRegistry": "[variables('containerRegistry')]" + }, + "resources": [ + { + "type": "Microsoft.MachineLearningServices/workspaces/computes", + "name": "[concat(parameters('amlWorkspaceName'), '/', concat('ci-',uniqueString(resourceGroup().id)))]", + "apiVersion": "2021-07-01", + "dependsOn": [ + "[resourceId('Microsoft.MachineLearningServices/workspaces', concat(parameters('amlWorkspaceName')))]" + ], + "location": "[parameters('location')]", + "properties": { + "computeType": "ComputeInstance", + "properties": { + "vmSize": "[parameters('vmSize')]", + "setupScripts": { + "scripts": { + "creationScript": { + "scriptSource": "inline", + "scriptData": "[base64('conda activate azureml_py38;pip install feast[azure];pip install pymssql')]" + } + } + } + } + } + } + ] + }, + { + "name": "[parameters('registryBlobStore')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "tags": { + "displayName": "Feast Registry Store" + }, + "location": "[resourceGroup().location]", + "kind": "StorageV2", + "sku": { + "name": "Standard_LRS", + "tier": "Standard" + }, + "properties": { + "allowBlobPublicAccess": false + }, + "resources": [ + { + "type": "blobServices/containers", + "apiVersion": "2019-06-01", + "name": "[concat('default/', 'fs-reg-container')]", + "dependsOn": [ + "[variables('registryAccount')]" + ] + } + ] + }, + { + "name": "[parameters('sqlServerName')]", + "type": "Microsoft.Sql/servers", + "apiVersion": "2014-04-01", + "location": "[resourceGroup().location]", + "tags": { + "displayName": "Feast Offline Store Server" + }, + "properties": { + "administratorLogin": "[parameters('administratorLogin')]", + "administratorLoginPassword": "[parameters('administratorLoginPassword')]" + }, + "resources": [ + { + "type": "firewallRules", + "apiVersion": "2014-04-01", + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', concat(parameters('sqlServerName')))]" + ], + "location": "[resourceGroup().location]", + "name": "AllowAllWindowsAzureIps", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + } + }, + { + "name": "[parameters('sqlDbName')]", + "type": "databases", + "apiVersion": "2021-02-01-preview", + "location": "[resourceGroup().location]", + "sku": { + "tier": "Basic", + "name": "Basic" + }, + "tags": { + "displayName": "Feast Offline Store" + }, + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', concat(parameters('sqlServerName')))]" + ], + "properties": {} + } + ] + }, + { + "type": "Microsoft.Cache/redis", + "name": "[parameters('redisCacheName')]", + "apiVersion": "2020-12-01", + "location": "[resourceGroup().location]", + "tags": { + "displayName": "Feast Online Store" + }, + "properties": { + "sku": { + "name": "Basic", + "family": "C", + "capacity": 2 + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/FEAST-ONLINE-STORE-CONN')]", + "apiVersion": "2019-09-01", + "location": "[resourceGroup().location]", + "properties": { + "value": "[concat(parameters('redisCacheName'),'.redis.cache.windows.net:6380,password=',listKeys(concat('Microsoft.Cache/redis/', parameters('redisCacheName')), providers('Microsoft.Cache', 'Redis').apiVersions[0]).primaryKey, ',ssl=True')]" + }, + "dependsOn": [ + "[variables('keyVault')]", + "[variables('redisCache')]" + ] + } + ] + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2020-04-01-preview", + "name": "[variables('roleAssignmentName')]", + "properties": { + "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', parameters('roleDefinitionId'))]", + "principalId": "[parameters('principalId')]", + "scope": "[resourceGroup().id]" + }, + "dependsOn": [ + "[variables('registryAccount')]" + ] + } + ], + "outputs": {} +} \ No newline at end of file diff --git a/docs/tutorials/azure/deployment/fs_synapse_azuredeploy.json b/docs/tutorials/azure/deployment/fs_synapse_azuredeploy.json new file mode 100644 index 0000000000..476d332c56 --- /dev/null +++ b/docs/tutorials/azure/deployment/fs_synapse_azuredeploy.json @@ -0,0 +1,413 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "administratorLoginPassword": { + "type": "securestring", + "metadata": { + "description": "The administrator password of the SQL logical server." + } + }, + "principalId": { + "type": "string", + "metadata": { + "description": "Specifies the principal ID assigned to the role. You can find in cloud shell using 'az ad signed-in-user show --query id -o tsv'" + } + }, + "sku": { + "type": "string", + "defaultValue": "DW100c", + "allowedValues": [ + "DW100c", + "DW200c", + "DW300c", + "DW400c", + "DW500c", + "DW1000c", + "DW1500c", + "DW2000c", + "DW2500c", + "DW3000c" + ], + "metadata": { + "description": "Select the SKU of the SQL pool." + } + }, + "allowAllConnections": { + "type": "string", + "allowedValues": [ + "true", + "false" + ], + "defaultValue": "true", + "metadata": { + "description": "Specifies whether to allow client IPs to connect to Synapse" + } + }, + "administratorLogin": { + "type": "string", + "metadata": { + "description": "The administrator username of the SQL logical server." + }, + "defaultValue": "azureuser" + }, + "vmSize": { + "type": "string", + "metadata": { + "description": "description" + }, + "defaultValue": "Standard_DS3_v2" + }, + "roleDefinitionID": { + "type": "string", + "metadata": { + "description": "Specifies the role definition ID used in the role assignment. Defaults to Storage Blob Data Contributor." + }, + "defaultValue": "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + } + }, + "functions": [], + "variables": { + "location": "[resourceGroup().location]", + "tenantId": "[subscription().tenantId]", + "registryBlobStore": "[concat('fsregistry',uniqueString(resourceGroup().id))]", + "redisCacheName": "[concat('fsonline',uniqueString(resourceGroup().id))]", + "amlWorkspaceName": "[concat('ml',uniqueString(resourceGroup().id))]", + "synapseName": "[concat('sy',uniqueString(resourceGroup().id))]", + "storageAccountName": "[concat('st', uniqueString(resourceGroup().id))]", + "keyVaultName": "[concat('kv-', uniqueString(resourceGroup().id))]", + "applicationInsightsName": "[concat('appi-', uniqueString(resourceGroup().id))]", + "containerRegistryName": "[concat('cr', uniqueString(resourceGroup().id))]", + "storageAccount": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]", + "registryAccount": "[resourceId('Microsoft.Storage/storageAccounts', variables('registryBlobStore'))]", + "keyVault": "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", + "applicationInsights": "[resourceId('Microsoft.Insights/components', variables('applicationInsightsName'))]", + "containerRegistry": "[resourceId('Microsoft.ContainerRegistry/registries', variables('containerRegistryName'))]", + "redisCache": "[resourceId('Microsoft.Cache/redis', variables('redisCacheName'))]", + "roleAssignmentName": "[guid(parameters('principalId'), parameters('roleDefinitionID'), resourceGroup().id)]", + "sqlPoolName": "[toLower(concat(variables('workspaceName'),'p1'))]", + "workspaceName": "[toLower(concat(variables('synapseName'),'ws1'))]", + "dlsName": "[toLower(concat('dls',variables('synapseName')))]", + "dlsFsName": "[toLower(concat(variables('dlsName'),'fs1'))]" + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-01-01", + "name": "[variables('storageAccountName')]", + "location": "[variables('location')]", + "sku": { + "name": "Standard_RAGRS" + }, + "kind": "StorageV2", + "properties": { + "encryption": { + "services": { + "blob": { + "enabled": true + }, + "file": { + "enabled": true + } + }, + "keySource": "Microsoft.Storage" + }, + "supportsHttpsTrafficOnly": true + } + }, + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2021-04-01-preview", + "name": "[variables('keyVaultName')]", + "location": "[variables('location')]", + "properties": { + "tenantId": "[variables('tenantId')]", + "sku": { + "name": "standard", + "family": "A" + }, + "accessPolicies": [], + "enableSoftDelete": true + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/FEAST-OFFLINE-STORE-CONN')]", + "apiVersion": "2019-09-01", + "location": "[resourceGroup().location]", + "properties": { + "value": "[concat('mssql+pyodbc://',parameters('administratorLogin'),':',parameters('administratorLoginPassword'),'@', variables('workspaceName'),'.database.windows.net:1433/', variables('sqlPoolName'), '?driver=ODBC+Driver+17+for+SQL+Server&autocommit=True')]" + }, + "dependsOn": [ + "[variables('keyVault')]" + ] + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/FEAST-REGISTRY-PATH')]", + "apiVersion": "2019-09-01", + "location": "[resourceGroup().location]", + "properties": { + "value": "[concat('https://',variables('registryBlobStore'),'.blob.core.windows.net/fs-reg-container/registry.db')]" + }, + "dependsOn": [ + "[variables('keyVault')]" + ] + } + ] + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[variables('applicationInsightsName')]", + "location": "[if(or(equals(variables('location'),'eastus2'), equals(variables('location'),'westcentralus')),'southcentralus',variables('location'))]", + "kind": "web", + "properties": { + "Application_Type": "web" + } + }, + { + "type": "Microsoft.ContainerRegistry/registries", + "sku": { + "name": "Standard", + "tier": "Standard" + }, + "name": "[variables('containerRegistryName')]", + "apiVersion": "2019-12-01-preview", + "location": "[variables('location')]", + "properties": { + "adminUserEnabled": true + } + }, + { + "type": "Microsoft.MachineLearningServices/workspaces", + "apiVersion": "2021-04-01", + "name": "[variables('amlWorkspaceName')]", + "location": "[resourceGroup().location]", + "identity": { + "type": "SystemAssigned" + }, + "tags": { + "displayName": "Azure ML Workspace" + }, + "dependsOn": [ + "[variables('storageAccount')]", + "[variables('keyVault')]", + "[variables('applicationInsights')]", + "[variables('containerRegistry')]" + ], + "properties": { + "storageAccount": "[variables('storageAccount')]", + "keyVault": "[variables('keyVault')]", + "applicationInsights": "[variables('applicationInsights')]", + "containerRegistry": "[variables('containerRegistry')]" + }, + "resources": [ + { + "type": "Microsoft.MachineLearningServices/workspaces/computes", + "name": "[concat(variables('amlWorkspaceName'), '/', concat('ci-',uniqueString(resourceGroup().id)))]", + "apiVersion": "2021-07-01", + "dependsOn": [ + "[resourceId('Microsoft.MachineLearningServices/workspaces', concat(variables('amlWorkspaceName')))]" + ], + "location": "[variables('location')]", + "properties": { + "computeType": "ComputeInstance", + "properties": { + "vmSize": "[parameters('vmSize')]", + "setupScripts": { + "scripts": { + "creationScript": { + "scriptSource": "inline", + "scriptData": "[base64('conda activate azureml_py38;pip install feast[azure];pip install pymssql')]" + } + } + } + } + } + } + ] + }, + { + "name": "[variables('registryBlobStore')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "tags": { + "displayName": "Feast Registry Store" + }, + "location": "[resourceGroup().location]", + "kind": "StorageV2", + "sku": { + "name": "Standard_LRS", + "tier": "Standard" + }, + "properties": { + "allowBlobPublicAccess": false + }, + "resources": [ + { + "type": "blobServices/containers", + "apiVersion": "2019-06-01", + "name": "[concat('default/', 'fs-reg-container')]", + "dependsOn": [ + "[variables('registryAccount')]" + ] + } + ] + }, + { + "type": "Microsoft.Cache/redis", + "name": "[variables('redisCacheName')]", + "apiVersion": "2020-12-01", + "location": "[resourceGroup().location]", + "tags": { + "displayName": "Feast Online Store" + }, + "properties": { + "sku": { + "name": "Basic", + "family": "C", + "capacity": 2 + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "name": "[concat(variables('keyVaultName'), '/FEAST-ONLINE-STORE-CONN')]", + "apiVersion": "2019-09-01", + "location": "[resourceGroup().location]", + "properties": { + "value": "[concat(variables('redisCacheName'),'.redis.cache.windows.net:6380,password=',listKeys(concat('Microsoft.Cache/redis/', variables('redisCacheName')), providers('Microsoft.Cache', 'Redis').apiVersions[0]).primaryKey, ',ssl=True')]" + }, + "dependsOn": [ + "[variables('keyVault')]", + "[variables('redisCache')]" + ] + } + ] + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2020-04-01-preview", + "name": "[variables('roleAssignmentName')]", + "properties": { + "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', parameters('roleDefinitionId'))]", + "principalId": "[parameters('principalId')]", + "scope": "[resourceGroup().id]" + }, + "dependsOn": [ + "[variables('registryAccount')]" + ] + }, + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2019-06-01", + "name": "[variables('dlsName')]", + "location": "[variables('location')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "accessTier": "Hot", + "supportsHttpsTrafficOnly": true, + "isHnsEnabled": true + }, + "resources": [ + { + "name": "[concat('default/', variables('dlsFsName'))]", + "type": "blobServices/containers", + "apiVersion": "2019-06-01", + "dependsOn": [ + "[variables('dlsName')]" + ], + "properties": { + "publicAccess": "None" + } + } + ] + }, + { + "type": "Microsoft.Synapse/workspaces", + "apiVersion": "2019-06-01-preview", + "name": "[variables('workspaceName')]", + "location": "[variables('location')]", + "identity": { + "type": "SystemAssigned" + }, + "dependsOn": [ + "[variables('dlsName')]", + "[variables('dlsFsName')]" + ], + "properties": { + "defaultDataLakeStorage": { + "accountUrl": "[reference(variables('dlsName')).primaryEndpoints.dfs]", + "filesystem": "[variables('dlsFsName')]" + }, + "sqlAdministratorLogin": "[parameters('administratorLogin')]", + "sqlAdministratorLoginPassword": "[parameters('administratorLoginPassword')]", + "managedVirtualNetwork": "default" + }, + "resources": [ + { + "condition": "[equals(parameters('allowAllConnections'),'true')]", + "type": "firewallrules", + "apiVersion": "2019-06-01-preview", + "name": "allowAll", + "location": "[variables('location')]", + "dependsOn": [ + "[variables('workspaceName')]" + ], + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + } + }, + { + "type": "firewallrules", + "apiVersion": "2019-06-01-preview", + "name": "AllowAllWindowsAzureIps", + "location": "[variables('location')]", + "dependsOn": [ + "[variables('workspaceName')]" + ], + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + } + }, + { + "type": "managedIdentitySqlControlSettings", + "apiVersion": "2019-06-01-preview", + "name": "default", + "location": "[variables('location')]", + "dependsOn": [ + "[variables('workspaceName')]" + ], + "properties": { + "grantSqlControlToManagedIdentity": { + "desiredState": "Enabled" + } + } + } + ] + }, + { + "type": "Microsoft.Synapse/workspaces/sqlPools", + "apiVersion": "2019-06-01-preview", + "name": "[concat(variables('workspaceName'), '/', variables('sqlPoolName'))]", + "location": "[variables('location')]", + "sku": { + "name": "[parameters('sku')]" + }, + "dependsOn": [ + "[variables('workspaceName')]" + ], + "properties": { + "createMode": "Default", + "collation": "SQL_Latin1_General_CP1_CI_AS" + } + } + ], + "outputs": {} +} \ No newline at end of file diff --git a/docs/tutorials/azure/media/arch.png b/docs/tutorials/azure/media/arch.png new file mode 100644 index 0000000000000000000000000000000000000000..c386c65f53f60d6ccfa790b52b76f36e8a0fbaee GIT binary patch literal 113721 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/ToVsawYX}I%-phjxbJ@BAaQXid3R~s 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 literal 0 HcmV?d00001 diff --git a/docs/tutorials/azure/media/ci-kernel.png b/docs/tutorials/azure/media/ci-kernel.png new file mode 100644 index 0000000000000000000000000000000000000000..eeab1993b823cf1cea6bf401cb940a3f50c82752 GIT binary patch literal 2873 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 literal 0 HcmV?d00001 diff --git a/docs/tutorials/azure/media/ci.png b/docs/tutorials/azure/media/ci.png new file mode 100644 index 0000000000000000000000000000000000000000..3b93391efccf4a865cbcada70a69f3fa504097ba GIT binary patch literal 40476 zcmdqIWl&r}yDmBe3mzcoAPI!v?oNU`1Q}d1xZ9w?LVyGf?hqUXx4~V51{-vc;2zwQ zGs$;$)%|m7*R6fepF35sW_9)S>ea7xzt8i2(NL4e!=}Ur004N33Nr5i0CeP6l)a}< zke7EKF>e3fxxbTt3#c5Y+C$!;S-(+z0|3;-;yjrD?TY21pzjU

28hLy6rwwL~@& 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 IE?PhRALE*iumAu6 literal 0 HcmV?d00001 diff --git a/docs/tutorials/azure/media/feast-overview.png b/docs/tutorials/azure/media/feast-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..d8eb54514305a8339a41d81e412bf3d9326829e9 GIT binary patch literal 84942 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≫OG`#CF7phW`bxLnzSEyriHUCpYlg#cv6&E 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

>&Cf2YJE}^$|usKE87W&3pAnvp=M5?!6*a+Mw89 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

  • mEET}L(AS}DELnYe+7z!{?mP~m#b>A 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 0: + data = feature_vector[ + [ + "conv_rate", + "avg_daily_trips", + "acc_rate", + "current_balance", + "avg_passenger_count", + "lifetime_trip_count", + ] + ] + + y_hat = model.predict(data) + return y_hat.tolist() + else: + return 0.0 \ No newline at end of file diff --git a/docs/tutorials/azure/sql/create_cx_profile_table.sql b/docs/tutorials/azure/sql/create_cx_profile_table.sql new file mode 100644 index 0000000000..c1cd09c9f3 --- /dev/null +++ b/docs/tutorials/azure/sql/create_cx_profile_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE dbo.customer_profile +( + [datetime] DATETIME2(0), + [customer_id] bigint, + [current_balance] float, + [lifetime_trip_count] bigint, + [avg_passenger_count] float, + [created] datetime2(3) +) +WITH +( +DISTRIBUTION = ROUND_ROBIN, + CLUSTERED COLUMNSTORE INDEX +) diff --git a/docs/tutorials/azure/sql/create_drivers_table.sql b/docs/tutorials/azure/sql/create_drivers_table.sql new file mode 100644 index 0000000000..39b4b1371d --- /dev/null +++ b/docs/tutorials/azure/sql/create_drivers_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE dbo.driver_hourly +( + [datetime] DATETIME2(0), + [driver_id] bigint, + [avg_daily_trips] float, + [conv_rate] float, + [acc_rate] float, + [created] datetime2(3) +) +WITH +( +DISTRIBUTION = ROUND_ROBIN, + CLUSTERED COLUMNSTORE INDEX +) diff --git a/docs/tutorials/azure/sql/create_orders_table.sql b/docs/tutorials/azure/sql/create_orders_table.sql new file mode 100644 index 0000000000..e2325e85f6 --- /dev/null +++ b/docs/tutorials/azure/sql/create_orders_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE dbo.orders +( + [order_id] bigint, + [driver_id] bigint, + [customer_id] bigint, + [order_is_success] int, + [event_timestamp] datetime2(3) +) +WITH +( +DISTRIBUTION = ROUND_ROBIN, + CLUSTERED COLUMNSTORE INDEX +) diff --git a/docs/tutorials/azure/sql/load_cx_profile_data.sql b/docs/tutorials/azure/sql/load_cx_profile_data.sql new file mode 100644 index 0000000000..c3f55f4d72 --- /dev/null +++ b/docs/tutorials/azure/sql/load_cx_profile_data.sql @@ -0,0 +1,8 @@ +COPY INTO dbo.customer_profile +FROM 'https://feastonazuredatasamples.blob.core.windows.net/feastdatasamples/customer_profile.csv' +WITH +( + FILE_TYPE = 'CSV' + ,FIRSTROW = 2 + ,MAXERRORS = 0 +) diff --git a/docs/tutorials/azure/sql/load_drivers_data.sql b/docs/tutorials/azure/sql/load_drivers_data.sql new file mode 100644 index 0000000000..37aa357b9d --- /dev/null +++ b/docs/tutorials/azure/sql/load_drivers_data.sql @@ -0,0 +1,8 @@ +COPY INTO dbo.driver_hourly +FROM 'https://feastonazuredatasamples.blob.core.windows.net/feastdatasamples/driver_hourly.csv' +WITH +( + FILE_TYPE = 'CSV' + ,FIRSTROW = 2 + ,MAXERRORS = 0 +) diff --git a/docs/tutorials/azure/sql/load_orders_data.sql b/docs/tutorials/azure/sql/load_orders_data.sql new file mode 100644 index 0000000000..eaa062eac2 --- /dev/null +++ b/docs/tutorials/azure/sql/load_orders_data.sql @@ -0,0 +1,8 @@ +COPY INTO dbo.orders +FROM 'https://feastonazuredatasamples.blob.core.windows.net/feastdatasamples/orders.csv' +WITH +( + FILE_TYPE = 'CSV' + ,FIRSTROW = 2 + ,MAXERRORS = 0 +) From 554ca1a512168619e911824abc32cfd595d87a62 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 11 Aug 2022 11:25:20 -0700 Subject: [PATCH 10/51] Fix Signed-off-by: Kevin Zhang --- docs/getting-started/concepts/registry.md | 22 +++---- docs/reference/data-sources/README.md | 8 ++- docs/reference/data-sources/mssql.md | 29 +++++++++ docs/reference/offline-stores/mssql.md | 59 +++++++++++++++++++ docs/reference/providers/README.md | 2 + docs/reference/providers/azure.md | 26 ++++++++ .../contrib/mssql_offline_store/mssql.py | 7 ++- 7 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 docs/reference/data-sources/mssql.md create mode 100644 docs/reference/offline-stores/mssql.md create mode 100644 docs/reference/providers/azure.md diff --git a/docs/getting-started/concepts/registry.md b/docs/getting-started/concepts/registry.md index 99d6d746d5..ac34829008 100644 --- a/docs/getting-started/concepts/registry.md +++ b/docs/getting-started/concepts/registry.md @@ -1,15 +1,15 @@ # Registry -Feast uses a registry to store all applied Feast objects (e.g. Feature views, entities, etc). The registry exposes +Feast uses a registry to store all applied Feast objects (e.g. Feature views, entities, etc). The registry exposes methods to apply, list, retrieve and delete these objects, and is an abstraction with multiple implementations. ### Options for registry implementations #### File-based registry -By default, Feast uses a file-based registry implementation, which stores the protobuf representation of the registry as -a serialized file. This registry file can be stored in a local file system, or in cloud storage (in, say, S3 or GCS). +By default, Feast uses a file-based registry implementation, which stores the protobuf representation of the registry as +a serialized file. This registry file can be stored in a local file system, or in cloud storage (in, say, S3 or GCS, or Azure). -The quickstart guides that use `feast init` will use a registry on a local file system. To allow Feast to configure +The quickstart guides that use `feast init` will use a registry on a local file system. To allow Feast to configure a remote file registry, you need to create a GCS / S3 bucket that Feast can understand: {% tabs %} {% tab title="Example S3 file registry" %} @@ -35,9 +35,9 @@ offline_store: {% endtab %} {% endtabs %} -However, there are inherent limitations with a file-based registry, since changing a single field in the registry -requires re-writing the whole registry file. With multiple concurrent writers, this presents a risk of data loss, or -bottlenecks writes to the registry since all changes have to be serialized (e.g. when running materialization for +However, there are inherent limitations with a file-based registry, since changing a single field in the registry +requires re-writing the whole registry file. With multiple concurrent writers, this presents a risk of data loss, or +bottlenecks writes to the registry since all changes have to be serialized (e.g. when running materialization for multiple feature views or time ranges concurrently). #### SQL Registry @@ -47,14 +47,14 @@ This supports any SQLAlchemy compatible database as a backend. The exact schema ### Updating the registry -We recommend users store their Feast feature definitions in a version controlled repository, which then via CI/CD -automatically stays synced with the registry. Users will often also want multiple registries to correspond to -different environments (e.g. dev vs staging vs prod), with staging and production registries with locked down write +We recommend users store their Feast feature definitions in a version controlled repository, which then via CI/CD +automatically stays synced with the registry. Users will often also want multiple registries to correspond to +different environments (e.g. dev vs staging vs prod), with staging and production registries with locked down write access since they can impact real user traffic. See [Running Feast in Production](../../how-to-guides/running-feast-in-production.md#1.-automatically-deploying-changes-to-your-feature-definitions) for details on how to set this up. ### Accessing the registry from clients -Users can specify the registry through a `feature_store.yaml` config file, or programmatically. We often see teams +Users can specify the registry through a `feature_store.yaml` config file, or programmatically. We often see teams preferring the programmatic approach because it makes notebook driven development very easy: #### Option 1: programmatically specifying the registry diff --git a/docs/reference/data-sources/README.md b/docs/reference/data-sources/README.md index 6ab2e4b083..ae5e25dbc5 100644 --- a/docs/reference/data-sources/README.md +++ b/docs/reference/data-sources/README.md @@ -35,9 +35,13 @@ Please see [Data Source](../../getting-started/concepts/data-ingestion.md) for a {% endcontent-ref %} {% content-ref url="postgres.md" %} -[postgres.md]([postgres].md) +[postgres.md](postgres.md) {% endcontent-ref %} {% content-ref url="trino.md" %} -[trino.md]([trino].md) +[trino.md](trino.md) +{% endcontent-ref %} + +{% content-ref url="mssql.md" %} +[mssql.md](mssql.md) {% endcontent-ref %} diff --git a/docs/reference/data-sources/mssql.md b/docs/reference/data-sources/mssql.md new file mode 100644 index 0000000000..9e7fbdb3b2 --- /dev/null +++ b/docs/reference/data-sources/mssql.md @@ -0,0 +1,29 @@ +# MsSql source (contrib) + +## Description + +MsSql data sources are Microsoft sql table sources. +These can be specified either by a table reference or a SQL query. + +## Disclaimer + +The MsSql data source does not achieve full test coverage. +Please do not assume complete stability. + +## Examples + +Defining a MsSql source: + +```python +from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( + MsSqlServerSource, +) + +driver_hourly_table = "driver_hourly" + +driver_source = MsSqlServerSource( + table_ref=driver_hourly_table, + event_timestamp_column="datetime", + created_timestamp_column="created", +) +``` diff --git a/docs/reference/offline-stores/mssql.md b/docs/reference/offline-stores/mssql.md new file mode 100644 index 0000000000..e6196f03f4 --- /dev/null +++ b/docs/reference/offline-stores/mssql.md @@ -0,0 +1,59 @@ +# MsSQl offline store (contrib) + +## Description + +The MsSql offline store provides support for reading [MsSQL Sources](../data-sources/mssql.md). + +* Entity dataframes can be provided as a SQL query or can be provided as a Pandas dataframe. + +## Disclaimer + +The MsSql offline store does not achieve full test coverage. +Please do not assume complete stability. + +## Example + +{% code title="feature_store.yaml" %} +```yaml +registry: + registry_store_type: AzureRegistryStore + path: ${REGISTRY_PATH} # Environment Variable +project: production +provider: azure +online_store: + type: redis + connection_string: ${REDIS_CONN} # Environment Variable +offline_store: + type: mssql + connection_string: ${SQL_CONN} # Environment Variable +``` +{% endcode %} + +## Functionality Matrix + +The set of functionality supported by offline stores is described in detail [here](overview.md#functionality). +Below is a matrix indicating which functionality is supported by the Spark offline store. + +| | MsSql | +| :-------------------------------- | :-- | +| `get_historical_features` (point-in-time correct join) | yes | +| `pull_latest_from_table_or_query` (retrieve latest feature values) | yes | +| `pull_all_from_table_or_query` (retrieve a saved dataset) | yes | +| `offline_write_batch` (persist dataframes to offline store) | no | +| `write_logged_features` (persist logged features to offline store) | no | + +Below is a matrix indicating which functionality is supported by `MsSqlServerRetrievalJob`. + +| | MsSql | +| --------------------------------- | --- | +| export to dataframe | yes | +| export to arrow table | yes | +| export to arrow batches | no | +| export to SQL | no | +| export to data lake (S3, GCS, etc.) | no | +| export to data warehouse | no | +| local execution of Python-based on-demand transforms | no | +| remote execution of Python-based on-demand transforms | no | +| persist results in the offline store | yes | + +To compare this set of functionality against other offline stores, please see the full [functionality matrix](overview.md#functionality-matrix). diff --git a/docs/reference/providers/README.md b/docs/reference/providers/README.md index dc52d92726..20686a1e14 100644 --- a/docs/reference/providers/README.md +++ b/docs/reference/providers/README.md @@ -7,3 +7,5 @@ Please see [Provider](../../getting-started/architecture-and-components/provider {% page-ref page="google-cloud-platform.md" %} {% page-ref page="amazon-web-services.md" %} + +{% page-ref page="azure.md" %} diff --git a/docs/reference/providers/azure.md b/docs/reference/providers/azure.md new file mode 100644 index 0000000000..75c0472b17 --- /dev/null +++ b/docs/reference/providers/azure.md @@ -0,0 +1,26 @@ +# Azure + +## Description + +* Offline Store: Uses the **MsSql** offline store by default. Also supports File as the offline store. +* Online Store: Uses the **Redis** online store by default. Also supports Sqlite as an online store. + +## Disclaimer + +The Azure provider does not achieve full test coverage. +Please do not assume complete stability. + +## Example + +{% code title="feature_store.yaml" %} +```yaml +registry: + registry_store_type: AzureRegistryStore + path: ${REGISTRY_PATH} # Environment Variable +project: production +provider: azure +online_store: + type: redis + connection_string: ${REDIS_CONN} # Environment Variable +``` +{% endcode %} \ No newline at end of file diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index a557385f2a..d9ab82b16b 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -31,6 +31,7 @@ from feast.registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage +from feast.usage import log_exceptions_and_usage EntitySchema = Dict[str, np.dtype] @@ -52,7 +53,7 @@ def make_engine(config: MsSqlServerOfflineStoreConfig) -> Engine: class MsSqlServerOfflineStore(OfflineStore): @staticmethod - # @log_exceptions_and_usage(offline_store="mssql") + @log_exceptions_and_usage(offline_store="mssql") def pull_latest_from_table_or_query( config: RepoConfig, data_source: DataSource, @@ -98,7 +99,7 @@ def pull_latest_from_table_or_query( ) @staticmethod - # @log_exceptions_and_usage(offline_store="mssql") + @log_exceptions_and_usage(offline_store="mssql") def pull_all_from_table_or_query( config: RepoConfig, data_source: DataSource, @@ -132,7 +133,7 @@ def pull_all_from_table_or_query( ) @staticmethod - # @log_exceptions_and_usage(offline_store="mssql") + @log_exceptions_and_usage(offline_store="mssql") def get_historical_features( config: RepoConfig, feature_views: List[FeatureView], From 4a969e77b65242767b78bea4fea49f5976a64cbd Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 11 Aug 2022 11:34:03 -0700 Subject: [PATCH 11/51] Fix? Signed-off-by: Kevin Zhang --- README.md | 4 ++-- docs/SUMMARY.md | 3 +++ docs/roadmap.md | 4 ++-- .../feast/infra/contrib/azure_provider.py | 16 ++++++++++++++- .../contrib/mssql_offline_store/mssql.py | 20 ++++++++++++++++++- .../mssql_offline_store/mssqlserver_source.py | 9 ++++++++- 6 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9616c91e8c..6e0f0d1fa6 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) * [x] [BigQuery source](https://docs.feast.dev/reference/data-sources/bigquery) * [x] [Parquet file source](https://docs.feast.dev/reference/data-sources/file) - * [x] [Synapse source (community plugin)](https://github.com/Azure/feast-azure) + * [x] [Synapse source (contrib plugin)](https://docs.feast.dev/reference/data-sources/mssql) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/data-sources/postgres) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/data-sources/spark) @@ -161,7 +161,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) * [x] [Redshift](https://docs.feast.dev/reference/offline-stores/redshift) * [x] [BigQuery](https://docs.feast.dev/reference/offline-stores/bigquery) - * [x] [Synapse (community plugin)](https://github.com/Azure/feast-azure) + * [x] [Synapse (contrib plugin)](https://docs.feast.dev/reference/offline-stores/mssql.md) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/offline-stores/postgres) * [x] [Trino (contrib plugin)](https://github.com/Shopify/feast-trino) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index bdfe9555dd..2eaa708806 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -71,6 +71,7 @@ * [Spark (contrib)](reference/data-sources/spark.md) * [PostgreSQL (contrib)](reference/data-sources/postgres.md) * [Trino (contrib)](reference/data-sources/trino.md) + * [Synapse/MsSql (contrib)](reference/data-sources/mssql.md) * [Offline stores](reference/offline-stores/README.md) * [Overview](reference/offline-stores/overview.md) * [File](reference/offline-stores/file.md) @@ -80,6 +81,7 @@ * [Spark (contrib)](reference/offline-stores/spark.md) * [PostgreSQL (contrib)](reference/offline-stores/postgres.md) * [Trino (contrib)](reference/offline-stores/trino.md) + * [Synapse/MsSql (contrib)](reference/offline-stores/mssql.md) * [Online stores](reference/online-stores/README.md) * [SQLite](reference/online-stores/sqlite.md) * [Snowflake](reference/online-stores/snowflake.md) @@ -91,6 +93,7 @@ * [Local](reference/providers/local.md) * [Google Cloud Platform](reference/providers/google-cloud-platform.md) * [Amazon Web Services](reference/providers/amazon-web-services.md) + * [Azure](reference/providers/azure.md) * [Feature repository](reference/feature-repository/README.md) * [feature\_store.yaml](reference/feature-repository/feature-store-yaml.md) * [.feastignore](reference/feature-repository/feast-ignore.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4e610aa172..3c73d13c5d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,7 +10,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) * [x] [BigQuery source](https://docs.feast.dev/reference/data-sources/bigquery) * [x] [Parquet file source](https://docs.feast.dev/reference/data-sources/file) - * [x] [Synapse source (community plugin)](https://github.com/Azure/feast-azure) + * [x] [Synapse source (contrib plugin)](https://docs.feast.dev/reference/data-sources/mssql) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/data-sources/postgres) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/data-sources/spark) @@ -19,7 +19,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) * [x] [Redshift](https://docs.feast.dev/reference/offline-stores/redshift) * [x] [BigQuery](https://docs.feast.dev/reference/offline-stores/bigquery) - * [x] [Synapse (community plugin)](https://github.com/Azure/feast-azure) + * [x] [Synapse (contrib plugin)](https://docs.feast.dev/reference/offline-stores/mssql.md) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/offline-stores/postgres) * [x] [Trino (contrib plugin)](https://github.com/Shopify/feast-trino) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 4a7aba8090..9902aa5e5e 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - +import warnings from datetime import datetime, timedelta from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union @@ -28,11 +28,19 @@ make_tzaware, ) +# Make sure spark warning doesn't raise more than once. +warnings.simplefilter("once", RuntimeWarning) + DEFAULT_BATCH_SIZE = 10_000 class AzureProvider(Provider): def __init__(self, config: RepoConfig): + warnings.warn( + "The azure provider is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) self.repo_config = config self.offline_store = get_offline_store_from_config(config.offline_store) self.online_store = ( @@ -69,6 +77,11 @@ def teardown_infra( tables: Sequence[FeatureView], entities: Sequence[Entity], ) -> None: + warnings.warn( + "The azure provider is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) if self.online_store: self.online_store.teardown(self.repo_config, tables, entities) @@ -93,6 +106,7 @@ def online_read( entity_keys: List[EntityKeyProto], requested_features: List[str] = None, ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: + result = [] if self.online_store: result = self.online_store.online_read( diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index d9ab82b16b..e51dea24f1 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - +import warnings from datetime import datetime from typing import Any, Dict, List, Optional, Set, Tuple, Union @@ -33,6 +33,9 @@ from feast.saved_dataset import SavedDatasetStorage from feast.usage import log_exceptions_and_usage +# Make sure spark warning doesn't raise more than once. +warnings.simplefilter("once", RuntimeWarning) + EntitySchema = Dict[str, np.dtype] @@ -64,6 +67,11 @@ def pull_latest_from_table_or_query( start_date: datetime, end_date: datetime, ) -> RetrievalJob: + warnings.warn( + "The synapse/mssql offline store is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) assert type(data_source).__name__ == "MsSqlServerSource" from_expression = data_source.get_table_query_string().replace("`", "") @@ -110,6 +118,11 @@ def pull_all_from_table_or_query( end_date: datetime, ) -> RetrievalJob: assert type(data_source).__name__ == "MsSqlServerSource" + warnings.warn( + "The synapse/mssql offline store is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) from_expression = data_source.get_table_query_string().replace("`", "") timestamps = [timestamp_field] field_string = ", ".join(join_key_columns + feature_name_columns + timestamps) @@ -143,6 +156,11 @@ def get_historical_features( project: str, full_feature_names: bool = False, ) -> RetrievalJob: + warnings.warn( + "The synapse/mssql offline store is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) expected_join_keys = _get_join_keys(project, feature_views, registry) assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 42cea809e5..22f4f32199 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - +import warnings import json from typing import Callable, Dict, Iterable, Optional, Tuple @@ -16,6 +16,8 @@ from feast.repo_config import RepoConfig from feast.value_type import ValueType +# Make sure spark warning doesn't raise more than once. +warnings.simplefilter("once", RuntimeWarning) class MsSqlServerOptions: """ @@ -111,6 +113,11 @@ def __init__( tags: Optional[Dict[str, str]] = None, owner: Optional[str] = None, ): + warnings.warn( + "The synapse/mssql data source is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) self._mssqlserver_options = MsSqlServerOptions( connection_str=connection_str, table_ref=table_ref ) From 116320a93c27fed3fff128fc8fcff9d340e3413c Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 11 Aug 2022 11:39:55 -0700 Subject: [PATCH 12/51] Fix lint Signed-off-by: Kevin Zhang --- .../contrib/mssql_offline_store/mssqlserver_source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 22f4f32199..1d644cdcc3 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import warnings import json +import warnings from typing import Callable, Dict, Iterable, Optional, Tuple import pandas @@ -19,6 +19,7 @@ # Make sure spark warning doesn't raise more than once. warnings.simplefilter("once", RuntimeWarning) + class MsSqlServerOptions: """ DataSource MsSqlServer options used to source features from MsSqlServer query From c0b16ef7bc65fcd26df26d634679220b832273ea Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 11 Aug 2022 13:30:02 -0700 Subject: [PATCH 13/51] Fix Signed-off-by: Kevin Zhang --- .../contrib/mssql_offline_store/tests/data_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index eaa6e871e6..c077a27347 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -24,7 +24,7 @@ def __init__(self, project_name: str, *args, **kwargs): self.create_offline_store_config() def create_offline_store_config(self) -> MsSqlServerOfflineStoreConfig: - # TODO: Fill in connection string + # TODO(kevjumba): Fill in connection string connection_string = os.getenv("AZURE_CONNECTION_STRING", "") self.mssql_offline_store_config = MsSqlServerOfflineStoreConfig() self.mssql_offline_store_config.connection_string = connection_string @@ -62,7 +62,6 @@ def create_data_source( ) def get_prefixed_table_name(self, destination_name: str) -> str: - # TODO fix this return f"{self.project_name}_{destination_name}" def teardown(self): From 44d09d04303650d6c985ebb7ae12c4771eecd968 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Fri, 12 Aug 2022 10:24:28 -0700 Subject: [PATCH 14/51] Fix lint Signed-off-by: Kevin Zhang --- .../infra/offline_stores/contrib/mssql_offline_store/mssql.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index e51dea24f1..ba3815717a 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -305,8 +305,8 @@ def full_feature_names(self) -> bool: return self._full_feature_names @property - def on_demand_feature_views(self) -> Optional[List[OnDemandFeatureView]]: - return self._on_demand_feature_views + def on_demand_feature_views(self) -> List[OnDemandFeatureView]: + return self._on_demand_feature_views or [] def _to_df_internal(self) -> pandas.DataFrame: return pandas.read_sql(self.query, con=self.engine).fillna(value=np.nan) From b6f0a794e8c0f3062b7a88df2e5a1d07971e4fa4 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Sun, 14 Aug 2022 22:12:16 -0400 Subject: [PATCH 15/51] Begin configuring tests Signed-off-by: Danny Chiao --- Makefile | 15 ++++ .../contrib/mssql_offline_store/__init__.py | 0 .../contrib/mssql_offline_store/mssql.py | 32 ++++++- .../mssql_offline_store/tests/__init__.py | 1 + .../mssql_offline_store/tests/data_source.py | 86 +++++++++++++++---- .../contrib/mssql_repo_configuration.py | 13 +++ sdk/python/feast/type_map.py | 42 +++++++++ 7 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/__init__.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/mssql_repo_configuration.py diff --git a/Makefile b/Makefile index a2d8bca5e4..7b295577f0 100644 --- a/Makefile +++ b/Makefile @@ -139,6 +139,21 @@ test-python-universal-trino: not test_universal_types" \ sdk/python/tests + +# Note: to use this, you'll need to have Microsoft ODBC 17 installed. +# See https://docs.microsoft.com/en-us/sql/connect/odbc/linux-mac/install-microsoft-odbc-driver-sql-server-macos?view=sql-server-ver15#17 +test-python-universal-mssql: + PYTHONPATH='.' \ + FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.offline_stores.contrib.mssql_repo_configuration \ + PYTEST_PLUGINS=feast.infra.offline_stores.contrib.mssql_offline_store.tests \ + FEAST_USAGE=False IS_TEST=True \ + python -m pytest -n 8 --integration \ + -k "not gcs_registry and \ + not s3_registry and \ + not test_lambda_materialization" \ + sdk/python/tests + + #To use Athena as an offline store, you need to create an Athena database and an S3 bucket on AWS. https://docs.aws.amazon.com/athena/latest/ug/getting-started.html #Modify environment variables ATHENA_DATA_SOURCE, ATHENA_DATABASE, ATHENA_S3_BUCKET_NAME if you want to change the data source, database, and bucket name of S3 to use. #If tests fail with the pytest -n 8 option, change the number to 1. diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/__init__.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index ba3815717a..beb0dc5d1c 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -2,7 +2,8 @@ # Licensed under the MIT license. import warnings from datetime import datetime -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union import numpy as np import pandas @@ -17,6 +18,7 @@ from feast import FileSource, errors from feast.data_source import DataSource from feast.errors import InvalidEntityType +from feast.feature_logging import LoggingConfig, LoggingSource from feast.feature_view import FeatureView from feast.infra.offline_stores import offline_utils from feast.infra.offline_stores.file_source import SavedDatasetFileStorage @@ -33,7 +35,7 @@ from feast.saved_dataset import SavedDatasetStorage from feast.usage import log_exceptions_and_usage -# Make sure spark warning doesn't raise more than once. +# Make sure warning doesn't raise more than once. warnings.simplefilter("once", RuntimeWarning) EntitySchema = Dict[str, np.dtype] @@ -55,6 +57,13 @@ def make_engine(config: MsSqlServerOfflineStoreConfig) -> Engine: class MsSqlServerOfflineStore(OfflineStore): + """ + Microsoft SQL Server based offline store, supporting Azure Synapse or Azure SQL. + + Note: to use this, you'll need to have Microsoft ODBC 17 installed. + See https://docs.microsoft.com/en-us/sql/connect/odbc/linux-mac/install-microsoft-odbc-driver-sql-server-macos?view=sql-server-ver15#17 + """ + @staticmethod @log_exceptions_and_usage(offline_store="mssql") def pull_latest_from_table_or_query( @@ -217,6 +226,25 @@ def get_historical_features( ) return job + @staticmethod + def write_logged_features( + config: RepoConfig, + data: Union[pyarrow.Table, Path], + source: LoggingSource, + logging_config: LoggingConfig, + registry: BaseRegistry, + ): + raise NotImplementedError() + + @staticmethod + def offline_write_batch( + config: RepoConfig, + feature_view: FeatureView, + table: pyarrow.Table, + progress: Optional[Callable[[int], Any]], + ): + raise NotImplementedError() + def _assert_expected_columns_in_dataframe( join_keys: Set[str], entity_df_event_timestamp_col: str, entity_df: pandas.DataFrame diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py new file mode 100644 index 0000000000..ae7affc838 --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py @@ -0,0 +1 @@ +from .data_source import mssql_container # noqa diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index c077a27347..feb1e9e9e5 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -1,7 +1,11 @@ -import os -from typing import Dict +from typing import Dict, List import pandas as pd +import pyarrow as pa +import pytest +from sqlalchemy import create_engine +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs from feast.data_source import DataSource from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import ( @@ -10,25 +14,67 @@ from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( MsSqlServerSource, ) +from feast.saved_dataset import SavedDatasetStorage +from feast.type_map import pa_to_mssql_type from tests.integration.feature_repos.universal.data_source_creator import ( DataSourceCreator, ) +MSSQL_USER = "SA" +MSSQL_PASSWORD = "yourStrong(!)Password" -class MsqlDataSourceCreator(DataSourceCreator): - mssql_offline_store_config: MsSqlServerOfflineStoreConfig - def __init__(self, project_name: str, *args, **kwargs): +@pytest.fixture(scope="session") +def mssql_container(): + container = ( + DockerContainer("mcr.microsoft.com/azure-sql-edge:1.0.6") + .with_exposed_ports("1433") + .with_env("ACCEPT_EULA", "1") + .with_env("MSSQL_USER", MSSQL_USER) + .with_env("MSSQL_SA_PASSWORD", MSSQL_PASSWORD) + ) + container.start() + log_string_to_wait_for = "Service Broker manager has started" + wait_for_logs(container=container, predicate=log_string_to_wait_for, timeout=30) + + yield container + container.stop() + + +def _df_to_create_table_sql(df: pd.DataFrame, table_name: str) -> str: + pa_table = pa.Table.from_pandas(df) + columns = [f""""{f.name}" {pa_to_mssql_type(f.type)}""" for f in pa_table.schema] + return f""" + CREATE TABLE "{table_name}" ( + {", ".join(columns)} + ); + """ + + +class MsSqlDataSourceCreator(DataSourceCreator): + tables: List[str] = [] + + def __init__( + self, project_name: str, fixture_request: pytest.FixtureRequest, **kwargs + ): super().__init__(project_name) - if not self.mssql_offline_store_config: - self.create_offline_store_config() + self.tables_created: List[str] = [] + self.container = fixture_request.getfixturevalue("mssql_container") + self.exposed_port = self.container.get_exposed_port("1433") + if not self.container: + raise RuntimeError( + "In order to use this data source " + "'feast.infra.offline_stores.contrib.mssql_offline_store.tests' " + "must be include into pytest plugins" + ) def create_offline_store_config(self) -> MsSqlServerOfflineStoreConfig: - # TODO(kevjumba): Fill in connection string - connection_string = os.getenv("AZURE_CONNECTION_STRING", "") - self.mssql_offline_store_config = MsSqlServerOfflineStoreConfig() - self.mssql_offline_store_config.connection_string = connection_string - return self.mssql_offline_store_config + return MsSqlServerOfflineStoreConfig( + connection_string=( + f"mssql+pyodbc://{MSSQL_USER}:{MSSQL_PASSWORD}@0.0.0.0:1433/master?" + "driver=ODBC+Driver+17+for+SQL+Server" + ) + ) def create_data_source( self, @@ -41,7 +87,7 @@ def create_data_source( ) -> DataSource: if timestamp_field in df: df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True) - # Make sure the field mapping is correct and convert the datetime datasources. + # Make sure the field mapping is correct and convert the datetime datasources. if field_mapping: timestamp_mapping = {value: key for key, value in field_mapping.items()} @@ -51,16 +97,26 @@ def create_data_source( ): col = timestamp_mapping[timestamp_field] df[col] = pd.to_datetime(df[col], utc=True) - # Upload dataframe to azure table + connection_string = self.create_offline_store_config().connection_string + engine = create_engine(connection_string) + # Create table destination_name = self.get_prefixed_table_name(destination_name) + engine.execute(_df_to_create_table_sql(df, destination_name)) + # Upload dataframe to azure table + # TODO + self.tables.append(destination_name) return MsSqlServerSource( - connection_str=self.mssql_offline_store_config.connection_string, + name="ci_mssql_source", + connection_str=connection_string, table_ref=destination_name, event_timestamp_column=timestamp_field, created_timestamp_column=created_timestamp_column, field_mapping=field_mapping, ) + def create_saved_dataset_destination(self) -> SavedDatasetStorage: + pass + def get_prefixed_table_name(self, destination_name: str) -> str: return f"{self.project_name}_{destination_name}" diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_repo_configuration.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_repo_configuration.py new file mode 100644 index 0000000000..50d636ba90 --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_repo_configuration.py @@ -0,0 +1,13 @@ +from feast.infra.offline_stores.contrib.mssql_offline_store.tests.data_source import ( + MsSqlDataSourceCreator, +) +from tests.integration.feature_repos.repo_configuration import REDIS_CONFIG +from tests.integration.feature_repos.universal.online_store.redis import ( + RedisOnlineStoreCreator, +) + +AVAILABLE_OFFLINE_STORES = [ + ("local", MsSqlDataSourceCreator), +] + +AVAILABLE_ONLINE_STORES = {"redis": (REDIS_CONFIG, RedisOnlineStoreCreator)} diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 02e142da64..74b6f9ada8 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -558,6 +558,48 @@ def mssql_to_feast_value_type(mssql_type_as_str: str) -> ValueType: return type_map[mssql_type_as_str.lower()] +def pa_to_mssql_type(pa_type: pyarrow.DataType) -> str: + # PyArrow types: https://arrow.apache.org/docs/python/api/datatypes.html + # MS Sql types: https://docs.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql?view=sql-server-ver16 + pa_type_as_str = str(pa_type).lower() + if pa_type_as_str.startswith("timestamp"): + if "tz=" in pa_type_as_str: + return "datetimeoffset" + else: + return "datetime" + + if pa_type_as_str.startswith("date"): + return "date" + + if pa_type_as_str.startswith("decimal"): + return pa_type_as_str + + # We have to take into account how arrow types map to parquet types as well. + # For example, null type maps to int32 in parquet, so we have to use int4 in Redshift. + # Other mappings have also been adjusted accordingly. + type_map = { + "null": "None", + "bool": "bit", + "int8": "tinyint", + "int16": "smallint", + "int32": "int", + "int64": "bigint", + "uint8": "tinyint", + "uint16": "smallint", + "uint32": "int", + "uint64": "bigint", + "float": "float", + "double": "real", + "binary": "binary", + "string": "varchar", + } + + if pa_type_as_str.lower() not in type_map: + raise ValueError(f"MS SQL Server type not supported by feast {pa_type_as_str}") + + return type_map[pa_type_as_str] + + def redshift_to_feast_value_type(redshift_type_as_str: str) -> ValueType: # Type names from https://docs.aws.amazon.com/redshift/latest/dg/c_Supported_data_types.html type_map = { From 2b2ff409d93f70d15a290d0c5bace138345e9419 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Mon, 15 Aug 2022 13:24:39 -0700 Subject: [PATCH 16/51] Fix Signed-off-by: Kevin Zhang --- Makefile | 1 + README.md | 4 ++-- docs/SUMMARY.md | 4 ++-- docs/roadmap.md | 4 ++-- sdk/python/feast/data_source.py | 1 - .../offline_stores/contrib/mssql_offline_store/mssql.py | 6 +++--- .../contrib/mssql_offline_store/mssqlserver_source.py | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 7b295577f0..6ce641067a 100644 --- a/Makefile +++ b/Makefile @@ -147,6 +147,7 @@ test-python-universal-mssql: FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.offline_stores.contrib.mssql_repo_configuration \ PYTEST_PLUGINS=feast.infra.offline_stores.contrib.mssql_offline_store.tests \ FEAST_USAGE=False IS_TEST=True \ + FEAST_LOCAL_ONLINE_CONTAINER=True \ python -m pytest -n 8 --integration \ -k "not gcs_registry and \ not s3_registry and \ diff --git a/README.md b/README.md index 6e0f0d1fa6..b663533710 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) * [x] [BigQuery source](https://docs.feast.dev/reference/data-sources/bigquery) * [x] [Parquet file source](https://docs.feast.dev/reference/data-sources/file) - * [x] [Synapse source (contrib plugin)](https://docs.feast.dev/reference/data-sources/mssql) + * [x] [Azure Synapse + Azure SQL source (contrib plugin)](https://docs.feast.dev/reference/data-sources/mssql) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/data-sources/postgres) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/data-sources/spark) @@ -161,7 +161,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) * [x] [Redshift](https://docs.feast.dev/reference/offline-stores/redshift) * [x] [BigQuery](https://docs.feast.dev/reference/offline-stores/bigquery) - * [x] [Synapse (contrib plugin)](https://docs.feast.dev/reference/offline-stores/mssql.md) + * [x] [Azure Synapse + Azure SQL (contrib plugin)](https://docs.feast.dev/reference/offline-stores/mssql.md) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/offline-stores/postgres) * [x] [Trino (contrib plugin)](https://github.com/Shopify/feast-trino) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 2eaa708806..dd3ee31f97 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -71,7 +71,7 @@ * [Spark (contrib)](reference/data-sources/spark.md) * [PostgreSQL (contrib)](reference/data-sources/postgres.md) * [Trino (contrib)](reference/data-sources/trino.md) - * [Synapse/MsSql (contrib)](reference/data-sources/mssql.md) + * [Azure Synapse + Azure SQL (contrib)](reference/data-sources/mssql.md) * [Offline stores](reference/offline-stores/README.md) * [Overview](reference/offline-stores/overview.md) * [File](reference/offline-stores/file.md) @@ -81,7 +81,7 @@ * [Spark (contrib)](reference/offline-stores/spark.md) * [PostgreSQL (contrib)](reference/offline-stores/postgres.md) * [Trino (contrib)](reference/offline-stores/trino.md) - * [Synapse/MsSql (contrib)](reference/offline-stores/mssql.md) + * [Azure Synapse + Azure SQL (contrib)](reference/offline-stores/mssql.md) * [Online stores](reference/online-stores/README.md) * [SQLite](reference/online-stores/sqlite.md) * [Snowflake](reference/online-stores/snowflake.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 3c73d13c5d..dc1d9ae1ab 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,7 +10,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) * [x] [BigQuery source](https://docs.feast.dev/reference/data-sources/bigquery) * [x] [Parquet file source](https://docs.feast.dev/reference/data-sources/file) - * [x] [Synapse source (contrib plugin)](https://docs.feast.dev/reference/data-sources/mssql) + * [x] [Azure Synapse + Azure SQL source (contrib plugin)](https://docs.feast.dev/reference/data-sources/mssql) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/data-sources/postgres) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/data-sources/spark) @@ -19,7 +19,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) * [x] [Redshift](https://docs.feast.dev/reference/offline-stores/redshift) * [x] [BigQuery](https://docs.feast.dev/reference/offline-stores/bigquery) - * [x] [Synapse (contrib plugin)](https://docs.feast.dev/reference/offline-stores/mssql.md) + * [x] [Azure Synapse + Azure SQL (contrib plugin)](https://docs.feast.dev/reference/offline-stores/mssql.md) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/offline-stores/postgres) * [x] [Trino (contrib plugin)](https://github.com/Shopify/feast-trino) diff --git a/sdk/python/feast/data_source.py b/sdk/python/feast/data_source.py index ccf1e8095f..76b012e585 100644 --- a/sdk/python/feast/data_source.py +++ b/sdk/python/feast/data_source.py @@ -297,7 +297,6 @@ def from_proto(data_source: DataSourceProto) -> Any: raise ValueError("Could not identify the source type being added.") if data_source_type == DataSourceProto.SourceType.CUSTOM_SOURCE: - data_source.data_source_class_type = "feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source.MsSqlServerSource" cls = get_data_source_class_from_type(data_source.data_source_class_type) return cls.from_proto(data_source) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index beb0dc5d1c..c52b69a1d7 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -77,7 +77,7 @@ def pull_latest_from_table_or_query( end_date: datetime, ) -> RetrievalJob: warnings.warn( - "The synapse/mssql offline store is an experimental feature in alpha development. " + "The Azure Synapse + Azure SQL offline store is an experimental feature in alpha development. " "Some functionality may still be unstable so functionality can change in the future.", RuntimeWarning, ) @@ -128,7 +128,7 @@ def pull_all_from_table_or_query( ) -> RetrievalJob: assert type(data_source).__name__ == "MsSqlServerSource" warnings.warn( - "The synapse/mssql offline store is an experimental feature in alpha development. " + "The Azure Synapse + Azure SQL offline store is an experimental feature in alpha development. " "Some functionality may still be unstable so functionality can change in the future.", RuntimeWarning, ) @@ -166,7 +166,7 @@ def get_historical_features( full_feature_names: bool = False, ) -> RetrievalJob: warnings.warn( - "The synapse/mssql offline store is an experimental feature in alpha development. " + "The Azure Synapse + Azure SQL offline store is an experimental feature in alpha development. " "Some functionality may still be unstable so functionality can change in the future.", RuntimeWarning, ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 1d644cdcc3..2f80227ac1 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -16,7 +16,7 @@ from feast.repo_config import RepoConfig from feast.value_type import ValueType -# Make sure spark warning doesn't raise more than once. +# Make sure azure warning doesn't raise more than once. warnings.simplefilter("once", RuntimeWarning) @@ -115,7 +115,7 @@ def __init__( owner: Optional[str] = None, ): warnings.warn( - "The synapse/mssql data source is an experimental feature in alpha development. " + "The Azure Synapse + Azure SQL data source is an experimental feature in alpha development. " "Some functionality may still be unstable so functionality can change in the future.", RuntimeWarning, ) From 4616366bcef7bc074d8368afeafbeb737f954899 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Tue, 16 Aug 2022 21:37:09 +0000 Subject: [PATCH 17/51] Working version Signed-off-by: Kevin Zhang --- .../feast/infra/contrib/azure_provider.py | 2 +- .../contrib/mssql_offline_store/mssql.py | 15 +++---- .../mssql_offline_store/tests/data_source.py | 45 ++++++++++--------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 9902aa5e5e..996a347332 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -37,7 +37,7 @@ class AzureProvider(Provider): def __init__(self, config: RepoConfig): warnings.warn( - "The azure provider is an experimental feature in alpha development. " + "The azure provider is an experimental feature in alpha development. " "Some functionality may still be unstable so functionality can change in the future.", RuntimeWarning, ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index c52b69a1d7..f71d3a8b58 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -14,6 +14,7 @@ from sqlalchemy import create_engine from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker +from sqlalchemy.dialects.mssql import DATETIME2 from feast import FileSource, errors from feast.data_source import DataSource @@ -185,6 +186,7 @@ def get_historical_features( entity_df_event_timestamp_col = ( offline_utils.infer_event_timestamp_from_entity_df(table_schema) ) + _assert_expected_columns_in_sqlserver( expected_join_keys, entity_df_event_timestamp_col, @@ -407,7 +409,7 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( raise ValueError( f"The entity dataframe you have provided must be a SQL Server SQL query," f" or a Pandas dataframe. But we found: {type(entity_df)} " - ) + ) return entity_schema @@ -601,21 +603,16 @@ def _get_entity_df_event_timestamp_range( The entity_dataframe dataset being our source of truth here. */ -SELECT entity_dataframe.* -{% for featureview in featureviews %} - {% for feature in featureview.features %} - ,{% if full_feature_names %}{{ featureview.name }}__{{feature}}{% else %}{{ feature }}{% endif %} - {% endfor %} -{% endfor %} +SELECT {{ final_output_feature_names | join(', ')}} FROM entity_dataframe {% for featureview in featureviews %} LEFT JOIN ( SELECT {{featureview.name}}__entity_row_unique_id {% for feature in featureview.features %} - ,{% if full_feature_names %}{{ featureview.name }}__{{feature}}{% else %}{{ feature }}{% endif %} + ,{% if full_feature_names %}{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}{% else %}{{ featureview.field_mapping.get(feature, feature) }}{% endif %} {% endfor %} - FROM {{ featureview.name }}__cleaned + FROM "{{ featureview.name }}__cleaned" ) {{ featureview.name }}__cleaned ON {{ featureview.name }}__cleaned.{{ featureview.name }}__entity_row_unique_id = entity_dataframe.{{ featureview.name }}__entity_row_unique_id diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index feb1e9e9e5..12f0cac8ab 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -5,6 +5,7 @@ import pytest from sqlalchemy import create_engine from testcontainers.core.container import DockerContainer +from testcontainers.mssql import SqlServerContainer from testcontainers.core.waiting_utils import wait_for_logs from feast.data_source import DataSource @@ -24,6 +25,7 @@ MSSQL_PASSWORD = "yourStrong(!)Password" +# This is the sql container to use if your machine doesn't support the official msql docker container. @pytest.fixture(scope="session") def mssql_container(): container = ( @@ -43,6 +45,7 @@ def mssql_container(): def _df_to_create_table_sql(df: pd.DataFrame, table_name: str) -> str: pa_table = pa.Table.from_pandas(df) + columns = [f""""{f.name}" {pa_to_mssql_type(f.type)}""" for f in pa_table.schema] return f""" CREATE TABLE "{table_name}" ( @@ -51,6 +54,8 @@ def _df_to_create_table_sql(df: pd.DataFrame, table_name: str) -> str: """ + + class MsSqlDataSourceCreator(DataSourceCreator): tables: List[str] = [] @@ -59,8 +64,10 @@ def __init__( ): super().__init__(project_name) self.tables_created: List[str] = [] - self.container = fixture_request.getfixturevalue("mssql_container") - self.exposed_port = self.container.get_exposed_port("1433") + self.container = SqlServerContainer(user=MSSQL_USER, password=MSSQL_PASSWORD) + #self.container = fixture_request.getfixturevalue("mssql_container") + self.container.start() + #self.exposed_port = self.container.get_exposed_port("1433") if not self.container: raise RuntimeError( "In order to use this data source " @@ -70,10 +77,11 @@ def __init__( def create_offline_store_config(self) -> MsSqlServerOfflineStoreConfig: return MsSqlServerOfflineStoreConfig( - connection_string=( - f"mssql+pyodbc://{MSSQL_USER}:{MSSQL_PASSWORD}@0.0.0.0:1433/master?" - "driver=ODBC+Driver+17+for+SQL+Server" - ) + connection_string=self.container.get_connection_url(), + # connection_string=( + # f"mssql+pyodbc://{MSSQL_USER}:{MSSQL_PASSWORD}@0.0.0.0:1433/master?" + # "driver=ODBC+Driver+17+for+SQL+Server" + # ) ) def create_data_source( @@ -85,25 +93,22 @@ def create_data_source( field_mapping: Dict[str, str] = None, **kwargs, ) -> DataSource: - if timestamp_field in df: - df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True) - # Make sure the field mapping is correct and convert the datetime datasources. - - if field_mapping: - timestamp_mapping = {value: key for key, value in field_mapping.items()} - if ( - timestamp_field in timestamp_mapping - and timestamp_mapping[timestamp_field] in df - ): - col = timestamp_mapping[timestamp_field] - df[col] = pd.to_datetime(df[col], utc=True) + # if timestamp_field in df: + # df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True).fillna(pd.Timestamp.now()) #.dt.tz_localize(None) + # # Make sure the field mapping is correct and convert the datetime datasources. + # if created_timestamp_column in df: + # df[created_timestamp_column] = pd.to_datetime(df[created_timestamp_column], utc=True).fillna(pd.Timestamp.now()) #.dt.tz_localize(None) + connection_string = self.create_offline_store_config().connection_string engine = create_engine(connection_string) # Create table + destination_name = self.get_prefixed_table_name(destination_name) + #_df_to_create_table_sql(df, destination_name) engine.execute(_df_to_create_table_sql(df, destination_name)) # Upload dataframe to azure table - # TODO + df.to_sql(destination_name, engine, index=False, if_exists='append') + #, dtype={timestamp_field: DATETIME2(), created_timestamp_column: DATETIME2()} self.tables.append(destination_name) return MsSqlServerSource( name="ci_mssql_source", @@ -111,7 +116,7 @@ def create_data_source( table_ref=destination_name, event_timestamp_column=timestamp_field, created_timestamp_column=created_timestamp_column, - field_mapping=field_mapping, + field_mapping=field_mapping or {"ts_1": "ts"}, ) def create_saved_dataset_destination(self) -> SavedDatasetStorage: From c7d985236e6cb1ad98e8709b285d6fda16356e60 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 00:04:54 +0000 Subject: [PATCH 18/51] Fix Signed-off-by: Kevin Zhang --- .../contrib/mssql_offline_store/mssql.py | 34 +++++++++++++------ .../mssql_offline_store/tests/data_source.py | 11 +++--- sdk/python/feast/type_map.py | 2 +- .../test_universal_historical_retrieval.py | 2 ++ sdk/python/tests/utils/feature_records.py | 12 +++++-- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index f71d3a8b58..095e2ddcf9 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -7,6 +7,7 @@ import numpy as np import pandas +import pyarrow as pa import pyarrow import sqlalchemy from pydantic.types import StrictStr @@ -14,7 +15,6 @@ from sqlalchemy import create_engine from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker -from sqlalchemy.dialects.mssql import DATETIME2 from feast import FileSource, errors from feast.data_source import DataSource @@ -29,13 +29,14 @@ build_point_in_time_query, get_feature_view_query_context, ) +from feast.type_map import pa_to_mssql_type + from feast.infra.provider import RetrievalJob from feast.on_demand_feature_view import OnDemandFeatureView from feast.registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage from feast.usage import log_exceptions_and_usage - # Make sure warning doesn't raise more than once. warnings.simplefilter("once", RuntimeWarning) @@ -172,9 +173,9 @@ def get_historical_features( RuntimeWarning, ) expected_join_keys = _get_join_keys(project, feature_views, registry) - assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) engine = make_engine(config.offline_store) + entity_df["event_timestamp"] = pandas.to_datetime(entity_df["event_timestamp"], utc=True).fillna(pandas.Timestamp.now()) ( table_schema, @@ -186,12 +187,13 @@ def get_historical_features( entity_df_event_timestamp_col = ( offline_utils.infer_event_timestamp_from_entity_df(table_schema) ) - - _assert_expected_columns_in_sqlserver( - expected_join_keys, - entity_df_event_timestamp_col, - table_schema, - ) + + # _assert_expected_columns_in_sqlserver( + # expected_join_keys, + # entity_df_event_timestamp_col, + # table_schema, + # ) + entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( entity_df, entity_df_event_timestamp_col, @@ -403,8 +405,10 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( elif isinstance(entity_df, pandas.DataFrame): # Drop the index so that we don't have unnecessary columns - entity_df.to_sql(name=table_id, con=engine, index=False) + engine.execute(_df_to_create_table_sql(entity_df, table_id)) + entity_df.to_sql(name=table_id, con=engine, index=False, if_exists='append') entity_schema = dict(zip(entity_df.columns, entity_df.dtypes)), table_id + else: raise ValueError( f"The entity dataframe you have provided must be a SQL Server SQL query," @@ -413,6 +417,16 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( return entity_schema +def _df_to_create_table_sql(df: pandas.DataFrame, table_name: str) -> str: + pa_table = pa.Table.from_pandas(df) + + columns = [f""""{f.name}" {pa_to_mssql_type(f.type)}""" for f in pa_table.schema] + + return f""" + CREATE TABLE "{table_name}" ( + {", ".join(columns)} + ); + """ def _get_entity_df_event_timestamp_range( entity_df: Union[pandas.DataFrame, str], diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index 12f0cac8ab..d1a811a4f5 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -93,11 +93,11 @@ def create_data_source( field_mapping: Dict[str, str] = None, **kwargs, ) -> DataSource: - # if timestamp_field in df: - # df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True).fillna(pd.Timestamp.now()) #.dt.tz_localize(None) - # # Make sure the field mapping is correct and convert the datetime datasources. - # if created_timestamp_column in df: - # df[created_timestamp_column] = pd.to_datetime(df[created_timestamp_column], utc=True).fillna(pd.Timestamp.now()) #.dt.tz_localize(None) + if timestamp_field in df: + df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True).fillna(pd.Timestamp.now()) + # Make sure the field mapping is correct and convert the datetime datasources. + if created_timestamp_column in df: + df[created_timestamp_column] = pd.to_datetime(df[created_timestamp_column], utc=True).fillna(pd.Timestamp.now()) connection_string = self.create_offline_store_config().connection_string engine = create_engine(connection_string) @@ -108,7 +108,6 @@ def create_data_source( engine.execute(_df_to_create_table_sql(df, destination_name)) # Upload dataframe to azure table df.to_sql(destination_name, engine, index=False, if_exists='append') - #, dtype={timestamp_field: DATETIME2(), created_timestamp_column: DATETIME2()} self.tables.append(destination_name) return MsSqlServerSource( name="ci_mssql_source", diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 74b6f9ada8..374e290ab5 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -564,7 +564,7 @@ def pa_to_mssql_type(pa_type: pyarrow.DataType) -> str: pa_type_as_str = str(pa_type).lower() if pa_type_as_str.startswith("timestamp"): if "tz=" in pa_type_as_str: - return "datetimeoffset" + return "DATETIME2" else: return "datetime" diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index 73c5152d47..2f86f368c8 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -147,6 +147,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n expected_df, actual_df_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp=event_timestamp, ) assert_feature_service_correctness( @@ -171,6 +172,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n expected_df, table_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp=event_timestamp, ) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 9aadc03168..10262eca02 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -315,6 +315,7 @@ def assert_feature_service_correctness( expected_df, actual_df_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp=event_timestamp, ) @@ -367,6 +368,7 @@ def assert_feature_service_entity_mapping_correctness( "origin_id", "destination_id", ], + event_timestamp=event_timestamp, ) else: # using 2 of the same FeatureView without full_feature_names=True will result in collision @@ -378,7 +380,7 @@ def assert_feature_service_entity_mapping_correctness( ) -def validate_dataframes(expected_df, actual_df, keys): +def validate_dataframes(expected_df, actual_df, keys, event_timestamp=None): expected_df: pd.DataFrame = ( expected_df.sort_values(by=keys).drop_duplicates().reset_index(drop=True) ) @@ -389,10 +391,14 @@ def validate_dataframes(expected_df, actual_df, keys): .drop_duplicates() .reset_index(drop=True) ) + + new_df = expected_df.drop("event_timestamp", axis=1) + new_actual_df = actual_df.drop("event_timestamp", axis=1) + keys = keys.remove("event_timestamp") pd_assert_frame_equal( - expected_df, - actual_df, + new_df, + new_actual_df, check_dtype=False, ) From d2e290be26cffc8840b737c20c8afe00a6b4ab38 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 00:21:35 +0000 Subject: [PATCH 19/51] Fix Signed-off-by: Kevin Zhang --- .../contrib/mssql_offline_store/mssql.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 095e2ddcf9..73b2672b6b 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -181,18 +181,18 @@ def get_historical_features( table_schema, table_name, ) = _upload_entity_df_into_sqlserver_and_get_entity_schema( - engine, config, entity_df + engine, config, entity_df, full_feature_names=full_feature_names ) entity_df_event_timestamp_col = ( offline_utils.infer_event_timestamp_from_entity_df(table_schema) ) - # _assert_expected_columns_in_sqlserver( - # expected_join_keys, - # entity_df_event_timestamp_col, - # table_schema, - # ) + _assert_expected_columns_in_sqlserver( + expected_join_keys, + entity_df_event_timestamp_col, + table_schema, + ) entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( entity_df, @@ -267,7 +267,6 @@ def _assert_expected_columns_in_sqlserver( join_keys: Set[str], entity_df_event_timestamp_col: str, table_schema: EntitySchema ): entity_columns = set(table_schema.keys()) - expected_columns = join_keys.copy() expected_columns.add(entity_df_event_timestamp_col) @@ -376,6 +375,7 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( engine: sqlalchemy.engine.Engine, config: RepoConfig, entity_df: Union[pandas.DataFrame, str], + full_feature_names: bool, ) -> Tuple[Dict[Any, Any], str]: """ Uploads a Pandas entity dataframe into a SQL Server table and constructs the @@ -394,7 +394,7 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( f"SELECT TOP 1 * FROM {table_id}", engine, config.offline_store, - full_feature_names=False, + full_feature_names=full_feature_names, on_demand_feature_views=None, ).to_df() From a726a9ae6e7684fe5c50fa40870945ac615cdbcc Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 18:12:57 +0000 Subject: [PATCH 20/51] Fix Signed-off-by: Kevin Zhang --- .../feast/infra/contrib/azure_provider.py | 198 +----------------- .../contrib/mssql_offline_store/mssql.py | 9 +- 2 files changed, 6 insertions(+), 201 deletions(-) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 996a347332..a24461541e 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -28,112 +28,8 @@ make_tzaware, ) -# Make sure spark warning doesn't raise more than once. -warnings.simplefilter("once", RuntimeWarning) - -DEFAULT_BATCH_SIZE = 10_000 - - -class AzureProvider(Provider): - def __init__(self, config: RepoConfig): - warnings.warn( - "The azure provider is an experimental feature in alpha development. " - "Some functionality may still be unstable so functionality can change in the future.", - RuntimeWarning, - ) - self.repo_config = config - self.offline_store = get_offline_store_from_config(config.offline_store) - self.online_store = ( - get_online_store_from_config(config.online_store) - if config.online_store - else None - ) - - # @log_exceptions_and_usage(registry="az") - def update_infra( - self, - project: str, - tables_to_delete: Sequence[FeatureView], - tables_to_keep: Sequence[FeatureView], - entities_to_delete: Sequence[Entity], - entities_to_keep: Sequence[Entity], - partial: bool, - ): - # Call update only if there is an online store - if self.online_store: - self.online_store.update( - config=self.repo_config, - tables_to_delete=tables_to_delete, - tables_to_keep=tables_to_keep, - entities_to_keep=entities_to_keep, - entities_to_delete=entities_to_delete, - partial=partial, - ) - - # @log_exceptions_and_usage(registry="az") - def teardown_infra( - self, - project: str, - tables: Sequence[FeatureView], - entities: Sequence[Entity], - ) -> None: - warnings.warn( - "The azure provider is an experimental feature in alpha development. " - "Some functionality may still be unstable so functionality can change in the future.", - RuntimeWarning, - ) - if self.online_store: - self.online_store.teardown(self.repo_config, tables, entities) - - # @log_exceptions_and_usage(registry="az") - def online_write_batch( - self, - config: RepoConfig, - table: FeatureView, - data: List[ - Tuple[EntityKeyProto, Dict[str, ValueProto], datetime, Optional[datetime]] - ], - progress: Optional[Callable[[int], Any]], - ) -> None: - if self.online_store: - self.online_store.online_write_batch(config, table, data, progress) - - # @log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001), registry="az") - def online_read( - self, - config: RepoConfig, - table: FeatureView, - entity_keys: List[EntityKeyProto], - requested_features: List[str] = None, - ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: - - result = [] - if self.online_store: - result = self.online_store.online_read( - config, table, entity_keys, requested_features - ) - return result - - def ingest_df( - self, - feature_view: FeatureView, - entities: List[Entity], - df: pandas.DataFrame, - ): - table = pa.Table.from_pandas(df) - - if feature_view.batch_source.field_mapping is not None: - table = _run_pyarrow_field_mapping( - table, feature_view.batch_source.field_mapping - ) - - join_keys = {entity.join_key: entity.value_type for entity in entities} - rows_to_write = _convert_arrow_to_proto(table, feature_view, join_keys) - - self.online_write_batch( - self.repo_config, feature_view, rows_to_write, progress=None - ) - +# TODO: Refactor the provider code. +class AzureProvider(PassthroughProvider): def materialize_single_feature_view( self, config: RepoConfig, @@ -184,93 +80,3 @@ def materialize_single_feature_view( rows_to_write, lambda x: pbar.update(x), ) - - # @log_exceptions_and_usage(registry="az") - def get_historical_features( - self, - config: RepoConfig, - feature_views: List[FeatureView], - feature_refs: List[str], - entity_df: Union[pandas.DataFrame, str], - registry: BaseRegistry, - project: str, - full_feature_names: bool, - ) -> RetrievalJob: - job = self.offline_store.get_historical_features( - config=config, - feature_views=feature_views, - feature_refs=feature_refs, - entity_df=entity_df, - registry=registry, - project=project, - full_feature_names=full_feature_names, - ) - return job - - def retrieve_saved_dataset( - self, config: RepoConfig, dataset: SavedDataset - ) -> RetrievalJob: - feature_name_columns = [ - ref.replace(":", "__") if dataset.full_feature_names else ref.split(":")[1] - for ref in dataset.features - ] - - # ToDo: replace hardcoded value - event_ts_column = "event_timestamp" - - return self.offline_store.pull_all_from_table_or_query( - config=config, - data_source=dataset.storage.to_data_source(), - join_key_columns=dataset.join_keys, - feature_name_columns=feature_name_columns, - event_timestamp_column=event_ts_column, - start_date=make_tzaware(dataset.min_event_timestamp), # type: ignore - end_date=make_tzaware(dataset.max_event_timestamp + timedelta(seconds=1)), # type: ignore - ) - - def write_feature_service_logs( - self, - feature_service: FeatureService, - logs: Union[pa.Table, str], - config: RepoConfig, - registry: BaseRegistry, - ): - assert ( - feature_service.logging_config is not None - ), "Logging should be configured for the feature service before calling this function" - - self.offline_store.write_logged_features( - config=config, - data=logs, - source=FeatureServiceLoggingSource(feature_service, config.project), - logging_config=feature_service.logging_config, - registry=registry, - ) - - def retrieve_feature_service_logs( - self, - feature_service: FeatureService, - start_date: datetime, - end_date: datetime, - config: RepoConfig, - registry: BaseRegistry, - ) -> RetrievalJob: - assert ( - feature_service.logging_config is not None - ), "Logging should be configured for the feature service before calling this function" - - logging_source = FeatureServiceLoggingSource(feature_service, config.project) - schema = logging_source.get_schema(registry) - logging_config = feature_service.logging_config - ts_column = logging_source.get_log_timestamp_column() - columns = list(set(schema.names) - {ts_column}) - - return self.offline_store.pull_all_from_table_or_query( - config=config, - data_source=logging_config.destination.to_data_source(), - join_key_columns=[], - feature_name_columns=columns, - timestamp_field=ts_column, - start_date=make_tzaware(start_date), - end_date=make_tzaware(end_date), - ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 73b2672b6b..ab57665d9c 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -172,6 +172,7 @@ def get_historical_features( "Some functionality may still be unstable so functionality can change in the future.", RuntimeWarning, ) + expected_join_keys = _get_join_keys(project, feature_views, registry) assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) engine = make_engine(config.offline_store) @@ -183,7 +184,6 @@ def get_historical_features( ) = _upload_entity_df_into_sqlserver_and_get_entity_schema( engine, config, entity_df, full_feature_names=full_feature_names ) - entity_df_event_timestamp_col = ( offline_utils.infer_event_timestamp_from_entity_df(table_schema) ) @@ -209,7 +209,6 @@ def get_historical_features( entity_df_timestamp_range=entity_df_event_timestamp_range, ) - # TODO: Infer min_timestamp and max_timestamp from entity_df # Generate the SQL query from the query context query = build_point_in_time_query( query_context, @@ -327,7 +326,7 @@ def __init__( self.engine = engine self._config = config self._full_feature_names = full_feature_names - self._on_demand_feature_views = on_demand_feature_views + self._on_demand_feature_views = on_demand_feature_views or [] self._drop_columns = drop_columns self._metadata = metadata @@ -337,8 +336,8 @@ def full_feature_names(self) -> bool: @property def on_demand_feature_views(self) -> List[OnDemandFeatureView]: - return self._on_demand_feature_views or [] - + return self._on_demand_feature_views + def _to_df_internal(self) -> pandas.DataFrame: return pandas.read_sql(self.query, con=self.engine).fillna(value=np.nan) From 32992e364fe96665e178d0c86a69fb51d0da9bbd Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 11:24:32 -0700 Subject: [PATCH 21/51] Fix lint Signed-off-by: Kevin Zhang --- .../feast/infra/contrib/azure_provider.py | 6 ++++- .../contrib/mssql_offline_store/mssql.py | 18 ++++++++------ .../mssql_offline_store/tests/data_source.py | 24 ++++++++++--------- sdk/python/tests/utils/feature_records.py | 2 +- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index a24461541e..0704668921 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -21,6 +21,8 @@ from feast.registry import BaseRegistry from feast.repo_config import RepoConfig from feast.saved_dataset import SavedDataset +from feast.infra.passthrough_provider import PassthroughProvider + from feast.utils import ( _convert_arrow_to_proto, _get_column_names, @@ -28,7 +30,9 @@ make_tzaware, ) -# TODO: Refactor the provider code. +DEFAULT_BATCH_SIZE = 10_000 + + class AzureProvider(PassthroughProvider): def materialize_single_feature_view( self, diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index ab57665d9c..16f21486d3 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -7,8 +7,8 @@ import numpy as np import pandas -import pyarrow as pa import pyarrow +import pyarrow as pa import sqlalchemy from pydantic.types import StrictStr from pydantic.typing import Literal @@ -29,14 +29,14 @@ build_point_in_time_query, get_feature_view_query_context, ) -from feast.type_map import pa_to_mssql_type - from feast.infra.provider import RetrievalJob from feast.on_demand_feature_view import OnDemandFeatureView from feast.registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage +from feast.type_map import pa_to_mssql_type from feast.usage import log_exceptions_and_usage + # Make sure warning doesn't raise more than once. warnings.simplefilter("once", RuntimeWarning) @@ -176,7 +176,9 @@ def get_historical_features( expected_join_keys = _get_join_keys(project, feature_views, registry) assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) engine = make_engine(config.offline_store) - entity_df["event_timestamp"] = pandas.to_datetime(entity_df["event_timestamp"], utc=True).fillna(pandas.Timestamp.now()) + entity_df["event_timestamp"] = pandas.to_datetime( + entity_df["event_timestamp"], utc=True + ).fillna(pandas.Timestamp.now()) ( table_schema, @@ -337,7 +339,7 @@ def full_feature_names(self) -> bool: @property def on_demand_feature_views(self) -> List[OnDemandFeatureView]: return self._on_demand_feature_views - + def _to_df_internal(self) -> pandas.DataFrame: return pandas.read_sql(self.query, con=self.engine).fillna(value=np.nan) @@ -405,17 +407,18 @@ def _upload_entity_df_into_sqlserver_and_get_entity_schema( elif isinstance(entity_df, pandas.DataFrame): # Drop the index so that we don't have unnecessary columns engine.execute(_df_to_create_table_sql(entity_df, table_id)) - entity_df.to_sql(name=table_id, con=engine, index=False, if_exists='append') + entity_df.to_sql(name=table_id, con=engine, index=False, if_exists="append") entity_schema = dict(zip(entity_df.columns, entity_df.dtypes)), table_id else: raise ValueError( f"The entity dataframe you have provided must be a SQL Server SQL query," f" or a Pandas dataframe. But we found: {type(entity_df)} " - ) + ) return entity_schema + def _df_to_create_table_sql(df: pandas.DataFrame, table_name: str) -> str: pa_table = pa.Table.from_pandas(df) @@ -427,6 +430,7 @@ def _df_to_create_table_sql(df: pandas.DataFrame, table_name: str) -> str: ); """ + def _get_entity_df_event_timestamp_range( entity_df: Union[pandas.DataFrame, str], entity_df_event_timestamp_col: str, diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index d1a811a4f5..8fa3d7813a 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -5,8 +5,8 @@ import pytest from sqlalchemy import create_engine from testcontainers.core.container import DockerContainer -from testcontainers.mssql import SqlServerContainer from testcontainers.core.waiting_utils import wait_for_logs +from testcontainers.mssql import SqlServerContainer from feast.data_source import DataSource from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import ( @@ -54,8 +54,6 @@ def _df_to_create_table_sql(df: pd.DataFrame, table_name: str) -> str: """ - - class MsSqlDataSourceCreator(DataSourceCreator): tables: List[str] = [] @@ -65,9 +63,9 @@ def __init__( super().__init__(project_name) self.tables_created: List[str] = [] self.container = SqlServerContainer(user=MSSQL_USER, password=MSSQL_PASSWORD) - #self.container = fixture_request.getfixturevalue("mssql_container") + # self.container = fixture_request.getfixturevalue("mssql_container") self.container.start() - #self.exposed_port = self.container.get_exposed_port("1433") + # self.exposed_port = self.container.get_exposed_port("1433") if not self.container: raise RuntimeError( "In order to use this data source " @@ -94,20 +92,24 @@ def create_data_source( **kwargs, ) -> DataSource: if timestamp_field in df: - df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True).fillna(pd.Timestamp.now()) + df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True).fillna( + pd.Timestamp.now() + ) # Make sure the field mapping is correct and convert the datetime datasources. if created_timestamp_column in df: - df[created_timestamp_column] = pd.to_datetime(df[created_timestamp_column], utc=True).fillna(pd.Timestamp.now()) - + df[created_timestamp_column] = pd.to_datetime( + df[created_timestamp_column], utc=True + ).fillna(pd.Timestamp.now()) + connection_string = self.create_offline_store_config().connection_string engine = create_engine(connection_string) # Create table - + destination_name = self.get_prefixed_table_name(destination_name) - #_df_to_create_table_sql(df, destination_name) + # _df_to_create_table_sql(df, destination_name) engine.execute(_df_to_create_table_sql(df, destination_name)) # Upload dataframe to azure table - df.to_sql(destination_name, engine, index=False, if_exists='append') + df.to_sql(destination_name, engine, index=False, if_exists="append") self.tables.append(destination_name) return MsSqlServerSource( name="ci_mssql_source", diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 10262eca02..111f9a590e 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -391,7 +391,7 @@ def validate_dataframes(expected_df, actual_df, keys, event_timestamp=None): .drop_duplicates() .reset_index(drop=True) ) - + new_df = expected_df.drop("event_timestamp", axis=1) new_actual_df = actual_df.drop("event_timestamp", axis=1) keys = keys.remove("event_timestamp") From ebb934bbc4cc752ca92d1d296c35fb95becfa0d6 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 11:56:57 -0700 Subject: [PATCH 22/51] Fix lint Signed-off-by: Kevin Zhang --- sdk/python/feast/infra/contrib/azure_provider.py | 3 +-- .../contrib/mssql_offline_store/mssql.py | 16 +++++++++------- .../mssql_offline_store/tests/data_source.py | 15 +-------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 0704668921..f83d2a1351 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -15,14 +15,13 @@ from feast.infra.offline_stores.offline_store import RetrievalJob from feast.infra.offline_stores.offline_utils import get_offline_store_from_config from feast.infra.online_stores.helpers import get_online_store_from_config +from feast.infra.passthrough_provider import PassthroughProvider from feast.infra.provider import Provider from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto from feast.registry import BaseRegistry from feast.repo_config import RepoConfig from feast.saved_dataset import SavedDataset -from feast.infra.passthrough_provider import PassthroughProvider - from feast.utils import ( _convert_arrow_to_proto, _get_column_names, diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 16f21486d3..0e136c4bd6 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -16,7 +16,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker -from feast import FileSource, errors +from feast import FileSource, entity, errors from feast.data_source import DataSource from feast.errors import InvalidEntityType from feast.feature_logging import LoggingConfig, LoggingSource @@ -176,19 +176,21 @@ def get_historical_features( expected_join_keys = _get_join_keys(project, feature_views, registry) assert isinstance(config.offline_store, MsSqlServerOfflineStoreConfig) engine = make_engine(config.offline_store) - entity_df["event_timestamp"] = pandas.to_datetime( - entity_df["event_timestamp"], utc=True - ).fillna(pandas.Timestamp.now()) + if isinstance(entity_df, pandas.DataFrame): + entity_df_event_timestamp_col = ( + offline_utils.infer_event_timestamp_from_entity_df(dict(zip(list(entity_df.columns), list(entity_df.dtypes)))) + ) + entity_df[entity_df_event_timestamp_col] = pandas.to_datetime( + entity_df[entity_df_event_timestamp_col], utc=True + ).fillna(pandas.Timestamp.now()) + # TODO: figure out how to deal with entity dataframes that are strings ( table_schema, table_name, ) = _upload_entity_df_into_sqlserver_and_get_entity_schema( engine, config, entity_df, full_feature_names=full_feature_names ) - entity_df_event_timestamp_col = ( - offline_utils.infer_event_timestamp_from_entity_df(table_schema) - ) _assert_expected_columns_in_sqlserver( expected_join_keys, diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index 8fa3d7813a..a1e9c65514 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -10,13 +10,12 @@ from feast.data_source import DataSource from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import ( - MsSqlServerOfflineStoreConfig, + MsSqlServerOfflineStoreConfig, _df_to_create_table_sql ) from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( MsSqlServerSource, ) from feast.saved_dataset import SavedDatasetStorage -from feast.type_map import pa_to_mssql_type from tests.integration.feature_repos.universal.data_source_creator import ( DataSourceCreator, ) @@ -43,17 +42,6 @@ def mssql_container(): container.stop() -def _df_to_create_table_sql(df: pd.DataFrame, table_name: str) -> str: - pa_table = pa.Table.from_pandas(df) - - columns = [f""""{f.name}" {pa_to_mssql_type(f.type)}""" for f in pa_table.schema] - return f""" - CREATE TABLE "{table_name}" ( - {", ".join(columns)} - ); - """ - - class MsSqlDataSourceCreator(DataSourceCreator): tables: List[str] = [] @@ -106,7 +94,6 @@ def create_data_source( # Create table destination_name = self.get_prefixed_table_name(destination_name) - # _df_to_create_table_sql(df, destination_name) engine.execute(_df_to_create_table_sql(df, destination_name)) # Upload dataframe to azure table df.to_sql(destination_name, engine, index=False, if_exists="append") From e456acb93857d198156f5aebb86e068bfdc50b37 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 12:19:40 -0700 Subject: [PATCH 23/51] Fix Signed-off-by: Kevin Zhang --- docs/tutorials/azure/notebooks/src/score.py | 3 ++- sdk/python/feast/infra/contrib/azure_provider.py | 2 +- .../infra/offline_stores/contrib/mssql_offline_store/mssql.py | 4 ++-- .../infra/registry_stores/contrib/azure/registry_store.py | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/azure/notebooks/src/score.py b/docs/tutorials/azure/notebooks/src/score.py index ede51c9517..93b248240d 100644 --- a/docs/tutorials/azure/notebooks/src/score.py +++ b/docs/tutorials/azure/notebooks/src/score.py @@ -6,7 +6,8 @@ import json import joblib from feast import FeatureStore, RepoConfig -from feast.registry import RegistryConfig +from feast.infra.registry.registry import RegistryConfig + from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import MsSqlServerOfflineStoreConfig from feast.infra.online_stores.redis import RedisOnlineStoreConfig, RedisOnlineStore diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index f83d2a1351..75bca3ee98 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -19,7 +19,7 @@ from feast.infra.provider import Provider from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto -from feast.registry import BaseRegistry +from feast.infra.registry.base_registry import BaseRegistry from feast.repo_config import RepoConfig from feast.saved_dataset import SavedDataset from feast.utils import ( diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 0e136c4bd6..1433e8a4b6 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -16,7 +16,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker -from feast import FileSource, entity, errors +from feast import FileSource, errors from feast.data_source import DataSource from feast.errors import InvalidEntityType from feast.feature_logging import LoggingConfig, LoggingSource @@ -31,7 +31,7 @@ ) from feast.infra.provider import RetrievalJob from feast.on_demand_feature_view import OnDemandFeatureView -from feast.registry import BaseRegistry +from feast.infra.registry.base_registry import BaseRegistry from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage from feast.type_map import pa_to_mssql_type diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py index d5cdbb00bb..760d9e049e 100644 --- a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py +++ b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py @@ -9,8 +9,8 @@ from urllib.parse import urlparse from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto -from feast.registry import RegistryConfig -from feast.registry_store import RegistryStore +from feast.infra.registry.registry import RegistryConfig +from feast.infra.registry.registry_store import RegistryStore REGISTRY_SCHEMA_VERSION = "1" From 45f479ff7f260171db9b29f8473b30b171048c59 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 12:22:01 -0700 Subject: [PATCH 24/51] Fix lint Signed-off-by: Kevin Zhang --- .../feast/infra/contrib/azure_provider.py | 18 ++---------------- .../contrib/mssql_offline_store/mssql.py | 6 ++++-- .../mssql_offline_store/tests/data_source.py | 4 ++-- .../contrib/azure/registry_store.py | 2 +- 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 75bca3ee98..1cb24a7aa8 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -1,32 +1,18 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import warnings -from datetime import datetime, timedelta -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from datetime import datetime +from typing import Callable -import pandas -import pyarrow as pa from tqdm import tqdm -from feast import FeatureService -from feast.entity import Entity -from feast.feature_logging import FeatureServiceLoggingSource from feast.feature_view import FeatureView -from feast.infra.offline_stores.offline_store import RetrievalJob -from feast.infra.offline_stores.offline_utils import get_offline_store_from_config -from feast.infra.online_stores.helpers import get_online_store_from_config from feast.infra.passthrough_provider import PassthroughProvider -from feast.infra.provider import Provider -from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto -from feast.protos.feast.types.Value_pb2 import Value as ValueProto from feast.infra.registry.base_registry import BaseRegistry from feast.repo_config import RepoConfig -from feast.saved_dataset import SavedDataset from feast.utils import ( _convert_arrow_to_proto, _get_column_names, _run_pyarrow_field_mapping, - make_tzaware, ) DEFAULT_BATCH_SIZE = 10_000 diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 1433e8a4b6..baed574268 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -30,8 +30,8 @@ get_feature_view_query_context, ) from feast.infra.provider import RetrievalJob -from feast.on_demand_feature_view import OnDemandFeatureView from feast.infra.registry.base_registry import BaseRegistry +from feast.on_demand_feature_view import OnDemandFeatureView from feast.repo_config import FeastBaseModel, RepoConfig from feast.saved_dataset import SavedDatasetStorage from feast.type_map import pa_to_mssql_type @@ -178,7 +178,9 @@ def get_historical_features( engine = make_engine(config.offline_store) if isinstance(entity_df, pandas.DataFrame): entity_df_event_timestamp_col = ( - offline_utils.infer_event_timestamp_from_entity_df(dict(zip(list(entity_df.columns), list(entity_df.dtypes)))) + offline_utils.infer_event_timestamp_from_entity_df( + dict(zip(list(entity_df.columns), list(entity_df.dtypes))) + ) ) entity_df[entity_df_event_timestamp_col] = pandas.to_datetime( entity_df[entity_df_event_timestamp_col], utc=True diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index a1e9c65514..46bbf7625b 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -1,7 +1,6 @@ from typing import Dict, List import pandas as pd -import pyarrow as pa import pytest from sqlalchemy import create_engine from testcontainers.core.container import DockerContainer @@ -10,7 +9,8 @@ from feast.data_source import DataSource from feast.infra.offline_stores.contrib.mssql_offline_store.mssql import ( - MsSqlServerOfflineStoreConfig, _df_to_create_table_sql + MsSqlServerOfflineStoreConfig, + _df_to_create_table_sql, ) from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( MsSqlServerSource, diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py index 760d9e049e..9c00170b0f 100644 --- a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py +++ b/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py @@ -8,9 +8,9 @@ from tempfile import TemporaryFile from urllib.parse import urlparse -from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.infra.registry.registry import RegistryConfig from feast.infra.registry.registry_store import RegistryStore +from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto REGISTRY_SCHEMA_VERSION = "1" From 4b8c4a20c502b4cb7737947b6184e11589679b0d Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 12:32:34 -0700 Subject: [PATCH 25/51] Fix Signed-off-by: Kevin Zhang --- .../test_universal_historical_retrieval.py | 2 -- sdk/python/tests/utils/feature_records.py | 12 +++--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index 2f86f368c8..73c5152d47 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -147,7 +147,6 @@ def test_historical_features(environment, universal_data_sources, full_feature_n expected_df, actual_df_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], - event_timestamp=event_timestamp, ) assert_feature_service_correctness( @@ -172,7 +171,6 @@ def test_historical_features(environment, universal_data_sources, full_feature_n expected_df, table_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], - event_timestamp=event_timestamp, ) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 111f9a590e..9aadc03168 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -315,7 +315,6 @@ def assert_feature_service_correctness( expected_df, actual_df_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], - event_timestamp=event_timestamp, ) @@ -368,7 +367,6 @@ def assert_feature_service_entity_mapping_correctness( "origin_id", "destination_id", ], - event_timestamp=event_timestamp, ) else: # using 2 of the same FeatureView without full_feature_names=True will result in collision @@ -380,7 +378,7 @@ def assert_feature_service_entity_mapping_correctness( ) -def validate_dataframes(expected_df, actual_df, keys, event_timestamp=None): +def validate_dataframes(expected_df, actual_df, keys): expected_df: pd.DataFrame = ( expected_df.sort_values(by=keys).drop_duplicates().reset_index(drop=True) ) @@ -392,13 +390,9 @@ def validate_dataframes(expected_df, actual_df, keys, event_timestamp=None): .reset_index(drop=True) ) - new_df = expected_df.drop("event_timestamp", axis=1) - new_actual_df = actual_df.drop("event_timestamp", axis=1) - keys = keys.remove("event_timestamp") - pd_assert_frame_equal( - new_df, - new_actual_df, + expected_df, + actual_df, check_dtype=False, ) From b1bf60234a0deeb919de27d9a34c0890ff416cc0 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 21:49:16 +0000 Subject: [PATCH 26/51] Fix Signed-off-by: Kevin Zhang --- sdk/python/feast/data_source.py | 1 - .../mssql_offline_store/mssqlserver_source.py | 1 + .../mssql_offline_store/tests/data_source.py | 4 ---- sdk/python/feast/type_map.py | 2 +- .../test_universal_historical_retrieval.py | 17 ++++++++++++++++- sdk/python/tests/utils/feature_records.py | 18 +++++++++++++++--- 6 files changed, 33 insertions(+), 10 deletions(-) diff --git a/sdk/python/feast/data_source.py b/sdk/python/feast/data_source.py index 76b012e585..19a780b32c 100644 --- a/sdk/python/feast/data_source.py +++ b/sdk/python/feast/data_source.py @@ -299,7 +299,6 @@ def from_proto(data_source: DataSourceProto) -> Any: if data_source_type == DataSourceProto.SourceType.CUSTOM_SOURCE: cls = get_data_source_class_from_type(data_source.data_source_class_type) return cls.from_proto(data_source) - cls = get_data_source_class_from_type(_DATA_SOURCE_OPTIONS[data_source_type]) return cls.from_proto(data_source) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 2f80227ac1..52cae30975 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -194,6 +194,7 @@ def from_proto(data_source: DataSourceProto): def to_proto(self) -> DataSourceProto: data_source_proto = DataSourceProto( type=DataSourceProto.CUSTOM_SOURCE, + data_source_class_type="feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source.MsSqlServerSource", field_mapping=self.field_mapping, custom_options=self.mssqlserver_options.to_proto(), ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index 46bbf7625b..21188a1a97 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -64,10 +64,6 @@ def __init__( def create_offline_store_config(self) -> MsSqlServerOfflineStoreConfig: return MsSqlServerOfflineStoreConfig( connection_string=self.container.get_connection_url(), - # connection_string=( - # f"mssql+pyodbc://{MSSQL_USER}:{MSSQL_PASSWORD}@0.0.0.0:1433/master?" - # "driver=ODBC+Driver+17+for+SQL+Server" - # ) ) def create_data_source( diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 374e290ab5..57c5a665c5 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -564,7 +564,7 @@ def pa_to_mssql_type(pa_type: pyarrow.DataType) -> str: pa_type_as_str = str(pa_type).lower() if pa_type_as_str.startswith("timestamp"): if "tz=" in pa_type_as_str: - return "DATETIME2" + return "datetime2" else: return "datetime" diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index 73c5152d47..b881365112 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -147,6 +147,8 @@ def test_historical_features(environment, universal_data_sources, full_feature_n expected_df, actual_df_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp_column=event_timestamp, + timestamp_imprecision=timedelta(milliseconds=1), ) assert_feature_service_correctness( @@ -171,6 +173,8 @@ def test_historical_features(environment, universal_data_sources, full_feature_n expected_df, table_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp_column=event_timestamp, + timestamp_imprecision=timedelta(milliseconds=1), ) @@ -330,6 +334,7 @@ def test_historical_features_with_entities_from_query( expected_df_query, actual_df_from_sql_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp_column=timedelta(milliseconds=1), ) table_from_sql_entities = job_from_sql.to_arrow().to_pandas() @@ -342,6 +347,8 @@ def test_historical_features_with_entities_from_query( expected_df_query, table_from_sql_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp_column=event_timestamp, + timestamp_precision=timedelta(milliseconds=1), ) @@ -416,12 +423,16 @@ def test_historical_features_persisting( expected_df, saved_dataset.to_df(), keys=[event_timestamp, "driver_id", "customer_id"], + event_timestamp_column=event_timestamp, + timestamp_precision=timedelta(milliseconds=1), ) validate_dataframes( job.to_df(), saved_dataset.to_df(), keys=[event_timestamp, "driver_id", "customer_id"], + event_timestamp_column=event_timestamp, + timestamp_precision=timedelta(milliseconds=1), ) @@ -494,6 +505,8 @@ def test_historical_features_with_no_ttl( expected_df, job.to_df(), keys=[event_timestamp, "driver_id", "customer_id"], + event_timestamp_column=event_timestamp, + timestamp_precision=timedelta(milliseconds=1), ) @@ -590,4 +603,6 @@ def test_historical_features_from_bigquery_sources_containing_backfills(environm print(str(f"Time to execute job_from_df.to_df() = '{(end_time - start_time)}'\n")) assert sorted(expected_df.columns) == sorted(actual_df.columns) - validate_dataframes(expected_df, actual_df, keys=["driver_id"]) + validate_dataframes( + expected_df, actual_df, keys=["driver_id"], event_timestamp_column=event_timestamp, + timestamp_precision=timedelta(milliseconds=1),) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 9aadc03168..967a40e6d5 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -315,6 +315,8 @@ def assert_feature_service_correctness( expected_df, actual_df_from_df_entities, keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp_column=event_timestamp, + timestamp_imprecision=timedelta(milliseconds=1), ) @@ -367,6 +369,8 @@ def assert_feature_service_entity_mapping_correctness( "origin_id", "destination_id", ], + event_timestamp_column=event_timestamp, + timestamp_imprecision=timedelta(milliseconds=1), ) else: # using 2 of the same FeatureView without full_feature_names=True will result in collision @@ -377,8 +381,8 @@ def assert_feature_service_entity_mapping_correctness( full_feature_names=full_feature_names, ) - -def validate_dataframes(expected_df, actual_df, keys): +# Specify timestamp_imprecision to relax timestamp equality constraints +def validate_dataframes(expected_df, actual_df, keys, event_timestamp_column, timestamp_imprecision=0): expected_df: pd.DataFrame = ( expected_df.sort_values(by=keys).drop_duplicates().reset_index(drop=True) ) @@ -389,13 +393,21 @@ def validate_dataframes(expected_df, actual_df, keys): .drop_duplicates() .reset_index(drop=True) ) - + expected_timestamp_col = expected_df[event_timestamp_column].to_frame() + actual_timestamp_col = expected_df[event_timestamp_column].to_frame() + expected_df = expected_df.drop(event_timestamp_column, axis=1) + actual_df = actual_df.drop(event_timestamp_column, axis = 1) + if event_timestamp_column in keys: + keys = keys.remove(event_timestamp_column) pd_assert_frame_equal( expected_df, actual_df, check_dtype=False, ) + for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): + assert abs(t1 - t2) < timestamp_imprecision + def _get_feature_view_ttl( feature_view: FeatureView, default_ttl: timedelta From 4586f00b0c67a93677c01549f62415691bde70e8 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 22:05:57 +0000 Subject: [PATCH 27/51] Fix azure Signed-off-by: Kevin Zhang --- docs/how-to-guides/adding-or-reusing-tests.md | 3 +- .../contrib/azure/__init__.py | 0 .../contrib/azure/azure_registry_store.py} | 0 sdk/python/feast/infra/registry/registry.py | 8 ++--- sdk/python/tests/README.md | 2 +- .../test_universal_historical_retrieval.py | 20 ++++++----- sdk/python/tests/utils/feature_records.py | 35 +++++++++++-------- 7 files changed, 39 insertions(+), 29 deletions(-) rename sdk/python/feast/infra/{registry_stores => registry}/contrib/azure/__init__.py (100%) rename sdk/python/feast/infra/{registry_stores/contrib/azure/registry_store.py => registry/contrib/azure/azure_registry_store.py} (100%) diff --git a/docs/how-to-guides/adding-or-reusing-tests.md b/docs/how-to-guides/adding-or-reusing-tests.md index 45b9aa26e0..d68e47df5c 100644 --- a/docs/how-to-guides/adding-or-reusing-tests.md +++ b/docs/how-to-guides/adding-or-reusing-tests.md @@ -241,7 +241,8 @@ def test_historical_features(environment, universal_data_sources, full_feature_n validate_dataframes( expected_df, table_from_df_entities, - keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], + event_timestamp = event_timestamp, ) # ... more test code ``` diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/__init__.py b/sdk/python/feast/infra/registry/contrib/azure/__init__.py similarity index 100% rename from sdk/python/feast/infra/registry_stores/contrib/azure/__init__.py rename to sdk/python/feast/infra/registry/contrib/azure/__init__.py diff --git a/sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py b/sdk/python/feast/infra/registry/contrib/azure/azure_registry_store.py similarity index 100% rename from sdk/python/feast/infra/registry_stores/contrib/azure/registry_store.py rename to sdk/python/feast/infra/registry/contrib/azure/azure_registry_store.py diff --git a/sdk/python/feast/infra/registry/registry.py b/sdk/python/feast/infra/registry/registry.py index 27c4ed9c5f..97b39466c9 100644 --- a/sdk/python/feast/infra/registry/registry.py +++ b/sdk/python/feast/infra/registry/registry.py @@ -57,11 +57,11 @@ REGISTRY_SCHEMA_VERSION = "1" REGISTRY_STORE_CLASS_FOR_TYPE = { - "GCSRegistryStore": "feast.infra.gcp.GCSRegistryStore", - "S3RegistryStore": "feast.infra.aws.S3RegistryStore", + "GCSRegistryStore": "feast.infra.registry.gcs.GCSRegistryStore", + "S3RegistryStore": "feast.infra.registry.s3.S3RegistryStore", "FileRegistryStore": "feast.infra.registry.file.FileRegistryStore", - "PostgreSQLRegistryStore": "feast.infra.registry_stores.contrib.postgres.registry_store.PostgreSQLRegistryStore", - "AzureRegistryStore": "feast.infra.registry_stores.contrib.azure.registry_store.AzBlobRegistryStore", + "PostgreSQLRegistryStore": "feast.infra.registry.contrib.postgres.postgres_registry_store.PostgreSQLRegistryStore", + "AzureRegistryStore": "feast.infra.registry.contrib.azure.azure_registry_store.AzBlobRegistryStore", } REGISTRY_STORE_CLASS_FOR_SCHEME = { diff --git a/sdk/python/tests/README.md b/sdk/python/tests/README.md index 0f56e0eee2..3212f02482 100644 --- a/sdk/python/tests/README.md +++ b/sdk/python/tests/README.md @@ -239,7 +239,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n validate_dataframes( expected_df, table_from_df_entities, - keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], ) # ... more test code ``` diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index b881365112..3401042e55 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -146,7 +146,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n validate_dataframes( expected_df, actual_df_from_df_entities, - keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=event_timestamp, timestamp_imprecision=timedelta(milliseconds=1), ) @@ -172,7 +172,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n validate_dataframes( expected_df, table_from_df_entities, - keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=event_timestamp, timestamp_imprecision=timedelta(milliseconds=1), ) @@ -333,7 +333,7 @@ def test_historical_features_with_entities_from_query( validate_dataframes( expected_df_query, actual_df_from_sql_entities, - keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=timedelta(milliseconds=1), ) @@ -346,7 +346,7 @@ def test_historical_features_with_entities_from_query( validate_dataframes( expected_df_query, table_from_sql_entities, - keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=event_timestamp, timestamp_precision=timedelta(milliseconds=1), ) @@ -422,7 +422,7 @@ def test_historical_features_persisting( validate_dataframes( expected_df, saved_dataset.to_df(), - keys=[event_timestamp, "driver_id", "customer_id"], + sort_by=[event_timestamp, "driver_id", "customer_id"], event_timestamp_column=event_timestamp, timestamp_precision=timedelta(milliseconds=1), ) @@ -430,7 +430,7 @@ def test_historical_features_persisting( validate_dataframes( job.to_df(), saved_dataset.to_df(), - keys=[event_timestamp, "driver_id", "customer_id"], + sort_by=[event_timestamp, "driver_id", "customer_id"], event_timestamp_column=event_timestamp, timestamp_precision=timedelta(milliseconds=1), ) @@ -504,7 +504,7 @@ def test_historical_features_with_no_ttl( validate_dataframes( expected_df, job.to_df(), - keys=[event_timestamp, "driver_id", "customer_id"], + sort_by=[event_timestamp, "driver_id", "customer_id"], event_timestamp_column=event_timestamp, timestamp_precision=timedelta(milliseconds=1), ) @@ -604,5 +604,7 @@ def test_historical_features_from_bigquery_sources_containing_backfills(environm assert sorted(expected_df.columns) == sorted(actual_df.columns) validate_dataframes( - expected_df, actual_df, keys=["driver_id"], event_timestamp_column=event_timestamp, - timestamp_precision=timedelta(milliseconds=1),) + expected_df, + actual_df, + sort_by=["driver_id"], + ) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 967a40e6d5..7d32f0a8e7 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -314,7 +314,7 @@ def assert_feature_service_correctness( validate_dataframes( expected_df, actual_df_from_df_entities, - keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=event_timestamp, timestamp_imprecision=timedelta(milliseconds=1), ) @@ -361,7 +361,7 @@ def assert_feature_service_entity_mapping_correctness( validate_dataframes( expected_df, actual_df_from_df_entities, - keys=[ + sort_by=[ event_timestamp, "order_id", "driver_id", @@ -381,33 +381,40 @@ def assert_feature_service_entity_mapping_correctness( full_feature_names=full_feature_names, ) + # Specify timestamp_imprecision to relax timestamp equality constraints -def validate_dataframes(expected_df, actual_df, keys, event_timestamp_column, timestamp_imprecision=0): +def validate_dataframes( + expected_df, + actual_df, + sort_by, + event_timestamp_column=None, + timestamp_imprecision=0, +): expected_df: pd.DataFrame = ( - expected_df.sort_values(by=keys).drop_duplicates().reset_index(drop=True) + expected_df.sort_values(by=sort_by).drop_duplicates().reset_index(drop=True) ) actual_df = ( actual_df[expected_df.columns] - .sort_values(by=keys) + .sort_values(by=sort_by) .drop_duplicates() .reset_index(drop=True) ) - expected_timestamp_col = expected_df[event_timestamp_column].to_frame() - actual_timestamp_col = expected_df[event_timestamp_column].to_frame() - expected_df = expected_df.drop(event_timestamp_column, axis=1) - actual_df = actual_df.drop(event_timestamp_column, axis = 1) - if event_timestamp_column in keys: - keys = keys.remove(event_timestamp_column) + if event_timestamp_column: + expected_timestamp_col = expected_df[event_timestamp_column].to_frame() + actual_timestamp_col = expected_df[event_timestamp_column].to_frame() + expected_df = expected_df.drop(event_timestamp_column, axis=1) + actual_df = actual_df.drop(event_timestamp_column, axis=1) + if event_timestamp_column in sort_by: + sort_by = sort_by.remove(event_timestamp_column) + for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): + assert abs(t1 - t2) <= timestamp_imprecision pd_assert_frame_equal( expected_df, actual_df, check_dtype=False, ) - for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): - assert abs(t1 - t2) < timestamp_imprecision - def _get_feature_view_ttl( feature_view: FeatureView, default_ttl: timedelta From 3b88c0be1a5f5bf15f944adb5c9469c7cd67f26b Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 23:06:28 +0000 Subject: [PATCH 28/51] Fix Signed-off-by: Kevin Zhang --- docs/reference/data-sources/mssql.md | 8 ++++---- docs/reference/offline-stores/mssql.md | 6 +++--- docs/tutorials/azure/README.md | 8 ++++---- sdk/python/feast/infra/contrib/azure_provider.py | 1 + .../contrib/mssql_offline_store/mssql.py | 5 +++++ .../contrib/mssql_offline_store/mssqlserver_source.py | 8 ++++---- .../contrib/mssql_offline_store/tests/__init__.py | 1 - .../contrib/mssql_offline_store/tests/data_source.py | 11 +++++------ .../feast/infra/offline_stores/offline_utils.py | 1 + sdk/python/feast/infra/registry/registry.py | 3 +++ setup.py | 2 -- 11 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/reference/data-sources/mssql.md b/docs/reference/data-sources/mssql.md index 9e7fbdb3b2..8bf1ede6aa 100644 --- a/docs/reference/data-sources/mssql.md +++ b/docs/reference/data-sources/mssql.md @@ -1,18 +1,18 @@ -# MsSql source (contrib) +# MsSQL source (contrib) ## Description -MsSql data sources are Microsoft sql table sources. +MsSQL data sources are Microsoft sql table sources. These can be specified either by a table reference or a SQL query. ## Disclaimer -The MsSql data source does not achieve full test coverage. +The MsSQL data source does not achieve full test coverage. Please do not assume complete stability. ## Examples -Defining a MsSql source: +Defining a MsSQL source: ```python from feast.infra.offline_stores.contrib.mssql_offline_store.mssqlserver_source import ( diff --git a/docs/reference/offline-stores/mssql.md b/docs/reference/offline-stores/mssql.md index e6196f03f4..bec0c8deb8 100644 --- a/docs/reference/offline-stores/mssql.md +++ b/docs/reference/offline-stores/mssql.md @@ -1,14 +1,14 @@ -# MsSQl offline store (contrib) +# MsSQL/Synapse offline store (contrib) ## Description -The MsSql offline store provides support for reading [MsSQL Sources](../data-sources/mssql.md). +The MsSQL offline store provides support for reading [MsSQL Sources](../data-sources/mssql.md). Specifically, it is developed to read from [Synapse SQL](https://docs.microsoft.com/en-us/azure/synapse-analytics/sql/overview-features) on Microsoft Azure * Entity dataframes can be provided as a SQL query or can be provided as a Pandas dataframe. ## Disclaimer -The MsSql offline store does not achieve full test coverage. +The MsSQL offline store does not achieve full test coverage. Please do not assume complete stability. ## Example diff --git a/docs/tutorials/azure/README.md b/docs/tutorials/azure/README.md index 65c086a565..5b96f74ce9 100644 --- a/docs/tutorials/azure/README.md +++ b/docs/tutorials/azure/README.md @@ -3,16 +3,16 @@ The objective of this tutorial is to build a model that predicts if a driver will complete a trip based on a number of features ingested into Feast. During this tutorial you will: 1. Deploy the infrastructure for a feature store (using an ARM template) -1. Register features into a central feature registry hosted on Blob Storage -1. Consume features from the feature store for training and inference +2. Register features into a central feature registry hosted on Blob Storage +3. Consume features from the feature store for training and inference ## Prerequisites For this tutorial you will require: 1. An Azure subscription. -1. Working knowledge of Python and ML concepts. -1. Basic understanding of Azure Machine Learning - using notebooks, etc. +2. Working knowledge of Python and ML concepts. +3. Basic understanding of Azure Machine Learning - using notebooks, etc. ## 1. Deploy Infrastructure diff --git a/sdk/python/feast/infra/contrib/azure_provider.py b/sdk/python/feast/infra/contrib/azure_provider.py index 1cb24a7aa8..ac56a2b33e 100644 --- a/sdk/python/feast/infra/contrib/azure_provider.py +++ b/sdk/python/feast/infra/contrib/azure_provider.py @@ -29,6 +29,7 @@ def materialize_single_feature_view( project: str, tqdm_builder: Callable[[int], tqdm], ) -> None: + # TODO(kevjumba): untested entities = [] for entity_name in feature_view.entities: entities.append(registry.get_entity(entity_name, project)) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index baed574268..75ed7a959f 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -370,6 +370,11 @@ def persist(self, storage: SavedDatasetStorage): pyarrow.parquet.write_to_dataset( self.to_arrow(), root_path=path, filesystem=filesystem ) + def supports_remote_storage_export(self) -> bool: + return False + + def to_remote_storage(self) -> List[str]: + raise NotImplementedError() @property def metadata(self) -> Optional[RetrievalMetadata]: diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index 52cae30975..6b126fa40c 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -22,7 +22,7 @@ class MsSqlServerOptions: """ - DataSource MsSqlServer options used to source features from MsSqlServer query + DataSource MsSQLServer options used to source features from MsSQLServer query """ def __init__( @@ -66,11 +66,11 @@ def from_proto( cls, sqlserver_options_proto: DataSourceProto.CustomSourceOptions ) -> "MsSqlServerOptions": """ - Creates an MsSqlServerOptions from a protobuf representation of a SqlServer option + Creates an MsSQLServerOptions from a protobuf representation of a SqlServer option Args: sqlserver_options_proto: A protobuf representation of a DataSource Returns: - Returns a SqlServerOptions object based on the sqlserver_options protobuf + Returns a SQLServerOptions object based on the sqlserver_options protobuf """ options = json.loads(sqlserver_options_proto.configuration) @@ -83,7 +83,7 @@ def from_proto( def to_proto(self) -> DataSourceProto.CustomSourceOptions: """ - Converts a MsSqlServerOptions object to a protobuf representation. + Converts a MsSQLServerOptions object to a protobuf representation. Returns: CustomSourceOptions protobuf """ diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py index ae7affc838..e69de29bb2 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py @@ -1 +0,0 @@ -from .data_source import mssql_container # noqa diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index 21188a1a97..7f876c4b6b 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -51,9 +51,7 @@ def __init__( super().__init__(project_name) self.tables_created: List[str] = [] self.container = SqlServerContainer(user=MSSQL_USER, password=MSSQL_PASSWORD) - # self.container = fixture_request.getfixturevalue("mssql_container") self.container.start() - # self.exposed_port = self.container.get_exposed_port("1433") if not self.container: raise RuntimeError( "In order to use this data source " @@ -75,11 +73,11 @@ def create_data_source( field_mapping: Dict[str, str] = None, **kwargs, ) -> DataSource: + # Make sure the field mapping is correct and convert the datetime datasources. if timestamp_field in df: df[timestamp_field] = pd.to_datetime(df[timestamp_field], utc=True).fillna( pd.Timestamp.now() ) - # Make sure the field mapping is correct and convert the datetime datasources. if created_timestamp_column in df: df[created_timestamp_column] = pd.to_datetime( df[created_timestamp_column], utc=True @@ -87,12 +85,13 @@ def create_data_source( connection_string = self.create_offline_store_config().connection_string engine = create_engine(connection_string) - # Create table - destination_name = self.get_prefixed_table_name(destination_name) + # Create table engine.execute(_df_to_create_table_sql(df, destination_name)) + # Upload dataframe to azure table df.to_sql(destination_name, engine, index=False, if_exists="append") + self.tables.append(destination_name) return MsSqlServerSource( name="ci_mssql_source", @@ -110,4 +109,4 @@ def get_prefixed_table_name(self, destination_name: str) -> str: return f"{self.project_name}_{destination_name}" def teardown(self): - pass + container.stop() diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index a1ceb607a3..a8a34da1f4 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -212,6 +212,7 @@ def build_point_in_time_query( "full_feature_names": full_feature_names, "final_output_feature_names": final_output_feature_names, } + query = template.render(template_context) return query diff --git a/sdk/python/feast/infra/registry/registry.py b/sdk/python/feast/infra/registry/registry.py index 97b39466c9..09d22ee376 100644 --- a/sdk/python/feast/infra/registry/registry.py +++ b/sdk/python/feast/infra/registry/registry.py @@ -323,6 +323,9 @@ def apply_data_source( f"{data_source.__class__.__module__}.{data_source.__class__.__name__}" ) data_source_proto.project = project + data_source_proto.data_source_class_type = ( + f"{data_source.__class__.__module__}.{data_source.__class__.__name__}" + ) registry.data_sources.append(data_source_proto) if commit: self.commit() diff --git a/setup.py b/setup.py index 1f235f70fa..ea3fa1861c 100644 --- a/setup.py +++ b/setup.py @@ -131,8 +131,6 @@ AZURE_REQUIRED = ( [ - "redis==4.2.2", - "hiredis>=2.0.0,<3", "azure-storage-blob>=0.37.0", "azure-identity>=1.6.1", "SQLAlchemy>=1.4.19", From 9ae8ee331313fe6de9a08062922cf9ceb3b2530c Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Wed, 17 Aug 2022 16:12:01 -0700 Subject: [PATCH 29/51] Fix Signed-off-by: Kevin Zhang --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ea3fa1861c..2ac7ad271a 100644 --- a/setup.py +++ b/setup.py @@ -135,7 +135,8 @@ "azure-identity>=1.6.1", "SQLAlchemy>=1.4.19", "dill==0.3.4", - "pyodbc>=4.0.30" + "pyodbc>=4.0.30", + "pymssql", ] ) From 1b12e4a5858a33e0c2ee64f3b84c41fc61f64bb2 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 10:23:16 -0700 Subject: [PATCH 30/51] Fix lint and address issues Signed-off-by: Kevin Zhang --- docs/tutorials/azure/README.md | 2 +- .../contrib/mssql_offline_store/mssql.py | 8 ++++++-- .../mssql_offline_store/tests/__init__.py | 1 + .../mssql_offline_store/tests/data_source.py | 17 +++++------------ .../feast/infra/offline_stores/offline_utils.py | 2 +- .../feature_repos/repo_configuration.py | 2 ++ .../registration/test_universal_cli.py | 14 +++++++++++--- sdk/python/tests/utils/e2e_test_validation.py | 11 +++++++++-- 8 files changed, 36 insertions(+), 21 deletions(-) diff --git a/docs/tutorials/azure/README.md b/docs/tutorials/azure/README.md index 5b96f74ce9..2bfd53adf7 100644 --- a/docs/tutorials/azure/README.md +++ b/docs/tutorials/azure/README.md @@ -82,7 +82,7 @@ In the Azure ML Studio, select *Notebooks* from the left-hand menu and then open ## 6. Running Feast Azure Tutorials locally without Azure workspace * If you are on a free tier instance, you will not be able to deploy the azure deployment because the azure workspace requires VCPUs and the free trial subscription does not have a quota. -* The workaround is to remove the `Microsoft.MachineLearningServices/workspaces/computes` resource from `fs_snapse_azure_deploy.json` and setting up the environment locally. +* The workaround is to remove the `Microsoft.MachineLearningServices/workspaces/computes` resource from `fs_synapse_azure_deploy.json` and setting up the environment locally. 1. After deployment, find your `Azure SQL Pool` secrets by going to `Subscriptions->->Resource Group->Key Vault` and giving your account admin permissions to the keyvault. Retrieve the `FEAST-REGISTRY-PATH`, `FEAST-OFFLINE-STORE-CONN`, and `FEAST-ONLINE-STORE-CONN` secrets to use in your local environment. 2. In your local environment, you will need to install the azure cli and login to the cli using `az login`. 3. After everything is setup, you should be able to work through the first 2 tutorial notebooks without any errors (The 3rd notebook requires Azure workspace resources). \ No newline at end of file diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index 75ed7a959f..bdede935ec 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -186,7 +186,10 @@ def get_historical_features( entity_df[entity_df_event_timestamp_col], utc=True ).fillna(pandas.Timestamp.now()) - # TODO: figure out how to deal with entity dataframes that are strings + elif isinstance(entity_df, str): + raise ValueError( + "string entities are currently not supported in the MsSQL offline store." + ) ( table_schema, table_name, @@ -370,9 +373,10 @@ def persist(self, storage: SavedDatasetStorage): pyarrow.parquet.write_to_dataset( self.to_arrow(), root_path=path, filesystem=filesystem ) + def supports_remote_storage_export(self) -> bool: return False - + def to_remote_storage(self) -> List[str]: raise NotImplementedError() diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py index e69de29bb2..ae7affc838 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/__init__.py @@ -0,0 +1 @@ +from .data_source import mssql_container # noqa diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index 7f876c4b6b..f51be454da 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -3,7 +3,6 @@ import pandas as pd import pytest from sqlalchemy import create_engine -from testcontainers.core.container import DockerContainer from testcontainers.core.waiting_utils import wait_for_logs from testcontainers.mssql import SqlServerContainer @@ -24,16 +23,10 @@ MSSQL_PASSWORD = "yourStrong(!)Password" -# This is the sql container to use if your machine doesn't support the official msql docker container. @pytest.fixture(scope="session") def mssql_container(): - container = ( - DockerContainer("mcr.microsoft.com/azure-sql-edge:1.0.6") - .with_exposed_ports("1433") - .with_env("ACCEPT_EULA", "1") - .with_env("MSSQL_USER", MSSQL_USER) - .with_env("MSSQL_SA_PASSWORD", MSSQL_PASSWORD) - ) + container = SqlServerContainer(user=MSSQL_USER, password=MSSQL_PASSWORD) + container.start() container.start() log_string_to_wait_for = "Service Broker manager has started" wait_for_logs(container=container, predicate=log_string_to_wait_for, timeout=30) @@ -50,8 +43,8 @@ def __init__( ): super().__init__(project_name) self.tables_created: List[str] = [] - self.container = SqlServerContainer(user=MSSQL_USER, password=MSSQL_PASSWORD) - self.container.start() + self.container = fixture_request.getfixturevalue("mssql_container") + if not self.container: raise RuntimeError( "In order to use this data source " @@ -109,4 +102,4 @@ def get_prefixed_table_name(self, destination_name: str) -> str: return f"{self.project_name}_{destination_name}" def teardown(self): - container.stop() + pass diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index a8a34da1f4..42b8f8497a 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -212,7 +212,7 @@ def build_point_in_time_query( "full_feature_names": full_feature_names, "final_output_feature_names": final_output_feature_names, } - + query = template.render(template_context) return query diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index c2cf286fdc..708d9c0a14 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -349,6 +349,7 @@ class Environment: python_feature_server: bool worker_id: str online_store_creator: Optional[OnlineStoreCreator] = None + fixture_request: Optional[pytest.FixtureRequest] = None def __post_init__(self): self.end_date = datetime.utcnow().replace(microsecond=0, second=0, minute=0) @@ -457,6 +458,7 @@ def construct_test_environment( python_feature_server=test_repo_config.python_feature_server, worker_id=worker_id, online_store_creator=online_creator, + fixture_request=fixture_request, ) return environment diff --git a/sdk/python/tests/integration/registration/test_universal_cli.py b/sdk/python/tests/integration/registration/test_universal_cli.py index 1fb82ce59f..02f290abe6 100644 --- a/sdk/python/tests/integration/registration/test_universal_cli.py +++ b/sdk/python/tests/integration/registration/test_universal_cli.py @@ -26,7 +26,10 @@ def test_universal_cli(environment: Environment): try: repo_path = Path(repo_dir_name) feature_store_yaml = make_feature_store_yaml( - project, environment.test_repo_config, repo_path + project, + environment.test_repo_config, + repo_path, + fixture_request=environment.fixture_request, ) repo_config = repo_path / "feature_store.yaml" @@ -120,7 +123,10 @@ def test_odfv_apply(environment) -> None: try: repo_path = Path(repo_dir_name) feature_store_yaml = make_feature_store_yaml( - project, environment.test_repo_config, repo_path + project, + environment.test_repo_config, + repo_path, + fixture_request=environment.fixture_request, ) repo_config = repo_path / "feature_store.yaml" @@ -151,7 +157,9 @@ def test_nullable_online_store(test_nullable_online_store) -> None: try: repo_path = Path(repo_dir_name) feature_store_yaml = make_feature_store_yaml( - project, test_nullable_online_store, repo_path + project, + test_nullable_online_store, + repo_path, ) repo_config = repo_path / "feature_store.yaml" diff --git a/sdk/python/tests/utils/e2e_test_validation.py b/sdk/python/tests/utils/e2e_test_validation.py index c87f8fd61f..d3223aaf2d 100644 --- a/sdk/python/tests/utils/e2e_test_validation.py +++ b/sdk/python/tests/utils/e2e_test_validation.py @@ -164,8 +164,15 @@ def _check_offline_and_online_features( ) -def make_feature_store_yaml(project, test_repo_config, repo_dir_name: Path): - offline_creator: DataSourceCreator = test_repo_config.offline_store_creator(project) +def make_feature_store_yaml( + project, + test_repo_config, + repo_dir_name: Path, + fixture_request: Optional[pytest.FixtureRequest], +): + offline_creator: DataSourceCreator = test_repo_config.offline_store_creator( + project, fixture_request=fixture_request + ) offline_store_config = offline_creator.create_offline_store_config() online_store = test_repo_config.online_store From 0ca504882d95712ef97f7e28d9ff929ee3647f0a Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 10:44:25 -0700 Subject: [PATCH 31/51] Fix integration tests Signed-off-by: Kevin Zhang --- sdk/python/tests/utils/feature_records.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 7d32f0a8e7..d56e4c621a 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -388,7 +388,7 @@ def validate_dataframes( actual_df, sort_by, event_timestamp_column=None, - timestamp_imprecision=0, + timestamp_imprecision=timedelta(seconds=0), ): expected_df: pd.DataFrame = ( expected_df.sort_values(by=sort_by).drop_duplicates().reset_index(drop=True) From 883f31478f8207a5413450321b20f109fd5eebb5 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 11:39:21 -0700 Subject: [PATCH 32/51] Fix Signed-off-by: Kevin Zhang --- .../test_universal_historical_retrieval.py | 7 ++++--- sdk/python/tests/utils/e2e_test_validation.py | 2 +- sdk/python/tests/utils/feature_records.py | 18 +++++++++--------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index 3401042e55..0abb290563 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -148,7 +148,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n actual_df_from_df_entities, sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=event_timestamp, - timestamp_imprecision=timedelta(milliseconds=1), + timestamp_precision=timedelta(milliseconds=1), ) assert_feature_service_correctness( @@ -174,7 +174,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n table_from_df_entities, sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=event_timestamp, - timestamp_imprecision=timedelta(milliseconds=1), + timestamp_precision=timedelta(milliseconds=1), ) @@ -334,7 +334,8 @@ def test_historical_features_with_entities_from_query( expected_df_query, actual_df_from_sql_entities, sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], - event_timestamp_column=timedelta(milliseconds=1), + event_timestamp_column=event_timestamp, + timestamp_precision=timedelta(milliseconds=1), ) table_from_sql_entities = job_from_sql.to_arrow().to_pandas() diff --git a/sdk/python/tests/utils/e2e_test_validation.py b/sdk/python/tests/utils/e2e_test_validation.py index d3223aaf2d..964192c6e7 100644 --- a/sdk/python/tests/utils/e2e_test_validation.py +++ b/sdk/python/tests/utils/e2e_test_validation.py @@ -168,7 +168,7 @@ def make_feature_store_yaml( project, test_repo_config, repo_dir_name: Path, - fixture_request: Optional[pytest.FixtureRequest], + fixture_request: Optional[pytest.FixtureRequest]=None, ): offline_creator: DataSourceCreator = test_repo_config.offline_store_creator( project, fixture_request=fixture_request diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index d56e4c621a..c4b74d7bcf 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -316,7 +316,7 @@ def assert_feature_service_correctness( actual_df_from_df_entities, sort_by=[event_timestamp, "order_id", "driver_id", "customer_id"], event_timestamp_column=event_timestamp, - timestamp_imprecision=timedelta(milliseconds=1), + timestamp_precision=timedelta(milliseconds=1), ) @@ -370,7 +370,7 @@ def assert_feature_service_entity_mapping_correctness( "destination_id", ], event_timestamp_column=event_timestamp, - timestamp_imprecision=timedelta(milliseconds=1), + timestamp_precision=timedelta(milliseconds=1), ) else: # using 2 of the same FeatureView without full_feature_names=True will result in collision @@ -382,13 +382,13 @@ def assert_feature_service_entity_mapping_correctness( ) -# Specify timestamp_imprecision to relax timestamp equality constraints +# Specify timestamp_precision to relax timestamp equality constraints def validate_dataframes( - expected_df, - actual_df, - sort_by, - event_timestamp_column=None, - timestamp_imprecision=timedelta(seconds=0), + expected_df: pd.DataFrame, + actual_df: pd.DataFrame, + sort_by: List[str], + event_timestamp_column: Optional[str]=None, + timestamp_precision: timedelta=timedelta(seconds=0), ): expected_df: pd.DataFrame = ( expected_df.sort_values(by=sort_by).drop_duplicates().reset_index(drop=True) @@ -408,7 +408,7 @@ def validate_dataframes( if event_timestamp_column in sort_by: sort_by = sort_by.remove(event_timestamp_column) for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): - assert abs(t1 - t2) <= timestamp_imprecision + assert abs(t1 - t2) <= timestamp_precision pd_assert_frame_equal( expected_df, actual_df, From ccf87169a9418ddfde19240d2c1e330fd86d84a5 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 11:55:02 -0700 Subject: [PATCH 33/51] Fix lint and address issues Signed-off-by: Kevin Zhang --- .../contrib/mssql_offline_store/tests/data_source.py | 6 +++++- .../tests/integration/registration/test_universal_cli.py | 5 +++-- sdk/python/tests/utils/e2e_test_validation.py | 5 +---- sdk/python/tests/utils/feature_records.py | 8 ++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index f51be454da..d23a82123f 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -25,7 +25,11 @@ @pytest.fixture(scope="session") def mssql_container(): - container = SqlServerContainer(user=MSSQL_USER, password=MSSQL_PASSWORD) + container = SqlServerContainer( + user=MSSQL_USER, + password=MSSQL_PASSWORD, + image="mcr.microsoft.com/azure-sql-edge:1.0.6", + ) container.start() container.start() log_string_to_wait_for = "Service Broker manager has started" diff --git a/sdk/python/tests/integration/registration/test_universal_cli.py b/sdk/python/tests/integration/registration/test_universal_cli.py index 02f290abe6..f565383cb4 100644 --- a/sdk/python/tests/integration/registration/test_universal_cli.py +++ b/sdk/python/tests/integration/registration/test_universal_cli.py @@ -29,7 +29,7 @@ def test_universal_cli(environment: Environment): project, environment.test_repo_config, repo_path, - fixture_request=environment.fixture_request, + environment.data_source_creator, ) repo_config = repo_path / "feature_store.yaml" @@ -126,7 +126,7 @@ def test_odfv_apply(environment) -> None: project, environment.test_repo_config, repo_path, - fixture_request=environment.fixture_request, + environment.data_source_creator, ) repo_config = repo_path / "feature_store.yaml" @@ -160,6 +160,7 @@ def test_nullable_online_store(test_nullable_online_store) -> None: project, test_nullable_online_store, repo_path, + test_nullable_online_store.offline_store_creator, ) repo_config = repo_path / "feature_store.yaml" diff --git a/sdk/python/tests/utils/e2e_test_validation.py b/sdk/python/tests/utils/e2e_test_validation.py index 964192c6e7..e2b8b14eb4 100644 --- a/sdk/python/tests/utils/e2e_test_validation.py +++ b/sdk/python/tests/utils/e2e_test_validation.py @@ -168,11 +168,8 @@ def make_feature_store_yaml( project, test_repo_config, repo_dir_name: Path, - fixture_request: Optional[pytest.FixtureRequest]=None, + offline_creator: DataSourceCreator, ): - offline_creator: DataSourceCreator = test_repo_config.offline_store_creator( - project, fixture_request=fixture_request - ) offline_store_config = offline_creator.create_offline_store_config() online_store = test_repo_config.online_store diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index c4b74d7bcf..d074949e47 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -387,10 +387,10 @@ def validate_dataframes( expected_df: pd.DataFrame, actual_df: pd.DataFrame, sort_by: List[str], - event_timestamp_column: Optional[str]=None, - timestamp_precision: timedelta=timedelta(seconds=0), + event_timestamp_column: Optional[str] = None, + timestamp_precision: timedelta = timedelta(seconds=0), ): - expected_df: pd.DataFrame = ( + expected_df = ( expected_df.sort_values(by=sort_by).drop_duplicates().reset_index(drop=True) ) @@ -406,7 +406,7 @@ def validate_dataframes( expected_df = expected_df.drop(event_timestamp_column, axis=1) actual_df = actual_df.drop(event_timestamp_column, axis=1) if event_timestamp_column in sort_by: - sort_by = sort_by.remove(event_timestamp_column) + sort_by.remove(event_timestamp_column) for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): assert abs(t1 - t2) <= timestamp_precision pd_assert_frame_equal( From f05288e631cb9effea86be18a3b015cf17beb8a3 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 13:27:02 -0700 Subject: [PATCH 34/51] Fix Signed-off-by: Kevin Zhang --- .../offline_store/test_universal_historical_retrieval.py | 4 ++-- sdk/python/tests/utils/feature_records.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index 0abb290563..52f80c6cf1 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -39,7 +39,7 @@ @pytest.mark.integration -@pytest.mark.universal_offline_stores +@pytest.mark.universal_offline_stores(only=["snowflake"]) @pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: f"full:{v}") def test_historical_features(environment, universal_data_sources, full_feature_names): store = environment.feature_store @@ -176,6 +176,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n event_timestamp_column=event_timestamp, timestamp_precision=timedelta(milliseconds=1), ) + assert(False) @pytest.mark.integration @@ -218,7 +219,6 @@ def test_historical_features_with_shared_batch_source( full_feature_names=full_feature_names, ).to_df() - @pytest.mark.integration @pytest.mark.universal_offline_stores def test_historical_features_with_missing_request_data( diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index d074949e47..e6619ff668 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional import pandas as pd +import numpy as np import pytest from pandas.testing import assert_frame_equal as pd_assert_frame_equal from pytz import utc @@ -407,8 +408,12 @@ def validate_dataframes( actual_df = actual_df.drop(event_timestamp_column, axis=1) if event_timestamp_column in sort_by: sort_by.remove(event_timestamp_column) + for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): - assert abs(t1 - t2) <= timestamp_precision + if isinstance(t1, int): + assert abs(t1 - t2) < timestamp_precision.seconds + else: + assert abs(t1 - t2) < timestamp_precision pd_assert_frame_equal( expected_df, actual_df, From ee30e73961907afbc026e447a58b88f5d81b6cd1 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 13:27:35 -0700 Subject: [PATCH 35/51] Fix Signed-off-by: Kevin Zhang --- .../offline_store/test_universal_historical_retrieval.py | 3 ++- sdk/python/tests/utils/feature_records.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index 52f80c6cf1..db1faa798b 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -176,7 +176,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n event_timestamp_column=event_timestamp, timestamp_precision=timedelta(milliseconds=1), ) - assert(False) + assert False @pytest.mark.integration @@ -219,6 +219,7 @@ def test_historical_features_with_shared_batch_source( full_feature_names=full_feature_names, ).to_df() + @pytest.mark.integration @pytest.mark.universal_offline_stores def test_historical_features_with_missing_request_data( diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index e6619ff668..9d9e428da9 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -2,7 +2,6 @@ from typing import Any, Dict, List, Optional import pandas as pd -import numpy as np import pytest from pandas.testing import assert_frame_equal as pd_assert_frame_equal from pytz import utc From ab17db94bf41ff5611a4e0c33cbee3a9e3c919f0 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 14:13:25 -0700 Subject: [PATCH 36/51] Fix Signed-off-by: Kevin Zhang --- sdk/python/tests/integration/registration/test_universal_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/tests/integration/registration/test_universal_cli.py b/sdk/python/tests/integration/registration/test_universal_cli.py index f565383cb4..e7f7a7cb63 100644 --- a/sdk/python/tests/integration/registration/test_universal_cli.py +++ b/sdk/python/tests/integration/registration/test_universal_cli.py @@ -160,7 +160,7 @@ def test_nullable_online_store(test_nullable_online_store) -> None: project, test_nullable_online_store, repo_path, - test_nullable_online_store.offline_store_creator, + test_nullable_online_store.offline_store_creator(project), ) repo_config = repo_path / "feature_store.yaml" From be162f5a82d386d144b60bb22484f9b88af8c012 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 14:27:09 -0700 Subject: [PATCH 37/51] Revert Signed-off-by: Kevin Zhang --- .../offline_store/test_universal_historical_retrieval.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index db1faa798b..0abb290563 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -39,7 +39,7 @@ @pytest.mark.integration -@pytest.mark.universal_offline_stores(only=["snowflake"]) +@pytest.mark.universal_offline_stores @pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: f"full:{v}") def test_historical_features(environment, universal_data_sources, full_feature_names): store = environment.feature_store @@ -176,7 +176,6 @@ def test_historical_features(environment, universal_data_sources, full_feature_n event_timestamp_column=event_timestamp, timestamp_precision=timedelta(milliseconds=1), ) - assert False @pytest.mark.integration From f5aa476cedfa9bdd43dd74142f12a9a9d60e0195 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 15:20:23 -0700 Subject: [PATCH 38/51] Fix Signed-off-by: Kevin Zhang --- sdk/python/tests/utils/feature_records.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 9d9e428da9..bf9493b1a1 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -409,8 +409,9 @@ def validate_dataframes( sort_by.remove(event_timestamp_column) for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): - if isinstance(t1, int): - assert abs(t1 - t2) < timestamp_precision.seconds + td = abs(t1-t2) + if isinstance(td, int): + assert td < timestamp_precision.seconds else: assert abs(t1 - t2) < timestamp_precision pd_assert_frame_equal( From 4423dfa56e76014ad64bd78fae9b15d10f0032bb Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 16:10:09 -0700 Subject: [PATCH 39/51] Fix Signed-off-by: Kevin Zhang --- sdk/python/tests/utils/feature_records.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index bf9493b1a1..68bf5a08b6 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -3,6 +3,7 @@ import pandas as pd import pytest +import numpy as np from pandas.testing import assert_frame_equal as pd_assert_frame_equal from pytz import utc @@ -408,12 +409,14 @@ def validate_dataframes( if event_timestamp_column in sort_by: sort_by.remove(event_timestamp_column) - for t1, t2 in zip(expected_timestamp_col.values, actual_timestamp_col.values): - td = abs(t1-t2) - if isinstance(td, int): - assert td < timestamp_precision.seconds + diffs = expected_timestamp_col.to_numpy() - actual_timestamp_col.to_numpy() + for diff in diffs: + if isinstance(diff, np.ndarray): + diff = diff[0] + if isinstance(diff, np.timedelta64): + assert abs(diff) <= timestamp_precision.seconds else: - assert abs(t1 - t2) < timestamp_precision + assert abs(diff) < timestamp_precision pd_assert_frame_equal( expected_df, actual_df, From 58065076dcb9682c77b3254c34c646b1464eed27 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 16:13:42 -0700 Subject: [PATCH 40/51] Fix Signed-off-by: Kevin Zhang --- sdk/python/tests/utils/feature_records.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 68bf5a08b6..301c7b405b 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -416,7 +416,7 @@ def validate_dataframes( if isinstance(diff, np.timedelta64): assert abs(diff) <= timestamp_precision.seconds else: - assert abs(diff) < timestamp_precision + assert abs(diff) <= timestamp_precision pd_assert_frame_equal( expected_df, actual_df, From 7a4d055f19d3710050a75f705ee5c789d7d6fa76 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 19:53:46 -0700 Subject: [PATCH 41/51] Fix lint Signed-off-by: Kevin Zhang --- sdk/python/tests/utils/feature_records.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/tests/utils/feature_records.py b/sdk/python/tests/utils/feature_records.py index 301c7b405b..3f210f9e1c 100644 --- a/sdk/python/tests/utils/feature_records.py +++ b/sdk/python/tests/utils/feature_records.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional +import numpy as np import pandas as pd import pytest -import numpy as np from pandas.testing import assert_frame_equal as pd_assert_frame_equal from pytz import utc From 78b74b1fe558c8c4c85a285022edd7b7ce15176c Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 19:55:34 -0700 Subject: [PATCH 42/51] Fix Signed-off-by: Kevin Zhang --- sdk/python/feast/type_map.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 57c5a665c5..b410ce9f71 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -46,8 +46,7 @@ from feast.protos.feast.types.Value_pb2 import Value as ProtoValue from feast.value_type import ListType, ValueType -if TYPE_CHECKING: - import pyarrow +import pyarrow # null timestamps get converted to -9223372036854775808 From a9e811987c992c14dce3bd6033431e3cff092122 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Thu, 18 Aug 2022 19:56:30 -0700 Subject: [PATCH 43/51] Fix lint Signed-off-by: Kevin Zhang --- sdk/python/feast/type_map.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index b410ce9f71..8a817358b3 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -15,7 +15,6 @@ from collections import defaultdict from datetime import datetime, timezone from typing import ( - TYPE_CHECKING, Any, Dict, Iterator, @@ -32,6 +31,7 @@ import numpy as np import pandas as pd +import pyarrow from google.protobuf.timestamp_pb2 import Timestamp from feast.protos.feast.types.Value_pb2 import ( @@ -46,9 +46,6 @@ from feast.protos.feast.types.Value_pb2 import Value as ProtoValue from feast.value_type import ListType, ValueType -import pyarrow - - # null timestamps get converted to -9223372036854775808 NULL_TIMESTAMP_INT_VALUE = np.datetime64("NaT").astype(int) From 1341e3e76b1a12811512e933c1a747662e73b7d9 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Fri, 19 Aug 2022 10:02:07 -0700 Subject: [PATCH 44/51] Fix pyarrow Signed-off-by: Kevin Zhang --- sdk/python/feast/infra/offline_stores/snowflake.py | 5 ++--- sdk/python/feast/type_map.py | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/snowflake.py b/sdk/python/feast/infra/offline_stores/snowflake.py index 241627ba01..50f92164cc 100644 --- a/sdk/python/feast/infra/offline_stores/snowflake.py +++ b/sdk/python/feast/infra/offline_stores/snowflake.py @@ -19,7 +19,6 @@ import numpy as np import pandas as pd import pyarrow -import pyarrow as pa from pydantic import Field, StrictStr from pydantic.typing import Literal from pytz import utc @@ -410,7 +409,7 @@ def _to_df_internal(self) -> pd.DataFrame: return df - def _to_arrow_internal(self) -> pa.Table: + def _to_arrow_internal(self) -> pyarrow.Table: with self._query_generator() as query: pa_table = execute_snowflake_statement( @@ -423,7 +422,7 @@ def _to_arrow_internal(self) -> pa.Table: else: empty_result = execute_snowflake_statement(self.snowflake_conn, query) - return pa.Table.from_pandas( + return pyarrow.Table.from_pandas( pd.DataFrame(columns=[md.name for md in empty_result.description]) ) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 8a817358b3..2cb1c4fefb 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -15,6 +15,7 @@ from collections import defaultdict from datetime import datetime, timezone from typing import ( + TYPE_CHECKING, Any, Dict, Iterator, @@ -31,7 +32,6 @@ import numpy as np import pandas as pd -import pyarrow from google.protobuf.timestamp_pb2 import Timestamp from feast.protos.feast.types.Value_pb2 import ( @@ -46,6 +46,9 @@ from feast.protos.feast.types.Value_pb2 import Value as ProtoValue from feast.value_type import ListType, ValueType +if TYPE_CHECKING: + import pyarrow + # null timestamps get converted to -9223372036854775808 NULL_TIMESTAMP_INT_VALUE = np.datetime64("NaT").astype(int) @@ -554,7 +557,7 @@ def mssql_to_feast_value_type(mssql_type_as_str: str) -> ValueType: return type_map[mssql_type_as_str.lower()] -def pa_to_mssql_type(pa_type: pyarrow.DataType) -> str: +def pa_to_mssql_type(pa_type: "pyarrow.DataType") -> str: # PyArrow types: https://arrow.apache.org/docs/python/api/datatypes.html # MS Sql types: https://docs.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql?view=sql-server-ver16 pa_type_as_str = str(pa_type).lower() From 3d42093dcc4a3c4f9909722f84326b3ca6af4ef8 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Fri, 19 Aug 2022 10:10:16 -0700 Subject: [PATCH 45/51] Fix lint Signed-off-by: Kevin Zhang --- .../infra/offline_stores/contrib/mssql_offline_store/mssql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py index bdede935ec..8dc5f6c654 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssql.py @@ -356,7 +356,7 @@ def _to_arrow_internal(self) -> pyarrow.Table: ## Implements persist in Feast 0.18 - This persists to filestorage ## ToDo: Persist to Azure Storage - def persist(self, storage: SavedDatasetStorage): + def persist(self, storage: SavedDatasetStorage, allow_overwrite: bool = False): assert isinstance(storage, SavedDatasetFileStorage) filesystem, path = FileSource.create_filesystem_and_path( From 1c591f01306bef37f78e3090d2cf5fb910dbf2c4 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 19 Aug 2022 15:50:25 -0400 Subject: [PATCH 46/51] add requirements files Signed-off-by: Danny Chiao --- Makefile | 21 ++++++++---- .../mssql_offline_store/tests/data_source.py | 1 - .../requirements/py3.10-ci-requirements.txt | 28 ++++++++++----- .../requirements/py3.10-requirements.txt | 6 ++-- .../requirements/py3.8-ci-requirements.txt | 28 ++++++++++----- .../requirements/py3.8-requirements.txt | 6 ++-- .../requirements/py3.9-ci-requirements.txt | 34 +++++++++++++------ .../requirements/py3.9-requirements.txt | 6 ++-- setup.py | 1 - 9 files changed, 89 insertions(+), 42 deletions(-) diff --git a/Makefile b/Makefile index 6ce641067a..8894496729 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,8 @@ test-python-integration-local: python -m pytest -n 8 --integration \ -k "not gcs_registry and \ not s3_registry and \ - not test_lambda_materialization" \ + not test_lambda_materialization and \ + not test_snowflake" \ sdk/python/tests \ ) || echo "This script uses Docker, and it isn't running - please start the Docker Daemon and try again!"; @@ -113,7 +114,8 @@ test-python-universal-spark: not test_push_features_to_offline_store.py and \ not gcs_registry and \ not s3_registry and \ - not test_universal_types" \ + not test_universal_types and \ + not test_snowflake" \ sdk/python/tests test-python-universal-trino: @@ -136,7 +138,8 @@ test-python-universal-trino: not test_push_features_to_offline_store.py and \ not gcs_registry and \ not s3_registry and \ - not test_universal_types" \ + not test_universal_types and \ + not test_snowflake" \ sdk/python/tests @@ -151,7 +154,8 @@ test-python-universal-mssql: python -m pytest -n 8 --integration \ -k "not gcs_registry and \ not s3_registry and \ - not test_lambda_materialization" \ + not test_lambda_materialization and \ + not test_snowflake" \ sdk/python/tests @@ -177,7 +181,8 @@ test-python-universal-athena: not test_historical_features_persisting and \ not test_historical_retrieval_fails_on_validation and \ not gcs_registry and \ - not s3_registry" \ + not s3_registry and \ + not test_snowflake" \ sdk/python/tests test-python-universal-postgres-offline: @@ -219,7 +224,8 @@ test-python-universal-postgres-online: not test_push_features_to_offline_store and \ not gcs_registry and \ not s3_registry and \ - not test_universal_types" \ + not test_universal_types and \ + not test_snowflake" \ sdk/python/tests test-python-universal-cassandra: @@ -246,7 +252,8 @@ test-python-universal-cassandra-no-cloud-providers: not test_apply_data_source_integration and \ not test_nullable_online_store and \ not gcs_registry and \ - not s3_registry" \ + not s3_registry and \ + not test_snowflake" \ sdk/python/tests test-python-universal: diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py index d23a82123f..9b751d98ef 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/tests/data_source.py @@ -31,7 +31,6 @@ def mssql_container(): image="mcr.microsoft.com/azure-sql-edge:1.0.6", ) container.start() - container.start() log_string_to_wait_for = "Service Broker manager has started" wait_for_logs(container=container, predicate=log_string_to_wait_for, timeout=30) diff --git a/sdk/python/requirements/py3.10-ci-requirements.txt b/sdk/python/requirements/py3.10-ci-requirements.txt index a8c24947b0..9d10b2c313 100644 --- a/sdk/python/requirements/py3.10-ci-requirements.txt +++ b/sdk/python/requirements/py3.10-ci-requirements.txt @@ -65,9 +65,13 @@ azure-core==1.25.0 azure-datalake-store==0.0.52 # via adlfs azure-identity==1.10.0 - # via adlfs + # via + # adlfs + # feast (setup.py) azure-storage-blob==12.13.1 - # via adlfs + # via + # adlfs + # feast (setup.py) babel==2.10.3 # via sphinx backcall==0.2.0 @@ -144,6 +148,7 @@ cryptography==35.0.0 # great-expectations # moto # msal + # pyjwt # pyopenssl # snowflake-connector-python dask==2022.1.1 @@ -180,7 +185,7 @@ execnet==1.9.0 # via pytest-xdist executing==0.10.0 # via stack-data -fastapi==0.79.0 +fastapi==0.79.1 # via feast (setup.py) fastavro==1.6.0 # via @@ -272,6 +277,8 @@ googleapis-common-protos==1.56.4 # tensorflow-metadata great-expectations==0.14.13 # via feast (setup.py) +greenlet==1.1.2 + # via sqlalchemy grpcio==1.47.0 # via # feast (setup.py) @@ -338,7 +345,7 @@ jsonpatch==1.32 # via great-expectations jsonpointer==2.3 # via jsonpatch -jsonschema==4.12.1 +jsonschema==4.13.0 # via # altair # feast (setup.py) @@ -548,6 +555,10 @@ pyjwt[crypto]==2.4.0 # adal # msal # snowflake-connector-python +pymssql==2.2.5 + # via feast (setup.py) +pyodbc==4.0.34 + # via feast (setup.py) pyopenssl==22.0.0 # via snowflake-connector-python pyparsing==2.4.7 @@ -665,6 +676,7 @@ six==1.16.0 # google-auth-httplib2 # grpcio # happybase + # isodate # kubernetes # mock # msrestazure @@ -751,19 +763,19 @@ types-protobuf==3.19.22 # mypy-protobuf types-python-dateutil==2.8.19 # via feast (setup.py) -types-pytz==2022.1.2 +types-pytz==2022.2.1.0 # via feast (setup.py) types-pyyaml==6.0.11 # via feast (setup.py) types-redis==4.3.14 # via feast (setup.py) -types-requests==2.28.8 +types-requests==2.28.9 # via feast (setup.py) -types-setuptools==64.0.1 +types-setuptools==65.1.0 # via feast (setup.py) types-tabulate==0.8.11 # via feast (setup.py) -types-urllib3==1.26.22 +types-urllib3==1.26.23 # via types-requests typing-extensions==4.3.0 # via diff --git a/sdk/python/requirements/py3.10-requirements.txt b/sdk/python/requirements/py3.10-requirements.txt index 43e814b668..ac12befb87 100644 --- a/sdk/python/requirements/py3.10-requirements.txt +++ b/sdk/python/requirements/py3.10-requirements.txt @@ -38,7 +38,7 @@ dask==2022.1.1 # via feast (setup.py) dill==0.3.5.1 # via feast (setup.py) -fastapi==0.79.0 +fastapi==0.79.1 # via feast (setup.py) fastavro==1.6.0 # via @@ -57,6 +57,8 @@ googleapis-common-protos==1.56.4 # feast (setup.py) # google-api-core # tensorflow-metadata +greenlet==1.1.2 + # via sqlalchemy grpcio==1.47.0 # via # feast (setup.py) @@ -73,7 +75,7 @@ idna==3.3 # requests jinja2==3.1.2 # via feast (setup.py) -jsonschema==4.12.1 +jsonschema==4.13.0 # via feast (setup.py) locket==1.0.0 # via partd diff --git a/sdk/python/requirements/py3.8-ci-requirements.txt b/sdk/python/requirements/py3.8-ci-requirements.txt index 7b0c13f23b..93011cfdcf 100644 --- a/sdk/python/requirements/py3.8-ci-requirements.txt +++ b/sdk/python/requirements/py3.8-ci-requirements.txt @@ -65,9 +65,13 @@ azure-core==1.25.0 azure-datalake-store==0.0.52 # via adlfs azure-identity==1.10.0 - # via adlfs + # via + # adlfs + # feast (setup.py) azure-storage-blob==12.13.1 - # via adlfs + # via + # adlfs + # feast (setup.py) babel==2.10.3 # via sphinx backcall==0.2.0 @@ -148,6 +152,7 @@ cryptography==35.0.0 # great-expectations # moto # msal + # pyjwt # pyopenssl # snowflake-connector-python dask==2022.1.1 @@ -184,7 +189,7 @@ execnet==1.9.0 # via pytest-xdist executing==0.10.0 # via stack-data -fastapi==0.79.0 +fastapi==0.79.1 # via feast (setup.py) fastavro==1.6.0 # via @@ -276,6 +281,8 @@ googleapis-common-protos==1.56.4 # tensorflow-metadata great-expectations==0.14.13 # via feast (setup.py) +greenlet==1.1.2 + # via sqlalchemy grpcio==1.47.0 # via # feast (setup.py) @@ -344,7 +351,7 @@ jsonpatch==1.32 # via great-expectations jsonpointer==2.3 # via jsonpatch -jsonschema==4.12.1 +jsonschema==4.13.0 # via # altair # feast (setup.py) @@ -556,6 +563,10 @@ pyjwt[crypto]==2.4.0 # adal # msal # snowflake-connector-python +pymssql==2.2.5 + # via feast (setup.py) +pyodbc==4.0.34 + # via feast (setup.py) pyopenssl==22.0.0 # via snowflake-connector-python pyparsing==2.4.7 @@ -675,6 +686,7 @@ six==1.16.0 # google-auth-httplib2 # grpcio # happybase + # isodate # kubernetes # mock # msrestazure @@ -761,19 +773,19 @@ types-protobuf==3.19.22 # mypy-protobuf types-python-dateutil==2.8.19 # via feast (setup.py) -types-pytz==2022.1.2 +types-pytz==2022.2.1.0 # via feast (setup.py) types-pyyaml==6.0.11 # via feast (setup.py) types-redis==4.3.14 # via feast (setup.py) -types-requests==2.28.8 +types-requests==2.28.9 # via feast (setup.py) -types-setuptools==64.0.1 +types-setuptools==65.1.0 # via feast (setup.py) types-tabulate==0.8.11 # via feast (setup.py) -types-urllib3==1.26.22 +types-urllib3==1.26.23 # via types-requests typing-extensions==4.3.0 # via diff --git a/sdk/python/requirements/py3.8-requirements.txt b/sdk/python/requirements/py3.8-requirements.txt index aea3bfb3f4..c2aef63673 100644 --- a/sdk/python/requirements/py3.8-requirements.txt +++ b/sdk/python/requirements/py3.8-requirements.txt @@ -38,7 +38,7 @@ dask==2022.1.1 # via feast (setup.py) dill==0.3.5.1 # via feast (setup.py) -fastapi==0.79.0 +fastapi==0.79.1 # via feast (setup.py) fastavro==1.6.0 # via @@ -57,6 +57,8 @@ googleapis-common-protos==1.56.4 # feast (setup.py) # google-api-core # tensorflow-metadata +greenlet==1.1.2 + # via sqlalchemy grpcio==1.47.0 # via # feast (setup.py) @@ -75,7 +77,7 @@ importlib-resources==5.9.0 # via jsonschema jinja2==3.1.2 # via feast (setup.py) -jsonschema==4.12.1 +jsonschema==4.13.0 # via feast (setup.py) locket==1.0.0 # via partd diff --git a/sdk/python/requirements/py3.9-ci-requirements.txt b/sdk/python/requirements/py3.9-ci-requirements.txt index efeea5d08f..e13eee056b 100644 --- a/sdk/python/requirements/py3.9-ci-requirements.txt +++ b/sdk/python/requirements/py3.9-ci-requirements.txt @@ -65,9 +65,13 @@ azure-core==1.25.0 azure-datalake-store==0.0.52 # via adlfs azure-identity==1.10.0 - # via adlfs + # via + # adlfs + # feast (setup.py) azure-storage-blob==12.13.1 - # via adlfs + # via + # adlfs + # feast (setup.py) babel==2.10.3 # via sphinx backcall==0.2.0 @@ -144,6 +148,7 @@ cryptography==35.0.0 # great-expectations # moto # msal + # pyjwt # pyopenssl # snowflake-connector-python dask==2022.1.1 @@ -180,7 +185,7 @@ execnet==1.9.0 # via pytest-xdist executing==0.10.0 # via stack-data -fastapi==0.79.0 +fastapi==0.79.1 # via feast (setup.py) fastavro==1.6.0 # via @@ -272,6 +277,8 @@ googleapis-common-protos==1.56.4 # tensorflow-metadata great-expectations==0.14.13 # via feast (setup.py) +greenlet==1.1.2 + # via sqlalchemy grpcio==1.47.0 # via # feast (setup.py) @@ -338,7 +345,7 @@ jsonpatch==1.32 # via great-expectations jsonpointer==2.3 # via jsonpatch -jsonschema==4.12.1 +jsonschema==4.13.0 # via # altair # feast (setup.py) @@ -548,6 +555,10 @@ pyjwt[crypto]==2.4.0 # adal # msal # snowflake-connector-python +pymssql==2.2.5 + # via feast (setup.py) +pyodbc==4.0.34 + # via feast (setup.py) pyopenssl==22.0.0 # via snowflake-connector-python pyparsing==2.4.7 @@ -647,10 +658,10 @@ responses==0.21.0 # via moto rsa==4.9 # via google-auth -ruamel-yaml==0.17.17 +ruamel.yaml==0.17.17 # via great-expectations -ruamel-yaml-clib==0.2.6 - # via ruamel-yaml +ruamel.yaml.clib==0.2.6 + # via ruamel.yaml s3fs==2022.1.0 # via feast (setup.py) s3transfer==0.5.2 @@ -667,6 +678,7 @@ six==1.16.0 # google-auth-httplib2 # grpcio # happybase + # isodate # kubernetes # mock # msrestazure @@ -753,19 +765,19 @@ types-protobuf==3.19.22 # mypy-protobuf types-python-dateutil==2.8.19 # via feast (setup.py) -types-pytz==2022.1.2 +types-pytz==2022.2.1.0 # via feast (setup.py) types-pyyaml==6.0.11 # via feast (setup.py) types-redis==4.3.14 # via feast (setup.py) -types-requests==2.28.8 +types-requests==2.28.9 # via feast (setup.py) -types-setuptools==64.0.1 +types-setuptools==65.1.0 # via feast (setup.py) types-tabulate==0.8.11 # via feast (setup.py) -types-urllib3==1.26.22 +types-urllib3==1.26.23 # via types-requests typing-extensions==4.3.0 # via diff --git a/sdk/python/requirements/py3.9-requirements.txt b/sdk/python/requirements/py3.9-requirements.txt index 738ad25bd1..0d3cb22bbc 100644 --- a/sdk/python/requirements/py3.9-requirements.txt +++ b/sdk/python/requirements/py3.9-requirements.txt @@ -38,7 +38,7 @@ dask==2022.1.1 # via feast (setup.py) dill==0.3.5.1 # via feast (setup.py) -fastapi==0.79.0 +fastapi==0.79.1 # via feast (setup.py) fastavro==1.6.0 # via @@ -57,6 +57,8 @@ googleapis-common-protos==1.56.4 # feast (setup.py) # google-api-core # tensorflow-metadata +greenlet==1.1.2 + # via sqlalchemy grpcio==1.47.0 # via # feast (setup.py) @@ -73,7 +75,7 @@ idna==3.3 # requests jinja2==3.1.2 # via feast (setup.py) -jsonschema==4.12.1 +jsonschema==4.13.0 # via feast (setup.py) locket==1.0.0 # via partd diff --git a/setup.py b/setup.py index 2ac7ad271a..37ed471cfa 100644 --- a/setup.py +++ b/setup.py @@ -134,7 +134,6 @@ "azure-storage-blob>=0.37.0", "azure-identity>=1.6.1", "SQLAlchemy>=1.4.19", - "dill==0.3.4", "pyodbc>=4.0.30", "pymssql", ] From b4da607495287581fceabde70293222bb5e06c68 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 19 Aug 2022 15:57:27 -0400 Subject: [PATCH 47/51] fix name of docs Signed-off-by: Danny Chiao --- docs/reference/providers/azure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/providers/azure.md b/docs/reference/providers/azure.md index 75c0472b17..123bf08763 100644 --- a/docs/reference/providers/azure.md +++ b/docs/reference/providers/azure.md @@ -1,4 +1,4 @@ -# Azure +# Azure (contrib) ## Description From c3a04236027eb6bd845f2aef72e16eff3d69cb4a Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 19 Aug 2022 15:58:45 -0400 Subject: [PATCH 48/51] fix offline store readme Signed-off-by: Danny Chiao --- docs/reference/offline-stores/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/offline-stores/README.md b/docs/reference/offline-stores/README.md index 08a28f9e7e..ba59a2bf0a 100644 --- a/docs/reference/offline-stores/README.md +++ b/docs/reference/offline-stores/README.md @@ -35,3 +35,7 @@ Please see [Offline Store](../../getting-started/architecture-and-components/off {% content-ref url="trino.md" %} [trino.md](trino.md) {% endcontent-ref %} + +{% content-ref url="mssql.md" %} +[mssql.md](mssql.md) +{% endcontent-ref %} \ No newline at end of file From 576b57ea82e4abb24844c38de2040d82a774cd6c Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 19 Aug 2022 15:59:48 -0400 Subject: [PATCH 49/51] fix offline store readme Signed-off-by: Danny Chiao --- docs/SUMMARY.md | 1 + docs/reference/online-stores/cassandra.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index dd3ee31f97..43c270f891 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -89,6 +89,7 @@ * [Datastore](reference/online-stores/datastore.md) * [DynamoDB](reference/online-stores/dynamodb.md) * [PostgreSQL (contrib)](reference/online-stores/postgres.md) + * [Cassandra Astra DB (contrib)](reference/online-stores/cassandra.md) * [Providers](reference/providers/README.md) * [Local](reference/providers/local.md) * [Google Cloud Platform](reference/providers/google-cloud-platform.md) diff --git a/docs/reference/online-stores/cassandra.md b/docs/reference/online-stores/cassandra.md index 7a83f905ed..c2abc3857f 100644 --- a/docs/reference/online-stores/cassandra.md +++ b/docs/reference/online-stores/cassandra.md @@ -1,4 +1,4 @@ -# Cassandra / Astra DB online store +# Cassandra / Astra DB online store (contrib) ## Description From 69940ac789910d22d300f871220bc0cfda42b6a7 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 19 Aug 2022 16:03:41 -0400 Subject: [PATCH 50/51] fix Signed-off-by: Danny Chiao --- docs/reference/offline-stores/README.md | 2 +- docs/reference/online-stores/README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/offline-stores/README.md b/docs/reference/offline-stores/README.md index ba59a2bf0a..02b873bb59 100644 --- a/docs/reference/offline-stores/README.md +++ b/docs/reference/offline-stores/README.md @@ -38,4 +38,4 @@ Please see [Offline Store](../../getting-started/architecture-and-components/off {% content-ref url="mssql.md" %} [mssql.md](mssql.md) -{% endcontent-ref %} \ No newline at end of file +{% endcontent-ref %} diff --git a/docs/reference/online-stores/README.md b/docs/reference/online-stores/README.md index b7e7d4e7ca..6d616b46f2 100644 --- a/docs/reference/online-stores/README.md +++ b/docs/reference/online-stores/README.md @@ -29,3 +29,4 @@ Please see [Online Store](../../getting-started/architecture-and-components/onli {% content-ref url="cassandra.md" %} [cassandra.md](cassandra.md) {% endcontent-ref %} + From 516ff768218de0b2deb923fd89896ab7fe4fcf34 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 19 Aug 2022 16:06:58 -0400 Subject: [PATCH 51/51] fix Signed-off-by: Danny Chiao --- docs/SUMMARY.md | 2 +- docs/reference/online-stores/cassandra.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 43c270f891..4330bc2564 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -89,7 +89,7 @@ * [Datastore](reference/online-stores/datastore.md) * [DynamoDB](reference/online-stores/dynamodb.md) * [PostgreSQL (contrib)](reference/online-stores/postgres.md) - * [Cassandra Astra DB (contrib)](reference/online-stores/cassandra.md) + * [Cassandra + Astra DB (contrib)](reference/online-stores/cassandra.md) * [Providers](reference/providers/README.md) * [Local](reference/providers/local.md) * [Google Cloud Platform](reference/providers/google-cloud-platform.md) diff --git a/docs/reference/online-stores/cassandra.md b/docs/reference/online-stores/cassandra.md index c2abc3857f..3355c3728c 100644 --- a/docs/reference/online-stores/cassandra.md +++ b/docs/reference/online-stores/cassandra.md @@ -1,4 +1,4 @@ -# Cassandra / Astra DB online store (contrib) +# Cassandra + Astra DB online store (contrib) ## Description