From b7f3d100215870ec0cfc8f83a43ef3b479a4af2e Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 18:36:04 -0700 Subject: [PATCH 01/10] Initial commit --- README.md | 50 ++++++++ basic_feature_repo/data/driver_stats.parquet | Bin 0 -> 34730 bytes basic_feature_repo/feature_store.yaml | 8 ++ basic_feature_repo/online_store.db | Bin 0 -> 16384 bytes basic_feature_repo/registry.db | Bin 0 -> 337 bytes basic_feature_repo/repo.py | 24 ++++ feast_custom_provider/README.md | 3 + feast_custom_provider/custom_provider.py | 122 +++++++++++++++++++ feast_custom_provider/setup.py | 9 ++ requirements.txt | 2 + test_custom_provider.py | 24 ++++ 11 files changed, 242 insertions(+) create mode 100644 README.md create mode 100644 basic_feature_repo/data/driver_stats.parquet create mode 100644 basic_feature_repo/feature_store.yaml create mode 100644 basic_feature_repo/online_store.db create mode 100644 basic_feature_repo/registry.db create mode 100644 basic_feature_repo/repo.py create mode 100644 feast_custom_provider/README.md create mode 100644 feast_custom_provider/custom_provider.py create mode 100644 feast_custom_provider/setup.py create mode 100644 requirements.txt create mode 100644 test_custom_provider.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d53a45 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Feast Custom Provider + +### Overview + +This repository demonstrates how developers can create their own custom `providers` for Feast. Custom providers can be +used like plugins and allow Feast users to execute any custom logic. Typical examples include +* Launching custom Spark streaming ingestion jobs +* Launching custom batch ingestion (materialization) jobs +* Adding custom validation to feature repositories during `apply` +* Adding custom infrastructure setup logic which runs during `apply` +* Extending Feast commands with in-house metrics, logging, or tracing + +### Extending this provider +All Feast operations execute through a provider. Operations like materializing data from the offline to the online +store, updating infrastructure like databases, launching streaming ingestion jobs, building training datasets, and +reading features from the online store. + +Feast comes with providers built in, e.g, LocalProvider, GcpProvider, and AwsProvider. However, users can develop their +own providers by creating a class that implements the contract in the [Provider class](https://github.com/feast-dev/feast/blob/745a1b43d20c0169b675b1f28039854205fb8180/sdk/python/feast/infra/provider.py#L22). + +Most developers, however, simply want to add new logic to Feast and don't necessarily want to create a whole provider on +their own. In that case, the simplest way to add custom logic to Feast is to extend a provider. The most generic +provider is the LocalProvider, which contains no custom logic specific to a cloud environment. + +This repository contains an example of a custom provider, `MyCustomProvider`, which simply extends the Feast +`LocalProvider`. + +### Testing the provider + +Run the following commands to test the provider + +```bash +pip install -r requirements.txt +``` + +``` +pytest test_custom_provider.py +``` + +It is also possible to run Feast CLI command, which in turn will call the provider. It may be necessary to add the +`PYTHONPATH` to the path where your provider module is stored. +``` +PYTHONPATH=$PYTHONPATH:/$(pwd) feast -c basic_feature_repo apply +``` +``` +Registered entity driver_id +Registered feature view driver_hourly_stats +Deploying infrastructure for driver_hourly_stats +Launching custom streaming jobs is pretty easy... +``` \ No newline at end of file diff --git a/basic_feature_repo/data/driver_stats.parquet b/basic_feature_repo/data/driver_stats.parquet new file mode 100644 index 0000000000000000000000000000000000000000..d6addad98c23a717e348ac516b9854c5e191fd15 GIT binary patch literal 34730 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 literal 0 HcmV?d00001 diff --git a/basic_feature_repo/feature_store.yaml b/basic_feature_repo/feature_store.yaml new file mode 100644 index 0000000..e9b79c6 --- /dev/null +++ b/basic_feature_repo/feature_store.yaml @@ -0,0 +1,8 @@ +project: repo +registry: registry.db +provider: feast_custom_provider.custom_provider.MyCustomProvider +online_store: + type: sqlite + path: online_store.db +offline_store: + type: file \ No newline at end of file diff --git a/basic_feature_repo/online_store.db b/basic_feature_repo/online_store.db new file mode 100644 index 0000000000000000000000000000000000000000..5769fece97a171475d73d4d93b8d10792725a0a9 GIT binary patch literal 16384 zcmeI#&riZI6bJAQh!_)oc;fnw5{brmUKDT#B7))$a4^k;M_kw#ZXJ;${xkj={vG}& z9_YBXj?#Uab7<1^V-->%Wz@nT+wd9L>;RYc15 zS*!T78Vy7J4Ue`)>9*IpsrLqSZ4JI9_H%`5wVTqMlI0z7c00bZa0SG_<0uX=z1Rwx`-52-*>O-~I literal 0 HcmV?d00001 diff --git a/basic_feature_repo/registry.db b/basic_feature_repo/registry.db new file mode 100644 index 0000000000000000000000000000000000000000..f2130816f33bf1a2ee0760959ca524192d7036d6 GIT binary patch literal 337 zcmYL^u}Z{17=&*jp1D;?$aSeCSm3m z>)9@opp*Db;GC{#BN)lGZzYhwMRQISoex|+P*78O--3EUp?+*sAj(|Qpvv None: + # online_write_batch writes feature values to the online store + super().online_write_batch(config, table, data, progress) + + 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: + # materialize_single_feature_view loads the latest feature values for a specific feature value from the offline + # store into the online store. + # This method can be overridden to also launch custom batch ingestion jobs that loads the latest batch feature + # values into the online store. + + # Replace the line below with your custom logic in order to launch your own batch ingestion job + super().materialize_single_feature_view( + config, feature_view, start_date, end_date, registry, project, tqdm_builder + ) + print("Launching custom batch jobs is pretty easy...") + + 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: + # get_historical_features returns a training dataframe from the offline store + return super().get_historical_features( + config, + feature_views, + feature_refs, + entity_df, + registry, + project, + full_feature_names, + ) + + def online_read( + self, + config: RepoConfig, + table: Union[FeatureTable, FeatureView], + entity_keys: List[EntityKeyProto], + requested_features: List[str] = None, + ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: + # get_historical_features returns a training dataframe from the offline store + return super().online_read(config, table, entity_keys, requested_features) diff --git a/feast_custom_provider/setup.py b/feast_custom_provider/setup.py new file mode 100644 index 0000000..b1756e6 --- /dev/null +++ b/feast_custom_provider/setup.py @@ -0,0 +1,9 @@ +from distutils.core import setup + +setup( + name="feast_custom_provider", + version="0.0.1", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + install_requires=["feast==0.12.1"], +) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3817456 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +feast_custom_provider/ +pytest==6.2.4 \ No newline at end of file diff --git a/test_custom_provider.py b/test_custom_provider.py new file mode 100644 index 0000000..54a8861 --- /dev/null +++ b/test_custom_provider.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from feast import FeatureStore +from basic_feature_repo.repo import driver, driver_hourly_stats_view + + +def test_end_to_end(): + fs = FeatureStore("basic_feature_repo/") + + # apply repository + fs.apply([driver, driver_hourly_stats_view]) + + # load data into online store + fs.materialize_incremental(end_date=datetime.now()) + + # Read features from online store + feature_vector = fs.get_online_features( + features=["driver_hourly_stats:conv_rate"], entity_rows=[{"driver_id": 1001}] + ).to_dict() + conv_rate = feature_vector["conv_rate"][0] + assert conv_rate > 0 + + # tear down feature store + fs.teardown() From ca9851c3bc8003ced7dae64fe69bba31360dc70f Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 18:36:57 -0700 Subject: [PATCH 02/10] Add gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00e34d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +*.pyc +**__pycache__ \ No newline at end of file From 43cb8b26bbfabb75f7854302551d78b110eae505 Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 18:38:16 -0700 Subject: [PATCH 03/10] Add link to provider --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d53a45..95b404c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ This repository contains an example of a custom provider, `MyCustomProvider`, wh ### Testing the provider -Run the following commands to test the provider +Run the following commands to test the custom provider ([MyCustomProvider](https://github.com/feast-dev/feast-custom-provider-demo/blob/master/feast_custom_provider/custom_provider.py)) ```bash pip install -r requirements.txt From 84101e2b3842c64b0de8543a4d5c756b33aa936f Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 18:48:21 -0700 Subject: [PATCH 04/10] Update readme --- README.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 95b404c..6de40de 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,15 @@ ### Overview This repository demonstrates how developers can create their own custom `providers` for Feast. Custom providers can be -used like plugins and allow Feast users to execute any custom logic. Typical examples include -* Launching custom Spark streaming ingestion jobs -* Launching custom batch ingestion (materialization) jobs -* Adding custom validation to feature repositories during `apply` -* Adding custom infrastructure setup logic which runs during `apply` +used like plugins which allow Feast users to execute any custom logic. Typical examples include +* Launching custom streaming ingestion jobs (Spark, Beam) +* Launching custom batch ingestion (materialization) jobs (Spark, Beam) +* Adding custom validation to feature repositories during `feast apply` +* Adding custom infrastructure setup logic which runs during `feast apply` * Extending Feast commands with in-house metrics, logging, or tracing -### Extending this provider +### Why create a custom provider? + All Feast operations execute through a provider. Operations like materializing data from the offline to the online store, updating infrastructure like databases, launching streaming ingestion jobs, building training datasets, and reading features from the online store. @@ -19,13 +20,17 @@ Feast comes with providers built in, e.g, LocalProvider, GcpProvider, and AwsPro own providers by creating a class that implements the contract in the [Provider class](https://github.com/feast-dev/feast/blob/745a1b43d20c0169b675b1f28039854205fb8180/sdk/python/feast/infra/provider.py#L22). Most developers, however, simply want to add new logic to Feast and don't necessarily want to create a whole provider on -their own. In that case, the simplest way to add custom logic to Feast is to extend a provider. The most generic +their own. The fastest way to add custom logic to Feast is to extend an existing provider. The most generic provider is the LocalProvider, which contains no custom logic specific to a cloud environment. -This repository contains an example of a custom provider, `MyCustomProvider`, which simply extends the Feast -`LocalProvider`. +### What is included in this repository? + +* [feast_custom_provider/](feast_custom_provider): An example of a custom provider, `MyCustomProvider`, which extends the Feast +`LocalProvider`. This example provider simply prints messages to the console. +* [basic_feature_repo/](basic_feature_repo): A simple feature repository that is used to test the custom provider. The repository has been configured to use the custom provider as part of it's `feature_store.yaml` +* [test_custom_provider.py](test_custom_provider.py): A test case that uses `MyCustomProvider` through the `basic_feature_repo/` -### Testing the provider +### Testing the custom provider in this repository Run the following commands to test the custom provider ([MyCustomProvider](https://github.com/feast-dev/feast-custom-provider-demo/blob/master/feast_custom_provider/custom_provider.py)) From d0292c440546f17e9a6e2e70b78238d5e96cc8b7 Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 18:49:07 -0700 Subject: [PATCH 05/10] Remove registry and db --- basic_feature_repo/online_store.db | Bin 16384 -> 0 bytes basic_feature_repo/registry.db | Bin 337 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 basic_feature_repo/online_store.db delete mode 100644 basic_feature_repo/registry.db diff --git a/basic_feature_repo/online_store.db b/basic_feature_repo/online_store.db deleted file mode 100644 index 5769fece97a171475d73d4d93b8d10792725a0a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#&riZI6bJAQh!_)oc;fnw5{brmUKDT#B7))$a4^k;M_kw#ZXJ;${xkj={vG}& z9_YBXj?#Uab7<1^V-->%Wz@nT+wd9L>;RYc15 zS*!T78Vy7J4Ue`)>9*IpsrLqSZ4JI9_H%`5wVTqMlI0z7c00bZa0SG_<0uX=z1Rwx`-52-*>O-~I diff --git a/basic_feature_repo/registry.db b/basic_feature_repo/registry.db deleted file mode 100644 index f2130816f33bf1a2ee0760959ca524192d7036d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 337 zcmYL^u}Z{17=&*jp1D;?$aSeCSm3m z>)9@opp*Db;GC{#BN)lGZzYhwMRQISoex|+P*78O--3EUp?+*sAj(|Qpvv Date: Sat, 21 Aug 2021 18:51:48 -0700 Subject: [PATCH 06/10] Add test workflow --- .github/workflows/test_provider.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/test_provider.yml diff --git a/.github/workflows/test_provider.yml b/.github/workflows/test_provider.yml new file mode 100644 index 0000000..b8b16f5 --- /dev/null +++ b/.github/workflows/test_provider.yml @@ -0,0 +1,25 @@ +name: test-provider + +on: [push, pull_request] +jobs: + test-provider: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: [ 3.7, 3.8, 3.9 ] + os: [ ubuntu-latest, macOS-latest] + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run tests + run: pytest test_custom_provider.py \ No newline at end of file From f16ea66fe2aba96f0b49034d39cf537c3131b836 Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 18:53:47 -0700 Subject: [PATCH 07/10] Add badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6de40de..cba314a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Feast Custom Provider +[![test-provider](https://github.com/feast-dev/feast-custom-provider-demo/actions/workflows/test_provider.yml/badge.svg?branch=master)](https://github.com/feast-dev/feast-custom-provider-demo/actions/workflows/test_provider.yml) ### Overview From 45c21f494d9367d65de843e83e06b1170c9603b5 Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 18:57:19 -0700 Subject: [PATCH 08/10] Add CLI test case --- .gitignore | 4 +++- test_custom_provider.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 00e34d2..821c218 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea *.pyc -**__pycache__ \ No newline at end of file +**__pycache__ +basic_feature_repo/online_store.db +basic_feature_repo/registry.db \ No newline at end of file diff --git a/test_custom_provider.py b/test_custom_provider.py index 54a8861..3e86099 100644 --- a/test_custom_provider.py +++ b/test_custom_provider.py @@ -22,3 +22,7 @@ def test_end_to_end(): # tear down feature store fs.teardown() + +def test_cli(): + import os + os.system('PYTHONPATH=$PYTHONPATH:/$(pwd) feast -c basic_feature_repo apply') From e32b809783395e3fe9b16ea6d1d3c66c27f8ceef Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 21 Aug 2021 19:15:15 -0700 Subject: [PATCH 09/10] Add CLI test --- feast_custom_provider/custom_provider.py | 5 ++--- test_custom_provider.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/feast_custom_provider/custom_provider.py b/feast_custom_provider/custom_provider.py index 235c979..bc4cbba 100644 --- a/feast_custom_provider/custom_provider.py +++ b/feast_custom_provider/custom_provider.py @@ -2,17 +2,16 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union import pandas -from feast.infra.local import LocalProvider -from tqdm import tqdm - from feast.entity import Entity from feast.feature_table import FeatureTable from feast.feature_view import FeatureView +from feast.infra.local import LocalProvider from feast.infra.offline_stores.offline_store import RetrievalJob 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 tqdm import tqdm class MyCustomProvider(LocalProvider): diff --git a/test_custom_provider.py b/test_custom_provider.py index 3e86099..0090d74 100644 --- a/test_custom_provider.py +++ b/test_custom_provider.py @@ -1,6 +1,8 @@ +import os from datetime import datetime from feast import FeatureStore + from basic_feature_repo.repo import driver, driver_hourly_stats_view @@ -23,6 +25,15 @@ def test_end_to_end(): # tear down feature store fs.teardown() + def test_cli(): - import os - os.system('PYTHONPATH=$PYTHONPATH:/$(pwd) feast -c basic_feature_repo apply') + os.system( + "PYTHONPATH=$PYTHONPATH:/$(pwd) feast -c basic_feature_repo apply > output" + ) + with open("output", "r") as f: + output = f.read() + + if "Launching custom streaming jobs is pretty easy" not in output: + raise Exception( + 'Failed to successfully use provider from CLI. See "output" for more details.' + ) From 8358f1349eaf0121307fa24d8bff70159a3526e1 Mon Sep 17 00:00:00 2001 From: Willem Pienaar <6728866+woop@users.noreply.github.com> Date: Mon, 23 Aug 2021 12:10:45 -0700 Subject: [PATCH 10/10] Update custom_provider.py --- feast_custom_provider/custom_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feast_custom_provider/custom_provider.py b/feast_custom_provider/custom_provider.py index bc4cbba..5180a30 100644 --- a/feast_custom_provider/custom_provider.py +++ b/feast_custom_provider/custom_provider.py @@ -17,7 +17,7 @@ class MyCustomProvider(LocalProvider): def __init__(self, config: RepoConfig, repo_path): super().__init__(config) - # Add your custom init code here. This code runs on every + # Add your custom init code here. This code runs on every feast operation. def update_infra( self,