From 94c780bf419c66feaab553b88455e52a5c539de1 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Tue, 4 Jan 2022 18:11:56 +0200 Subject: [PATCH 01/85] Support multiple application properties files (incl from classpath) (#2187) * support spring-like properties paths Signed-off-by: pyalex * typo Signed-off-by: pyalex --- .../feature-server/templates/deployment.yaml | 3 +- infra/docker-compose/docker-compose.yml | 4 +- .../serving/ServingGuiceApplication.java | 2 +- .../serving/config/ApplicationProperties.java | 18 +++++++-- .../config/ApplicationPropertiesModule.java | 37 +++++++++++++++++-- .../feast/serving/config/ServerModule.java | 3 +- 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/infra/charts/feast/charts/feature-server/templates/deployment.yaml b/infra/charts/feast/charts/feature-server/templates/deployment.yaml index 9327747423e..ad0529978d9 100644 --- a/infra/charts/feast/charts/feature-server/templates/deployment.yaml +++ b/infra/charts/feast/charts/feature-server/templates/deployment.yaml @@ -89,8 +89,7 @@ spec: - java - -jar - /opt/feast/feast-serving.jar - - --spring.config.location= - {{- if index .Values "application.yaml" "enabled" -}} + - {{- if index .Values "application.yaml" "enabled" -}} classpath:/application.yml {{- end }} {{- if index .Values "application-generated.yaml" "enabled" -}} diff --git a/infra/docker-compose/docker-compose.yml b/infra/docker-compose/docker-compose.yml index 98131d6ccf0..579dc6d65fb 100644 --- a/infra/docker-compose/docker-compose.yml +++ b/infra/docker-compose/docker-compose.yml @@ -16,7 +16,7 @@ services: - java - -jar - /opt/feast/feast-core.jar - - --spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml + - classpath:/application.yml,file:/etc/feast/application.yml jobservice: image: gcr.io/kf-feast/feast-jobservice:${FEAST_VERSION} @@ -104,7 +104,7 @@ services: - java - -jar - /opt/feast/feast-serving.jar - - --spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml + - classpath:/application.yml,file:/etc/feast/application.yml redis: image: redis:5-alpine diff --git a/java/serving/src/main/java/feast/serving/ServingGuiceApplication.java b/java/serving/src/main/java/feast/serving/ServingGuiceApplication.java index 224c3e8e55e..664d6dd4ec5 100644 --- a/java/serving/src/main/java/feast/serving/ServingGuiceApplication.java +++ b/java/serving/src/main/java/feast/serving/ServingGuiceApplication.java @@ -27,7 +27,7 @@ public class ServingGuiceApplication { public static void main(String[] args) throws InterruptedException, IOException { if (args.length == 0) { throw new RuntimeException( - "Path to application configuration file needs to be specifed via CLI"); + "Path to application configuration file needs to be specified via CLI"); } final Injector i = diff --git a/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java b/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java index 4d822d8dbcd..c75ca984514 100644 --- a/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java +++ b/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java @@ -21,6 +21,8 @@ // https://www.baeldung.com/configuration-properties-in-spring-boot // https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-external-config.html#boot-features-external-config-typesafe-configuration-properties +import com.fasterxml.jackson.annotation.JsonMerge; +import com.fasterxml.jackson.annotation.OptBoolean; import feast.common.logging.config.LoggingProperties; import feast.storage.connectors.redis.retriever.RedisClusterStoreConfig; import feast.storage.connectors.redis.retriever.RedisStoreConfig; @@ -83,6 +85,7 @@ public void setActiveStore(String activeStore) { /** * Collection of store configurations. The active store is selected by the "activeStore" field. */ + @JsonMerge(OptBoolean.FALSE) private List stores = new ArrayList<>(); /* Metric tracing properties. */ @@ -177,6 +180,9 @@ public static class Store { private Map config = new HashMap<>(); + // default construct for deserialization + public Store() {} + public Store(String name, String type, Map config) { this.name = name; this.type = type; @@ -210,6 +216,10 @@ public StoreType getType() { return StoreType.valueOf(this.type); } + public void setType(String type) { + this.type = type; + } + /** * Gets the configuration to this specific store. This is a map of strings. These options are * unique to the store. Please see protos/feast/core/Store.proto for the store specific @@ -217,10 +227,6 @@ public StoreType getType() { * * @return Returns the store specific configuration */ - public Map getConfig() { - return config; - } - public RedisClusterStoreConfig getRedisClusterConfig() { return new RedisClusterStoreConfig( this.config.get("connection_string"), @@ -235,6 +241,10 @@ public RedisStoreConfig getRedisConfig() { Boolean.valueOf(this.config.getOrDefault("ssl", "false")), this.config.getOrDefault("password", "")); } + + public void setConfig(Map config) { + this.config = config; + } } public static class Server { diff --git a/java/serving/src/main/java/feast/serving/config/ApplicationPropertiesModule.java b/java/serving/src/main/java/feast/serving/config/ApplicationPropertiesModule.java index f5a542137c8..07183fc7101 100644 --- a/java/serving/src/main/java/feast/serving/config/ApplicationPropertiesModule.java +++ b/java/serving/src/main/java/feast/serving/config/ApplicationPropertiesModule.java @@ -17,12 +17,15 @@ package feast.serving.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.common.io.Resources; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; -import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; public class ApplicationPropertiesModule extends AbstractModule { private final String[] args; @@ -36,9 +39,37 @@ public ApplicationPropertiesModule(String[] args) { public ApplicationProperties provideApplicationProperties() throws IOException { ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); mapper.findAndRegisterModules(); - ApplicationProperties properties = - mapper.readValue(new File(this.args[0]), ApplicationProperties.class); + mapper.setDefaultMergeable(Boolean.TRUE); + + ApplicationProperties properties = new ApplicationProperties(); + ObjectReader objectReader = mapper.readerForUpdating(properties); + + String[] filePaths = this.args[0].split(","); + for (String filePath : filePaths) { + objectReader.readValue(readPropertiesFile(filePath)); + } return properties; } + + /** + * Read file path in spring compatible format, eg classpath:/application.yml or + * file:/path/application.yml + */ + private byte[] readPropertiesFile(String filePath) throws IOException { + if (filePath.startsWith("classpath:")) { + filePath = filePath.substring("classpath:".length()); + if (filePath.startsWith("/")) { + filePath = filePath.substring(1); + } + + return Resources.toByteArray(Resources.getResource(filePath)); + } + + if (filePath.startsWith("file")) { + filePath = filePath.substring("file:".length()); + } + + return Files.readAllBytes(Path.of(filePath)); + } } diff --git a/java/serving/src/main/java/feast/serving/config/ServerModule.java b/java/serving/src/main/java/feast/serving/config/ServerModule.java index 6993857935b..cb3a18cf956 100644 --- a/java/serving/src/main/java/feast/serving/config/ServerModule.java +++ b/java/serving/src/main/java/feast/serving/config/ServerModule.java @@ -17,6 +17,7 @@ package feast.serving.config; import com.google.inject.AbstractModule; +import com.google.inject.Provides; import feast.serving.grpc.OnlineServingGrpcServiceV2; import io.grpc.Server; import io.grpc.ServerBuilder; @@ -30,7 +31,7 @@ protected void configure() { bind(OnlineServingGrpcServiceV2.class); } - // @Provides + @Provides public Server provideGrpcServer( ApplicationProperties applicationProperties, OnlineServingGrpcServiceV2 onlineServingGrpcServiceV2, From ecdf15e208a4736b6c111936079c61bfec9fd37a Mon Sep 17 00:00:00 2001 From: ptoman-pa <95256508+ptoman-pa@users.noreply.github.com> Date: Tue, 4 Jan 2022 09:28:57 -0800 Subject: [PATCH 02/85] Fixes large payload runtime exception in Datastore (issue 1633) (#2181) * Fixes runtime exception when feature values are larger than 1500 bytes in Datastore. Datastore indexes values as well as keys so large payloads are disallowed. This change clarifies that values should not be indexed. It avoids google.api_core.exceptions.InvalidArgument: 400 The value of property _ is longer than 1500 bytes. Signed-off-by: Pamela Toman * Linted Signed-off-by: Pamela Toman --- .../feast/infra/online_stores/datastore.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/sdk/python/feast/infra/online_stores/datastore.py b/sdk/python/feast/infra/online_stores/datastore.py index 0442eda1220..f8964129cde 100644 --- a/sdk/python/feast/infra/online_stores/datastore.py +++ b/sdk/python/feast/infra/online_stores/datastore.py @@ -196,18 +196,18 @@ def _write_minibatch( key=key, exclude_from_indexes=("created_ts", "event_ts", "values") ) - entity.update( - dict( - key=entity_key.SerializeToString(), - values={k: v.SerializeToString() for k, v in features.items()}, - event_ts=utils.make_tzaware(timestamp), - created_ts=( - utils.make_tzaware(created_ts) - if created_ts is not None - else None - ), - ) + content_entity = datastore.Entity( + exclude_from_indexes=tuple(features.keys()) ) + for k, v in features.items(): + content_entity[k] = v.SerializeToString() + entity["key"] = entity_key.SerializeToString() + entity["values"] = content_entity + entity["event_ts"] = utils.make_tzaware(timestamp) + entity["created_ts"] = ( + utils.make_tzaware(created_ts) if created_ts is not None else None + ) + entities.append(entity) with client.transaction(): client.put_multi(entities) From d447db229965feabb6a8371a5005e2c989c9c01e Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Tue, 4 Jan 2022 19:13:56 +0000 Subject: [PATCH 03/85] Avoid requesting features from OnlineStore twice (#2185) * Avoid requesting features from OnlineStore twice Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Fix edge case where multiple ODFVs reference the same FeatureView. Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/feature_store.py | 49 +++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index ce8125520ee..0141b8f8bcc 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -1144,6 +1144,9 @@ def get_online_features( # Also create entity values to append to the result result_rows.append(_entity_row_to_field_values(entity_row_proto)) + # Keep track of what has been requested from the OnlineStore + # to avoid requesting the same thing twice for ODFVs. + retrieved_feature_refs: Set[str] = set() for table, requested_features in grouped_refs: table_join_keys = [ entity_name_to_join_key_map[entity_name] @@ -1158,6 +1161,11 @@ def get_online_features( table, union_of_entity_keys, ) + table_feature_names = {feature.name for feature in table.features} + retrieved_feature_refs |= { + f"{table.name}:{feature}" if feature in table_feature_names else feature + for feature in requested_features + } requested_result_row_names = self._get_requested_result_fields( result_rows, needed_request_fv_features @@ -1170,6 +1178,7 @@ def get_online_features( request_data_features, result_rows, union_of_entity_keys, + retrieved_feature_refs, ) self._augment_response_with_on_demand_transforms( @@ -1205,6 +1214,7 @@ def _populate_odfv_dependencies( request_data_features: Dict[str, List[Any]], result_rows: List[GetOnlineFeaturesResponse.FieldValues], union_of_entity_keys: List[EntityKeyProto], + retrieved_feature_refs: Set[str], ): # Add more feature values to the existing result rows for the request data features for feature_name, feature_values in request_data_features.items(): @@ -1223,19 +1233,32 @@ def _populate_odfv_dependencies( if len(grouped_odfv_refs) > 0: for odfv, _ in grouped_odfv_refs: for fv in odfv.input_feature_views.values(): - table_join_keys = [ - entity_name_to_join_key_map[entity_name] - for entity_name in fv.entities - ] - self._populate_result_rows_from_feature_view( - table_join_keys, - full_feature_names, - provider, - [feature.name for feature in fv.features], - result_rows, - fv, - union_of_entity_keys, - ) + # Find the set of required Features which have not yet + # been retrieved. + not_yet_retrieved = { + feature.name + for feature in fv.projection.features + if f"{fv.name}:{feature.name}" not in retrieved_feature_refs + } + # If there are required Features which have not yet been retrieved + # retrieve them. + if not_yet_retrieved: + table_join_keys = [ + entity_name_to_join_key_map[entity_name] + for entity_name in fv.entities + ] + self._populate_result_rows_from_feature_view( + table_join_keys, + full_feature_names, + provider, + list(not_yet_retrieved), + result_rows, + fv, + union_of_entity_keys, + ) + # Update the set of retrieved Features with any newly retrieved + # Features. + retrieved_feature_refs |= not_yet_retrieved def get_needed_request_data( self, From b1efc80b473c4c842ecb10f4a6040020b497d3a3 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Wed, 5 Jan 2022 06:42:10 +0200 Subject: [PATCH 04/85] [Java feature server] Converge ServingService API to make Python and Java feature servers consistent (#2166) * hgetall Signed-off-by: pyalex optimized version of Serving proto Signed-off-by: pyalex temp Signed-off-by: pyalex * refactored online service Signed-off-by: pyalex * java tests pass Signed-off-by: pyalex * remove project from request & entities from response Signed-off-by: pyalex * go sdk updated to use new protos Signed-off-by: pyalex * benchmark serving in ITs Signed-off-by: pyalex * fix api docs build Signed-off-by: pyalex * fixes after rebase Signed-off-by: pyalex * rename FeatureReferenceV2.name -> FeatureReferenceV2.feature_name Signed-off-by: pyalex * change proto property name in go sdk Signed-off-by: pyalex * refactoring FeastClient Signed-off-by: pyalex * add some comments Signed-off-by: pyalex * populate metrics Signed-off-by: pyalex * format after rebase Signed-off-by: pyalex * comment Signed-off-by: pyalex * todo added Signed-off-by: pyalex --- .../models/{FeatureV2.java => Feature.java} | 18 +- .../feast/common/models/FeatureTable.java | 48 -- .../logging/entry/AuditLogEntryTest.java | 8 +- .../feast/common/models/FeaturesTest.java | 6 +- .../{com/gojek => dev}/feast/FeastClient.java | 108 +-- .../{com/gojek => dev}/feast/RequestUtil.java | 6 +- .../java/{com/gojek => dev}/feast/Row.java | 4 +- .../gojek => dev}/feast/SecurityConfig.java | 2 +- .../gojek => dev}/feast/FeastClientTest.java | 80 +- .../gojek => dev}/feast/RequestUtilTest.java | 14 +- .../serving/config/ApplicationProperties.java | 18 +- .../config/ServingServiceConfigV2.java | 18 +- .../ServingServiceGRpcController.java | 17 +- .../ServingServiceRestController.java | 8 +- .../grpc/OnlineServingGrpcServiceV2.java | 6 +- .../java/feast/serving/registry/Registry.java | 40 +- .../serving/registry/RegistryRepository.java | 27 +- .../service/OnlineServingServiceV2.java | 452 +++++----- .../service/OnlineTransformationService.java | 143 ++-- .../serving/service/ServingServiceV2.java | 24 +- .../service/TransformationService.java | 12 +- .../feast/serving/util/RequestHelper.java | 15 +- .../util/mappers/ResponseJSONMapper.java | 21 +- .../src/main/resources/application.yml | 1 + .../java/feast/serving/it/ServingBase.java | 307 ------- .../feast/serving/it/ServingBaseTests.java | 161 ++++ .../feast/serving/it/ServingBenchmarkIT.java | 151 ++++ .../feast/serving/it/ServingEnvironment.java | 159 ++++ .../serving/it/ServingRedisGSRegistryIT.java | 15 +- .../it/ServingRedisLocalRegistryIT.java | 19 +- .../serving/it/ServingRedisS3RegistryIT.java | 15 +- .../test/java/feast/serving/it/TestUtils.java | 49 +- .../service/OnlineServingServiceTest.java | 337 ++++---- .../feast/serving/util/DataGenerator.java | 4 +- .../feast/serving/util/RequestHelperTest.java | 39 +- .../docker-compose-redis-it.yml | 2 +- .../docker-compose/feast10/materialize.py | 50 +- .../docker-compose/feast10/registry.db | Bin 374 -> 14374 bytes .../api/retriever/FeatureTableRequest.java | 3 +- .../api/retriever/OnlineRetrieverV2.java | 7 +- .../redis/common/RedisHashDecoder.java | 27 +- .../redis/common/RedisKeyGenerator.java | 9 +- .../redis/retriever/OnlineRetriever.java | 49 +- protos/feast/serving/ServingService.proto | 88 +- sdk/go/client.go | 2 +- sdk/go/client_test.go | 25 +- sdk/go/mocks/serving_mock.go | 10 +- sdk/go/protos/feast/core/DataFormat.pb.go | 2 +- sdk/go/protos/feast/core/DataSource.pb.go | 2 +- sdk/go/protos/feast/core/Entity.pb.go | 2 +- sdk/go/protos/feast/core/Feature.pb.go | 2 +- sdk/go/protos/feast/core/FeatureTable.pb.go | 2 +- sdk/go/protos/feast/core/Store.pb.go | 2 +- .../protos/feast/serving/ServingService.pb.go | 777 +++++++++++------- sdk/go/protos/feast/storage/Redis.pb.go | 2 +- sdk/go/protos/feast/types/Field.pb.go | 2 +- sdk/go/protos/feast/types/Value.pb.go | 2 +- sdk/go/request.go | 45 +- sdk/go/request_test.go | 34 +- sdk/go/response.go | 107 ++- sdk/go/response_test.go | 41 +- sdk/python/feast/feature_store.py | 19 +- 62 files changed, 2036 insertions(+), 1629 deletions(-) rename java/common/src/main/java/feast/common/models/{FeatureV2.java => Feature.java} (74%) delete mode 100644 java/common/src/main/java/feast/common/models/FeatureTable.java rename java/sdk/java/src/main/java/{com/gojek => dev}/feast/FeastClient.java (74%) rename java/sdk/java/src/main/java/{com/gojek => dev}/feast/RequestUtil.java (95%) rename java/sdk/java/src/main/java/{com/gojek => dev}/feast/Row.java (97%) rename java/sdk/java/src/main/java/{com/gojek => dev}/feast/SecurityConfig.java (98%) rename java/sdk/java/src/test/java/{com/gojek => dev}/feast/FeastClientTest.java (68%) rename java/sdk/java/src/test/java/{com/gojek => dev}/feast/RequestUtilTest.java (90%) delete mode 100644 java/serving/src/test/java/feast/serving/it/ServingBase.java create mode 100644 java/serving/src/test/java/feast/serving/it/ServingBaseTests.java create mode 100644 java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java create mode 100644 java/serving/src/test/java/feast/serving/it/ServingEnvironment.java diff --git a/java/common/src/main/java/feast/common/models/FeatureV2.java b/java/common/src/main/java/feast/common/models/Feature.java similarity index 74% rename from java/common/src/main/java/feast/common/models/FeatureV2.java rename to java/common/src/main/java/feast/common/models/Feature.java index 8420cca80c6..340a8cbe69e 100644 --- a/java/common/src/main/java/feast/common/models/FeatureV2.java +++ b/java/common/src/main/java/feast/common/models/Feature.java @@ -18,7 +18,7 @@ import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; -public class FeatureV2 { +public class Feature { /** * Accepts FeatureReferenceV2 object and returns its reference in String @@ -27,10 +27,10 @@ public class FeatureV2 { * @param featureReference {@link FeatureReferenceV2} * @return String format of FeatureReferenceV2 */ - public static String getFeatureStringRef(FeatureReferenceV2 featureReference) { - String ref = featureReference.getName(); - if (!featureReference.getFeatureTable().isEmpty()) { - ref = featureReference.getFeatureTable() + ":" + ref; + public static String getFeatureReference(FeatureReferenceV2 featureReference) { + String ref = featureReference.getFeatureName(); + if (!featureReference.getFeatureViewName().isEmpty()) { + ref = featureReference.getFeatureViewName() + ":" + ref; } return ref; } @@ -47,4 +47,12 @@ public static String getFeatureName(String featureReference) { String[] tokens = featureReference.split(":", 2); return tokens[tokens.length - 1]; } + + public static FeatureReferenceV2 parseFeatureReference(String featureReference) { + String[] tokens = featureReference.split(":", 2); + return FeatureReferenceV2.newBuilder() + .setFeatureViewName(tokens[0]) + .setFeatureName(tokens[1]) + .build(); + } } diff --git a/java/common/src/main/java/feast/common/models/FeatureTable.java b/java/common/src/main/java/feast/common/models/FeatureTable.java deleted file mode 100644 index 88fac151ce7..00000000000 --- a/java/common/src/main/java/feast/common/models/FeatureTable.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2020 The Feast Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feast.common.models; - -import feast.proto.core.FeatureTableProto.FeatureTableSpec; -import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; - -public class FeatureTable { - - /** - * Accepts FeatureTableSpec object and returns its reference in String - * "project/featuretable_name". - * - * @param project project name - * @param featureTableSpec {@link FeatureTableSpec} - * @return String format of FeatureTableReference - */ - public static String getFeatureTableStringRef(String project, FeatureTableSpec featureTableSpec) { - return String.format("%s/%s", project, featureTableSpec.getName()); - } - - /** - * Accepts FeatureReferenceV2 object and returns its reference in String - * "project/featuretable_name". - * - * @param project project name - * @param featureReference {@link FeatureReferenceV2} - * @return String format of FeatureTableReference - */ - public static String getFeatureTableStringRef( - String project, FeatureReferenceV2 featureReference) { - return String.format("%s/%s", project, featureReference.getFeatureTable()); - } -} diff --git a/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java b/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java index cf355e09e4b..0c96ee9c560 100644 --- a/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java +++ b/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java @@ -39,12 +39,12 @@ public List getTestAuditLogs() { .addAllFeatures( Arrays.asList( FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature1") + .setFeatureViewName("featuretable_1") + .setFeatureName("feature1") .build(), FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature2") + .setFeatureViewName("featuretable_1") + .setFeatureName("feature2") .build())) .build(); diff --git a/java/common/src/test/java/feast/common/models/FeaturesTest.java b/java/common/src/test/java/feast/common/models/FeaturesTest.java index 180f7e4e697..953da61afeb 100644 --- a/java/common/src/test/java/feast/common/models/FeaturesTest.java +++ b/java/common/src/test/java/feast/common/models/FeaturesTest.java @@ -31,14 +31,14 @@ public class FeaturesTest { public void setUp() { featureReference = FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature1") + .setFeatureViewName("featuretable_1") + .setFeatureName("feature1") .build(); } @Test public void shouldReturnFeatureStringRef() { - String actualFeatureStringRef = FeatureV2.getFeatureStringRef(featureReference); + String actualFeatureStringRef = Feature.getFeatureReference(featureReference); String expectedFeatureStringRef = "featuretable_1:feature1"; assertThat(actualFeatureStringRef, equalTo(expectedFeatureStringRef)); diff --git a/java/sdk/java/src/main/java/com/gojek/feast/FeastClient.java b/java/sdk/java/src/main/java/dev/feast/FeastClient.java similarity index 74% rename from java/sdk/java/src/main/java/com/gojek/feast/FeastClient.java rename to java/sdk/java/src/main/java/dev/feast/FeastClient.java index 0c0b279be6b..e9aaab151a0 100644 --- a/java/sdk/java/src/main/java/com/gojek/feast/FeastClient.java +++ b/java/sdk/java/src/main/java/dev/feast/FeastClient.java @@ -14,16 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.gojek.feast; +package dev.feast; -import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import com.google.common.collect.Lists; +import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2; import feast.proto.serving.ServingServiceGrpc; import feast.proto.serving.ServingServiceGrpc.ServingServiceBlockingStub; +import feast.proto.types.ValueProto; import io.grpc.CallCredentials; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; @@ -32,9 +33,8 @@ import io.opentracing.contrib.grpc.TracingClientInterceptor; import io.opentracing.util.GlobalTracer; import java.io.File; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; +import java.time.Instant; +import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import javax.net.ssl.SSLException; @@ -118,11 +118,60 @@ public GetFeastServingInfoResponse getFeastServingInfo() { * @param featureRefs list of string feature references to retrieve in the following format * featureTable:feature, where 'featureTable' and 'feature' refer to the FeatureTable and * Feature names respectively. Only the Feature name is required. - * @param rows list of {@link Row} to select the entities to retrieve the features for. + * @param entities list of {@link Row} to select the entities to retrieve the features for. * @return list of {@link Row} containing retrieved data fields. */ - public List getOnlineFeatures(List featureRefs, List rows) { - return getOnlineFeatures(featureRefs, rows, ""); + public List getOnlineFeatures(List featureRefs, List entities) { + GetOnlineFeaturesRequest.Builder requestBuilder = GetOnlineFeaturesRequest.newBuilder(); + + requestBuilder.setFeatures( + ServingAPIProto.FeatureList.newBuilder().addAllVal(featureRefs).build()); + + requestBuilder.putAllEntities(getEntityValuesMap(entities)); + + GetOnlineFeaturesResponseV2 response = stub.getOnlineFeatures(requestBuilder.build()); + + List results = Lists.newArrayList(); + if (response.getResultsCount() == 0) { + return results; + } + + for (int rowIdx = 0; rowIdx < response.getResults(0).getValuesCount(); rowIdx++) { + Row row = Row.create(); + for (int featureIdx = 0; featureIdx < response.getResultsCount(); featureIdx++) { + row.set( + response.getMetadata().getFeatureNames().getVal(featureIdx), + response.getResults(featureIdx).getValues(rowIdx), + response.getResults(featureIdx).getStatuses(rowIdx)); + + row.setEntityTimestamp( + Instant.ofEpochSecond( + response.getResults(featureIdx).getEventTimestamps(rowIdx).getSeconds())); + } + for (Map.Entry entry : + entities.get(rowIdx).getFields().entrySet()) { + row.set(entry.getKey(), entry.getValue()); + } + + results.add(row); + } + return results; + } + + private Map getEntityValuesMap(List entities) { + Map columnarEntities = new HashMap<>(); + for (Row row : entities) { + for (Map.Entry field : row.getFields().entrySet()) { + if (!columnarEntities.containsKey(field.getKey())) { + columnarEntities.put(field.getKey(), ValueProto.RepeatedValue.newBuilder()); + } + columnarEntities.get(field.getKey()).addVal(field.getValue()); + } + } + + return columnarEntities.entrySet().stream() + .map((e) -> Map.entry(e.getKey(), e.getValue().build())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } /** @@ -149,42 +198,7 @@ public List getOnlineFeatures(List featureRefs, List rows) { * @return list of {@link Row} containing retrieved data fields. */ public List getOnlineFeatures(List featureRefs, List rows, String project) { - List features = RequestUtil.createFeatureRefs(featureRefs); - // build entity rows and collect entity references - HashSet entityRefs = new HashSet<>(); - List entityRows = - rows.stream() - .map( - row -> { - entityRefs.addAll(row.getFields().keySet()); - return EntityRow.newBuilder() - .setTimestamp(row.getEntityTimestamp()) - .putAllFields(row.getFields()) - .build(); - }) - .collect(Collectors.toList()); - - GetOnlineFeaturesResponse response = - stub.getOnlineFeaturesV2( - GetOnlineFeaturesRequestV2.newBuilder() - .addAllFeatures(features) - .addAllEntityRows(entityRows) - .setProject(project) - .build()); - - return response.getFieldValuesList().stream() - .map( - fieldValues -> { - Row row = Row.create(); - for (String fieldName : fieldValues.getFieldsMap().keySet()) { - row.set( - fieldName, - fieldValues.getFieldsMap().get(fieldName), - fieldValues.getStatusesMap().get(fieldName)); - } - return row; - }) - .collect(Collectors.toList()); + return getOnlineFeatures(featureRefs, rows); } protected FeastClient(ManagedChannel channel, Optional credentials) { diff --git a/java/sdk/java/src/main/java/com/gojek/feast/RequestUtil.java b/java/sdk/java/src/main/java/dev/feast/RequestUtil.java similarity index 95% rename from java/sdk/java/src/main/java/com/gojek/feast/RequestUtil.java rename to java/sdk/java/src/main/java/dev/feast/RequestUtil.java index 69c8f9f737a..fc13c453119 100644 --- a/java/sdk/java/src/main/java/com/gojek/feast/RequestUtil.java +++ b/java/sdk/java/src/main/java/dev/feast/RequestUtil.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.gojek.feast; +package dev.feast; import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; import java.util.List; @@ -71,8 +71,8 @@ public static FeatureReferenceV2 parseFeatureRef(String featureRefString) { String[] featureReferenceParts = featureRefString.split(":"); FeatureReferenceV2 featureRef = FeatureReferenceV2.newBuilder() - .setFeatureTable(featureReferenceParts[0]) - .setName(featureReferenceParts[1]) + .setFeatureViewName(featureReferenceParts[0]) + .setFeatureName(featureReferenceParts[1]) .build(); return featureRef; diff --git a/java/sdk/java/src/main/java/com/gojek/feast/Row.java b/java/sdk/java/src/main/java/dev/feast/Row.java similarity index 97% rename from java/sdk/java/src/main/java/com/gojek/feast/Row.java rename to java/sdk/java/src/main/java/dev/feast/Row.java index 51f820e320a..308daa5a2f0 100644 --- a/java/sdk/java/src/main/java/com/gojek/feast/Row.java +++ b/java/sdk/java/src/main/java/dev/feast/Row.java @@ -14,12 +14,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.gojek.feast; +package dev.feast; import com.google.protobuf.ByteString; import com.google.protobuf.Timestamp; import com.google.protobuf.util.Timestamps; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus; +import feast.proto.serving.ServingAPIProto.FieldStatus; import feast.proto.types.ValueProto.Value; import feast.proto.types.ValueProto.Value.ValCase; import java.time.Instant; diff --git a/java/sdk/java/src/main/java/com/gojek/feast/SecurityConfig.java b/java/sdk/java/src/main/java/dev/feast/SecurityConfig.java similarity index 98% rename from java/sdk/java/src/main/java/com/gojek/feast/SecurityConfig.java rename to java/sdk/java/src/main/java/dev/feast/SecurityConfig.java index 94c779cf440..29acb97631a 100644 --- a/java/sdk/java/src/main/java/com/gojek/feast/SecurityConfig.java +++ b/java/sdk/java/src/main/java/dev/feast/SecurityConfig.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.gojek.feast; +package dev.feast; import com.google.auto.value.AutoValue; import io.grpc.CallCredentials; diff --git a/java/sdk/java/src/test/java/com/gojek/feast/FeastClientTest.java b/java/sdk/java/src/test/java/dev/feast/FeastClientTest.java similarity index 68% rename from java/sdk/java/src/test/java/com/gojek/feast/FeastClientTest.java rename to java/sdk/java/src/test/java/dev/feast/FeastClientTest.java index 29185cd153c..3de5142a85d 100644 --- a/java/sdk/java/src/test/java/com/gojek/feast/FeastClientTest.java +++ b/java/sdk/java/src/test/java/dev/feast/FeastClientTest.java @@ -14,20 +14,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.gojek.feast; +package dev.feast; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.Mockito.mock; import com.google.protobuf.Timestamp; -import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.FieldStatus; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2; import feast.proto.serving.ServingServiceGrpc.ServingServiceImplBase; +import feast.proto.types.ValueProto; import feast.proto.types.ValueProto.Value; import io.grpc.*; import io.grpc.inprocess.InProcessChannelBuilder; @@ -56,9 +55,9 @@ public class FeastClientTest { delegatesTo( new ServingServiceImplBase() { @Override - public void getOnlineFeaturesV2( - GetOnlineFeaturesRequestV2 request, - StreamObserver responseObserver) { + public void getOnlineFeatures( + GetOnlineFeaturesRequest request, + StreamObserver responseObserver) { if (!request.equals(FeastClientTest.getFakeRequest())) { responseObserver.onError(Status.FAILED_PRECONDITION.asRuntimeException()); } @@ -125,35 +124,46 @@ private void shouldGetOnlineFeaturesWithClient(FeastClient client) { }); } - private static GetOnlineFeaturesRequestV2 getFakeRequest() { + private static GetOnlineFeaturesRequest getFakeRequest() { // setup mock serving service stub - return GetOnlineFeaturesRequestV2.newBuilder() - .addFeatures( - FeatureReferenceV2.newBuilder().setFeatureTable("driver").setName("name").build()) - .addFeatures( - FeatureReferenceV2.newBuilder().setFeatureTable("driver").setName("rating").build()) - .addFeatures( - FeatureReferenceV2.newBuilder().setFeatureTable("driver").setName("null_value").build()) - .addEntityRows( - EntityRow.newBuilder() - .setTimestamp(Timestamp.newBuilder().setSeconds(100)) - .putFields("driver_id", intValue(1))) - .setProject("driver_project") + return GetOnlineFeaturesRequest.newBuilder() + .setFeatures( + ServingAPIProto.FeatureList.newBuilder() + .addVal("driver:name") + .addVal("driver:rating") + .addVal("driver:null_value") + .build()) + .putEntities("driver_id", ValueProto.RepeatedValue.newBuilder().addVal(intValue(1)).build()) .build(); } - private static GetOnlineFeaturesResponse getFakeResponse() { - return GetOnlineFeaturesResponse.newBuilder() - .addFieldValues( - FieldValues.newBuilder() - .putFields("driver_id", intValue(1)) - .putStatuses("driver_id", FieldStatus.PRESENT) - .putFields("driver:name", strValue("david")) - .putStatuses("driver:name", FieldStatus.PRESENT) - .putFields("driver:rating", intValue(3)) - .putStatuses("driver:rating", FieldStatus.PRESENT) - .putFields("driver:null_value", Value.newBuilder().build()) - .putStatuses("driver:null_value", FieldStatus.NULL_VALUE) + private static GetOnlineFeaturesResponseV2 getFakeResponse() { + return GetOnlineFeaturesResponseV2.newBuilder() + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(strValue("david")) + .addStatuses(FieldStatus.PRESENT) + .addEventTimestamps(Timestamp.newBuilder()) + .build()) + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(intValue(3)) + .addStatuses(FieldStatus.PRESENT) + .addEventTimestamps(Timestamp.newBuilder()) + .build()) + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(Value.newBuilder().build()) + .addStatuses(FieldStatus.NULL_VALUE) + .addEventTimestamps(Timestamp.newBuilder()) + .build()) + .setMetadata( + ServingAPIProto.GetOnlineFeaturesResponseMetadata.newBuilder() + .setFeatureNames( + ServingAPIProto.FeatureList.newBuilder() + .addVal("driver:name") + .addVal("driver:rating") + .addVal("driver:null_value")) .build()) .build(); } diff --git a/java/sdk/java/src/test/java/com/gojek/feast/RequestUtilTest.java b/java/sdk/java/src/test/java/dev/feast/RequestUtilTest.java similarity index 90% rename from java/sdk/java/src/test/java/com/gojek/feast/RequestUtilTest.java rename to java/sdk/java/src/test/java/dev/feast/RequestUtilTest.java index 1592e20664d..21fb145b248 100644 --- a/java/sdk/java/src/test/java/com/gojek/feast/RequestUtilTest.java +++ b/java/sdk/java/src/test/java/dev/feast/RequestUtilTest.java @@ -14,14 +14,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.gojek.feast; +package dev.feast; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import com.google.common.collect.ImmutableList; import com.google.protobuf.TextFormat; -import feast.common.models.FeatureV2; +import feast.common.models.Feature; import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; import java.util.Arrays; import java.util.Comparator; @@ -41,8 +41,8 @@ private static Stream provideValidFeatureRefs() { Arrays.asList("driver:driver_id"), Arrays.asList( FeatureReferenceV2.newBuilder() - .setFeatureTable("driver") - .setName("driver_id") + .setFeatureViewName("driver") + .setFeatureName("driver_id") .build()))); } @@ -52,8 +52,8 @@ void createFeatureRefs_ShouldReturnFeaturesForValidFeatureRefs( List input, List expected) { List actual = RequestUtil.createFeatureRefs(input); // Order of the actual and expected FeatureTables do no not matter - actual.sort(Comparator.comparing(FeatureReferenceV2::getName)); - expected.sort(Comparator.comparing(FeatureReferenceV2::getName)); + actual.sort(Comparator.comparing(FeatureReferenceV2::getFeatureName)); + expected.sort(Comparator.comparing(FeatureReferenceV2::getFeatureName)); assertEquals(expected.size(), actual.size()); for (int i = 0; i < expected.size(); i++) { String expectedString = TextFormat.printer().printToString(expected.get(i)); @@ -68,7 +68,7 @@ void renderFeatureRef_ShouldReturnFeatureRefString( List expected, List input) { input = input.stream().map(ref -> ref.toBuilder().build()).collect(Collectors.toList()); List actual = - input.stream().map(ref -> FeatureV2.getFeatureStringRef(ref)).collect(Collectors.toList()); + input.stream().map(ref -> Feature.getFeatureReference(ref)).collect(Collectors.toList()); assertEquals(expected.size(), actual.size()); for (int i = 0; i < expected.size(); i++) { assertEquals(expected.get(i), actual.get(i)); diff --git a/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java b/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java index c75ca984514..2e2448ca902 100644 --- a/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java +++ b/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java @@ -44,22 +44,32 @@ public void setRegistry(String registry) { this.registry = registry; } - public void setRegistryRefreshInterval(int registryRefreshInterval) { - this.registryRefreshInterval = registryRefreshInterval; - } - @NotBlank private String registry; public String getRegistry() { return registry; } + @NotBlank private String project; + + public String getProject() { + return project; + } + + public void setProject(final String project) { + this.project = project; + } + private int registryRefreshInterval; public int getRegistryRefreshInterval() { return registryRefreshInterval; } + public void setRegistryRefreshInterval(int registryRefreshInterval) { + this.registryRefreshInterval = registryRefreshInterval; + } + /** * Finds and returns the active store * diff --git a/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java b/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java index 52d7d1c8d61..d3fe1ba116f 100644 --- a/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java +++ b/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java @@ -44,12 +44,20 @@ public ServingServiceV2 registryBasedServingServiceV2( case REDIS_CLUSTER: RedisClientAdapter redisClusterClient = RedisClusterClient.create(store.getRedisClusterConfig()); - retrieverV2 = new OnlineRetriever(redisClusterClient, new EntityKeySerializerV2()); + retrieverV2 = + new OnlineRetriever( + applicationProperties.getFeast().getProject(), + redisClusterClient, + new EntityKeySerializerV2()); break; case REDIS: RedisClientAdapter redisClient = RedisClient.create(store.getRedisConfig()); log.info("Created EntityKeySerializerV2"); - retrieverV2 = new OnlineRetriever(redisClient, new EntityKeySerializerV2()); + retrieverV2 = + new OnlineRetriever( + applicationProperties.getFeast().getProject(), + redisClient, + new EntityKeySerializerV2()); break; default: throw new RuntimeException( @@ -67,7 +75,11 @@ public ServingServiceV2 registryBasedServingServiceV2( servingService = new OnlineServingServiceV2( - retrieverV2, tracer, registryRepository, onlineTransformationService); + retrieverV2, + tracer, + registryRepository, + onlineTransformationService, + applicationProperties.getFeast().getProject()); return servingService; } diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java index 0a406930e6c..0f4ef7b5ae5 100644 --- a/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java +++ b/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java @@ -19,11 +19,9 @@ import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; import feast.proto.serving.ServingServiceGrpc.ServingServiceImplBase; import feast.serving.config.ApplicationProperties; import feast.serving.exception.SpecRetrievalException; -import feast.serving.interceptors.GrpcMonitoringContext; import feast.serving.service.ServingServiceV2; import feast.serving.util.RequestHelper; import io.grpc.Status; @@ -60,22 +58,19 @@ public void getFeastServingInfo( } @Override - public void getOnlineFeaturesV2( - ServingAPIProto.GetOnlineFeaturesRequestV2 request, - StreamObserver responseObserver) { + public void getOnlineFeatures( + ServingAPIProto.GetOnlineFeaturesRequest request, + StreamObserver responseObserver) { try { // authorize for the project in request object. - request.getProject(); - if (!request.getProject().isEmpty()) { - // update monitoring context - GrpcMonitoringContext.getInstance().setProject(request.getProject()); - } RequestHelper.validateOnlineRequest(request); Span span = tracer.buildSpan("getOnlineFeaturesV2").start(); - GetOnlineFeaturesResponse onlineFeatures = servingServiceV2.getOnlineFeatures(request); + ServingAPIProto.GetOnlineFeaturesResponseV2 onlineFeatures = + servingServiceV2.getOnlineFeatures(request); if (span != null) { span.finish(); } + responseObserver.onNext(onlineFeatures); responseObserver.onCompleted(); } catch (SpecRetrievalException e) { diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java index 2f446adf675..fe8f13d8bc2 100644 --- a/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java +++ b/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java @@ -18,10 +18,9 @@ import static feast.serving.util.mappers.ResponseJSONMapper.mapGetOnlineFeaturesResponse; +import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; import feast.serving.config.ApplicationProperties; import feast.serving.service.ServingServiceV2; import feast.serving.util.RequestHelper; @@ -53,9 +52,10 @@ public GetFeastServingInfoResponse getInfo() { produces = "application/json", consumes = "application/json") public List> getOnlineFeatures( - @RequestBody GetOnlineFeaturesRequestV2 request) { + @RequestBody ServingAPIProto.GetOnlineFeaturesRequest request) { RequestHelper.validateOnlineRequest(request); - GetOnlineFeaturesResponse onlineFeatures = servingService.getOnlineFeatures(request); + ServingAPIProto.GetOnlineFeaturesResponseV2 onlineFeatures = + servingService.getOnlineFeatures(request); return mapGetOnlineFeaturesResponse(onlineFeatures); } } diff --git a/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java b/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java index 68a17539abb..f3a35d1d0f7 100644 --- a/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java +++ b/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java @@ -39,9 +39,9 @@ public void getFeastServingInfo( } @Override - public void getOnlineFeaturesV2( - ServingAPIProto.GetOnlineFeaturesRequestV2 request, - StreamObserver responseObserver) { + public void getOnlineFeatures( + ServingAPIProto.GetOnlineFeaturesRequest request, + StreamObserver responseObserver) { responseObserver.onNext(this.servingServiceV2.getOnlineFeatures(request)); responseObserver.onCompleted(); } diff --git a/java/serving/src/main/java/feast/serving/registry/Registry.java b/java/serving/src/main/java/feast/serving/registry/Registry.java index 144135c3cae..37fae3d8dcb 100644 --- a/java/serving/src/main/java/feast/serving/registry/Registry.java +++ b/java/serving/src/main/java/feast/serving/registry/Registry.java @@ -16,10 +16,7 @@ */ package feast.serving.registry; -import feast.proto.core.FeatureProto; -import feast.proto.core.FeatureViewProto; -import feast.proto.core.OnDemandFeatureViewProto; -import feast.proto.core.RegistryProto; +import feast.proto.core.*; import feast.proto.serving.ServingAPIProto; import feast.serving.exception.SpecRetrievalException; import java.util.List; @@ -32,6 +29,7 @@ public class Registry { private Map featureViewNameToSpec; private Map onDemandFeatureViewNameToSpec; + private Map featureServiceNameToSpec; Registry(RegistryProto.Registry registry) { this.registry = registry; @@ -53,6 +51,12 @@ public class Registry { Collectors.toMap( OnDemandFeatureViewProto.OnDemandFeatureViewSpec::getName, Function.identity())); + this.featureServiceNameToSpec = + registry.getFeatureServicesList().stream() + .map(fs -> fs.getSpec()) + .collect( + Collectors.toMap( + FeatureServiceProto.FeatureServiceSpec::getName, Function.identity())); } public RegistryProto.Registry getRegistry() { @@ -60,8 +64,8 @@ public RegistryProto.Registry getRegistry() { } public FeatureViewProto.FeatureViewSpec getFeatureViewSpec( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - String featureViewName = featureReference.getFeatureTable(); + ServingAPIProto.FeatureReferenceV2 featureReference) { + String featureViewName = featureReference.getFeatureViewName(); if (featureViewNameToSpec.containsKey(featureViewName)) { return featureViewNameToSpec.get(featureViewName); } @@ -70,11 +74,10 @@ public FeatureViewProto.FeatureViewSpec getFeatureViewSpec( } public FeatureProto.FeatureSpecV2 getFeatureSpec( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - final FeatureViewProto.FeatureViewSpec spec = - this.getFeatureViewSpec(projectName, featureReference); + ServingAPIProto.FeatureReferenceV2 featureReference) { + final FeatureViewProto.FeatureViewSpec spec = this.getFeatureViewSpec(featureReference); for (final FeatureProto.FeatureSpecV2 featureSpec : spec.getFeaturesList()) { - if (featureSpec.getName().equals(featureReference.getName())) { + if (featureSpec.getName().equals(featureReference.getFeatureName())) { return featureSpec; } } @@ -82,12 +85,12 @@ public FeatureProto.FeatureSpecV2 getFeatureSpec( throw new SpecRetrievalException( String.format( "Unable to find feature with name: %s in feature view: %s", - featureReference.getName(), featureReference.getFeatureTable())); + featureReference.getFeatureName(), featureReference.getFeatureViewName())); } public OnDemandFeatureViewProto.OnDemandFeatureViewSpec getOnDemandFeatureViewSpec( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - String onDemandFeatureViewName = featureReference.getFeatureTable(); + ServingAPIProto.FeatureReferenceV2 featureReference) { + String onDemandFeatureViewName = featureReference.getFeatureViewName(); if (onDemandFeatureViewNameToSpec.containsKey(onDemandFeatureViewName)) { return onDemandFeatureViewNameToSpec.get(onDemandFeatureViewName); } @@ -97,7 +100,16 @@ public OnDemandFeatureViewProto.OnDemandFeatureViewSpec getOnDemandFeatureViewSp } public boolean isOnDemandFeatureReference(ServingAPIProto.FeatureReferenceV2 featureReference) { - String onDemandFeatureViewName = featureReference.getFeatureTable(); + String onDemandFeatureViewName = featureReference.getFeatureViewName(); return onDemandFeatureViewNameToSpec.containsKey(onDemandFeatureViewName); } + + public FeatureServiceProto.FeatureServiceSpec getFeatureServiceSpec(String name) { + FeatureServiceProto.FeatureServiceSpec spec = featureServiceNameToSpec.get(name); + if (spec == null) { + throw new SpecRetrievalException( + String.format("Unable to find feature service with name: %s", name)); + } + return spec; + } } diff --git a/java/serving/src/main/java/feast/serving/registry/RegistryRepository.java b/java/serving/src/main/java/feast/serving/registry/RegistryRepository.java index 23c204b5822..369493ee0fe 100644 --- a/java/serving/src/main/java/feast/serving/registry/RegistryRepository.java +++ b/java/serving/src/main/java/feast/serving/registry/RegistryRepository.java @@ -18,6 +18,7 @@ import com.google.protobuf.Duration; import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureServiceProto; import feast.proto.core.FeatureViewProto; import feast.proto.core.OnDemandFeatureViewProto; import feast.proto.core.RegistryProto; @@ -72,31 +73,33 @@ private void refresh() { } public FeatureViewProto.FeatureViewSpec getFeatureViewSpec( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - return this.registry.getFeatureViewSpec(projectName, featureReference); + ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registry.getFeatureViewSpec(featureReference); } public FeatureProto.FeatureSpecV2 getFeatureSpec( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - return this.registry.getFeatureSpec(projectName, featureReference); + ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registry.getFeatureSpec(featureReference); } public OnDemandFeatureViewProto.OnDemandFeatureViewSpec getOnDemandFeatureViewSpec( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - return this.registry.getOnDemandFeatureViewSpec(projectName, featureReference); + ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registry.getOnDemandFeatureViewSpec(featureReference); } public boolean isOnDemandFeatureReference(ServingAPIProto.FeatureReferenceV2 featureReference) { return this.registry.isOnDemandFeatureReference(featureReference); } - public Duration getMaxAge( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - return getFeatureViewSpec(projectName, featureReference).getTtl(); + public FeatureServiceProto.FeatureServiceSpec getFeatureServiceSpec(String name) { + return this.registry.getFeatureServiceSpec(name); } - public List getEntitiesList( - String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { - return getFeatureViewSpec(projectName, featureReference).getEntitiesList(); + public Duration getMaxAge(ServingAPIProto.FeatureReferenceV2 featureReference) { + return getFeatureViewSpec(featureReference).getTtl(); + } + + public List getEntitiesList(ServingAPIProto.FeatureReferenceV2 featureReference) { + return getFeatureViewSpec(featureReference).getEntitiesList(); } } diff --git a/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java index 2d5621e4b4f..5774dc361a9 100644 --- a/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java +++ b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java @@ -16,32 +16,29 @@ */ package feast.serving.service; -import static feast.common.models.FeatureTable.getFeatureTableStringRef; - +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; import com.google.protobuf.Duration; -import feast.common.models.FeatureV2; -import feast.proto.serving.ServingAPIProto.FeastServingType; +import com.google.protobuf.Timestamp; +import feast.common.models.Feature; +import feast.proto.core.FeatureServiceProto; +import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.FieldStatus; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesRequest; import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesResponse; import feast.proto.serving.TransformationServiceAPIProto.ValueType; import feast.proto.types.ValueProto; -import feast.serving.exception.SpecRetrievalException; import feast.serving.registry.RegistryRepository; import feast.serving.util.Metrics; -import feast.storage.api.retriever.Feature; import feast.storage.api.retriever.OnlineRetrieverV2; import io.grpc.Status; import io.opentracing.Span; import io.opentracing.Tracer; import java.util.*; -import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.IntStream; import org.apache.commons.lang3.tuple.Pair; import org.slf4j.Logger; @@ -52,126 +49,79 @@ public class OnlineServingServiceV2 implements ServingServiceV2 { private final OnlineRetrieverV2 retriever; private final RegistryRepository registryRepository; private final OnlineTransformationService onlineTransformationService; + private final String project; public OnlineServingServiceV2( OnlineRetrieverV2 retriever, Tracer tracer, RegistryRepository registryRepository, - OnlineTransformationService onlineTransformationService) { + OnlineTransformationService onlineTransformationService, + String project) { this.retriever = retriever; this.tracer = tracer; this.registryRepository = registryRepository; this.onlineTransformationService = onlineTransformationService; + this.project = project; } /** {@inheritDoc} */ @Override public GetFeastServingInfoResponse getFeastServingInfo( GetFeastServingInfoRequest getFeastServingInfoRequest) { - return GetFeastServingInfoResponse.newBuilder() - .setType(FeastServingType.FEAST_SERVING_TYPE_ONLINE) - .build(); + return GetFeastServingInfoResponse.getDefaultInstance(); } @Override - public GetOnlineFeaturesResponse getOnlineFeatures(GetOnlineFeaturesRequestV2 request) { - // Autofill default project if project is not specified - String projectName = request.getProject(); - if (projectName.isEmpty()) { - projectName = "default"; - } - + public ServingAPIProto.GetOnlineFeaturesResponseV2 getOnlineFeatures( + ServingAPIProto.GetOnlineFeaturesRequest request) { // Split all feature references into non-ODFV (e.g. batch and stream) references and ODFV. - List allFeatureReferences = request.getFeaturesList(); - List featureReferences = + List allFeatureReferences = getFeaturesList(request); + List retrievedFeatureReferences = allFeatureReferences.stream() .filter(r -> !this.registryRepository.isOnDemandFeatureReference(r)) .collect(Collectors.toList()); + int userRequestedFeaturesSize = retrievedFeatureReferences.size(); + List onDemandFeatureReferences = allFeatureReferences.stream() .filter(r -> this.registryRepository.isOnDemandFeatureReference(r)) .collect(Collectors.toList()); - // Get the set of request data feature names and feature inputs from the ODFV references. - Pair, List> pair = - this.onlineTransformationService.extractRequestDataFeatureNamesAndOnDemandFeatureInputs( - onDemandFeatureReferences, projectName); - Set requestDataFeatureNames = pair.getLeft(); - List onDemandFeatureInputs = pair.getRight(); + // ToDo (pyalex): refactor transformation service to delete unused left part of the returned + // Pair from extractRequestDataFeatureNamesAndOnDemandFeatureInputs. + // Currently, we can retrieve context variables directly from GetOnlineFeaturesRequest. + List onDemandFeatureInputs = + this.onlineTransformationService + .extractRequestDataFeatureNamesAndOnDemandFeatureInputs(onDemandFeatureReferences) + .getRight(); // Add on demand feature inputs to list of feature references to retrieve. - Set addedFeatureReferences = new HashSet(); for (FeatureReferenceV2 onDemandFeatureInput : onDemandFeatureInputs) { - if (!featureReferences.contains(onDemandFeatureInput)) { - featureReferences.add(onDemandFeatureInput); - addedFeatureReferences.add(onDemandFeatureInput); + if (!retrievedFeatureReferences.contains(onDemandFeatureInput)) { + retrievedFeatureReferences.add(onDemandFeatureInput); } } - // Separate entity rows into entity data and request feature data. - Pair, Map>> - entityRowsAndRequestDataFeatures = - this.onlineTransformationService.separateEntityRows(requestDataFeatureNames, request); - List entityRows = - entityRowsAndRequestDataFeatures.getLeft(); - Map> requestDataFeatures = - entityRowsAndRequestDataFeatures.getRight(); - // TODO: error checking on lengths of lists in entityRows and requestDataFeatures - - // Extract values and statuses to be used later in constructing FieldValues for the response. - // The online features retrieved will augment these two data structures. - List> values = - entityRows.stream().map(r -> new HashMap<>(r.getFieldsMap())).collect(Collectors.toList()); - List> statuses = - entityRows.stream() - .map( - r -> - r.getFieldsMap().entrySet().stream() - .map(entry -> Pair.of(entry.getKey(), getMetadata(entry.getValue(), false))) - .collect(Collectors.toMap(Pair::getLeft, Pair::getRight))) - .collect(Collectors.toList()); + List> entityRows = getEntityRows(request); - String finalProjectName = projectName; - Map featureMaxAges = - featureReferences.stream() - .distinct() - .collect( - Collectors.toMap( - Function.identity(), - ref -> this.registryRepository.getMaxAge(finalProjectName, ref))); - List entityNames = - featureReferences.stream() - .map(ref -> this.registryRepository.getEntitiesList(finalProjectName, ref)) - .findFirst() - .get(); - - Map featureValueTypes = - featureReferences.stream() - .distinct() - .collect( - Collectors.toMap( - Function.identity(), - ref -> { - try { - return this.registryRepository - .getFeatureSpec(finalProjectName, ref) - .getValueType(); - } catch (SpecRetrievalException e) { - return ValueProto.ValueType.Enum.INVALID; - } - })); + List entityNames; + if (retrievedFeatureReferences.size() > 0) { + entityNames = this.registryRepository.getEntitiesList(retrievedFeatureReferences.get(0)); + } else { + throw new RuntimeException("Requested features list must not be empty"); + } Span storageRetrievalSpan = tracer.buildSpan("storageRetrieval").start(); if (storageRetrievalSpan != null) { storageRetrievalSpan.setTag("entities", entityRows.size()); - storageRetrievalSpan.setTag("features", featureReferences.size()); + storageRetrievalSpan.setTag("features", retrievedFeatureReferences.size()); } - List> features = - retriever.getOnlineFeatures(projectName, entityRows, featureReferences, entityNames); + List> features = + retriever.getOnlineFeatures(entityRows, retrievedFeatureReferences, entityNames); + if (storageRetrievalSpan != null) { storageRetrievalSpan.finish(); } - if (features.size() != entityRows.size()) { throw Status.INTERNAL .withDescription( @@ -182,132 +132,188 @@ public GetOnlineFeaturesResponse getOnlineFeatures(GetOnlineFeaturesRequestV2 re Span postProcessingSpan = tracer.buildSpan("postProcessing").start(); - for (int i = 0; i < entityRows.size(); i++) { - GetOnlineFeaturesRequestV2.EntityRow entityRow = entityRows.get(i); - Map featureRow = features.get(i); + ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder = + ServingAPIProto.GetOnlineFeaturesResponseV2.newBuilder(); - Map rowValues = values.get(i); - Map rowStatuses = statuses.get(i); + Timestamp now = Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000).build(); + Timestamp nullTimestamp = Timestamp.newBuilder().build(); + ValueProto.Value nullValue = ValueProto.Value.newBuilder().build(); - for (FeatureReferenceV2 featureReference : featureReferences) { - if (featureRow.containsKey(featureReference)) { - Feature feature = featureRow.get(featureReference); + for (int featureIdx = 0; featureIdx < userRequestedFeaturesSize; featureIdx++) { + FeatureReferenceV2 featureReference = retrievedFeatureReferences.get(featureIdx); - ValueProto.Value value = feature.getFeatureValue(featureValueTypes.get(featureReference)); + ValueProto.ValueType.Enum valueType = + this.registryRepository.getFeatureSpec(featureReference).getValueType(); - Boolean isOutsideMaxAge = - checkOutsideMaxAge(feature, entityRow, featureMaxAges.get(featureReference)); + Duration maxAge = this.registryRepository.getMaxAge(featureReference); - if (value != null) { - rowValues.put(FeatureV2.getFeatureStringRef(featureReference), value); - } else { - rowValues.put( - FeatureV2.getFeatureStringRef(featureReference), - ValueProto.Value.newBuilder().build()); - } + ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector.Builder vectorBuilder = + responseBuilder.addResultsBuilder(); - rowStatuses.put( - FeatureV2.getFeatureStringRef(featureReference), getMetadata(value, isOutsideMaxAge)); - } else { - rowValues.put( - FeatureV2.getFeatureStringRef(featureReference), - ValueProto.Value.newBuilder().build()); + for (int rowIdx = 0; rowIdx < features.size(); rowIdx++) { + feast.storage.api.retriever.Feature feature = features.get(rowIdx).get(featureIdx); + if (feature == null) { + vectorBuilder.addValues(nullValue); + vectorBuilder.addStatuses(FieldStatus.NOT_FOUND); + vectorBuilder.addEventTimestamps(nullTimestamp); + continue; + } - rowStatuses.put( - FeatureV2.getFeatureStringRef(featureReference), getMetadata(null, false)); + ValueProto.Value featureValue = feature.getFeatureValue(valueType); + if (featureValue == null) { + vectorBuilder.addValues(nullValue); + vectorBuilder.addStatuses(FieldStatus.NOT_FOUND); + vectorBuilder.addEventTimestamps(nullTimestamp); + continue; } + + vectorBuilder.addValues(featureValue); + vectorBuilder.addStatuses( + getFeatureStatus(featureValue, checkOutsideMaxAge(feature, now, maxAge))); + vectorBuilder.addEventTimestamps(feature.getEventTimestamp()); } - // Populate metrics/log request - populateCountMetrics(rowStatuses, projectName); + + populateCountMetrics(featureReference, vectorBuilder); } + responseBuilder.setMetadata( + ServingAPIProto.GetOnlineFeaturesResponseMetadata.newBuilder() + .setFeatureNames( + ServingAPIProto.FeatureList.newBuilder() + .addAllVal( + retrievedFeatureReferences.stream() + .map(Feature::getFeatureReference) + .collect(Collectors.toList())))); + if (postProcessingSpan != null) { postProcessingSpan.finish(); } - populateHistogramMetrics(entityRows, featureReferences, projectName); - populateFeatureCountMetrics(featureReferences, projectName); - - // Handle ODFVs. For each ODFV reference, we send a TransformFeaturesRequest to the FTS. - // The request should contain the entity data, the retrieved features, and the request data. if (!onDemandFeatureReferences.isEmpty()) { - // Augment values, which contains the entity data and retrieved features, with the request - // data. Also augment statuses. - for (int i = 0; i < values.size(); i++) { - Map rowValues = values.get(i); - Map rowStatuses = statuses.get(i); - - for (Map.Entry> entry : requestDataFeatures.entrySet()) { - String key = entry.getKey(); - List fieldValues = entry.getValue(); - rowValues.put(key, fieldValues.get(i)); - rowStatuses.put(key, GetOnlineFeaturesResponse.FieldStatus.PRESENT); - } - } + // Handle ODFVs. For each ODFV reference, we send a TransformFeaturesRequest to the FTS. + // The request should contain the entity data, the retrieved features, and the request context + // data. + this.populateOnDemandFeatures( + onDemandFeatureReferences, + onDemandFeatureInputs, + retrievedFeatureReferences, + request, + features, + responseBuilder); + } - // Serialize the augmented values. - ValueType transformationInput = - this.onlineTransformationService.serializeValuesIntoArrowIPC(values); - - // Send out requests to the FTS and process the responses. - Set onDemandFeatureStringReferences = - onDemandFeatureReferences.stream() - .map(r -> FeatureV2.getFeatureStringRef(r)) - .collect(Collectors.toSet()); - for (FeatureReferenceV2 featureReference : onDemandFeatureReferences) { - String onDemandFeatureViewName = featureReference.getFeatureTable(); - TransformFeaturesRequest transformFeaturesRequest = - TransformFeaturesRequest.newBuilder() - .setOnDemandFeatureViewName(onDemandFeatureViewName) - .setProject(projectName) - .setTransformationInput(transformationInput) - .build(); - - TransformFeaturesResponse transformFeaturesResponse = - this.onlineTransformationService.transformFeatures(transformFeaturesRequest); - - this.onlineTransformationService.processTransformFeaturesResponse( - transformFeaturesResponse, - onDemandFeatureViewName, - onDemandFeatureStringReferences, - values, - statuses); - } + populateHistogramMetrics(entityRows, retrievedFeatureReferences); + populateFeatureCountMetrics(retrievedFeatureReferences); - // Remove all features that were added as inputs for ODFVs. - Set addedFeatureStringReferences = - addedFeatureReferences.stream() - .map(r -> FeatureV2.getFeatureStringRef(r)) - .collect(Collectors.toSet()); - for (int i = 0; i < values.size(); i++) { - Map rowValues = values.get(i); - Map rowStatuses = statuses.get(i); - List keysToRemove = - rowValues.keySet().stream() - .filter(k -> addedFeatureStringReferences.contains(k)) - .collect(Collectors.toList()); - for (String key : keysToRemove) { - rowValues.remove(key); - rowStatuses.remove(key); + return responseBuilder.build(); + } + + private List getFeaturesList( + ServingAPIProto.GetOnlineFeaturesRequest request) { + if (request.getFeatures().getValCount() > 0) { + return request.getFeatures().getValList().stream() + .map(Feature::parseFeatureReference) + .collect(Collectors.toList()); + } + + FeatureServiceProto.FeatureServiceSpec featureServiceSpec = + this.registryRepository.getFeatureServiceSpec(request.getFeatureService()); + + return featureServiceSpec.getFeaturesList().stream() + .flatMap( + featureViewProjection -> + featureViewProjection.getFeatureColumnsList().stream() + .map( + f -> + FeatureReferenceV2.newBuilder() + .setFeatureViewName(featureViewProjection.getFeatureViewName()) + .setFeatureName(f.getName()) + .build())) + .collect(Collectors.toList()); + } + + private List> getEntityRows( + ServingAPIProto.GetOnlineFeaturesRequest request) { + if (request.getEntitiesCount() == 0) { + throw new RuntimeException("Entities map shouldn't be empty"); + } + + Set entityNames = request.getEntitiesMap().keySet(); + String firstEntity = entityNames.stream().findFirst().get(); + int rowsCount = request.getEntitiesMap().get(firstEntity).getValCount(); + List> entityRows = Lists.newArrayListWithExpectedSize(rowsCount); + + for (Map.Entry entity : request.getEntitiesMap().entrySet()) { + for (int i = 0; i < rowsCount; i++) { + if (entityRows.size() < i + 1) { + entityRows.add(i, Maps.newHashMapWithExpectedSize(entityNames.size())); } + + entityRows.get(i).put(entity.getKey(), entity.getValue().getVal(i)); } } - // Build response field values from entityValuesMap and entityStatusesMap - // Response field values should be in the same order as the entityRows provided by the user. - List fieldValuesList = - IntStream.range(0, entityRows.size()) - .mapToObj( - entityRowIdx -> - GetOnlineFeaturesResponse.FieldValues.newBuilder() - .putAllFields(values.get(entityRowIdx)) - .putAllStatuses(statuses.get(entityRowIdx)) - .build()) + return entityRows; + } + + private void populateOnDemandFeatures( + List onDemandFeatureReferences, + List onDemandFeatureInputs, + List retrievedFeatureReferences, + ServingAPIProto.GetOnlineFeaturesRequest request, + List> features, + ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder) { + + List>> onDemandContext = + request.getRequestContextMap().entrySet().stream() + .map(e -> Pair.of(e.getKey(), e.getValue().getValList())) .collect(Collectors.toList()); - return GetOnlineFeaturesResponse.newBuilder().addAllFieldValues(fieldValuesList).build(); - } + for (int featureIdx = 0; featureIdx < retrievedFeatureReferences.size(); featureIdx++) { + FeatureReferenceV2 featureReference = retrievedFeatureReferences.get(featureIdx); + if (!onDemandFeatureInputs.contains(featureReference)) { + continue; + } + + ValueProto.ValueType.Enum valueType = + this.registryRepository.getFeatureSpec(featureReference).getValueType(); + + List valueList = Lists.newArrayListWithExpectedSize(features.size()); + for (int rowIdx = 0; rowIdx < features.size(); rowIdx++) { + valueList.add(features.get(rowIdx).get(featureIdx).getFeatureValue(valueType)); + } + + onDemandContext.add(Pair.of(Feature.getFeatureReference(featureReference), valueList)); + } + // Serialize the augmented values. + ValueType transformationInput = + this.onlineTransformationService.serializeValuesIntoArrowIPC(onDemandContext); + + // Send out requests to the FTS and process the responses. + Set onDemandFeatureStringReferences = + onDemandFeatureReferences.stream() + .map(r -> Feature.getFeatureReference(r)) + .collect(Collectors.toSet()); + + for (FeatureReferenceV2 featureReference : onDemandFeatureReferences) { + String onDemandFeatureViewName = featureReference.getFeatureViewName(); + TransformFeaturesRequest transformFeaturesRequest = + TransformFeaturesRequest.newBuilder() + .setOnDemandFeatureViewName(onDemandFeatureViewName) + .setTransformationInput(transformationInput) + .build(); + + TransformFeaturesResponse transformFeaturesResponse = + this.onlineTransformationService.transformFeatures(transformFeaturesRequest); + + this.onlineTransformationService.processTransformFeaturesResponse( + transformFeaturesResponse, + onDemandFeatureViewName, + onDemandFeatureStringReferences, + responseBuilder); + } + } /** * Generate Field level Status metadata for the given valueMap. * @@ -317,17 +323,16 @@ public GetOnlineFeaturesResponse getOnlineFeatures(GetOnlineFeaturesRequestV2 re * @return a 1:1 map keyed by field name containing field status metadata instead of values in the * given valueMap. */ - private static GetOnlineFeaturesResponse.FieldStatus getMetadata( - ValueProto.Value value, boolean isOutsideMaxAge) { + private static FieldStatus getFeatureStatus(ValueProto.Value value, boolean isOutsideMaxAge) { if (value == null) { - return GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND; + return FieldStatus.NOT_FOUND; } else if (isOutsideMaxAge) { - return GetOnlineFeaturesResponse.FieldStatus.OUTSIDE_MAX_AGE; + return FieldStatus.OUTSIDE_MAX_AGE; } else if (value.getValCase().equals(ValueProto.Value.ValCase.VAL_NOT_SET)) { - return GetOnlineFeaturesResponse.FieldStatus.NULL_VALUE; + return FieldStatus.NULL_VALUE; } - return GetOnlineFeaturesResponse.FieldStatus.PRESENT; + return FieldStatus.PRESENT; } /** @@ -336,17 +341,17 @@ private static GetOnlineFeaturesResponse.FieldStatus getMetadata( * in entity row exceeds FeatureTable max age. * * @param feature contains the ingestion timing and feature data. - * @param entityRow contains the retrieval timing of when features are pulled. + * @param entityTimestamp contains the retrieval timing of when features are pulled. * @param maxAge feature's max age. */ private static boolean checkOutsideMaxAge( - Feature feature, GetOnlineFeaturesRequestV2.EntityRow entityRow, Duration maxAge) { + feast.storage.api.retriever.Feature feature, Timestamp entityTimestamp, Duration maxAge) { if (maxAge.equals(Duration.getDefaultInstance())) { // max age is not set return false; } - long givenTimestamp = entityRow.getTimestamp().getSeconds(); + long givenTimestamp = entityTimestamp.getSeconds(); if (givenTimestamp == 0) { givenTimestamp = System.currentTimeMillis() / 1000; } @@ -359,54 +364,45 @@ private static boolean checkOutsideMaxAge( * * @param entityRows entity rows provided in request * @param featureReferences feature references provided in request - * @param project project name provided in request */ private void populateHistogramMetrics( - List entityRows, - List featureReferences, - String project) { + List> entityRows, List featureReferences) { Metrics.requestEntityCountDistribution - .labels(project) + .labels(this.project) .observe(Double.valueOf(entityRows.size())); Metrics.requestFeatureCountDistribution - .labels(project) + .labels(this.project) .observe(Double.valueOf(featureReferences.size())); - - long countDistinctFeatureTables = - featureReferences.stream() - .map(featureReference -> getFeatureTableStringRef(project, featureReference)) - .distinct() - .count(); - Metrics.requestFeatureTableCountDistribution - .labels(project) - .observe(Double.valueOf(countDistinctFeatureTables)); } /** * Populate count metrics that can be used for analysing online retrieval calls * - * @param statusMap Statuses of features which have been requested - * @param project Project where request for features was called from + * @param featureRef singe Feature Reference + * @param featureVector Feature Vector built for this requested feature */ private void populateCountMetrics( - Map statusMap, String project) { - statusMap.forEach( - (featureRefString, status) -> { - if (status == GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND) { - Metrics.notFoundKeyCount.labels(project, featureRefString).inc(); - } - if (status == GetOnlineFeaturesResponse.FieldStatus.OUTSIDE_MAX_AGE) { - Metrics.staleKeyCount.labels(project, featureRefString).inc(); - } - }); + FeatureReferenceV2 featureRef, + ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVectorOrBuilder featureVector) { + String featureRefString = Feature.getFeatureReference(featureRef); + featureVector + .getStatusesList() + .forEach( + (status) -> { + if (status == FieldStatus.NOT_FOUND) { + Metrics.notFoundKeyCount.labels(this.project, featureRefString).inc(); + } + if (status == FieldStatus.OUTSIDE_MAX_AGE) { + Metrics.staleKeyCount.labels(this.project, featureRefString).inc(); + } + }); } - private void populateFeatureCountMetrics( - List featureReferences, String project) { + private void populateFeatureCountMetrics(List featureReferences) { featureReferences.forEach( featureReference -> Metrics.requestFeatureCount - .labels(project, FeatureV2.getFeatureStringRef(featureReference)) + .labels(project, Feature.getFeatureReference(featureReference)) .inc()); } } diff --git a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java index 23ee9854b23..bfe717aa96a 100644 --- a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java +++ b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java @@ -16,8 +16,10 @@ */ package feast.serving.service; +import com.google.common.collect.Lists; import com.google.protobuf.ByteString; -import feast.common.models.FeatureV2; +import com.google.protobuf.Timestamp; +import feast.common.models.Feature; import feast.proto.core.DataSourceProto; import feast.proto.core.FeatureProto; import feast.proto.core.FeatureViewProto; @@ -82,13 +84,13 @@ public TransformFeaturesResponse transformFeatures( @Override public Pair, List> extractRequestDataFeatureNamesAndOnDemandFeatureInputs( - List onDemandFeatureReferences, String projectName) { + List onDemandFeatureReferences) { Set requestDataFeatureNames = new HashSet(); List onDemandFeatureInputs = new ArrayList(); for (ServingAPIProto.FeatureReferenceV2 featureReference : onDemandFeatureReferences) { OnDemandFeatureViewProto.OnDemandFeatureViewSpec onDemandFeatureViewSpec = - this.registryRepository.getOnDemandFeatureViewSpec(projectName, featureReference); + this.registryRepository.getOnDemandFeatureViewSpec(featureReference); Map inputs = onDemandFeatureViewSpec.getInputsMap(); @@ -110,8 +112,8 @@ public TransformFeaturesResponse transformFeatures( String featureName = featureSpec.getName(); ServingAPIProto.FeatureReferenceV2 onDemandFeatureInput = ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable(featureViewName) - .setName(featureName) + .setFeatureViewName(featureViewName) + .setFeatureName(featureName) .build(); onDemandFeatureInputs.add(onDemandFeatureInput); } @@ -187,8 +189,7 @@ public void processTransformFeaturesResponse( transformFeaturesResponse, String onDemandFeatureViewName, Set onDemandFeatureStringReferences, - List> values, - List> statuses) { + ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder) { try { BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); ArrowFileReader reader = @@ -203,6 +204,7 @@ public void processTransformFeaturesResponse( VectorSchemaRoot readBatch = reader.getVectorSchemaRoot(); Schema responseSchema = readBatch.getSchema(); List responseFields = responseSchema.getFields(); + Timestamp now = Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000).build(); for (Field field : responseFields) { String columnName = field.getName(); @@ -217,6 +219,9 @@ public void processTransformFeaturesResponse( FieldVector fieldVector = readBatch.getVector(field); int valueCount = fieldVector.getValueCount(); + ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector.Builder vectorBuilder = + responseBuilder.addResultsBuilder(); + List valueList = Lists.newArrayListWithExpectedSize(valueCount); // TODO: support all Feast types // TODO: clean up the switch statement @@ -226,27 +231,13 @@ public void processTransformFeaturesResponse( case INT64_BITWIDTH: for (int i = 0; i < valueCount; i++) { long int64Value = ((BigIntVector) fieldVector).get(i); - Map rowValues = values.get(i); - Map rowStatuses = - statuses.get(i); - ValueProto.Value value = - ValueProto.Value.newBuilder().setInt64Val(int64Value).build(); - rowValues.put(fullFeatureName, value); - rowStatuses.put( - fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + valueList.add(ValueProto.Value.newBuilder().setInt64Val(int64Value).build()); } break; case INT32_BITWIDTH: for (int i = 0; i < valueCount; i++) { - int intValue = ((IntVector) fieldVector).get(i); - Map rowValues = values.get(i); - Map rowStatuses = - statuses.get(i); - ValueProto.Value value = - ValueProto.Value.newBuilder().setInt32Val(intValue).build(); - rowValues.put(fullFeatureName, value); - rowStatuses.put( - fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + int int32Value = ((IntVector) fieldVector).get(i); + valueList.add(ValueProto.Value.newBuilder().setInt32Val(int32Value).build()); } break; default: @@ -265,27 +256,13 @@ public void processTransformFeaturesResponse( case DOUBLE: for (int i = 0; i < valueCount; i++) { double doubleValue = ((Float8Vector) fieldVector).get(i); - Map rowValues = values.get(i); - Map rowStatuses = - statuses.get(i); - ValueProto.Value value = - ValueProto.Value.newBuilder().setDoubleVal(doubleValue).build(); - rowValues.put(fullFeatureName, value); - rowStatuses.put( - fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + valueList.add(ValueProto.Value.newBuilder().setDoubleVal(doubleValue).build()); } break; case SINGLE: for (int i = 0; i < valueCount; i++) { float floatValue = ((Float4Vector) fieldVector).get(i); - Map rowValues = values.get(i); - Map rowStatuses = - statuses.get(i); - ValueProto.Value value = - ValueProto.Value.newBuilder().setFloatVal(floatValue).build(); - rowValues.put(fullFeatureName, value); - rowStatuses.put( - fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + valueList.add(ValueProto.Value.newBuilder().setFloatVal(floatValue).build()); } break; default: @@ -299,6 +276,14 @@ public void processTransformFeaturesResponse( .asRuntimeException(); } } + + for (ValueProto.Value v : valueList) { + vectorBuilder.addValues(v); + vectorBuilder.addStatuses(ServingAPIProto.FieldStatus.PRESENT); + vectorBuilder.addEventTimestamps(now); + } + + responseBuilder.getMetadataBuilder().getFeatureNamesBuilder().addVal(fullFeatureName); } } catch (IOException e) { log.info(e.toString()); @@ -310,30 +295,52 @@ public void processTransformFeaturesResponse( } /** {@inheritDoc} */ - public ValueType serializeValuesIntoArrowIPC(List> values) { + public ValueType serializeValuesIntoArrowIPC(List>> values) { // In order to be serialized correctly, the data must be packaged in a VectorSchemaRoot. // We first construct all the columns. Map columnNameToColumn = new HashMap(); BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); - Map firstAugmentedRowValues = values.get(0); - for (Map.Entry entry : firstAugmentedRowValues.entrySet()) { + + List columnFields = new ArrayList(); + List columns = new ArrayList(); + + for (Pair> columnEntry : values) { // The Python FTS does not expect full feature names, so we extract the feature name. - String columnName = FeatureV2.getFeatureName(entry.getKey()); - ValueProto.Value.ValCase valCase = entry.getValue().getValCase(); + String columnName = Feature.getFeatureName(columnEntry.getKey()); + + List columnValues = columnEntry.getValue(); FieldVector column; + ValueProto.Value.ValCase valCase = columnValues.get(0).getValCase(); // TODO: support all Feast types switch (valCase) { case INT32_VAL: column = new IntVector(columnName, allocator); + column.setValueCount(columnValues.size()); + for (int idx = 0; idx < columnValues.size(); idx++) { + ((IntVector) column).set(idx, columnValues.get(idx).getInt32Val()); + } break; case INT64_VAL: column = new BigIntVector(columnName, allocator); + column.setValueCount(columnValues.size()); + for (int idx = 0; idx < columnValues.size(); idx++) { + ((BigIntVector) column).set(idx, columnValues.get(idx).getInt64Val()); + } + break; case DOUBLE_VAL: column = new Float8Vector(columnName, allocator); + column.setValueCount(columnValues.size()); + for (int idx = 0; idx < columnValues.size(); idx++) { + ((Float8Vector) column).set(idx, columnValues.get(idx).getInt64Val()); + } break; case FLOAT_VAL: column = new Float4Vector(columnName, allocator); + column.setValueCount(columnValues.size()); + for (int idx = 0; idx < columnValues.size(); idx++) { + ((Float4Vector) column).set(idx, columnValues.get(idx).getInt64Val()); + } break; default: throw Status.INTERNAL @@ -341,53 +348,11 @@ public ValueType serializeValuesIntoArrowIPC(List> "Column " + columnName + " has a type that is currently not handled: " + valCase) .asRuntimeException(); } - column.allocateNew(); - columnNameToColumn.put(columnName, column); - } - - // Add the data, row by row. - for (int i = 0; i < values.size(); i++) { - Map augmentedRowValues = values.get(i); - - for (Map.Entry entry : augmentedRowValues.entrySet()) { - String columnName = FeatureV2.getFeatureName(entry.getKey()); - ValueProto.Value value = entry.getValue(); - ValueProto.Value.ValCase valCase = value.getValCase(); - FieldVector column = columnNameToColumn.get(columnName); - // TODO: support all Feast types - switch (valCase) { - case INT32_VAL: - ((IntVector) column).setSafe(i, value.getInt32Val()); - break; - case INT64_VAL: - ((BigIntVector) column).setSafe(i, value.getInt64Val()); - break; - case DOUBLE_VAL: - ((Float8Vector) column).setSafe(i, value.getDoubleVal()); - break; - case FLOAT_VAL: - ((Float4Vector) column).setSafe(i, value.getFloatVal()); - break; - default: - throw Status.INTERNAL - .withDescription( - "Column " - + columnName - + " has a type that is currently not handled: " - + valCase) - .asRuntimeException(); - } - } - } - // Construct the VectorSchemaRoot. - List columnFields = new ArrayList(); - List columns = new ArrayList(); - for (FieldVector column : columnNameToColumn.values()) { - column.setValueCount(values.size()); - columnFields.add(column.getField()); columns.add(column); + columnFields.add(column.getField()); } + VectorSchemaRoot schemaRoot = new VectorSchemaRoot(columnFields, columns); // Serialize the VectorSchemaRoot into Arrow IPC format. diff --git a/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java b/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java index 05acb31b78e..096b155a0ec 100644 --- a/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java +++ b/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java @@ -17,8 +17,6 @@ package feast.serving.service; import feast.proto.serving.ServingAPIProto; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; public interface ServingServiceV2 { /** @@ -36,23 +34,15 @@ ServingAPIProto.GetFeastServingInfoResponse getFeastServingInfo( /** * Get features from an online serving store, given a list of {@link - * feast.proto.serving.ServingAPIProto.FeatureReferenceV2}s to retrieve, and list of {@link - * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow}s to join the - * retrieved values to. - * - *

Features can be queried across feature tables, but each {@link - * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow} must contain all - * entities for all feature tables included in the request. + * feast.proto.serving.ServingAPIProto.FeatureReferenceV2}s to retrieve or name of the feature + * service, and vectorized entities Map<String, {@link + * feast.proto.types.ValueProto.RepeatedValue}> to join the retrieved values to. * *

This request is fulfilled synchronously. * - * @param getFeaturesRequest {@link GetOnlineFeaturesRequestV2} containing list of {@link - * feast.proto.serving.ServingAPIProto.FeatureReferenceV2}s to retrieve and list of {@link - * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow}s to join the - * retrieved values to. - * @return {@link GetOnlineFeaturesResponse} with list of {@link - * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues} for each {@link - * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow} supplied. + * @return {@link feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2} with list of + * {@link feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector}. */ - GetOnlineFeaturesResponse getOnlineFeatures(GetOnlineFeaturesRequestV2 getFeaturesRequest); + ServingAPIProto.GetOnlineFeaturesResponseV2 getOnlineFeatures( + ServingAPIProto.GetOnlineFeaturesRequest getFeaturesRequest); } diff --git a/java/serving/src/main/java/feast/serving/service/TransformationService.java b/java/serving/src/main/java/feast/serving/service/TransformationService.java index caa52793020..36cce43e0d2 100644 --- a/java/serving/src/main/java/feast/serving/service/TransformationService.java +++ b/java/serving/src/main/java/feast/serving/service/TransformationService.java @@ -18,7 +18,6 @@ import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesRequest; import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesResponse; import feast.proto.serving.TransformationServiceAPIProto.ValueType; @@ -42,13 +41,12 @@ public interface TransformationService { * list of ODFV references. * * @param onDemandFeatureReferences list of ODFV references to be parsed - * @param projectName project name * @return a pair containing the set of request data feature names and list of on demand feature * inputs */ Pair, List> extractRequestDataFeatureNamesAndOnDemandFeatureInputs( - List onDemandFeatureReferences, String projectName); + List onDemandFeatureReferences); /** * Separate the entity rows of a request into entity data and request feature data. @@ -68,15 +66,13 @@ public interface TransformationService { * @param transformFeaturesResponse response to be processed * @param onDemandFeatureViewName name of ODFV to which the response corresponds * @param onDemandFeatureStringReferences set of all ODFV references that should be kept - * @param values list of field maps to be augmented with additional fields from the response - * @param statuses list of status maps to be augmented + * @param responseBuilder {@link ServingAPIProto.GetOnlineFeaturesResponseV2.Builder} */ void processTransformFeaturesResponse( TransformFeaturesResponse transformFeaturesResponse, String onDemandFeatureViewName, Set onDemandFeatureStringReferences, - List> values, - List> statuses); + ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder); /** * Serialize data into Arrow IPC format, to be sent to the Python feature transformation server. @@ -84,5 +80,5 @@ void processTransformFeaturesResponse( * @param values list of field maps to be serialized * @return the data packaged into a ValueType proto object */ - ValueType serializeValuesIntoArrowIPC(List> values); + ValueType serializeValuesIntoArrowIPC(List>> values); } diff --git a/java/serving/src/main/java/feast/serving/util/RequestHelper.java b/java/serving/src/main/java/feast/serving/util/RequestHelper.java index 4d478f430f2..f730e019821 100644 --- a/java/serving/src/main/java/feast/serving/util/RequestHelper.java +++ b/java/serving/src/main/java/feast/serving/util/RequestHelper.java @@ -16,27 +16,28 @@ */ package feast.serving.util; +import feast.common.models.Feature; +import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; public class RequestHelper { - public static void validateOnlineRequest(GetOnlineFeaturesRequestV2 request) { + public static void validateOnlineRequest(ServingAPIProto.GetOnlineFeaturesRequest request) { // All EntityRows should not be empty - if (request.getEntityRowsCount() <= 0) { + if (request.getEntitiesCount() <= 0) { throw new IllegalArgumentException("Entity value must be provided"); } // All FeatureReferences should have FeatureTable name and Feature name - for (FeatureReferenceV2 featureReference : request.getFeaturesList()) { - validateOnlineRequestFeatureReference(featureReference); + for (String featureReference : request.getFeatures().getValList()) { + validateOnlineRequestFeatureReference(Feature.parseFeatureReference(featureReference)); } } public static void validateOnlineRequestFeatureReference(FeatureReferenceV2 featureReference) { - if (featureReference.getFeatureTable().isEmpty()) { + if (featureReference.getFeatureViewName().isEmpty()) { throw new IllegalArgumentException("FeatureTable name must be provided in FeatureReference"); } - if (featureReference.getName().isEmpty()) { + if (featureReference.getFeatureName().isEmpty()) { throw new IllegalArgumentException("Feature name must be provided in FeatureReference"); } } diff --git a/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java b/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java index 238df549513..1e82bf864c0 100644 --- a/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java +++ b/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java @@ -16,8 +16,7 @@ */ package feast.serving.util.mappers; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.serving.ServingAPIProto; import feast.proto.types.ValueProto.Value; import java.util.List; import java.util.Map; @@ -27,15 +26,23 @@ public class ResponseJSONMapper { public static List> mapGetOnlineFeaturesResponse( - GetOnlineFeaturesResponse response) { - return response.getFieldValuesList().stream() + ServingAPIProto.GetOnlineFeaturesResponseV2 response) { + return response.getResultsList().stream() .map(fieldValues -> convertFieldValuesToMap(fieldValues)) .collect(Collectors.toList()); } - private static Map convertFieldValuesToMap(FieldValues fieldValues) { - return fieldValues.getFieldsMap().entrySet().stream() - .collect(Collectors.toMap(es -> es.getKey(), es -> extractValue(es.getValue()))); + private static Map convertFieldValuesToMap( + ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector vec) { + return Map.of( + "values", + vec.getValuesList().stream() + .map(ResponseJSONMapper::extractValue) + .collect(Collectors.toList()), + "statuses", + vec.getStatusesList(), + "event_timestamp", + vec.getEventTimestampsList()); } private static Object extractValue(Value value) { diff --git a/java/serving/src/main/resources/application.yml b/java/serving/src/main/resources/application.yml index 4fba32a0ae6..1f6d5b34c43 100644 --- a/java/serving/src/main/resources/application.yml +++ b/java/serving/src/main/resources/application.yml @@ -1,4 +1,5 @@ feast: + project: "" registry: "prompt_dory/data/registry.db" registryRefreshInterval: 0 diff --git a/java/serving/src/test/java/feast/serving/it/ServingBase.java b/java/serving/src/test/java/feast/serving/it/ServingBase.java deleted file mode 100644 index 3a42f9a85e5..00000000000 --- a/java/serving/src/test/java/feast/serving/it/ServingBase.java +++ /dev/null @@ -1,307 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2021 The Feast Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feast.serving.it; - -import static org.awaitility.Awaitility.await; -import static org.hamcrest.Matchers.equalTo; -import static org.junit.jupiter.api.Assertions.*; - -import com.google.common.collect.ImmutableList; -import com.google.inject.*; -import com.google.inject.Module; -import com.google.inject.util.Modules; -import com.google.protobuf.Timestamp; -import feast.proto.core.FeatureProto; -import feast.proto.core.FeatureViewProto; -import feast.proto.core.RegistryProto; -import feast.proto.serving.ServingAPIProto; -import feast.proto.serving.ServingServiceGrpc; -import feast.proto.types.ValueProto; -import feast.serving.config.*; -import feast.serving.grpc.OnlineServingGrpcServiceV2; -import feast.serving.util.DataGenerator; -import io.grpc.*; -import io.grpc.inprocess.InProcessChannelBuilder; -import io.grpc.inprocess.InProcessServerBuilder; -import io.grpc.protobuf.services.ProtoReflectionService; -import io.grpc.util.MutableHandlerRegistry; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.*; -import org.testcontainers.containers.DockerComposeContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.junit.jupiter.Testcontainers; - -@Testcontainers -abstract class ServingBase { - static DockerComposeContainer environment; - - ServingServiceGrpc.ServingServiceBlockingStub servingStub; - Injector injector; - String serverName; - ManagedChannel channel; - Server server; - MutableHandlerRegistry serviceRegistry; - - @BeforeAll - static void globalSetup() { - environment = - new DockerComposeContainer( - new File("src/test/resources/docker-compose/docker-compose-redis-it.yml")) - .withExposedService("redis", 6379) - .withOptions() - .waitingFor( - "materialize", - Wait.forLogMessage(".*Materialization finished.*\\n", 1) - .withStartupTimeout(Duration.ofMinutes(5))); - environment.start(); - } - - @AfterAll - static void globalTeardown() { - environment.stop(); - } - - @BeforeEach - public void envSetUp() throws Exception { - - AbstractModule appPropertiesModule = - new AbstractModule() { - @Override - protected void configure() { - bind(OnlineServingGrpcServiceV2.class); - } - - @Provides - ApplicationProperties applicationProperties() { - final ApplicationProperties p = new ApplicationProperties(); - p.setAwsRegion("us-east-1"); - - final ApplicationProperties.FeastProperties feastProperties = createFeastProperties(); - p.setFeast(feastProperties); - - final ApplicationProperties.TracingProperties tracingProperties = - new ApplicationProperties.TracingProperties(); - feastProperties.setTracing(tracingProperties); - - tracingProperties.setEnabled(false); - return p; - } - }; - - Module overrideConfig = registryConfig(); - Module registryConfig; - if (overrideConfig != null) { - registryConfig = Modules.override(new RegistryConfig()).with(registryConfig()); - } else { - registryConfig = new RegistryConfig(); - } - - injector = - Guice.createInjector( - new ServingServiceConfigV2(), - registryConfig, - new InstrumentationConfig(), - appPropertiesModule); - - OnlineServingGrpcServiceV2 onlineServingGrpcServiceV2 = - injector.getInstance(OnlineServingGrpcServiceV2.class); - - serverName = InProcessServerBuilder.generateName(); - - server = - InProcessServerBuilder.forName(serverName) - .fallbackHandlerRegistry(serviceRegistry) - .addService(onlineServingGrpcServiceV2) - .addService(ProtoReflectionService.newInstance()) - .build(); - server.start(); - - channel = InProcessChannelBuilder.forName(serverName).usePlaintext().directExecutor().build(); - - servingStub = - ServingServiceGrpc.newBlockingStub(channel) - .withDeadlineAfter(5, TimeUnit.SECONDS) - .withWaitForReady(); - } - - @AfterEach - public void envTeardown() throws Exception { - // assume channel and server are not null - channel.shutdown(); - server.shutdown(); - // fail the test if cannot gracefully shutdown - try { - assert channel.awaitTermination(5, TimeUnit.SECONDS) - : "channel cannot be gracefully shutdown"; - assert server.awaitTermination(5, TimeUnit.SECONDS) : "server cannot be gracefully shutdown"; - } finally { - channel.shutdownNow(); - server.shutdownNow(); - } - } - - protected ServingAPIProto.GetOnlineFeaturesRequestV2 buildOnlineRequest(int driverId) { - // getOnlineFeatures Information - String projectName = "feast_project"; - String entityName = "driver_id"; - - // Instantiate EntityRows - final Timestamp timestamp = Timestamp.getDefaultInstance(); - ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow entityRow1 = - DataGenerator.createEntityRow( - entityName, DataGenerator.createInt64Value(driverId), timestamp.getSeconds()); - ImmutableList entityRows = - ImmutableList.of(entityRow1); - - // Instantiate FeatureReferences - ServingAPIProto.FeatureReferenceV2 feature1Reference = - DataGenerator.createFeatureReference("driver_hourly_stats", "conv_rate"); - ServingAPIProto.FeatureReferenceV2 feature2Reference = - DataGenerator.createFeatureReference("driver_hourly_stats", "avg_daily_trips"); - ImmutableList featureReferences = - ImmutableList.of(feature1Reference, feature2Reference); - - // Build GetOnlineFeaturesRequestV2 - return TestUtils.createOnlineFeatureRequest(projectName, featureReferences, entityRows); - } - - static RegistryProto.Registry registryProto = readLocalRegistry(); - - private static RegistryProto.Registry readLocalRegistry() { - try { - return RegistryProto.Registry.parseFrom( - Files.readAllBytes(Paths.get("src/test/resources/docker-compose/feast10/registry.db"))); - } catch (IOException e) { - e.printStackTrace(); - } - - return null; - } - - @Test - public void shouldGetOnlineFeatures() { - ServingAPIProto.GetOnlineFeaturesRequestV2 req = buildOnlineRequest(1005); - ServingAPIProto.GetOnlineFeaturesResponse featureResponse = - servingStub.withDeadlineAfter(1000, TimeUnit.MILLISECONDS).getOnlineFeaturesV2(req); - - assertEquals(1, featureResponse.getFieldValuesCount()); - - final ServingAPIProto.GetOnlineFeaturesResponse.FieldValues fieldValue = - featureResponse.getFieldValues(0); - for (final String key : - ImmutableList.of( - "driver_hourly_stats:avg_daily_trips", "driver_hourly_stats:conv_rate", "driver_id")) { - assertTrue(fieldValue.containsFields(key)); - assertTrue(fieldValue.containsStatuses(key)); - assertEquals( - ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT, - fieldValue.getStatusesOrThrow(key)); - } - - assertEquals( - 500, fieldValue.getFieldsOrThrow("driver_hourly_stats:avg_daily_trips").getInt64Val()); - assertEquals(1005, fieldValue.getFieldsOrThrow("driver_id").getInt64Val()); - assertEquals( - 0.5, fieldValue.getFieldsOrThrow("driver_hourly_stats:conv_rate").getDoubleVal(), 0.0001); - } - - @Test - public void shouldGetOnlineFeaturesWithOutsideMaxAgeStatus() { - ServingAPIProto.GetOnlineFeaturesResponse featureResponse = - servingStub.getOnlineFeaturesV2(buildOnlineRequest(1001)); - - assertEquals(1, featureResponse.getFieldValuesCount()); - - final ServingAPIProto.GetOnlineFeaturesResponse.FieldValues fieldValue = - featureResponse.getFieldValues(0); - for (final String key : - ImmutableList.of("driver_hourly_stats:avg_daily_trips", "driver_hourly_stats:conv_rate")) { - assertTrue(fieldValue.containsFields(key)); - assertTrue(fieldValue.containsStatuses(key)); - assertEquals( - ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.OUTSIDE_MAX_AGE, - fieldValue.getStatusesOrThrow(key)); - } - - assertEquals( - 100, fieldValue.getFieldsOrThrow("driver_hourly_stats:avg_daily_trips").getInt64Val()); - assertEquals(1001, fieldValue.getFieldsOrThrow("driver_id").getInt64Val()); - assertEquals( - 0.1, fieldValue.getFieldsOrThrow("driver_hourly_stats:conv_rate").getDoubleVal(), 0.0001); - } - - @Test - public void shouldGetOnlineFeaturesWithNotFoundStatus() { - ServingAPIProto.GetOnlineFeaturesResponse featureResponse = - servingStub.getOnlineFeaturesV2(buildOnlineRequest(-1)); - - assertEquals(1, featureResponse.getFieldValuesCount()); - - final ServingAPIProto.GetOnlineFeaturesResponse.FieldValues fieldValue = - featureResponse.getFieldValues(0); - for (final String key : - ImmutableList.of("driver_hourly_stats:avg_daily_trips", "driver_hourly_stats:conv_rate")) { - assertTrue(fieldValue.containsFields(key)); - assertTrue(fieldValue.containsStatuses(key)); - assertEquals( - ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND, - fieldValue.getStatusesOrThrow(key)); - } - } - - @Test - public void shouldRefreshRegistryAndServeNewFeatures() throws InterruptedException { - updateRegistryFile( - registryProto - .toBuilder() - .addFeatureViews( - FeatureViewProto.FeatureView.newBuilder() - .setSpec( - FeatureViewProto.FeatureViewSpec.newBuilder() - .setName("new_view") - .addEntities("driver_id") - .addFeatures( - FeatureProto.FeatureSpecV2.newBuilder() - .setName("new_feature") - .setValueType(ValueProto.ValueType.Enum.BOOL)))) - .build()); - - ServingAPIProto.GetOnlineFeaturesRequestV2 request = - buildOnlineRequest(1005) - .toBuilder() - .addFeatures(DataGenerator.createFeatureReference("new_view", "new_feature")) - .build(); - - await() - .ignoreException(StatusRuntimeException.class) - .atMost(5, TimeUnit.SECONDS) - .until(() -> servingStub.getOnlineFeaturesV2(request).getFieldValuesCount(), equalTo(1)); - } - - abstract ApplicationProperties.FeastProperties createFeastProperties(); - - AbstractModule registryConfig() { - return null; - } - - abstract void updateRegistryFile(RegistryProto.Registry registry); -} diff --git a/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java b/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java new file mode 100644 index 00000000000..4d4272324e2 --- /dev/null +++ b/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java @@ -0,0 +1,161 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.serving.it; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.*; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureViewProto; +import feast.proto.core.RegistryProto; +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.FieldStatus; +import feast.proto.types.ValueProto; +import feast.serving.util.DataGenerator; +import io.grpc.StatusRuntimeException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.*; + +abstract class ServingBaseTests extends ServingEnvironment { + + protected ServingAPIProto.GetOnlineFeaturesRequest buildOnlineRequest(int driverId) { + // getOnlineFeatures Information + String entityName = "driver_id"; + + // Instantiate EntityRows + Map entityRows = + ImmutableMap.of( + entityName, + ValueProto.RepeatedValue.newBuilder() + .addVal(DataGenerator.createInt64Value(driverId)) + .build()); + + ImmutableList featureReferences = + ImmutableList.of("driver_hourly_stats:conv_rate", "driver_hourly_stats:avg_daily_trips"); + + // Build GetOnlineFeaturesRequestV2 + return TestUtils.createOnlineFeatureRequest(featureReferences, entityRows); + } + + static RegistryProto.Registry registryProto = readLocalRegistry(); + + private static RegistryProto.Registry readLocalRegistry() { + try { + return RegistryProto.Registry.parseFrom( + Files.readAllBytes(Paths.get("src/test/resources/docker-compose/feast10/registry.db"))); + } catch (IOException e) { + e.printStackTrace(); + } + + return null; + } + + @Test + public void shouldGetOnlineFeatures() { + ServingAPIProto.GetOnlineFeaturesResponseV2 featureResponse = + servingStub.getOnlineFeatures(buildOnlineRequest(1005)); + + assertEquals(2, featureResponse.getResultsCount()); + assertEquals(1, featureResponse.getResults(0).getValuesCount()); + + assertEquals( + ImmutableList.of("driver_hourly_stats:conv_rate", "driver_hourly_stats:avg_daily_trips"), + featureResponse.getMetadata().getFeatureNames().getValList()); + + for (int featureIdx : List.of(0, 1)) { + assertEquals( + List.of(ServingAPIProto.FieldStatus.PRESENT), + featureResponse.getResults(featureIdx).getStatusesList()); + } + + assertEquals(0.5, featureResponse.getResults(0).getValues(0).getDoubleVal(), 0.0001); + assertEquals(500, featureResponse.getResults(1).getValues(0).getInt64Val()); + } + + @Test + public void shouldGetOnlineFeaturesWithOutsideMaxAgeStatus() { + ServingAPIProto.GetOnlineFeaturesResponseV2 featureResponse = + servingStub.getOnlineFeatures(buildOnlineRequest(1001)); + + assertEquals(2, featureResponse.getResultsCount()); + assertEquals(1, featureResponse.getResults(0).getValuesCount()); + + for (int featureIdx : List.of(0, 1)) { + assertEquals( + FieldStatus.OUTSIDE_MAX_AGE, featureResponse.getResults(featureIdx).getStatuses(0)); + } + + assertEquals(0.1, featureResponse.getResults(0).getValues(0).getDoubleVal(), 0.0001); + assertEquals(100, featureResponse.getResults(1).getValues(0).getInt64Val()); + } + + @Test + public void shouldGetOnlineFeaturesWithNotFoundStatus() { + ServingAPIProto.GetOnlineFeaturesResponseV2 featureResponse = + servingStub.getOnlineFeatures(buildOnlineRequest(-1)); + + assertEquals(2, featureResponse.getResultsCount()); + assertEquals(1, featureResponse.getResults(0).getValuesCount()); + + for (final int featureIdx : List.of(0, 1)) { + assertEquals(FieldStatus.NOT_FOUND, featureResponse.getResults(featureIdx).getStatuses(0)); + } + } + + @Test + public void shouldRefreshRegistryAndServeNewFeatures() throws InterruptedException { + updateRegistryFile( + registryProto + .toBuilder() + .addFeatureViews( + FeatureViewProto.FeatureView.newBuilder() + .setSpec( + FeatureViewProto.FeatureViewSpec.newBuilder() + .setName("new_view") + .addEntities("driver_id") + .addFeatures( + FeatureProto.FeatureSpecV2.newBuilder() + .setName("new_feature") + .setValueType(ValueProto.ValueType.Enum.BOOL)))) + .build()); + + ServingAPIProto.GetOnlineFeaturesRequest request = buildOnlineRequest(1005); + + ServingAPIProto.GetOnlineFeaturesRequest requestWithNewFeature = + request + .toBuilder() + .setFeatures(request.getFeatures().toBuilder().addVal("new_view:new_feature")) + .build(); + + await() + .ignoreException(StatusRuntimeException.class) + .atMost(5, TimeUnit.SECONDS) + .until( + () -> servingStub.getOnlineFeatures(requestWithNewFeature).getResultsCount(), + equalTo(3)); + } + + abstract void updateRegistryFile(RegistryProto.Registry registry); +} diff --git a/java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java b/java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java new file mode 100644 index 00000000000..43eed2fa33d --- /dev/null +++ b/java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.serving.it; + +import com.google.api.client.util.Lists; +import com.google.common.base.Stopwatch; +import com.google.common.collect.ImmutableMap; +import com.google.common.math.Quantiles; +import feast.proto.serving.ServingAPIProto; +import feast.proto.types.ValueProto; +import feast.serving.config.ApplicationProperties; +import feast.serving.util.DataGenerator; +import java.util.List; +import java.util.LongSummaryStatistics; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ServingBenchmarkIT extends ServingEnvironment { + private Random rand = new Random(); + public static final Logger log = LoggerFactory.getLogger(ServingBenchmarkIT.class); + + private static int WARM_UP_COUNT = 10; + + @Override + ApplicationProperties.FeastProperties createFeastProperties() { + return TestUtils.createBasicFeastProperties( + environment.getServiceHost("redis", 6379), environment.getServicePort("redis", 6379)); + } + + protected ServingAPIProto.GetOnlineFeaturesRequest buildOnlineRequest( + int rowsCount, int featuresCount) { + List entities = + IntStream.range(0, rowsCount) + .mapToObj(i -> DataGenerator.createInt64Value(rand.nextInt(1000))) + .collect(Collectors.toList()); + + List featureReferences = + IntStream.range(0, featuresCount) + .mapToObj(i -> String.format("feature_view_%d:feature_%d", i / 10, i)) + .collect(Collectors.toList()); + + Map entityRows = + ImmutableMap.of( + "entity", ValueProto.RepeatedValue.newBuilder().addAllVal(entities).build()); + + return TestUtils.createOnlineFeatureRequest(featureReferences, entityRows); + } + + protected ServingAPIProto.GetOnlineFeaturesRequest buildOnlineRequest(int rowsCount) { + List entities = + IntStream.range(0, rowsCount) + .mapToObj(i -> DataGenerator.createInt64Value(rand.nextInt(1000))) + .collect(Collectors.toList()); + + Map entityRows = + ImmutableMap.of( + "entity", ValueProto.RepeatedValue.newBuilder().addAllVal(entities).build()); + + return TestUtils.createOnlineFeatureRequest("benchmark_feature_service", entityRows); + } + + @Test + public void benchmarkServing100rows10features() { + ServingAPIProto.GetOnlineFeaturesRequest req = buildOnlineRequest(100, 10); + + measure( + () -> servingStub.withDeadlineAfter(1, TimeUnit.SECONDS).getOnlineFeatures(req), + "100 rows; 10 features", + 1000); + } + + @Test + public void benchmarkServing100rows50features() { + ServingAPIProto.GetOnlineFeaturesRequest req = buildOnlineRequest(100, 50); + + measure( + () -> servingStub.withDeadlineAfter(1, TimeUnit.SECONDS).getOnlineFeatures(req), + "100 rows; 50 features", + 1000); + } + + @Test + public void benchmarkServing100rows100features() { + ServingAPIProto.GetOnlineFeaturesRequest req = buildOnlineRequest(100, 100); + + measure( + () -> servingStub.withDeadlineAfter(1, TimeUnit.SECONDS).getOnlineFeatures(req), + "100 rows; 100 features", + 1000); + } + + @Test + public void benchmarkServing100rowsFullFeatureService() { + ServingAPIProto.GetOnlineFeaturesRequest req = buildOnlineRequest(100); + + measure( + () -> servingStub.withDeadlineAfter(1, TimeUnit.SECONDS).getOnlineFeatures(req), + "100 rows; Full FS", + 1000); + } + + private void measure(Runnable target, String name, int runs) { + Stopwatch timer = Stopwatch.createUnstarted(); + + List records = Lists.newArrayList(); + + for (int i = 0; i < runs; i++) { + timer.reset(); + timer.start(); + target.run(); + timer.stop(); + if (i >= WARM_UP_COUNT) { + records.add(timer.elapsed(TimeUnit.MILLISECONDS)); + } + } + + LongSummaryStatistics summary = + records.stream().collect(Collectors.summarizingLong(Long::longValue)); + + log.info(String.format("Test %s took (min): %d ms", name, summary.getMin())); + log.info(String.format("Test %s took (avg): %f ms", name, summary.getAverage())); + log.info( + String.format("Test %s took (median): %f ms", name, Quantiles.median().compute(records))); + log.info( + String.format( + "Test %s took (95p): %f ms", name, Quantiles.percentiles().index(95).compute(records))); + log.info( + String.format( + "Test %s took (99p): %f ms", name, Quantiles.percentiles().index(99).compute(records))); + } +} diff --git a/java/serving/src/test/java/feast/serving/it/ServingEnvironment.java b/java/serving/src/test/java/feast/serving/it/ServingEnvironment.java new file mode 100644 index 00000000000..0c622d7c421 --- /dev/null +++ b/java/serving/src/test/java/feast/serving/it/ServingEnvironment.java @@ -0,0 +1,159 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.serving.it; + +import com.google.inject.*; +import com.google.inject.Module; +import com.google.inject.util.Modules; +import feast.proto.serving.ServingServiceGrpc; +import feast.serving.config.ApplicationProperties; +import feast.serving.config.InstrumentationConfig; +import feast.serving.config.RegistryConfig; +import feast.serving.config.ServingServiceConfigV2; +import feast.serving.grpc.OnlineServingGrpcServiceV2; +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.protobuf.services.ProtoReflectionService; +import io.grpc.util.MutableHandlerRegistry; +import java.io.File; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +abstract class ServingEnvironment { + static DockerComposeContainer environment; + + ServingServiceGrpc.ServingServiceBlockingStub servingStub; + Injector injector; + String serverName; + ManagedChannel channel; + Server server; + MutableHandlerRegistry serviceRegistry; + + @BeforeAll + static void globalSetup() { + environment = + new DockerComposeContainer( + new File("src/test/resources/docker-compose/docker-compose-redis-it.yml")) + .withExposedService("redis", 6379) + .withOptions() + .waitingFor( + "materialize", + Wait.forLogMessage(".*Materialization finished.*\\n", 1) + .withStartupTimeout(Duration.ofMinutes(5))); + environment.start(); + } + + @AfterAll + static void globalTeardown() { + environment.stop(); + } + + @BeforeEach + public void envSetUp() throws Exception { + + AbstractModule appPropertiesModule = + new AbstractModule() { + @Override + protected void configure() { + bind(OnlineServingGrpcServiceV2.class); + } + + @Provides + ApplicationProperties applicationProperties() { + final ApplicationProperties p = new ApplicationProperties(); + p.setAwsRegion("us-east-1"); + + final ApplicationProperties.FeastProperties feastProperties = createFeastProperties(); + p.setFeast(feastProperties); + + final ApplicationProperties.TracingProperties tracingProperties = + new ApplicationProperties.TracingProperties(); + feastProperties.setTracing(tracingProperties); + + tracingProperties.setEnabled(false); + return p; + } + }; + + Module overrideConfig = registryConfig(); + Module registryConfig; + if (overrideConfig != null) { + registryConfig = Modules.override(new RegistryConfig()).with(registryConfig()); + } else { + registryConfig = new RegistryConfig(); + } + + injector = + Guice.createInjector( + new ServingServiceConfigV2(), + registryConfig, + new InstrumentationConfig(), + appPropertiesModule); + + OnlineServingGrpcServiceV2 onlineServingGrpcServiceV2 = + injector.getInstance(OnlineServingGrpcServiceV2.class); + + serverName = InProcessServerBuilder.generateName(); + + server = + InProcessServerBuilder.forName(serverName) + .fallbackHandlerRegistry(serviceRegistry) + .addService(onlineServingGrpcServiceV2) + .addService(ProtoReflectionService.newInstance()) + .build(); + server.start(); + + channel = InProcessChannelBuilder.forName(serverName).usePlaintext().directExecutor().build(); + + servingStub = + ServingServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(5, TimeUnit.SECONDS) + .withWaitForReady(); + } + + @AfterEach + public void envTeardown() throws Exception { + // assume channel and server are not null + channel.shutdown(); + server.shutdown(); + // fail the test if cannot gracefully shutdown + try { + assert channel.awaitTermination(5, TimeUnit.SECONDS) + : "channel cannot be gracefully shutdown"; + assert server.awaitTermination(5, TimeUnit.SECONDS) : "server cannot be gracefully shutdown"; + } finally { + channel.shutdownNow(); + server.shutdownNow(); + } + } + + abstract ApplicationProperties.FeastProperties createFeastProperties(); + + AbstractModule registryConfig() { + return null; + } +} diff --git a/java/serving/src/test/java/feast/serving/it/ServingRedisGSRegistryIT.java b/java/serving/src/test/java/feast/serving/it/ServingRedisGSRegistryIT.java index 36e0eebe8d4..78871cd45c2 100644 --- a/java/serving/src/test/java/feast/serving/it/ServingRedisGSRegistryIT.java +++ b/java/serving/src/test/java/feast/serving/it/ServingRedisGSRegistryIT.java @@ -20,8 +20,6 @@ import com.google.cloud.storage.*; import com.google.cloud.storage.testing.RemoteStorageHelper; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import feast.proto.core.RegistryProto; import feast.serving.config.ApplicationProperties; import java.util.concurrent.ExecutionException; @@ -29,7 +27,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; -public class ServingRedisGSRegistryIT extends ServingBase { +public class ServingRedisGSRegistryIT extends ServingBaseTests { static Storage storage = RemoteStorageHelper.create() .getOptions() @@ -64,16 +62,9 @@ static void tearDown() throws ExecutionException, InterruptedException { @Override ApplicationProperties.FeastProperties createFeastProperties() { final ApplicationProperties.FeastProperties feastProperties = - new ApplicationProperties.FeastProperties(); + TestUtils.createBasicFeastProperties( + environment.getServiceHost("redis", 6379), environment.getServicePort("redis", 6379)); feastProperties.setRegistry(blobId.toGsUtilUri()); - feastProperties.setRegistryRefreshInterval(1); - - feastProperties.setActiveStore("online"); - - feastProperties.setStores( - ImmutableList.of( - new ApplicationProperties.Store( - "online", "REDIS", ImmutableMap.of("host", "localhost", "port", "6379")))); return feastProperties; } diff --git a/java/serving/src/test/java/feast/serving/it/ServingRedisLocalRegistryIT.java b/java/serving/src/test/java/feast/serving/it/ServingRedisLocalRegistryIT.java index 53fda39466e..c83d8dbbf1c 100644 --- a/java/serving/src/test/java/feast/serving/it/ServingRedisLocalRegistryIT.java +++ b/java/serving/src/test/java/feast/serving/it/ServingRedisLocalRegistryIT.java @@ -16,27 +16,14 @@ */ package feast.serving.it; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import feast.proto.core.RegistryProto; import feast.serving.config.ApplicationProperties; -public class ServingRedisLocalRegistryIT extends ServingBase { +public class ServingRedisLocalRegistryIT extends ServingBaseTests { @Override ApplicationProperties.FeastProperties createFeastProperties() { - final ApplicationProperties.FeastProperties feastProperties = - new ApplicationProperties.FeastProperties(); - feastProperties.setRegistry("src/test/resources/docker-compose/feast10/registry.db"); - feastProperties.setRegistryRefreshInterval(1); - - feastProperties.setActiveStore("online"); - - feastProperties.setStores( - ImmutableList.of( - new ApplicationProperties.Store( - "online", "REDIS", ImmutableMap.of("host", "localhost", "port", "6379")))); - - return feastProperties; + return TestUtils.createBasicFeastProperties( + environment.getServiceHost("redis", 6379), environment.getServicePort("redis", 6379)); } @Override diff --git a/java/serving/src/test/java/feast/serving/it/ServingRedisS3RegistryIT.java b/java/serving/src/test/java/feast/serving/it/ServingRedisS3RegistryIT.java index 648fdaa5b59..d67fbf26215 100644 --- a/java/serving/src/test/java/feast/serving/it/ServingRedisS3RegistryIT.java +++ b/java/serving/src/test/java/feast/serving/it/ServingRedisS3RegistryIT.java @@ -21,8 +21,6 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.amazonaws.services.s3.model.ObjectMetadata; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import com.google.inject.AbstractModule; import com.google.inject.Provides; import feast.proto.core.RegistryProto; @@ -31,7 +29,7 @@ import org.junit.jupiter.api.BeforeAll; import org.testcontainers.junit.jupiter.Container; -public class ServingRedisS3RegistryIT extends ServingBase { +public class ServingRedisS3RegistryIT extends ServingBaseTests { @Container static final S3MockContainer s3Mock = new S3MockContainer("2.2.3"); private static AmazonS3 createClient() { @@ -64,16 +62,9 @@ static void setUp() { @Override ApplicationProperties.FeastProperties createFeastProperties() { final ApplicationProperties.FeastProperties feastProperties = - new ApplicationProperties.FeastProperties(); + TestUtils.createBasicFeastProperties( + environment.getServiceHost("redis", 6379), environment.getServicePort("redis", 6379)); feastProperties.setRegistry("s3://test-bucket/registry.db"); - feastProperties.setRegistryRefreshInterval(1); - - feastProperties.setActiveStore("online"); - - feastProperties.setStores( - ImmutableList.of( - new ApplicationProperties.Store( - "online", "REDIS", ImmutableMap.of("host", "localhost", "port", "6379")))); return feastProperties; } diff --git a/java/serving/src/test/java/feast/serving/it/TestUtils.java b/java/serving/src/test/java/feast/serving/it/TestUtils.java index fb88b2fb372..71d15e4a89d 100644 --- a/java/serving/src/test/java/feast/serving/it/TestUtils.java +++ b/java/serving/src/test/java/feast/serving/it/TestUtils.java @@ -16,9 +16,13 @@ */ package feast.serving.it; -import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; import feast.proto.serving.ServingServiceGrpc; +import feast.proto.types.ValueProto; +import feast.serving.config.ApplicationProperties; import io.grpc.Channel; import io.grpc.ManagedChannelBuilder; import java.util.*; @@ -32,14 +36,39 @@ public static ServingServiceGrpc.ServingServiceBlockingStub getServingServiceStu return ServingServiceGrpc.newBlockingStub(secureChannel); } - public static GetOnlineFeaturesRequestV2 createOnlineFeatureRequest( - String projectName, - List featureReferences, - List entityRows) { - return GetOnlineFeaturesRequestV2.newBuilder() - .setProject(projectName) - .addAllFeatures(featureReferences) - .addAllEntityRows(entityRows) + public static GetOnlineFeaturesRequest createOnlineFeatureRequest( + List featureReferences, Map entityRows) { + return GetOnlineFeaturesRequest.newBuilder() + .setFeatures(ServingAPIProto.FeatureList.newBuilder().addAllVal(featureReferences)) + .putAllEntities(entityRows) .build(); } + + public static GetOnlineFeaturesRequest createOnlineFeatureRequest( + String featureService, Map entityRows) { + return GetOnlineFeaturesRequest.newBuilder() + .setFeatureService(featureService) + .putAllEntities(entityRows) + .build(); + } + + public static ApplicationProperties.FeastProperties createBasicFeastProperties( + String redisHost, Integer redisPort) { + final ApplicationProperties.FeastProperties feastProperties = + new ApplicationProperties.FeastProperties(); + feastProperties.setRegistry("src/test/resources/docker-compose/feast10/registry.db"); + feastProperties.setRegistryRefreshInterval(1); + + feastProperties.setActiveStore("online"); + feastProperties.setProject("feast_project"); + + feastProperties.setStores( + ImmutableList.of( + new ApplicationProperties.Store( + "online", + "REDIS", + ImmutableMap.of("host", redisHost, "port", redisPort.toString())))); + + return feastProperties; + } } diff --git a/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java b/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java index c43e3218c7f..4234e9dce3e 100644 --- a/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java +++ b/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java @@ -29,10 +29,8 @@ import feast.proto.core.FeatureProto; import feast.proto.core.FeatureViewProto; import feast.proto.serving.ServingAPIProto; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.serving.ServingAPIProto.FieldStatus; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2; import feast.proto.types.ValueProto; import feast.serving.registry.Registry; import feast.serving.registry.RegistryRepository; @@ -42,8 +40,9 @@ import io.opentracing.Tracer; import io.opentracing.Tracer.SpanBuilder; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.Map; +import java.util.stream.Collectors; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentMatchers; @@ -62,6 +61,8 @@ public class OnlineServingServiceTest { List mockedFeatureRows; List featureSpecs; + Timestamp now = Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000).build(); + @Before public void setUp() { initMocks(this); @@ -71,56 +72,57 @@ public void setUp() { OnlineTransformationService onlineTransformationService = new OnlineTransformationService(transformationServiceEndpoint, registryRepo); onlineServingServiceV2 = - new OnlineServingServiceV2(retrieverV2, tracer, registryRepo, onlineTransformationService); + new OnlineServingServiceV2( + retrieverV2, tracer, registryRepo, onlineTransformationService, "feast_project"); mockedFeatureRows = new ArrayList<>(); mockedFeatureRows.add( new ProtoFeature( ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_1") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_1") .build(), - Timestamp.newBuilder().setSeconds(100).build(), + now, createStrValue("1"))); mockedFeatureRows.add( new ProtoFeature( ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_2") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_2") .build(), - Timestamp.newBuilder().setSeconds(100).build(), + now, createStrValue("2"))); mockedFeatureRows.add( new ProtoFeature( ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_1") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_1") .build(), - Timestamp.newBuilder().setSeconds(100).build(), + now, createStrValue("3"))); mockedFeatureRows.add( new ProtoFeature( ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_2") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_2") .build(), - Timestamp.newBuilder().setSeconds(100).build(), + now, createStrValue("4"))); mockedFeatureRows.add( new ProtoFeature( ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_3") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_3") .build(), - Timestamp.newBuilder().setSeconds(100).build(), + now, createStrValue("5"))); mockedFeatureRows.add( new ProtoFeature( ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_1") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_1") .build(), - Timestamp.newBuilder().setSeconds(50).build(), + Timestamp.newBuilder().setSeconds(1).build(), createStrValue("6"))); featureSpecs = new ArrayList<>(); @@ -141,66 +143,63 @@ public void shouldReturnResponseWithValuesAndMetadataIfKeysPresent() { String projectName = "default"; ServingAPIProto.FeatureReferenceV2 featureReference1 = ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_1") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_1") .build(); ServingAPIProto.FeatureReferenceV2 featureReference2 = ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_2") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_2") .build(); List featureReferences = List.of(featureReference1, featureReference2); - GetOnlineFeaturesRequestV2 request = getOnlineFeaturesRequestV2(projectName, featureReferences); + ServingAPIProto.GetOnlineFeaturesRequest request = getOnlineFeaturesRequest(featureReferences); - List> featureRows = + List> featureRows = List.of( - ImmutableMap.of( - mockedFeatureRows.get(0).getFeatureReference(), mockedFeatureRows.get(0), - mockedFeatureRows.get(1).getFeatureReference(), mockedFeatureRows.get(1)), - ImmutableMap.of( - mockedFeatureRows.get(2).getFeatureReference(), mockedFeatureRows.get(2), - mockedFeatureRows.get(3).getFeatureReference(), mockedFeatureRows.get(3))); + List.of(mockedFeatureRows.get(0), mockedFeatureRows.get(1)), + List.of(mockedFeatureRows.get(2), mockedFeatureRows.get(3))); - when(retrieverV2.getOnlineFeatures(any(), any(), any(), any())).thenReturn(featureRows); - when(registry.getFeatureViewSpec(any(), any())).thenReturn(getFeatureViewSpec()); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(0).getFeatureReference())) + when(retrieverV2.getOnlineFeatures(any(), any(), any())).thenReturn(featureRows); + when(registry.getFeatureViewSpec(any())).thenReturn(getFeatureViewSpec()); + when(registry.getFeatureSpec(mockedFeatureRows.get(0).getFeatureReference())) .thenReturn(featureSpecs.get(0)); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(1).getFeatureReference())) + when(registry.getFeatureSpec(mockedFeatureRows.get(1).getFeatureReference())) .thenReturn(featureSpecs.get(1)); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(2).getFeatureReference())) + when(registry.getFeatureSpec(mockedFeatureRows.get(2).getFeatureReference())) .thenReturn(featureSpecs.get(0)); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(3).getFeatureReference())) + when(registry.getFeatureSpec(mockedFeatureRows.get(3).getFeatureReference())) .thenReturn(featureSpecs.get(1)); when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); - GetOnlineFeaturesResponse expected = - GetOnlineFeaturesResponse.newBuilder() - .addFieldValues( - FieldValues.newBuilder() - .putFields("entity1", createInt64Value(1)) - .putStatuses("entity1", FieldStatus.PRESENT) - .putFields("entity2", createStrValue("a")) - .putStatuses("entity2", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_1", createStrValue("1")) - .putStatuses("featuretable_1:feature_1", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_2", createStrValue("2")) - .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) - .build()) - .addFieldValues( - FieldValues.newBuilder() - .putFields("entity1", createInt64Value(2)) - .putStatuses("entity1", FieldStatus.PRESENT) - .putFields("entity2", createStrValue("b")) - .putStatuses("entity2", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_1", createStrValue("3")) - .putStatuses("featuretable_1:feature_1", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_2", createStrValue("4")) - .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) - .build()) + GetOnlineFeaturesResponseV2 expected = + GetOnlineFeaturesResponseV2.newBuilder() + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(createStrValue("1")) + .addValues(createStrValue("3")) + .addStatuses(FieldStatus.PRESENT) + .addStatuses(FieldStatus.PRESENT) + .addEventTimestamps(now) + .addEventTimestamps(now)) + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(createStrValue("2")) + .addValues(createStrValue("4")) + .addStatuses(FieldStatus.PRESENT) + .addStatuses(FieldStatus.PRESENT) + .addEventTimestamps(now) + .addEventTimestamps(now)) + .setMetadata( + ServingAPIProto.GetOnlineFeaturesResponseMetadata.newBuilder() + .setFeatureNames( + ServingAPIProto.FeatureList.newBuilder() + .addVal("featureview_1:feature_1") + .addVal("featureview_1:feature_2"))) .build(); - GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); + ServingAPIProto.GetOnlineFeaturesResponseV2 actual = + onlineServingServiceV2.getOnlineFeatures(request); assertThat(actual, equalTo(expected)); } @@ -209,17 +208,17 @@ public void shouldReturnResponseWithUnsetValuesAndMetadataIfKeysNotPresent() { String projectName = "default"; ServingAPIProto.FeatureReferenceV2 featureReference1 = ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_1") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_1") .build(); ServingAPIProto.FeatureReferenceV2 featureReference2 = ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_2") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_2") .build(); List featureReferences = List.of(featureReference1, featureReference2); - GetOnlineFeaturesRequestV2 request = getOnlineFeaturesRequestV2(projectName, featureReferences); + ServingAPIProto.GetOnlineFeaturesRequest request = getOnlineFeaturesRequest(featureReferences); List entityKeyList1 = new ArrayList<>(); List entityKeyList2 = new ArrayList<>(); @@ -227,49 +226,46 @@ public void shouldReturnResponseWithUnsetValuesAndMetadataIfKeysNotPresent() { entityKeyList1.add(mockedFeatureRows.get(1)); entityKeyList2.add(mockedFeatureRows.get(4)); - List> featureRows = + List> featureRows = List.of( - ImmutableMap.of( - mockedFeatureRows.get(0).getFeatureReference(), mockedFeatureRows.get(0), - mockedFeatureRows.get(1).getFeatureReference(), mockedFeatureRows.get(1)), - ImmutableMap.of( - mockedFeatureRows.get(4).getFeatureReference(), mockedFeatureRows.get(4))); + List.of(mockedFeatureRows.get(0), mockedFeatureRows.get(1)), + Arrays.asList(null, mockedFeatureRows.get(4))); - when(retrieverV2.getOnlineFeatures(any(), any(), any(), any())).thenReturn(featureRows); - when(registry.getFeatureViewSpec(any(), any())).thenReturn(getFeatureViewSpec()); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(0).getFeatureReference())) + when(retrieverV2.getOnlineFeatures(any(), any(), any())).thenReturn(featureRows); + when(registry.getFeatureViewSpec(any())).thenReturn(getFeatureViewSpec()); + when(registry.getFeatureSpec(mockedFeatureRows.get(0).getFeatureReference())) .thenReturn(featureSpecs.get(0)); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(1).getFeatureReference())) + when(registry.getFeatureSpec(mockedFeatureRows.get(1).getFeatureReference())) .thenReturn(featureSpecs.get(1)); when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); - GetOnlineFeaturesResponse expected = - GetOnlineFeaturesResponse.newBuilder() - .addFieldValues( - FieldValues.newBuilder() - .putFields("entity1", createInt64Value(1)) - .putStatuses("entity1", FieldStatus.PRESENT) - .putFields("entity2", createStrValue("a")) - .putStatuses("entity2", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_1", createStrValue("1")) - .putStatuses("featuretable_1:feature_1", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_2", createStrValue("2")) - .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) - .build()) - .addFieldValues( - FieldValues.newBuilder() - .putFields("entity1", createInt64Value(2)) - .putStatuses("entity1", FieldStatus.PRESENT) - .putFields("entity2", createStrValue("b")) - .putStatuses("entity2", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_1", createEmptyValue()) - .putStatuses("featuretable_1:feature_1", FieldStatus.NOT_FOUND) - .putFields("featuretable_1:feature_2", createEmptyValue()) - .putStatuses("featuretable_1:feature_2", FieldStatus.NOT_FOUND) - .build()) + GetOnlineFeaturesResponseV2 expected = + GetOnlineFeaturesResponseV2.newBuilder() + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(createStrValue("1")) + .addValues(createEmptyValue()) + .addStatuses(FieldStatus.PRESENT) + .addStatuses(FieldStatus.NOT_FOUND) + .addEventTimestamps(now) + .addEventTimestamps(Timestamp.newBuilder().build())) + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(createStrValue("2")) + .addValues(createStrValue("5")) + .addStatuses(FieldStatus.PRESENT) + .addStatuses(FieldStatus.PRESENT) + .addEventTimestamps(now) + .addEventTimestamps(now)) + .setMetadata( + ServingAPIProto.GetOnlineFeaturesResponseMetadata.newBuilder() + .setFeatureNames( + ServingAPIProto.FeatureList.newBuilder() + .addVal("featureview_1:feature_1") + .addVal("featureview_1:feature_2"))) .build(); - GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); + GetOnlineFeaturesResponseV2 actual = onlineServingServiceV2.getOnlineFeatures(request); assertThat(actual, equalTo(expected)); } @@ -278,32 +274,28 @@ public void shouldReturnResponseWithValuesAndMetadataIfMaxAgeIsExceeded() { String projectName = "default"; ServingAPIProto.FeatureReferenceV2 featureReference1 = ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_1") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_1") .build(); ServingAPIProto.FeatureReferenceV2 featureReference2 = ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_2") + .setFeatureViewName("featureview_1") + .setFeatureName("feature_2") .build(); List featureReferences = List.of(featureReference1, featureReference2); - GetOnlineFeaturesRequestV2 request = getOnlineFeaturesRequestV2(projectName, featureReferences); + ServingAPIProto.GetOnlineFeaturesRequest request = getOnlineFeaturesRequest(featureReferences); - List> featureRows = + List> featureRows = List.of( - ImmutableMap.of( - mockedFeatureRows.get(5).getFeatureReference(), mockedFeatureRows.get(5), - mockedFeatureRows.get(1).getFeatureReference(), mockedFeatureRows.get(1)), - ImmutableMap.of( - mockedFeatureRows.get(5).getFeatureReference(), mockedFeatureRows.get(5), - mockedFeatureRows.get(1).getFeatureReference(), mockedFeatureRows.get(1))); + List.of(mockedFeatureRows.get(5), mockedFeatureRows.get(1)), + List.of(mockedFeatureRows.get(5), mockedFeatureRows.get(1))); - when(retrieverV2.getOnlineFeatures(any(), any(), any(), any())).thenReturn(featureRows); - when(registry.getFeatureViewSpec(any(), any())) + when(retrieverV2.getOnlineFeatures(any(), any(), any())).thenReturn(featureRows); + when(registry.getFeatureViewSpec(any())) .thenReturn( FeatureViewProto.FeatureViewSpec.newBuilder() - .setName("featuretable_1") + .setName("featureview_1") .addEntities("entity1") .addEntities("entity2") .addFeatures( @@ -316,47 +308,47 @@ public void shouldReturnResponseWithValuesAndMetadataIfMaxAgeIsExceeded() { .setName("feature_2") .setValueType(ValueProto.ValueType.Enum.STRING) .build()) - .setTtl(Duration.newBuilder().setSeconds(1)) + .setTtl(Duration.newBuilder().setSeconds(3600)) .build()); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(1).getFeatureReference())) + when(registry.getFeatureSpec(mockedFeatureRows.get(1).getFeatureReference())) .thenReturn(featureSpecs.get(1)); - when(registry.getFeatureSpec(projectName, mockedFeatureRows.get(5).getFeatureReference())) + when(registry.getFeatureSpec(mockedFeatureRows.get(5).getFeatureReference())) .thenReturn(featureSpecs.get(0)); when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); - GetOnlineFeaturesResponse expected = - GetOnlineFeaturesResponse.newBuilder() - .addFieldValues( - FieldValues.newBuilder() - .putFields("entity1", createInt64Value(1)) - .putStatuses("entity1", FieldStatus.PRESENT) - .putFields("entity2", createStrValue("a")) - .putStatuses("entity2", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_1", createStrValue("6")) - .putStatuses("featuretable_1:feature_1", FieldStatus.OUTSIDE_MAX_AGE) - .putFields("featuretable_1:feature_2", createStrValue("2")) - .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) - .build()) - .addFieldValues( - FieldValues.newBuilder() - .putFields("entity1", createInt64Value(2)) - .putStatuses("entity1", FieldStatus.PRESENT) - .putFields("entity2", createStrValue("b")) - .putStatuses("entity2", FieldStatus.PRESENT) - .putFields("featuretable_1:feature_1", createStrValue("6")) - .putStatuses("featuretable_1:feature_1", FieldStatus.OUTSIDE_MAX_AGE) - .putFields("featuretable_1:feature_2", createStrValue("2")) - .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) - .build()) + GetOnlineFeaturesResponseV2 expected = + GetOnlineFeaturesResponseV2.newBuilder() + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(createStrValue("6")) + .addValues(createStrValue("6")) + .addStatuses(FieldStatus.OUTSIDE_MAX_AGE) + .addStatuses(FieldStatus.OUTSIDE_MAX_AGE) + .addEventTimestamps(Timestamp.newBuilder().setSeconds(1).build()) + .addEventTimestamps(Timestamp.newBuilder().setSeconds(1).build())) + .addResults( + GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + .addValues(createStrValue("2")) + .addValues(createStrValue("2")) + .addStatuses(FieldStatus.PRESENT) + .addStatuses(FieldStatus.PRESENT) + .addEventTimestamps(now) + .addEventTimestamps(now)) + .setMetadata( + ServingAPIProto.GetOnlineFeaturesResponseMetadata.newBuilder() + .setFeatureNames( + ServingAPIProto.FeatureList.newBuilder() + .addVal("featureview_1:feature_1") + .addVal("featureview_1:feature_2"))) .build(); - GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); + GetOnlineFeaturesResponseV2 actual = onlineServingServiceV2.getOnlineFeatures(request); assertThat(actual, equalTo(expected)); } private FeatureViewProto.FeatureViewSpec getFeatureViewSpec() { return FeatureViewProto.FeatureViewSpec.newBuilder() - .setName("featuretable_1") + .setName("featureview_1") .addEntities("entity1") .addEntities("entity2") .addFeatures( @@ -373,31 +365,26 @@ private FeatureViewProto.FeatureViewSpec getFeatureViewSpec() { .build(); } - private GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2( - String projectName, List featureReferences) { - return GetOnlineFeaturesRequestV2.newBuilder() - .setProject(projectName) - .addAllFeatures(featureReferences) - .addEntityRows( - GetOnlineFeaturesRequestV2.EntityRow.newBuilder() - .setTimestamp(Timestamp.newBuilder().setSeconds(100)) - .putFields("entity1", createInt64Value(1)) - .putFields("entity2", createStrValue("a"))) - .addEntityRows( - GetOnlineFeaturesRequestV2.EntityRow.newBuilder() - .setTimestamp(Timestamp.newBuilder().setSeconds(100)) - .putFields("entity1", createInt64Value(2)) - .putFields("entity2", createStrValue("b"))) - .addFeatures( - ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_1") - .build()) - .addFeatures( - ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretable_1") - .setName("feature_2") + private ServingAPIProto.GetOnlineFeaturesRequest getOnlineFeaturesRequest( + List featureReferences) { + return ServingAPIProto.GetOnlineFeaturesRequest.newBuilder() + .setFeatures( + ServingAPIProto.FeatureList.newBuilder() + .addAllVal( + featureReferences.stream() + .map(feast.common.models.Feature::getFeatureReference) + .collect(Collectors.toList())) .build()) + .putAllEntities( + ImmutableMap.of( + "entity1", + ValueProto.RepeatedValue.newBuilder() + .addAllVal(List.of(createInt64Value(1), createInt64Value(2))) + .build(), + "entity2", + ValueProto.RepeatedValue.newBuilder() + .addAllVal(List.of(createStrValue("a"), createStrValue("b"))) + .build())) .build(); } } diff --git a/java/serving/src/test/java/feast/serving/util/DataGenerator.java b/java/serving/src/test/java/feast/serving/util/DataGenerator.java index ab537fa6f9b..d53632d0d64 100644 --- a/java/serving/src/test/java/feast/serving/util/DataGenerator.java +++ b/java/serving/src/test/java/feast/serving/util/DataGenerator.java @@ -260,8 +260,8 @@ public static ValueProto.Value createInt64Value(long value) { public static ServingAPIProto.FeatureReferenceV2 createFeatureReference( String featureTableName, String featureName) { return ServingAPIProto.FeatureReferenceV2.newBuilder() - .setFeatureTable(featureTableName) - .setName(featureName) + .setFeatureViewName(featureTableName) + .setFeatureName(featureName) .build(); } diff --git a/java/serving/src/test/java/feast/serving/util/RequestHelperTest.java b/java/serving/src/test/java/feast/serving/util/RequestHelperTest.java index 140d46cd569..fc19dbb02e0 100644 --- a/java/serving/src/test/java/feast/serving/util/RequestHelperTest.java +++ b/java/serving/src/test/java/feast/serving/util/RequestHelperTest.java @@ -16,39 +16,40 @@ */ package feast.serving.util; -import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto; import org.junit.Test; public class RequestHelperTest { @Test(expected = IllegalArgumentException.class) public void shouldErrorIfEntityRowEmpty() { - FeatureReferenceV2 featureReference = - FeatureReferenceV2.newBuilder() - .setFeatureTable("featuretablename") - .setName("featurename") + + ServingAPIProto.GetOnlineFeaturesRequest getOnlineFeaturesRequest = + ServingAPIProto.GetOnlineFeaturesRequest.newBuilder() + .setFeatures( + ServingAPIProto.FeatureList.newBuilder().addVal("view:featurename").build()) .build(); - GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2 = - GetOnlineFeaturesRequestV2.newBuilder().addFeatures(featureReference).build(); - RequestHelper.validateOnlineRequest(getOnlineFeaturesRequestV2); + + RequestHelper.validateOnlineRequest(getOnlineFeaturesRequest); } @Test(expected = IllegalArgumentException.class) public void shouldErrorIfFeatureReferenceTableEmpty() { - FeatureReferenceV2 featureReference = - FeatureReferenceV2.newBuilder().setName("featurename").build(); - GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2 = - GetOnlineFeaturesRequestV2.newBuilder().addFeatures(featureReference).build(); - RequestHelper.validateOnlineRequest(getOnlineFeaturesRequestV2); + ServingAPIProto.GetOnlineFeaturesRequest getOnlineFeaturesRequest = + ServingAPIProto.GetOnlineFeaturesRequest.newBuilder() + .setFeatures(ServingAPIProto.FeatureList.newBuilder().addVal("featurename").build()) + .build(); + + RequestHelper.validateOnlineRequest(getOnlineFeaturesRequest); } @Test(expected = IllegalArgumentException.class) public void shouldErrorIfFeatureReferenceNameEmpty() { - FeatureReferenceV2 featureReference = - FeatureReferenceV2.newBuilder().setFeatureTable("featuretablename").build(); - GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2 = - GetOnlineFeaturesRequestV2.newBuilder().addFeatures(featureReference).build(); - RequestHelper.validateOnlineRequest(getOnlineFeaturesRequestV2); + ServingAPIProto.GetOnlineFeaturesRequest getOnlineFeaturesRequest = + ServingAPIProto.GetOnlineFeaturesRequest.newBuilder() + .setFeatures(ServingAPIProto.FeatureList.newBuilder().addVal("view").build()) + .build(); + + RequestHelper.validateOnlineRequest(getOnlineFeaturesRequest); } } diff --git a/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml b/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml index 08a50233df1..22e054f8b11 100644 --- a/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml +++ b/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml @@ -2,7 +2,7 @@ version: '3' services: redis: - image: redis:5-alpine + image: redis:6.2 ports: - "6379:6379" materialize: diff --git a/java/serving/src/test/resources/docker-compose/feast10/materialize.py b/java/serving/src/test/resources/docker-compose/feast10/materialize.py index c347728c68b..ca4cc98db26 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/materialize.py +++ b/java/serving/src/test/resources/docker-compose/feast10/materialize.py @@ -56,8 +56,56 @@ tags={}, ) + +# For Benchmarks +# Please read more in Feast RFC-031 (link https://docs.google.com/document/d/12UuvTQnTTCJhdRgy6h10zSbInNGSyEJkIxpOcgOen1I/edit) +# about this benchmark setup +def generate_data(num_rows: int, num_features: int, key_space: int, destination: str) -> pd.DataFrame: + features = [f"feature_{i}" for i in range(num_features)] + columns = ["entity", "event_timestamp"] + features + df = pd.DataFrame(0, index=np.arange(num_rows), columns=columns) + df["event_timestamp"] = datetime.utcnow() + for column in ["entity"] + features: + df[column] = np.random.randint(1, key_space, num_rows) + + df.to_parquet(destination) + +generate_data(10**3, 250, 10**3, "benchmark_data.parquet") + +generated_data_source = FileSource( + path="benchmark_data.parquet", + event_timestamp_column="event_timestamp", +) + +entity = Entity( + name="entity", + value_type=ValueType.INT64, +) + +benchmark_feature_views = [ + FeatureView( + name=f"feature_view_{i}", + entities=["entity"], + ttl=Duration(seconds=86400), + features=[ + Feature(name=f"feature_{10 * i + j}", dtype=ValueType.INT64) + for j in range(10) + ], + online=True, + batch_source=generated_data_source, + ) + for i in range(25) +] + +benchmark_feature_service = FeatureService( + name=f"benchmark_feature_service", + features=benchmark_feature_views, +) + + fs = FeatureStore(".") -fs.apply([driver_hourly_stats_view, driver]) +fs.apply([driver_hourly_stats_view, driver, + entity, benchmark_feature_service, *benchmark_feature_views]) now = datetime.now() fs.materialize(start, now) diff --git a/java/serving/src/test/resources/docker-compose/feast10/registry.db b/java/serving/src/test/resources/docker-compose/feast10/registry.db index b9a19475af0789de930ed80b73e5d39b707c1fcf..4590c5800a67e03539018fecc910ffec3012ca6f 100644 GIT binary patch literal 14374 zcmdU$O>A6e6^3h*rtWRpSW{Gi5fYe`MWk5w`{vGHR$X<`Ev&j3O~%(`+Sm@`iBTjL zbc;lVSi&wyZMv(vs)VSz=?Zpix&X1H5*t7Qr4mTob7Cv!eR8L=)X3`We7HN7_ImxJ;~Z#tQj-K&>2?)~MRw=Zr!`20Wj*7IN2i{Gyomm7hLJM(&c zJga{)PJ@mr-a1jZeYtq;0 zKi>QFr=3e1_dhqnpT7O>drJrp&q8=e!b1`slJJm(ha@~C;UNhRNq9)Y!xbPrIt$?u z36Ds4M8YEy9+B{fghwPiBHXU%U~%xAZc9*(E~zHZ+%Y!ZF*e{aHsUch-@Emgy-mkLX5%O$Ap*%a~~37 z9?X42h>%TBu?G6 z@Em*AI%5pxJ-nayz`Td|^B$P@@P6I{^B&&Mdn?Yoe+K4#FzZZyTKguE5!W2 zZMUeFuAlLa6wT*E^EuIcPBfnr&F4h(InjJhG@ldA=XBoY^OspkTk$X5Nh5wb(}~V> zqBEW7OeZ?iiOzJQGo9#6Cpy#Vyq)PMj(_QT8u8P%PPDBPZRqOf+ z(Y8+KZCgKa{7X00h@YNzqNkncX(xKxiJo?%r=93&Cwkh6o_0EKPy31EU%IqL{4~51 z4evz5JJIk?G`tfH??l5p(eO?*ywiCb-cKC=()~5!_XSoCY~qmv8|~%5W*#}P;a(1G z>X8E*@8!Ve9yzc9Uk+A%@>#md#%JlftQ-)(Z?jTC{Jzgh1@ZevD;31=JFQf#4*u(( zTq!=jQhf8#hlNhv_M>`!Je$_^6y8~T{2qzi4C#cM63y!o!{(nMhRtgc!{#3$hRy2` z!{(nLhRtgb!{#3#h7JF%_bD`W!fs$=o5UtH=1psiO>B%!ZH!HBj7@KhO>m4&ag19f z=U8H+j>M2`gO21O*~T2nL$VDyl80m)aU|a=xf@GtJdhZYZ8(rTB->~pc}TXwK=P1m zV}aybC0DV;TB^j5YzwA=!LX$wRXF zsFH_d^HC+=D!Cs^?5$g3NVYd_$wRWeZA%`K?M++qkZf<+l5dqfh$ZIYNes#6;z=Ho z&Bc>EB%6yTc}O-FPx7sjhq1&=Fo_}AOfbnqvYB9#hh#IsBoE1Ef=Rwr@+g*=ha@p1 zn};NMNHz~i@{nvElH?)TJS54RTUhh3aYpDQ!A+6*3TC8J$aP3&8WUd_1-w8g6h3>N(I$> z^OOpz_x33j?dtoY-aBY~l-OO*<$_ogZp?dpf3-ur5NlznGGE6Coyv!E4ZZ{Jza3bMEFENBJU z+jkaP*pK8<-eRMA`#ykHP`!O0Kr5);z7L=kRBzu0&v-Vm6_5N9Vt!P&-y*9JgX7<`_cMIhAve#z2Tc8zGZ+8o{g6i#V zfmXDumtLFMYcqRo_8m07Tkw@|5|df!8(g6e$}m5O%t(rYt&ZDy~{zK+J<%U+v( zA(aZM_mxyCsNR=Ssc2U(y*9JgX7<|bn`!*L?6uimbO-%h21>U}?zibj3sss96- CL8z4g delta 69 zcmV-L0J{ICaP|U_Igwy6H8(V6GGk_8I4w11V__{cVL4_kVlX){En#FiH8nXhH#Rdl bW-1H_@$k5e1`x=Z(8&Ta*a4H(3)cYvC4d*P diff --git a/java/storage/api/src/main/java/feast/storage/api/retriever/FeatureTableRequest.java b/java/storage/api/src/main/java/feast/storage/api/retriever/FeatureTableRequest.java index 6188a270c40..2f181e6de83 100644 --- a/java/storage/api/src/main/java/feast/storage/api/retriever/FeatureTableRequest.java +++ b/java/storage/api/src/main/java/feast/storage/api/retriever/FeatureTableRequest.java @@ -56,6 +56,7 @@ public Builder addFeatureReference(FeatureReferenceV2 featureReference) { public Map getFeatureRefsByName() { return getFeatureReferences().stream() .collect( - Collectors.toMap(FeatureReferenceV2::getName, featureReference -> featureReference)); + Collectors.toMap( + FeatureReferenceV2::getFeatureName, featureReference -> featureReference)); } } diff --git a/java/storage/api/src/main/java/feast/storage/api/retriever/OnlineRetrieverV2.java b/java/storage/api/src/main/java/feast/storage/api/retriever/OnlineRetrieverV2.java index db5db8b63c4..fde8ba7396d 100644 --- a/java/storage/api/src/main/java/feast/storage/api/retriever/OnlineRetrieverV2.java +++ b/java/storage/api/src/main/java/feast/storage/api/retriever/OnlineRetrieverV2.java @@ -17,6 +17,7 @@ package feast.storage.api.retriever; import feast.proto.serving.ServingAPIProto; +import feast.proto.types.ValueProto; import java.util.List; import java.util.Map; @@ -31,16 +32,14 @@ public interface OnlineRetrieverV2 { * Feature} returned should match the no. of given {@link * ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow}s * - * @param project name of project to request features from. * @param entityRows list of entity rows to request features for. * @param featureReferences specifies the FeatureTable to retrieve data from * @param entityNames name of entities * @return list of {@link Feature}s corresponding to data retrieved for each entity row from * FeatureTable specified in FeatureTable request. */ - List> getOnlineFeatures( - String project, - List entityRows, + List> getOnlineFeatures( + List> entityRows, List featureReferences, List entityNames); } diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisHashDecoder.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisHashDecoder.java index fd0f0a56dc1..78b64fd141e 100644 --- a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisHashDecoder.java +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisHashDecoder.java @@ -16,7 +16,6 @@ */ package feast.storage.connectors.redis.common; -import com.google.common.collect.Maps; import com.google.common.hash.Hashing; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.Timestamp; @@ -35,13 +34,14 @@ public class RedisHashDecoder { * Converts all retrieved Redis Hash values based on EntityRows into {@link Feature} * * @param redisHashValues retrieved Redis Hash values based on EntityRows - * @param byteToFeatureReferenceMap map to decode bytes back to FeatureReference + * @param byteToFeatureIdxMap map to decode bytes back to FeatureReference * @param timestampPrefix timestamp prefix * @return Map of {@link ServingAPIProto.FeatureReferenceV2} to {@link Feature} */ - public static Map retrieveFeature( + public static List retrieveFeature( Map redisHashValues, - Map byteToFeatureReferenceMap, + Map byteToFeatureIdxMap, + List featureReferences, String timestampPrefix) { Map featureTableTimestampMap = redisHashValues.entrySet().stream() @@ -57,14 +57,11 @@ public static Map retrieveFeature( "Couldn't parse timestamp proto while pulling data from Redis"); } })); - Map results = - Maps.newHashMapWithExpectedSize(byteToFeatureReferenceMap.size()); + List results = new ArrayList<>(Collections.nCopies(featureReferences.size(), null)); for (Map.Entry entry : redisHashValues.entrySet()) { - ServingAPIProto.FeatureReferenceV2 featureReference = - byteToFeatureReferenceMap.get(ByteBuffer.wrap(entry.getKey())); - - if (featureReference == null) { + Integer featureIdx = byteToFeatureIdxMap.get(ByteBuffer.wrap(entry.getKey())); + if (featureIdx == null) { continue; } @@ -75,11 +72,11 @@ public static Map retrieveFeature( throw new RuntimeException( "Couldn't parse feature value proto while pulling data from Redis"); } - results.put( - featureReference, + results.set( + featureIdx, new ProtoFeature( - featureReference, - featureTableTimestampMap.get(featureReference.getFeatureTable()), + featureReferences.get(featureIdx), + featureTableTimestampMap.get(featureReferences.get(featureIdx).getFeatureViewName()), v)); } @@ -94,7 +91,7 @@ public static byte[] getTimestampRedisHashKeyBytes(String featureTable, String t public static byte[] getFeatureReferenceRedisHashKeyBytes( ServingAPIProto.FeatureReferenceV2 featureReference) { String delimitedFeatureReference = - featureReference.getFeatureTable() + ":" + featureReference.getName(); + featureReference.getFeatureViewName() + ":" + featureReference.getFeatureName(); return Hashing.murmur3_32() .hashString(delimitedFeatureReference, StandardCharsets.UTF_8) .asBytes(); diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisKeyGenerator.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisKeyGenerator.java index 797dd522151..389ca0abfde 100644 --- a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisKeyGenerator.java +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisKeyGenerator.java @@ -28,7 +28,7 @@ public class RedisKeyGenerator { public static List buildRedisKeys( - String project, List entityRows) { + String project, List> entityRows) { List redisKeys = entityRows.stream() .map(entityRow -> makeRedisKey(project, entityRow)) @@ -45,17 +45,16 @@ public static List buildRedisKeys( * @return {@link RedisProto.RedisKeyV2} */ private static RedisProto.RedisKeyV2 makeRedisKey( - String project, ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow entityRow) { + String project, Map entityRow) { RedisProto.RedisKeyV2.Builder builder = RedisProto.RedisKeyV2.newBuilder().setProject(project); - Map fieldsMap = entityRow.getFieldsMap(); - List entityNames = new ArrayList<>(new HashSet<>(fieldsMap.keySet())); + List entityNames = new ArrayList<>(new HashSet<>(entityRow.keySet())); // Sort entity names by alphabetical order entityNames.sort(String::compareTo); for (String entityName : entityNames) { builder.addEntityNames(entityName); - builder.addEntityValues(fieldsMap.get(entityName)); + builder.addEntityValues(entityRow.get(entityName)); } return builder.build(); } diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/OnlineRetriever.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/OnlineRetriever.java index ab03049b9fb..a71812e875e 100644 --- a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/OnlineRetriever.java +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/OnlineRetriever.java @@ -19,6 +19,7 @@ import com.google.common.collect.Lists; import feast.proto.serving.ServingAPIProto; import feast.proto.storage.RedisProto; +import feast.proto.types.ValueProto; import feast.storage.api.retriever.Feature; import feast.storage.api.retriever.OnlineRetrieverV2; import feast.storage.connectors.redis.common.RedisHashDecoder; @@ -38,52 +39,52 @@ public class OnlineRetriever implements OnlineRetrieverV2 { private static final String timestampPrefix = "_ts"; private final RedisClientAdapter redisClientAdapter; private final EntityKeySerializer keySerializer; + private final String project; // Number of fields in request to Redis which requires using HGETALL instead of HMGET public static final int HGETALL_NUMBER_OF_FIELDS_THRESHOLD = 50; - public OnlineRetriever(RedisClientAdapter redisClientAdapter, EntityKeySerializer keySerializer) { + public OnlineRetriever( + String project, RedisClientAdapter redisClientAdapter, EntityKeySerializer keySerializer) { + this.project = project; this.redisClientAdapter = redisClientAdapter; this.keySerializer = keySerializer; } @Override - public List> getOnlineFeatures( - String project, - List entityRows, + public List> getOnlineFeatures( + List> entityRows, List featureReferences, List entityNames) { - List redisKeys = RedisKeyGenerator.buildRedisKeys(project, entityRows); + List redisKeys = + RedisKeyGenerator.buildRedisKeys(this.project, entityRows); return getFeaturesFromRedis(redisKeys, featureReferences); } - private List> getFeaturesFromRedis( + private List> getFeaturesFromRedis( List redisKeys, List featureReferences) { - List> features = new ArrayList<>(); - // To decode bytes back to Feature Reference - Map byteToFeatureReferenceMap = new HashMap<>(); + // To decode bytes back to Feature + Map byteToFeatureIdxMap = new HashMap<>(); // Serialize using proto List binaryRedisKeys = redisKeys.stream().map(this.keySerializer::serialize).collect(Collectors.toList()); List retrieveFields = new ArrayList<>(); - featureReferences.stream() - .forEach( - featureReference -> { - - // eg. murmur() - byte[] featureReferenceBytes = - RedisHashDecoder.getFeatureReferenceRedisHashKeyBytes(featureReference); - retrieveFields.add(featureReferenceBytes); - byteToFeatureReferenceMap.put( - ByteBuffer.wrap(featureReferenceBytes), featureReference); - }); + for (int idx = 0; + idx < featureReferences.size(); + idx++) { // eg. murmur() + byte[] featureReferenceBytes = + RedisHashDecoder.getFeatureReferenceRedisHashKeyBytes(featureReferences.get(idx)); + retrieveFields.add(featureReferenceBytes); + + byteToFeatureIdxMap.put(ByteBuffer.wrap(featureReferenceBytes), idx); + } featureReferences.stream() - .map(ServingAPIProto.FeatureReferenceV2::getFeatureTable) + .map(ServingAPIProto.FeatureReferenceV2::getFeatureViewName) .distinct() .forEach( table -> { @@ -121,12 +122,12 @@ private List> getFeaturesFromRe } } - List> results = - Lists.newArrayListWithExpectedSize(futures.size()); + List> results = Lists.newArrayListWithExpectedSize(futures.size()); for (Future> f : futures) { try { results.add( - RedisHashDecoder.retrieveFeature(f.get(), byteToFeatureReferenceMap, timestampPrefix)); + RedisHashDecoder.retrieveFeature( + f.get(), byteToFeatureIdxMap, featureReferences, timestampPrefix)); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException("Unexpected error when pulling data from Redis"); } diff --git a/protos/feast/serving/ServingService.proto b/protos/feast/serving/ServingService.proto index e37ecbbdde4..7d45e61a5e6 100644 --- a/protos/feast/serving/ServingService.proto +++ b/protos/feast/serving/ServingService.proto @@ -29,8 +29,8 @@ service ServingService { // Get information about this Feast serving. rpc GetFeastServingInfo (GetFeastServingInfoRequest) returns (GetFeastServingInfoResponse); - // Get online features (v2) synchronously. - rpc GetOnlineFeaturesV2 (GetOnlineFeaturesRequestV2) returns (GetOnlineFeaturesResponse); + // Get online features synchronously. + rpc GetOnlineFeatures (GetOnlineFeaturesRequest) returns (GetOnlineFeaturesResponseV2); } message GetFeastServingInfoRequest {} @@ -38,24 +38,17 @@ message GetFeastServingInfoRequest {} message GetFeastServingInfoResponse { // Feast version of this serving deployment. string version = 1; - - // Type of serving deployment, either ONLINE or BATCH. Different store types support different - // feature retrieval methods. - FeastServingType type = 2; - - // Note: Batch specific options start from 10. - // Staging location for this serving store, if any. - string job_staging_location = 10; } message FeatureReferenceV2 { - // Name of the Feature Table to retrieve the feature from. - string feature_table = 1; + // Name of the Feature View to retrieve the feature from. + string feature_view_name = 1; // Name of the Feature to retrieve the feature from. - string name = 2; + string feature_name = 2; } +// ToDo (oleksii): remove this message (since it's not used) and move EntityRow on package level message GetOnlineFeaturesRequestV2 { // List of features that are being retrieved repeated FeatureReferenceV2 features = 4; @@ -94,6 +87,11 @@ message GetOnlineFeaturesRequest { // A map of entity name -> list of values map entities = 3; bool full_feature_names = 4; + + // Context for OnDemand Feature Transformation + // (was moved to dedicated parameter to avoid unnecessary separation logic on serving side) + // A map of variable name -> list of values + map request_context = 5; } message GetOnlineFeaturesResponse { @@ -107,35 +105,43 @@ message GetOnlineFeaturesResponse { // Map of feature or entity name to feature/entity statuses/metadata. map statuses = 2; } - - enum FieldStatus { - // Status is unset for this field. - INVALID = 0; - - // Field value is present for this field and age is within max age. - PRESENT = 1; - - // Values could be found for entity key and age is within max age, but - // this field value is assigned a value on ingestion into feast. - NULL_VALUE = 2; - - // Entity key did not return any values as they do not exist in Feast. - // This could suggest that the feature values have not yet been ingested - // into feast or the ingestion failed. - NOT_FOUND = 3; - - // Values could be found for entity key, but field values are outside the maximum - // allowable range. - OUTSIDE_MAX_AGE = 4; +} + +message GetOnlineFeaturesResponseV2 { + GetOnlineFeaturesResponseMetadata metadata = 1; + + // Length of "results" array should match length of requested features. + // We also preserve the same order of features here as in metadata.feature_names + repeated FeatureVector results = 2; + + message FeatureVector { + repeated feast.types.Value values = 1; + repeated FieldStatus statuses = 2; + repeated google.protobuf.Timestamp event_timestamps = 3; } } -enum FeastServingType { - FEAST_SERVING_TYPE_INVALID = 0; - // Online serving receives entity data directly and synchronously and will - // respond immediately. - FEAST_SERVING_TYPE_ONLINE = 1; - // Batch serving receives entity data asynchronously and orchestrates the - // retrieval through a staging location. - FEAST_SERVING_TYPE_BATCH = 2; +message GetOnlineFeaturesResponseMetadata { + FeatureList feature_names = 1; +} + +enum FieldStatus { + // Status is unset for this field. + INVALID = 0; + + // Field value is present for this field and age is within max age. + PRESENT = 1; + + // Values could be found for entity key and age is within max age, but + // this field value is assigned a value on ingestion into feast. + NULL_VALUE = 2; + + // Entity key did not return any values as they do not exist in Feast. + // This could suggest that the feature values have not yet been ingested + // into feast or the ingestion failed. + NOT_FOUND = 3; + + // Values could be found for entity key, but field values are outside the maximum + // allowable range. + OUTSIDE_MAX_AGE = 4; } diff --git a/sdk/go/client.go b/sdk/go/client.go index 4deb0a789cc..c7251e33195 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -110,7 +110,7 @@ func (fc *GrpcClient) GetOnlineFeatures(ctx context.Context, req *OnlineFeatures if err != nil { return nil, err } - resp, err := fc.cli.GetOnlineFeaturesV2(ctx, featuresRequest) + resp, err := fc.cli.GetOnlineFeatures(ctx, featuresRequest) // collect unqiue entity refs from entity rows entityRefs := make(map[string]struct{}) diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index a94a577e84c..95be34af734 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -33,19 +33,26 @@ func TestGetOnlineFeatures(t *testing.T) { Project: "driver_project", }, want: OnlineFeaturesResponse{ - RawResponse: &serving.GetOnlineFeaturesResponse{ - FieldValues: []*serving.GetOnlineFeaturesResponse_FieldValues{ + RawResponse: &serving.GetOnlineFeaturesResponseV2{ + Results: []*serving.GetOnlineFeaturesResponseV2_FeatureVector{ { - Fields: map[string]*types.Value{ - "driver:rating": Int64Val(1), - "driver:null_value": {}, + Values: []*types.Value{Int64Val(1)}, + Statuses: []serving.FieldStatus{ + serving.FieldStatus_PRESENT, }, - Statuses: map[string]serving.GetOnlineFeaturesResponse_FieldStatus{ - "driver:rating": serving.GetOnlineFeaturesResponse_PRESENT, - "driver:null_value": serving.GetOnlineFeaturesResponse_NULL_VALUE, + }, + { + Values: []*types.Value{{}}, + Statuses: []serving.FieldStatus{ + serving.FieldStatus_NULL_VALUE, }, }, }, + Metadata: &serving.GetOnlineFeaturesResponseMetadata{ + FeatureNames: &serving.FeatureList{ + Val: []string{"driver:rating", "driver:null_value"}, + }, + }, }, }, }, @@ -60,7 +67,7 @@ func TestGetOnlineFeatures(t *testing.T) { ctx := context.Background() rawRequest, _ := tc.req.buildRequest() resp := tc.want.RawResponse - cli.EXPECT().GetOnlineFeaturesV2(ctx, rawRequest).Return(resp, nil).Times(1) + cli.EXPECT().GetOnlineFeatures(ctx, rawRequest).Return(resp, nil).Times(1) client := &GrpcClient{ cli: cli, diff --git a/sdk/go/mocks/serving_mock.go b/sdk/go/mocks/serving_mock.go index 00d2e768ef8..57ee0c1ea40 100644 --- a/sdk/go/mocks/serving_mock.go +++ b/sdk/go/mocks/serving_mock.go @@ -57,21 +57,21 @@ func (mr *MockServingServiceClientMockRecorder) GetFeastServingInfo(arg0, arg1 i } // GetOnlineFeaturesV2 mocks base method -func (m *MockServingServiceClient) GetOnlineFeaturesV2(arg0 context.Context, arg1 *serving.GetOnlineFeaturesRequestV2, arg2 ...grpc.CallOption) (*serving.GetOnlineFeaturesResponse, error) { +func (m *MockServingServiceClient) GetOnlineFeatures(arg0 context.Context, arg1 *serving.GetOnlineFeaturesRequest, arg2 ...grpc.CallOption) (*serving.GetOnlineFeaturesResponseV2, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} for _, a := range arg2 { varargs = append(varargs, a) } - ret := m.ctrl.Call(m, "GetOnlineFeaturesV2", varargs...) - ret0, _ := ret[0].(*serving.GetOnlineFeaturesResponse) + ret := m.ctrl.Call(m, "GetOnlineFeatures", varargs...) + ret0, _ := ret[0].(*serving.GetOnlineFeaturesResponseV2) ret1, _ := ret[1].(error) return ret0, ret1 } // GetOnlineFeaturesV2 indicates an expected call of GetOnlineFeaturesV2 -func (mr *MockServingServiceClientMockRecorder) GetOnlineFeaturesV2(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { +func (mr *MockServingServiceClientMockRecorder) GetOnlineFeatures(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOnlineFeaturesV2", reflect.TypeOf((*MockServingServiceClient)(nil).GetOnlineFeaturesV2), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOnlineFeatures", reflect.TypeOf((*MockServingServiceClient)(nil).GetOnlineFeatures), varargs...) } diff --git a/sdk/go/protos/feast/core/DataFormat.pb.go b/sdk/go/protos/feast/core/DataFormat.pb.go index 13c6cdda989..6745171c903 100644 --- a/sdk/go/protos/feast/core/DataFormat.pb.go +++ b/sdk/go/protos/feast/core/DataFormat.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/core/DataFormat.proto package core diff --git a/sdk/go/protos/feast/core/DataSource.pb.go b/sdk/go/protos/feast/core/DataSource.pb.go index 8af638a834f..83f0bc6736c 100644 --- a/sdk/go/protos/feast/core/DataSource.pb.go +++ b/sdk/go/protos/feast/core/DataSource.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/core/DataSource.proto package core diff --git a/sdk/go/protos/feast/core/Entity.pb.go b/sdk/go/protos/feast/core/Entity.pb.go index c6d9014791e..87f5b45164e 100644 --- a/sdk/go/protos/feast/core/Entity.pb.go +++ b/sdk/go/protos/feast/core/Entity.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/core/Entity.proto package core diff --git a/sdk/go/protos/feast/core/Feature.pb.go b/sdk/go/protos/feast/core/Feature.pb.go index 5d332dddff7..50515a822b7 100644 --- a/sdk/go/protos/feast/core/Feature.pb.go +++ b/sdk/go/protos/feast/core/Feature.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/core/Feature.proto package core diff --git a/sdk/go/protos/feast/core/FeatureTable.pb.go b/sdk/go/protos/feast/core/FeatureTable.pb.go index 355ef50fb86..0fc3feb0cab 100644 --- a/sdk/go/protos/feast/core/FeatureTable.pb.go +++ b/sdk/go/protos/feast/core/FeatureTable.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/core/FeatureTable.proto package core diff --git a/sdk/go/protos/feast/core/Store.pb.go b/sdk/go/protos/feast/core/Store.pb.go index 6c46f10d24a..26e5a5918f5 100644 --- a/sdk/go/protos/feast/core/Store.pb.go +++ b/sdk/go/protos/feast/core/Store.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/core/Store.proto package core diff --git a/sdk/go/protos/feast/serving/ServingService.pb.go b/sdk/go/protos/feast/serving/ServingService.pb.go index 32e3461dfdf..68e771a31b7 100644 --- a/sdk/go/protos/feast/serving/ServingService.pb.go +++ b/sdk/go/protos/feast/serving/ServingService.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/serving/ServingService.proto package serving @@ -24,7 +24,6 @@ package serving import ( context "context" types "github.com/feast-dev/feast/sdk/go/protos/feast/types" - _ "github.com/feast-dev/feast/sdk/go/protos/tensorflow_metadata/proto/v0" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" @@ -42,88 +41,35 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -type FeastServingType int32 - -const ( - FeastServingType_FEAST_SERVING_TYPE_INVALID FeastServingType = 0 - // Online serving receives entity data directly and synchronously and will - // respond immediately. - FeastServingType_FEAST_SERVING_TYPE_ONLINE FeastServingType = 1 - // Batch serving receives entity data asynchronously and orchestrates the - // retrieval through a staging location. - FeastServingType_FEAST_SERVING_TYPE_BATCH FeastServingType = 2 -) - -// Enum value maps for FeastServingType. -var ( - FeastServingType_name = map[int32]string{ - 0: "FEAST_SERVING_TYPE_INVALID", - 1: "FEAST_SERVING_TYPE_ONLINE", - 2: "FEAST_SERVING_TYPE_BATCH", - } - FeastServingType_value = map[string]int32{ - "FEAST_SERVING_TYPE_INVALID": 0, - "FEAST_SERVING_TYPE_ONLINE": 1, - "FEAST_SERVING_TYPE_BATCH": 2, - } -) - -func (x FeastServingType) Enum() *FeastServingType { - p := new(FeastServingType) - *p = x - return p -} - -func (x FeastServingType) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (FeastServingType) Descriptor() protoreflect.EnumDescriptor { - return file_feast_serving_ServingService_proto_enumTypes[0].Descriptor() -} - -func (FeastServingType) Type() protoreflect.EnumType { - return &file_feast_serving_ServingService_proto_enumTypes[0] -} - -func (x FeastServingType) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use FeastServingType.Descriptor instead. -func (FeastServingType) EnumDescriptor() ([]byte, []int) { - return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{0} -} - -type GetOnlineFeaturesResponse_FieldStatus int32 +type FieldStatus int32 const ( // Status is unset for this field. - GetOnlineFeaturesResponse_INVALID GetOnlineFeaturesResponse_FieldStatus = 0 + FieldStatus_INVALID FieldStatus = 0 // Field value is present for this field and age is within max age. - GetOnlineFeaturesResponse_PRESENT GetOnlineFeaturesResponse_FieldStatus = 1 + FieldStatus_PRESENT FieldStatus = 1 // Values could be found for entity key and age is within max age, but // this field value is assigned a value on ingestion into feast. - GetOnlineFeaturesResponse_NULL_VALUE GetOnlineFeaturesResponse_FieldStatus = 2 + FieldStatus_NULL_VALUE FieldStatus = 2 // Entity key did not return any values as they do not exist in Feast. // This could suggest that the feature values have not yet been ingested // into feast or the ingestion failed. - GetOnlineFeaturesResponse_NOT_FOUND GetOnlineFeaturesResponse_FieldStatus = 3 + FieldStatus_NOT_FOUND FieldStatus = 3 // Values could be found for entity key, but field values are outside the maximum // allowable range. - GetOnlineFeaturesResponse_OUTSIDE_MAX_AGE GetOnlineFeaturesResponse_FieldStatus = 4 + FieldStatus_OUTSIDE_MAX_AGE FieldStatus = 4 ) -// Enum value maps for GetOnlineFeaturesResponse_FieldStatus. +// Enum value maps for FieldStatus. var ( - GetOnlineFeaturesResponse_FieldStatus_name = map[int32]string{ + FieldStatus_name = map[int32]string{ 0: "INVALID", 1: "PRESENT", 2: "NULL_VALUE", 3: "NOT_FOUND", 4: "OUTSIDE_MAX_AGE", } - GetOnlineFeaturesResponse_FieldStatus_value = map[string]int32{ + FieldStatus_value = map[string]int32{ "INVALID": 0, "PRESENT": 1, "NULL_VALUE": 2, @@ -132,31 +78,31 @@ var ( } ) -func (x GetOnlineFeaturesResponse_FieldStatus) Enum() *GetOnlineFeaturesResponse_FieldStatus { - p := new(GetOnlineFeaturesResponse_FieldStatus) +func (x FieldStatus) Enum() *FieldStatus { + p := new(FieldStatus) *p = x return p } -func (x GetOnlineFeaturesResponse_FieldStatus) String() string { +func (x FieldStatus) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } -func (GetOnlineFeaturesResponse_FieldStatus) Descriptor() protoreflect.EnumDescriptor { - return file_feast_serving_ServingService_proto_enumTypes[1].Descriptor() +func (FieldStatus) Descriptor() protoreflect.EnumDescriptor { + return file_feast_serving_ServingService_proto_enumTypes[0].Descriptor() } -func (GetOnlineFeaturesResponse_FieldStatus) Type() protoreflect.EnumType { - return &file_feast_serving_ServingService_proto_enumTypes[1] +func (FieldStatus) Type() protoreflect.EnumType { + return &file_feast_serving_ServingService_proto_enumTypes[0] } -func (x GetOnlineFeaturesResponse_FieldStatus) Number() protoreflect.EnumNumber { +func (x FieldStatus) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } -// Deprecated: Use GetOnlineFeaturesResponse_FieldStatus.Descriptor instead. -func (GetOnlineFeaturesResponse_FieldStatus) EnumDescriptor() ([]byte, []int) { - return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{6, 0} +// Deprecated: Use FieldStatus.Descriptor instead. +func (FieldStatus) EnumDescriptor() ([]byte, []int) { + return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{0} } type GetFeastServingInfoRequest struct { @@ -204,12 +150,6 @@ type GetFeastServingInfoResponse struct { // Feast version of this serving deployment. Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` - // Type of serving deployment, either ONLINE or BATCH. Different store types support different - // feature retrieval methods. - Type FeastServingType `protobuf:"varint,2,opt,name=type,proto3,enum=feast.serving.FeastServingType" json:"type,omitempty"` - // Note: Batch specific options start from 10. - // Staging location for this serving store, if any. - JobStagingLocation string `protobuf:"bytes,10,opt,name=job_staging_location,json=jobStagingLocation,proto3" json:"job_staging_location,omitempty"` } func (x *GetFeastServingInfoResponse) Reset() { @@ -251,29 +191,15 @@ func (x *GetFeastServingInfoResponse) GetVersion() string { return "" } -func (x *GetFeastServingInfoResponse) GetType() FeastServingType { - if x != nil { - return x.Type - } - return FeastServingType_FEAST_SERVING_TYPE_INVALID -} - -func (x *GetFeastServingInfoResponse) GetJobStagingLocation() string { - if x != nil { - return x.JobStagingLocation - } - return "" -} - type FeatureReferenceV2 struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Name of the Feature Table to retrieve the feature from. - FeatureTable string `protobuf:"bytes,1,opt,name=feature_table,json=featureTable,proto3" json:"feature_table,omitempty"` + // Name of the Feature View to retrieve the feature from. + FeatureViewName string `protobuf:"bytes,1,opt,name=feature_view_name,json=featureViewName,proto3" json:"feature_view_name,omitempty"` // Name of the Feature to retrieve the feature from. - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + FeatureName string `protobuf:"bytes,2,opt,name=feature_name,json=featureName,proto3" json:"feature_name,omitempty"` } func (x *FeatureReferenceV2) Reset() { @@ -308,20 +234,21 @@ func (*FeatureReferenceV2) Descriptor() ([]byte, []int) { return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{2} } -func (x *FeatureReferenceV2) GetFeatureTable() string { +func (x *FeatureReferenceV2) GetFeatureViewName() string { if x != nil { - return x.FeatureTable + return x.FeatureViewName } return "" } -func (x *FeatureReferenceV2) GetName() string { +func (x *FeatureReferenceV2) GetFeatureName() string { if x != nil { - return x.Name + return x.FeatureName } return "" } +// ToDo (oleksii): remove this message (since it's not used) and move EntityRow on package level type GetOnlineFeaturesRequestV2 struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -453,6 +380,7 @@ type GetOnlineFeaturesRequest struct { // A map of entity name -> list of values Entities map[string]*types.RepeatedValue `protobuf:"bytes,3,rep,name=entities,proto3" json:"entities,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` FullFeatureNames bool `protobuf:"varint,4,opt,name=full_feature_names,json=fullFeatureNames,proto3" json:"full_feature_names,omitempty"` + RequestContext map[string]*types.RepeatedValue `protobuf:"bytes,5,rep,name=request_context,json=requestContext,proto3" json:"request_context,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` } func (x *GetOnlineFeaturesRequest) Reset() { @@ -522,6 +450,13 @@ func (x *GetOnlineFeaturesRequest) GetFullFeatureNames() bool { return false } +func (x *GetOnlineFeaturesRequest) GetRequestContext() map[string]*types.RepeatedValue { + if x != nil { + return x.RequestContext + } + return nil +} + type isGetOnlineFeaturesRequest_Kind interface { isGetOnlineFeaturesRequest_Kind() } @@ -586,6 +521,108 @@ func (x *GetOnlineFeaturesResponse) GetFieldValues() []*GetOnlineFeaturesRespons return nil } +type GetOnlineFeaturesResponseV2 struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Metadata *GetOnlineFeaturesResponseMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + Results []*GetOnlineFeaturesResponseV2_FeatureVector `protobuf:"bytes,2,rep,name=results,proto3" json:"results,omitempty"` +} + +func (x *GetOnlineFeaturesResponseV2) Reset() { + *x = GetOnlineFeaturesResponseV2{} + if protoimpl.UnsafeEnabled { + mi := &file_feast_serving_ServingService_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetOnlineFeaturesResponseV2) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOnlineFeaturesResponseV2) ProtoMessage() {} + +func (x *GetOnlineFeaturesResponseV2) ProtoReflect() protoreflect.Message { + mi := &file_feast_serving_ServingService_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOnlineFeaturesResponseV2.ProtoReflect.Descriptor instead. +func (*GetOnlineFeaturesResponseV2) Descriptor() ([]byte, []int) { + return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{7} +} + +func (x *GetOnlineFeaturesResponseV2) GetMetadata() *GetOnlineFeaturesResponseMetadata { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *GetOnlineFeaturesResponseV2) GetResults() []*GetOnlineFeaturesResponseV2_FeatureVector { + if x != nil { + return x.Results + } + return nil +} + +type GetOnlineFeaturesResponseMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + FeatureNames *FeatureList `protobuf:"bytes,1,opt,name=feature_names,json=featureNames,proto3" json:"feature_names,omitempty"` +} + +func (x *GetOnlineFeaturesResponseMetadata) Reset() { + *x = GetOnlineFeaturesResponseMetadata{} + if protoimpl.UnsafeEnabled { + mi := &file_feast_serving_ServingService_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetOnlineFeaturesResponseMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOnlineFeaturesResponseMetadata) ProtoMessage() {} + +func (x *GetOnlineFeaturesResponseMetadata) ProtoReflect() protoreflect.Message { + mi := &file_feast_serving_ServingService_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOnlineFeaturesResponseMetadata.ProtoReflect.Descriptor instead. +func (*GetOnlineFeaturesResponseMetadata) Descriptor() ([]byte, []int) { + return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{8} +} + +func (x *GetOnlineFeaturesResponseMetadata) GetFeatureNames() *FeatureList { + if x != nil { + return x.FeatureNames + } + return nil +} + type GetOnlineFeaturesRequestV2_EntityRow struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -601,7 +638,7 @@ type GetOnlineFeaturesRequestV2_EntityRow struct { func (x *GetOnlineFeaturesRequestV2_EntityRow) Reset() { *x = GetOnlineFeaturesRequestV2_EntityRow{} if protoimpl.UnsafeEnabled { - mi := &file_feast_serving_ServingService_proto_msgTypes[7] + mi := &file_feast_serving_ServingService_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -614,7 +651,7 @@ func (x *GetOnlineFeaturesRequestV2_EntityRow) String() string { func (*GetOnlineFeaturesRequestV2_EntityRow) ProtoMessage() {} func (x *GetOnlineFeaturesRequestV2_EntityRow) ProtoReflect() protoreflect.Message { - mi := &file_feast_serving_ServingService_proto_msgTypes[7] + mi := &file_feast_serving_ServingService_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -653,13 +690,13 @@ type GetOnlineFeaturesResponse_FieldValues struct { // Timestamps are not returned in this response. Fields map[string]*types.Value `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Map of feature or entity name to feature/entity statuses/metadata. - Statuses map[string]GetOnlineFeaturesResponse_FieldStatus `protobuf:"bytes,2,rep,name=statuses,proto3" json:"statuses,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3,enum=feast.serving.GetOnlineFeaturesResponse_FieldStatus"` + Statuses map[string]FieldStatus `protobuf:"bytes,2,rep,name=statuses,proto3" json:"statuses,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3,enum=feast.serving.FieldStatus"` } func (x *GetOnlineFeaturesResponse_FieldValues) Reset() { *x = GetOnlineFeaturesResponse_FieldValues{} if protoimpl.UnsafeEnabled { - mi := &file_feast_serving_ServingService_proto_msgTypes[10] + mi := &file_feast_serving_ServingService_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -672,7 +709,7 @@ func (x *GetOnlineFeaturesResponse_FieldValues) String() string { func (*GetOnlineFeaturesResponse_FieldValues) ProtoMessage() {} func (x *GetOnlineFeaturesResponse_FieldValues) ProtoReflect() protoreflect.Message { - mi := &file_feast_serving_ServingService_proto_msgTypes[10] + mi := &file_feast_serving_ServingService_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -695,13 +732,76 @@ func (x *GetOnlineFeaturesResponse_FieldValues) GetFields() map[string]*types.Va return nil } -func (x *GetOnlineFeaturesResponse_FieldValues) GetStatuses() map[string]GetOnlineFeaturesResponse_FieldStatus { +func (x *GetOnlineFeaturesResponse_FieldValues) GetStatuses() map[string]FieldStatus { if x != nil { return x.Statuses } return nil } +type GetOnlineFeaturesResponseV2_FeatureVector struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Values []*types.Value `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + Statuses []FieldStatus `protobuf:"varint,2,rep,packed,name=statuses,proto3,enum=feast.serving.FieldStatus" json:"statuses,omitempty"` + EventTimestamps []*timestamppb.Timestamp `protobuf:"bytes,3,rep,name=event_timestamps,json=eventTimestamps,proto3" json:"event_timestamps,omitempty"` +} + +func (x *GetOnlineFeaturesResponseV2_FeatureVector) Reset() { + *x = GetOnlineFeaturesResponseV2_FeatureVector{} + if protoimpl.UnsafeEnabled { + mi := &file_feast_serving_ServingService_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetOnlineFeaturesResponseV2_FeatureVector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOnlineFeaturesResponseV2_FeatureVector) ProtoMessage() {} + +func (x *GetOnlineFeaturesResponseV2_FeatureVector) ProtoReflect() protoreflect.Message { + mi := &file_feast_serving_ServingService_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOnlineFeaturesResponseV2_FeatureVector.ProtoReflect.Descriptor instead. +func (*GetOnlineFeaturesResponseV2_FeatureVector) Descriptor() ([]byte, []int) { + return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{7, 0} +} + +func (x *GetOnlineFeaturesResponseV2_FeatureVector) GetValues() []*types.Value { + if x != nil { + return x.Values + } + return nil +} + +func (x *GetOnlineFeaturesResponseV2_FeatureVector) GetStatuses() []FieldStatus { + if x != nil { + return x.Statuses + } + return nil +} + +func (x *GetOnlineFeaturesResponseV2_FeatureVector) GetEventTimestamps() []*timestamppb.Timestamp { + if x != nil { + return x.EventTimestamps + } + return nil +} + var File_feast_serving_ServingService_proto protoreflect.FileDescriptor var file_feast_serving_ServingService_proto_rawDesc = []byte{ @@ -711,146 +811,171 @@ var file_feast_serving_ServingService_proto_rawDesc = []byte{ 0x69, 0x6e, 0x67, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x17, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x74, 0x79, 0x70, 0x65, - 0x73, 0x2f, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d, 0x74, - 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x74, 0x61, 0x74, - 0x69, 0x73, 0x74, 0x69, 0x63, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x1c, 0x0a, 0x1a, - 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x49, - 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x9e, 0x01, 0x0a, 0x1b, 0x47, + 0x73, 0x2f, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x1c, 0x0a, + 0x1a, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, + 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x37, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x54, - 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x30, 0x0a, 0x14, 0x6a, 0x6f, 0x62, - 0x5f, 0x73, 0x74, 0x61, 0x67, 0x69, 0x6e, 0x67, 0x5f, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x6a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x67, - 0x69, 0x6e, 0x67, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x4d, 0x0a, 0x12, 0x46, - 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x56, - 0x32, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x74, 0x61, 0x62, - 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, - 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0xbb, 0x03, 0x0a, 0x1a, 0x47, - 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, 0x32, 0x12, 0x3d, 0x0a, 0x08, 0x66, 0x65, 0x61, - 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x66, 0x65, - 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x74, - 0x75, 0x72, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x56, 0x32, 0x52, 0x08, - 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x12, 0x54, 0x0a, 0x0b, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x5f, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, - 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, - 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, 0x32, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, - 0x6f, 0x77, 0x52, 0x0a, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77, 0x73, 0x12, 0x18, - 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x1a, 0xed, 0x01, 0x0a, 0x09, 0x45, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x12, 0x57, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x3f, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, - 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, - 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, 0x32, 0x2e, 0x45, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x52, 0x6f, 0x77, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x4d, 0x0a, 0x0b, 0x46, 0x69, 0x65, - 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x66, 0x65, 0x61, 0x73, - 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1f, 0x0a, 0x0b, 0x46, 0x65, 0x61, 0x74, - 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x76, 0x61, 0x6c, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x76, 0x61, 0x6c, 0x22, 0xe1, 0x02, 0x0a, 0x18, 0x47, 0x65, - 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x29, 0x0a, 0x0f, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, - 0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, - 0x00, 0x52, 0x0e, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x38, 0x0a, 0x08, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x48, - 0x00, 0x52, 0x08, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x12, 0x51, 0x0a, 0x08, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x35, 0x2e, - 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, + 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x63, 0x0a, 0x12, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x52, + 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x56, 0x32, 0x12, 0x2a, 0x0a, 0x11, 0x66, 0x65, + 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x76, 0x69, 0x65, 0x77, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x69, + 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, + 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x66, 0x65, + 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0xbb, 0x03, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x2c, - 0x0a, 0x12, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x66, 0x75, 0x6c, 0x6c, - 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x1a, 0x57, 0x0a, 0x0d, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x30, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x70, - 0x65, 0x61, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x22, 0xdd, 0x04, - 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, - 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x66, - 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x34, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, - 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, - 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x69, 0x65, 0x6c, - 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x73, 0x1a, 0x89, 0x03, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x73, 0x12, 0x58, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x40, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, 0x32, 0x12, 0x3d, 0x0a, 0x08, 0x66, 0x65, 0x61, 0x74, + 0x75, 0x72, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x66, 0x65, 0x61, + 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x56, 0x32, 0x52, 0x08, 0x66, + 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x12, 0x54, 0x0a, 0x0b, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x5f, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x66, + 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, + 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, 0x32, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, + 0x77, 0x52, 0x0a, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77, 0x73, 0x12, 0x18, 0x0a, + 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x1a, 0xed, 0x01, 0x0a, 0x09, 0x45, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x52, 0x6f, 0x77, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, + 0x57, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x3f, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, + 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, 0x32, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x52, 0x6f, 0x77, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x4d, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, + 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, + 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1f, 0x0a, 0x0b, 0x46, 0x65, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x76, 0x61, 0x6c, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x03, 0x76, 0x61, 0x6c, 0x22, 0xa6, 0x04, 0x0a, 0x18, 0x47, 0x65, 0x74, + 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x29, 0x0a, 0x0f, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x0e, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x12, 0x38, 0x0a, 0x08, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x48, 0x00, + 0x52, 0x08, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x12, 0x51, 0x0a, 0x08, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x66, + 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, + 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x08, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x2c, 0x0a, + 0x12, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x66, 0x75, 0x6c, 0x6c, 0x46, + 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x64, 0x0a, 0x0f, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3b, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, + 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x1a, 0x57, 0x0a, 0x0d, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, + 0x73, 0x2e, 0x52, 0x65, 0x70, 0x65, 0x61, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x13, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, + 0x2e, 0x52, 0x65, 0x70, 0x65, 0x61, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x6b, 0x69, 0x6e, + 0x64, 0x22, 0xe6, 0x03, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, + 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x57, 0x0a, 0x0c, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, + 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x0b, 0x66, 0x69, 0x65, + 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0xef, 0x02, 0x0a, 0x0b, 0x46, 0x69, 0x65, + 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x58, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, + 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x40, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, + 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2e, 0x46, + 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, + 0x64, 0x73, 0x12, 0x5e, 0x0a, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x42, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, - 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x5e, - 0x0a, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x42, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, - 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, - 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x1a, 0x4d, - 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x28, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, + 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x65, 0x73, 0x1a, 0x4d, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, + 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x1a, 0x57, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xfc, 0x02, 0x0a, 0x1b, 0x47, + 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x56, 0x32, 0x12, 0x4c, 0x0a, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x66, + 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, + 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x52, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x66, 0x65, 0x61, 0x73, + 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, + 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x56, 0x32, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x1a, 0xba, 0x01, 0x0a, + 0x0d, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x2a, + 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x56, 0x61, 0x6c, - 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x71, 0x0a, - 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x4a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x34, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, - 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, - 0x22, 0x5b, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, - 0x0b, 0x0a, 0x07, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, - 0x50, 0x52, 0x45, 0x53, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x4e, 0x55, 0x4c, - 0x4c, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x4e, 0x4f, 0x54, - 0x5f, 0x46, 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4f, 0x55, 0x54, 0x53, - 0x49, 0x44, 0x45, 0x5f, 0x4d, 0x41, 0x58, 0x5f, 0x41, 0x47, 0x45, 0x10, 0x04, 0x2a, 0x6f, 0x0a, - 0x10, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, - 0x65, 0x12, 0x1e, 0x0a, 0x1a, 0x46, 0x45, 0x41, 0x53, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, - 0x4e, 0x47, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, - 0x00, 0x12, 0x1d, 0x0a, 0x19, 0x46, 0x45, 0x41, 0x53, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, - 0x4e, 0x47, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4f, 0x4e, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x01, - 0x12, 0x1c, 0x0a, 0x18, 0x46, 0x45, 0x41, 0x53, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, - 0x47, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x42, 0x41, 0x54, 0x43, 0x48, 0x10, 0x02, 0x32, 0xea, - 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x6c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, - 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x29, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, - 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x6a, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, - 0x75, 0x72, 0x65, 0x73, 0x56, 0x32, 0x12, 0x29, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, - 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x56, - 0x32, 0x1a, 0x28, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, - 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, - 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x5e, 0x0a, 0x13, 0x66, - 0x65, 0x61, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x6e, 0x67, 0x42, 0x0f, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x41, 0x50, 0x49, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x66, 0x65, 0x61, 0x73, 0x74, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, - 0x73, 0x64, 0x6b, 0x2f, 0x67, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x66, 0x65, - 0x61, 0x73, 0x74, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x75, 0x65, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x08, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x66, + 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x69, 0x65, + 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x22, 0x64, 0x0a, 0x21, 0x47, 0x65, 0x74, + 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x3f, + 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, + 0x74, 0x52, 0x0c, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x2a, + 0x5b, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, + 0x0a, 0x07, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, + 0x52, 0x45, 0x53, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x4e, 0x55, 0x4c, 0x4c, + 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x4e, 0x4f, 0x54, 0x5f, + 0x46, 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4f, 0x55, 0x54, 0x53, 0x49, + 0x44, 0x45, 0x5f, 0x4d, 0x41, 0x58, 0x5f, 0x41, 0x47, 0x45, 0x10, 0x04, 0x32, 0xe8, 0x01, 0x0a, + 0x0e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, + 0x6c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x29, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, + 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, + 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, + 0x11, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, + 0x65, 0x73, 0x12, 0x27, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, + 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x66, 0x65, + 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, + 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x56, 0x32, 0x42, 0x5e, 0x0a, 0x13, 0x66, 0x65, 0x61, 0x73, 0x74, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x42, 0x0f, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x41, 0x50, 0x49, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x5a, + 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66, 0x65, 0x61, 0x73, + 0x74, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x73, 0x64, 0x6b, 0x2f, + 0x67, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -865,52 +990,62 @@ func file_feast_serving_ServingService_proto_rawDescGZIP() []byte { return file_feast_serving_ServingService_proto_rawDescData } -var file_feast_serving_ServingService_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_feast_serving_ServingService_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_feast_serving_ServingService_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_feast_serving_ServingService_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_feast_serving_ServingService_proto_goTypes = []interface{}{ - (FeastServingType)(0), // 0: feast.serving.FeastServingType - (GetOnlineFeaturesResponse_FieldStatus)(0), // 1: feast.serving.GetOnlineFeaturesResponse.FieldStatus - (*GetFeastServingInfoRequest)(nil), // 2: feast.serving.GetFeastServingInfoRequest - (*GetFeastServingInfoResponse)(nil), // 3: feast.serving.GetFeastServingInfoResponse - (*FeatureReferenceV2)(nil), // 4: feast.serving.FeatureReferenceV2 - (*GetOnlineFeaturesRequestV2)(nil), // 5: feast.serving.GetOnlineFeaturesRequestV2 - (*FeatureList)(nil), // 6: feast.serving.FeatureList - (*GetOnlineFeaturesRequest)(nil), // 7: feast.serving.GetOnlineFeaturesRequest - (*GetOnlineFeaturesResponse)(nil), // 8: feast.serving.GetOnlineFeaturesResponse - (*GetOnlineFeaturesRequestV2_EntityRow)(nil), // 9: feast.serving.GetOnlineFeaturesRequestV2.EntityRow - nil, // 10: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry - nil, // 11: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry - (*GetOnlineFeaturesResponse_FieldValues)(nil), // 12: feast.serving.GetOnlineFeaturesResponse.FieldValues - nil, // 13: feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry - nil, // 14: feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry - (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp - (*types.Value)(nil), // 16: feast.types.Value - (*types.RepeatedValue)(nil), // 17: feast.types.RepeatedValue + (FieldStatus)(0), // 0: feast.serving.FieldStatus + (*GetFeastServingInfoRequest)(nil), // 1: feast.serving.GetFeastServingInfoRequest + (*GetFeastServingInfoResponse)(nil), // 2: feast.serving.GetFeastServingInfoResponse + (*FeatureReferenceV2)(nil), // 3: feast.serving.FeatureReferenceV2 + (*GetOnlineFeaturesRequestV2)(nil), // 4: feast.serving.GetOnlineFeaturesRequestV2 + (*FeatureList)(nil), // 5: feast.serving.FeatureList + (*GetOnlineFeaturesRequest)(nil), // 6: feast.serving.GetOnlineFeaturesRequest + (*GetOnlineFeaturesResponse)(nil), // 7: feast.serving.GetOnlineFeaturesResponse + (*GetOnlineFeaturesResponseV2)(nil), // 8: feast.serving.GetOnlineFeaturesResponseV2 + (*GetOnlineFeaturesResponseMetadata)(nil), // 9: feast.serving.GetOnlineFeaturesResponseMetadata + (*GetOnlineFeaturesRequestV2_EntityRow)(nil), // 10: feast.serving.GetOnlineFeaturesRequestV2.EntityRow + nil, // 11: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry + nil, // 12: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry + nil, // 13: feast.serving.GetOnlineFeaturesRequest.RequestContextEntry + (*GetOnlineFeaturesResponse_FieldValues)(nil), // 14: feast.serving.GetOnlineFeaturesResponse.FieldValues + nil, // 15: feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry + nil, // 16: feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry + (*GetOnlineFeaturesResponseV2_FeatureVector)(nil), // 17: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector + (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp + (*types.Value)(nil), // 19: feast.types.Value + (*types.RepeatedValue)(nil), // 20: feast.types.RepeatedValue } var file_feast_serving_ServingService_proto_depIdxs = []int32{ - 0, // 0: feast.serving.GetFeastServingInfoResponse.type:type_name -> feast.serving.FeastServingType - 4, // 1: feast.serving.GetOnlineFeaturesRequestV2.features:type_name -> feast.serving.FeatureReferenceV2 - 9, // 2: feast.serving.GetOnlineFeaturesRequestV2.entity_rows:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow - 6, // 3: feast.serving.GetOnlineFeaturesRequest.features:type_name -> feast.serving.FeatureList - 11, // 4: feast.serving.GetOnlineFeaturesRequest.entities:type_name -> feast.serving.GetOnlineFeaturesRequest.EntitiesEntry - 12, // 5: feast.serving.GetOnlineFeaturesResponse.field_values:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues - 15, // 6: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.timestamp:type_name -> google.protobuf.Timestamp - 10, // 7: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.fields:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry - 16, // 8: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry.value:type_name -> feast.types.Value - 17, // 9: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry.value:type_name -> feast.types.RepeatedValue - 13, // 10: feast.serving.GetOnlineFeaturesResponse.FieldValues.fields:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry - 14, // 11: feast.serving.GetOnlineFeaturesResponse.FieldValues.statuses:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry - 16, // 12: feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry.value:type_name -> feast.types.Value - 1, // 13: feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry.value:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldStatus - 2, // 14: feast.serving.ServingService.GetFeastServingInfo:input_type -> feast.serving.GetFeastServingInfoRequest - 5, // 15: feast.serving.ServingService.GetOnlineFeaturesV2:input_type -> feast.serving.GetOnlineFeaturesRequestV2 - 3, // 16: feast.serving.ServingService.GetFeastServingInfo:output_type -> feast.serving.GetFeastServingInfoResponse - 8, // 17: feast.serving.ServingService.GetOnlineFeaturesV2:output_type -> feast.serving.GetOnlineFeaturesResponse - 16, // [16:18] is the sub-list for method output_type - 14, // [14:16] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 3, // 0: feast.serving.GetOnlineFeaturesRequestV2.features:type_name -> feast.serving.FeatureReferenceV2 + 10, // 1: feast.serving.GetOnlineFeaturesRequestV2.entity_rows:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow + 5, // 2: feast.serving.GetOnlineFeaturesRequest.features:type_name -> feast.serving.FeatureList + 12, // 3: feast.serving.GetOnlineFeaturesRequest.entities:type_name -> feast.serving.GetOnlineFeaturesRequest.EntitiesEntry + 13, // 4: feast.serving.GetOnlineFeaturesRequest.request_context:type_name -> feast.serving.GetOnlineFeaturesRequest.RequestContextEntry + 14, // 5: feast.serving.GetOnlineFeaturesResponse.field_values:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues + 9, // 6: feast.serving.GetOnlineFeaturesResponseV2.metadata:type_name -> feast.serving.GetOnlineFeaturesResponseMetadata + 17, // 7: feast.serving.GetOnlineFeaturesResponseV2.results:type_name -> feast.serving.GetOnlineFeaturesResponseV2.FeatureVector + 5, // 8: feast.serving.GetOnlineFeaturesResponseMetadata.feature_names:type_name -> feast.serving.FeatureList + 18, // 9: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.timestamp:type_name -> google.protobuf.Timestamp + 11, // 10: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.fields:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry + 19, // 11: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry.value:type_name -> feast.types.Value + 20, // 12: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry.value:type_name -> feast.types.RepeatedValue + 20, // 13: feast.serving.GetOnlineFeaturesRequest.RequestContextEntry.value:type_name -> feast.types.RepeatedValue + 15, // 14: feast.serving.GetOnlineFeaturesResponse.FieldValues.fields:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry + 16, // 15: feast.serving.GetOnlineFeaturesResponse.FieldValues.statuses:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry + 19, // 16: feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry.value:type_name -> feast.types.Value + 0, // 17: feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry.value:type_name -> feast.serving.FieldStatus + 19, // 18: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector.values:type_name -> feast.types.Value + 0, // 19: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector.statuses:type_name -> feast.serving.FieldStatus + 18, // 20: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector.event_timestamps:type_name -> google.protobuf.Timestamp + 1, // 21: feast.serving.ServingService.GetFeastServingInfo:input_type -> feast.serving.GetFeastServingInfoRequest + 6, // 22: feast.serving.ServingService.GetOnlineFeatures:input_type -> feast.serving.GetOnlineFeaturesRequest + 2, // 23: feast.serving.ServingService.GetFeastServingInfo:output_type -> feast.serving.GetFeastServingInfoResponse + 8, // 24: feast.serving.ServingService.GetOnlineFeatures:output_type -> feast.serving.GetOnlineFeaturesResponseV2 + 23, // [23:25] is the sub-list for method output_type + 21, // [21:23] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name } func init() { file_feast_serving_ServingService_proto_init() } @@ -1004,6 +1139,30 @@ func file_feast_serving_ServingService_proto_init() { } } file_feast_serving_ServingService_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetOnlineFeaturesResponseV2); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_feast_serving_ServingService_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetOnlineFeaturesResponseMetadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_feast_serving_ServingService_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetOnlineFeaturesRequestV2_EntityRow); i { case 0: return &v.state @@ -1015,7 +1174,7 @@ func file_feast_serving_ServingService_proto_init() { return nil } } - file_feast_serving_ServingService_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + file_feast_serving_ServingService_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetOnlineFeaturesResponse_FieldValues); i { case 0: return &v.state @@ -1027,6 +1186,18 @@ func file_feast_serving_ServingService_proto_init() { return nil } } + file_feast_serving_ServingService_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetOnlineFeaturesResponseV2_FeatureVector); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } file_feast_serving_ServingService_proto_msgTypes[5].OneofWrappers = []interface{}{ (*GetOnlineFeaturesRequest_FeatureService)(nil), @@ -1037,8 +1208,8 @@ func file_feast_serving_ServingService_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_feast_serving_ServingService_proto_rawDesc, - NumEnums: 2, - NumMessages: 13, + NumEnums: 1, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, @@ -1067,8 +1238,8 @@ const _ = grpc.SupportPackageIsVersion6 type ServingServiceClient interface { // Get information about this Feast serving. GetFeastServingInfo(ctx context.Context, in *GetFeastServingInfoRequest, opts ...grpc.CallOption) (*GetFeastServingInfoResponse, error) - // Get online features (v2) synchronously. - GetOnlineFeaturesV2(ctx context.Context, in *GetOnlineFeaturesRequestV2, opts ...grpc.CallOption) (*GetOnlineFeaturesResponse, error) + // Get online features synchronously. + GetOnlineFeatures(ctx context.Context, in *GetOnlineFeaturesRequest, opts ...grpc.CallOption) (*GetOnlineFeaturesResponseV2, error) } type servingServiceClient struct { @@ -1088,9 +1259,9 @@ func (c *servingServiceClient) GetFeastServingInfo(ctx context.Context, in *GetF return out, nil } -func (c *servingServiceClient) GetOnlineFeaturesV2(ctx context.Context, in *GetOnlineFeaturesRequestV2, opts ...grpc.CallOption) (*GetOnlineFeaturesResponse, error) { - out := new(GetOnlineFeaturesResponse) - err := c.cc.Invoke(ctx, "/feast.serving.ServingService/GetOnlineFeaturesV2", in, out, opts...) +func (c *servingServiceClient) GetOnlineFeatures(ctx context.Context, in *GetOnlineFeaturesRequest, opts ...grpc.CallOption) (*GetOnlineFeaturesResponseV2, error) { + out := new(GetOnlineFeaturesResponseV2) + err := c.cc.Invoke(ctx, "/feast.serving.ServingService/GetOnlineFeatures", in, out, opts...) if err != nil { return nil, err } @@ -1101,8 +1272,8 @@ func (c *servingServiceClient) GetOnlineFeaturesV2(ctx context.Context, in *GetO type ServingServiceServer interface { // Get information about this Feast serving. GetFeastServingInfo(context.Context, *GetFeastServingInfoRequest) (*GetFeastServingInfoResponse, error) - // Get online features (v2) synchronously. - GetOnlineFeaturesV2(context.Context, *GetOnlineFeaturesRequestV2) (*GetOnlineFeaturesResponse, error) + // Get online features synchronously. + GetOnlineFeatures(context.Context, *GetOnlineFeaturesRequest) (*GetOnlineFeaturesResponseV2, error) } // UnimplementedServingServiceServer can be embedded to have forward compatible implementations. @@ -1112,8 +1283,8 @@ type UnimplementedServingServiceServer struct { func (*UnimplementedServingServiceServer) GetFeastServingInfo(context.Context, *GetFeastServingInfoRequest) (*GetFeastServingInfoResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetFeastServingInfo not implemented") } -func (*UnimplementedServingServiceServer) GetOnlineFeaturesV2(context.Context, *GetOnlineFeaturesRequestV2) (*GetOnlineFeaturesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetOnlineFeaturesV2 not implemented") +func (*UnimplementedServingServiceServer) GetOnlineFeatures(context.Context, *GetOnlineFeaturesRequest) (*GetOnlineFeaturesResponseV2, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOnlineFeatures not implemented") } func RegisterServingServiceServer(s *grpc.Server, srv ServingServiceServer) { @@ -1138,20 +1309,20 @@ func _ServingService_GetFeastServingInfo_Handler(srv interface{}, ctx context.Co return interceptor(ctx, in, info, handler) } -func _ServingService_GetOnlineFeaturesV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetOnlineFeaturesRequestV2) +func _ServingService_GetOnlineFeatures_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetOnlineFeaturesRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(ServingServiceServer).GetOnlineFeaturesV2(ctx, in) + return srv.(ServingServiceServer).GetOnlineFeatures(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/feast.serving.ServingService/GetOnlineFeaturesV2", + FullMethod: "/feast.serving.ServingService/GetOnlineFeatures", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ServingServiceServer).GetOnlineFeaturesV2(ctx, req.(*GetOnlineFeaturesRequestV2)) + return srv.(ServingServiceServer).GetOnlineFeatures(ctx, req.(*GetOnlineFeaturesRequest)) } return interceptor(ctx, in, info, handler) } @@ -1165,8 +1336,8 @@ var _ServingService_serviceDesc = grpc.ServiceDesc{ Handler: _ServingService_GetFeastServingInfo_Handler, }, { - MethodName: "GetOnlineFeaturesV2", - Handler: _ServingService_GetOnlineFeaturesV2_Handler, + MethodName: "GetOnlineFeatures", + Handler: _ServingService_GetOnlineFeatures_Handler, }, }, Streams: []grpc.StreamDesc{}, diff --git a/sdk/go/protos/feast/storage/Redis.pb.go b/sdk/go/protos/feast/storage/Redis.pb.go index 08ee629b7c0..8fff34e5171 100644 --- a/sdk/go/protos/feast/storage/Redis.pb.go +++ b/sdk/go/protos/feast/storage/Redis.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/storage/Redis.proto package storage diff --git a/sdk/go/protos/feast/types/Field.pb.go b/sdk/go/protos/feast/types/Field.pb.go index 73f46bb1ac3..c529d76153b 100644 --- a/sdk/go/protos/feast/types/Field.pb.go +++ b/sdk/go/protos/feast/types/Field.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/types/Field.proto package types diff --git a/sdk/go/protos/feast/types/Value.pb.go b/sdk/go/protos/feast/types/Value.pb.go index 9ae2806d515..fe53c2ec298 100644 --- a/sdk/go/protos/feast/types/Value.pb.go +++ b/sdk/go/protos/feast/types/Value.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.11.2 +// protoc v3.17.3 // source: feast/types/Value.proto package types diff --git a/sdk/go/request.go b/sdk/go/request.go index 360603b3a3e..e6da10ff9bb 100644 --- a/sdk/go/request.go +++ b/sdk/go/request.go @@ -3,6 +3,7 @@ package feast import ( "fmt" "github.com/feast-dev/feast/sdk/go/protos/feast/serving" + "github.com/feast-dev/feast/sdk/go/protos/feast/types" "strings" ) @@ -29,24 +30,44 @@ type OnlineFeaturesRequest struct { } // Builds the feast-specified request payload from the wrapper. -func (r OnlineFeaturesRequest) buildRequest() (*serving.GetOnlineFeaturesRequestV2, error) { - featureRefs, err := buildFeatureRefs(r.Features) +func (r OnlineFeaturesRequest) buildRequest() (*serving.GetOnlineFeaturesRequest, error) { + _, err := buildFeatureRefs(r.Features) if err != nil { return nil, err } + if len(r.Entities) == 0 { + return nil, fmt.Errorf("Entities must be provided") + } + + firstRow := r.Entities[0] + columnSize := len(firstRow) // build request entity rows from native entities - entityRows := make([]*serving.GetOnlineFeaturesRequestV2_EntityRow, len(r.Entities)) - for i, entity := range r.Entities { - entityRows[i] = &serving.GetOnlineFeaturesRequestV2_EntityRow{ - Fields: entity, + entityColumns := make(map[string][]*types.Value, columnSize) + for rowIdx, entityRow := range r.Entities { + for name, val := range entityRow { + if _, ok := entityColumns[name]; !ok { + entityColumns[name] = make([]*types.Value, len(r.Entities)) + } + + entityColumns[name][rowIdx] = val + } + } + + entities := make(map[string]*types.RepeatedValue, len(entityColumns)) + for column, values := range entityColumns { + entities[column] = &types.RepeatedValue{ + Val: values, } } - return &serving.GetOnlineFeaturesRequestV2{ - Features: featureRefs, - EntityRows: entityRows, - Project: r.Project, + return &serving.GetOnlineFeaturesRequest{ + Kind: &serving.GetOnlineFeaturesRequest_Features{ + Features: &serving.FeatureList{ + Val: r.Features, + }, + }, + Entities: entities, }, nil } @@ -84,9 +105,9 @@ func parseFeatureRef(featureRefStr string) (*serving.FeatureReferenceV2, error) // parse featuretable if specified if strings.Contains(featureRefStr, ":") { refSplit := strings.Split(featureRefStr, ":") - featureRef.FeatureTable, featureRefStr = refSplit[0], refSplit[1] + featureRef.FeatureViewName, featureRefStr = refSplit[0], refSplit[1] } - featureRef.Name = featureRefStr + featureRef.FeatureName = featureRefStr return &featureRef, nil } diff --git a/sdk/go/request_test.go b/sdk/go/request_test.go index 0e9b89d119a..6beb15f7f71 100644 --- a/sdk/go/request_test.go +++ b/sdk/go/request_test.go @@ -13,7 +13,7 @@ func TestGetOnlineFeaturesRequest(t *testing.T) { tt := []struct { name string req OnlineFeaturesRequest - want *serving.GetOnlineFeaturesRequestV2 + want *serving.GetOnlineFeaturesRequest wantErr bool err error }{ @@ -30,34 +30,24 @@ func TestGetOnlineFeaturesRequest(t *testing.T) { }, Project: "driver_project", }, - want: &serving.GetOnlineFeaturesRequestV2{ - Features: []*serving.FeatureReferenceV2{ - { - FeatureTable: "driver", - Name: "driver_id", + want: &serving.GetOnlineFeaturesRequest{ + Kind: &serving.GetOnlineFeaturesRequest_Features{ + Features: &serving.FeatureList{ + Val: []string{"driver:driver_id"}, }, }, - EntityRows: []*serving.GetOnlineFeaturesRequestV2_EntityRow{ - { - Fields: map[string]*types.Value{ - "entity1": Int64Val(1), - "entity2": StrVal("bob"), + Entities: map[string]*types.RepeatedValue{ + "entity1": &types.RepeatedValue{ + Val: []*types.Value{ + Int64Val(1), Int64Val(1), Int64Val(1), }, }, - { - Fields: map[string]*types.Value{ - "entity1": Int64Val(1), - "entity2": StrVal("annie"), - }, - }, - { - Fields: map[string]*types.Value{ - "entity1": Int64Val(1), - "entity2": StrVal("jane"), + "entity2": &types.RepeatedValue{ + Val: []*types.Value{ + StrVal("bob"), StrVal("annie"), StrVal("jane"), }, }, }, - Project: "driver_project", }, wantErr: false, err: nil, diff --git a/sdk/go/response.go b/sdk/go/response.go index 7fa50761b69..49c8904ab70 100644 --- a/sdk/go/response.go +++ b/sdk/go/response.go @@ -19,51 +19,88 @@ var ( // OnlineFeaturesResponse is a wrapper around serving.GetOnlineFeaturesResponse. type OnlineFeaturesResponse struct { - RawResponse *serving.GetOnlineFeaturesResponse + RawResponse *serving.GetOnlineFeaturesResponseV2 } // Rows retrieves the result of the request as a list of Rows. func (r OnlineFeaturesResponse) Rows() []Row { - rows := make([]Row, len(r.RawResponse.FieldValues)) - for i, fieldValues := range r.RawResponse.FieldValues { - rows[i] = fieldValues.Fields + if len(r.RawResponse.Results) == 0 { + return []Row{} + } + + rowsCount := len(r.RawResponse.Results[0].Values) + rows := make([]Row, rowsCount) + for rowIdx := 0; rowIdx < rowsCount; rowIdx++ { + row := make(map[string]*types.Value) + for featureIdx := 0; featureIdx < len(r.RawResponse.Results); featureIdx++ { + row[r.RawResponse.Metadata.FeatureNames.Val[featureIdx]] = r.RawResponse.Results[featureIdx].Values[rowIdx] + } + + rows[rowIdx] = row } return rows } // Statuses retrieves field level status metadata for each row in Rows(). // Each status map returned maps status 1:1 to each returned row from Rows() -func (r OnlineFeaturesResponse) Statuses() []map[string]serving.GetOnlineFeaturesResponse_FieldStatus { - statuses := make([]map[string]serving.GetOnlineFeaturesResponse_FieldStatus, len(r.RawResponse.FieldValues)) - for i, fieldValues := range r.RawResponse.FieldValues { - statuses[i] = fieldValues.Statuses +func (r OnlineFeaturesResponse) Statuses() []map[string]serving.FieldStatus { + if len(r.RawResponse.Results) == 0 { + return []map[string]serving.FieldStatus{} + } + + rowsCount := len(r.RawResponse.Results[0].Statuses) + rows := make([]map[string]serving.FieldStatus, rowsCount) + + for rowIdx := 0; rowIdx < rowsCount; rowIdx++ { + row := make(map[string]serving.FieldStatus) + for featureIdx := 0; featureIdx < len(r.RawResponse.Results); featureIdx++ { + row[r.RawResponse.Metadata.FeatureNames.Val[featureIdx]] = r.RawResponse.Results[featureIdx].Statuses[rowIdx] + } + + rows[rowIdx] = row } - return statuses + return rows } // Int64Arrays retrieves the result of the request as a list of int64 slices. Any missing values will be filled // with the missing values provided. func (r OnlineFeaturesResponse) Int64Arrays(order []string, fillNa []int64) ([][]int64, error) { - rows := make([][]int64, len(r.RawResponse.FieldValues)) if len(fillNa) != len(order) { return nil, fmt.Errorf(ErrLengthMismatch, len(fillNa), len(order)) } - for i, fieldValues := range r.RawResponse.FieldValues { - rows[i] = make([]int64, len(order)) - for j, fname := range order { - value, exists := fieldValues.Fields[fname] + + if len(r.RawResponse.Results) == 0 { + return [][]int64{}, nil + } + + rowsCount := len(r.RawResponse.Results[0].Values) + rows := make([][]int64, rowsCount) + + featureNameToIdx := make(map[string]int) + + for idx, featureName := range r.RawResponse.Metadata.FeatureNames.Val { + featureNameToIdx[featureName] = idx + } + + for rowIdx := 0; rowIdx < rowsCount; rowIdx++ { + row := make([]int64, len(order)) + for idx, feature := range order { + featureIdx, exists := featureNameToIdx[feature] if !exists { - return nil, fmt.Errorf(ErrFeatureNotFound, fname) + return nil, fmt.Errorf(ErrFeatureNotFound, feature) } - valType := value.GetVal() + + valType := r.RawResponse.Results[featureIdx].Values[rowIdx].GetVal() if valType == nil { - rows[i][j] = fillNa[j] + row[idx] = fillNa[idx] } else if int64Val, ok := valType.(*types.Value_Int64Val); ok { - rows[i][j] = int64Val.Int64Val + row[idx] = int64Val.Int64Val } else { return nil, fmt.Errorf(ErrTypeMismatch, "int64") } } + + rows[rowIdx] = row } return rows, nil } @@ -71,26 +108,42 @@ func (r OnlineFeaturesResponse) Int64Arrays(order []string, fillNa []int64) ([][ // Float64Arrays retrieves the result of the request as a list of float64 slices. Any missing values will be filled // with the missing values provided. func (r OnlineFeaturesResponse) Float64Arrays(order []string, fillNa []float64) ([][]float64, error) { - rows := make([][]float64, len(r.RawResponse.FieldValues)) if len(fillNa) != len(order) { return nil, fmt.Errorf(ErrLengthMismatch, len(fillNa), len(order)) } - for i, records := range r.RawResponse.FieldValues { - rows[i] = make([]float64, len(order)) - for j, fname := range order { - value, exists := records.Fields[fname] + + if len(r.RawResponse.Results) == 0 { + return [][]float64{}, nil + } + + rowsCount := len(r.RawResponse.Results[0].Values) + rows := make([][]float64, rowsCount) + + featureNameToIdx := make(map[string]int) + + for idx, featureName := range r.RawResponse.Metadata.FeatureNames.Val { + featureNameToIdx[featureName] = idx + } + + for rowIdx := 0; rowIdx < rowsCount; rowIdx++ { + row := make([]float64, len(order)) + for idx, feature := range order { + featureIdx, exists := featureNameToIdx[feature] if !exists { - return nil, fmt.Errorf(ErrFeatureNotFound, fname) + return nil, fmt.Errorf(ErrFeatureNotFound, feature) } - valType := value.GetVal() + + valType := r.RawResponse.Results[featureIdx].Values[rowIdx].GetVal() if valType == nil { - rows[i][j] = fillNa[j] + row[idx] = fillNa[idx] } else if doubleVal, ok := valType.(*types.Value_DoubleVal); ok { - rows[i][j] = doubleVal.DoubleVal + row[idx] = doubleVal.DoubleVal } else { return nil, fmt.Errorf(ErrTypeMismatch, "float64") } } + + rows[rowIdx] = row } return rows, nil } diff --git a/sdk/go/response_test.go b/sdk/go/response_test.go index a6176527451..e9a9bc1605a 100644 --- a/sdk/go/response_test.go +++ b/sdk/go/response_test.go @@ -9,29 +9,28 @@ import ( ) var response = OnlineFeaturesResponse{ - RawResponse: &serving.GetOnlineFeaturesResponse{ - FieldValues: []*serving.GetOnlineFeaturesResponse_FieldValues{ + RawResponse: &serving.GetOnlineFeaturesResponseV2{ + Results: []*serving.GetOnlineFeaturesResponseV2_FeatureVector{ { - Fields: map[string]*types.Value{ - "featuretable1:feature1": Int64Val(1), - "featuretable1:feature2": {}, - }, - Statuses: map[string]serving.GetOnlineFeaturesResponse_FieldStatus{ - "featuretable1:feature1": serving.GetOnlineFeaturesResponse_PRESENT, - "featuretable1:feature2": serving.GetOnlineFeaturesResponse_NULL_VALUE, + Values: []*types.Value{Int64Val(1), Int64Val(2)}, + Statuses: []serving.FieldStatus{ + serving.FieldStatus_PRESENT, + serving.FieldStatus_PRESENT, }, }, { - Fields: map[string]*types.Value{ - "featuretable1:feature1": Int64Val(2), - "featuretable1:feature2": Int64Val(2), - }, - Statuses: map[string]serving.GetOnlineFeaturesResponse_FieldStatus{ - "featuretable1:feature1": serving.GetOnlineFeaturesResponse_PRESENT, - "featuretable1:feature2": serving.GetOnlineFeaturesResponse_PRESENT, + Values: []*types.Value{{}, Int64Val(2)}, + Statuses: []serving.FieldStatus{ + serving.FieldStatus_NULL_VALUE, + serving.FieldStatus_PRESENT, }, }, }, + Metadata: &serving.GetOnlineFeaturesResponseMetadata{ + FeatureNames: &serving.FeatureList{ + Val: []string{"featuretable1:feature1", "featuretable1:feature2"}, + }, + }, }, } @@ -53,14 +52,14 @@ func TestOnlineFeaturesResponseToRow(t *testing.T) { func TestOnlineFeaturesResponseoToStatuses(t *testing.T) { actual := response.Statuses() - expected := []map[string]serving.GetOnlineFeaturesResponse_FieldStatus{ + expected := []map[string]serving.FieldStatus{ { - "featuretable1:feature1": serving.GetOnlineFeaturesResponse_PRESENT, - "featuretable1:feature2": serving.GetOnlineFeaturesResponse_NULL_VALUE, + "featuretable1:feature1": serving.FieldStatus_PRESENT, + "featuretable1:feature2": serving.FieldStatus_NULL_VALUE, }, { - "featuretable1:feature1": serving.GetOnlineFeaturesResponse_PRESENT, - "featuretable1:feature2": serving.GetOnlineFeaturesResponse_PRESENT, + "featuretable1:feature1": serving.FieldStatus_PRESENT, + "featuretable1:feature2": serving.FieldStatus_PRESENT, }, } if len(expected) != len(actual) { diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 0141b8f8bcc..d43a114b366 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -65,6 +65,7 @@ from feast.online_response import OnlineResponse, _infer_online_entity_rows from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.protos.feast.serving.ServingService_pb2 import ( + FieldStatus, GetOnlineFeaturesRequestV2, GetOnlineFeaturesResponse, ) @@ -1225,9 +1226,7 @@ def _populate_odfv_dependencies( for row_idx, proto_value in enumerate(proto_values): result_row = result_rows[row_idx] result_row.fields[feature_name].CopyFrom(proto_value) - result_row.statuses[ - feature_name - ] = GetOnlineFeaturesResponse.FieldStatus.PRESENT + result_row.statuses[feature_name] = FieldStatus.PRESENT # Add data if odfv requests specific feature views as dependencies if len(grouped_odfv_refs) > 0: @@ -1326,9 +1325,7 @@ def _populate_result_rows_from_feature_view( if full_feature_names else feature_name ) - result_row.statuses[ - feature_ref - ] = GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND + result_row.statuses[feature_ref] = FieldStatus.NOT_FOUND else: for feature_name in feature_data: feature_ref = ( @@ -1340,9 +1337,7 @@ def _populate_result_rows_from_feature_view( result_row.fields[feature_ref].CopyFrom( feature_data[feature_name] ) - result_row.statuses[ - feature_ref - ] = GetOnlineFeaturesResponse.FieldStatus.PRESENT + result_row.statuses[feature_ref] = FieldStatus.PRESENT def _augment_response_with_on_demand_transforms( self, @@ -1417,9 +1412,7 @@ def _augment_response_with_on_demand_transforms( result_row.fields[transformed_feature].CopyFrom( proto_values_by_column[transformed_feature][row_idx] ) - result_row.statuses[ - transformed_feature - ] = GetOnlineFeaturesResponse.FieldStatus.PRESENT + result_row.statuses[transformed_feature] = FieldStatus.PRESENT # Drop values that aren't needed unneeded_features = [ @@ -1530,7 +1523,7 @@ def _entity_row_to_field_values( result = GetOnlineFeaturesResponse.FieldValues() for k in row.fields: result.fields[k].CopyFrom(row.fields[k]) - result.statuses[k] = GetOnlineFeaturesResponse.FieldStatus.PRESENT + result.statuses[k] = FieldStatus.PRESENT return result From 4b16ca6baba199f560bd3a91272f599ada5eb7cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Jan 2022 08:38:10 -0800 Subject: [PATCH 05/85] Bump log4j-core from 2.17.0 to 2.17.1 in /java (#2189) Bumps log4j-core from 2.17.0 to 2.17.1. --- updated-dependencies: - dependency-name: org.apache.logging.log4j:log4j-core dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- java/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/pom.xml b/java/pom.xml index ead8af13096..38f037431c8 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -59,7 +59,7 @@ 0.26.0 - 2.17.0 + 2.17.1 2.9.9 2.0.2 2.5.0.RELEASE From e8e49723962020294905434d286d9078106af98d Mon Sep 17 00:00:00 2001 From: ptoman-pa <95256508+ptoman-pa@users.noreply.github.com> Date: Wed, 5 Jan 2022 08:40:10 -0800 Subject: [PATCH 06/85] Speed up Datastore deletes by batch deletions with multithreading (#2182) * Speed up Datastore deletes by batch deletions with multithreading Signed-off-by: Pamela Toman * Linted Signed-off-by: Pamela Toman * Move AtomicCounter inside _delete_all_values Signed-off-by: Pamela Toman * Add link to datastore limits Signed-off-by: Pamela Toman --- .../feast/infra/online_stores/datastore.py | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/sdk/python/feast/infra/online_stores/datastore.py b/sdk/python/feast/infra/online_stores/datastore.py index f8964129cde..f788f1bc741 100644 --- a/sdk/python/feast/infra/online_stores/datastore.py +++ b/sdk/python/feast/infra/online_stores/datastore.py @@ -12,8 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. import itertools +import logging from datetime import datetime from multiprocessing.pool import ThreadPool +from queue import Queue +from threading import Lock, Thread from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple from pydantic import PositiveInt, StrictStr @@ -33,6 +36,8 @@ from feast.repo_config import FeastConfigBaseModel, RepoConfig from feast.usage import log_exceptions_and_usage, tracing_span +LOGGER = logging.getLogger(__name__) + try: from google.auth.exceptions import DefaultCredentialsError from google.cloud import datastore @@ -262,15 +267,46 @@ def online_read( def _delete_all_values(client, key): """ Delete all data under the key path in datastore. + + Creates and uses a queue of lists of entity keys, which are batch deleted + by multiple threads. """ + + class AtomicCounter(object): + # for tracking how many deletions have already occurred; not used outside this method + def __init__(self): + self.value = 0 + self.lock = Lock() + + def increment(self): + with self.lock: + self.value += 1 + + BATCH_SIZE = 500 # Dec 2021: delete_multi has a max size of 500: https://cloud.google.com/datastore/docs/concepts/limits + NUM_THREADS = 3 + deletion_queue = Queue() + status_info_counter = AtomicCounter() + + def worker(shared_counter): + while True: + client.delete_multi(deletion_queue.get()) + shared_counter.increment() + LOGGER.debug( + f"batch deletions completed: {shared_counter.value} ({shared_counter.value * BATCH_SIZE} total entries) & outstanding queue size: {deletion_queue.qsize()}" + ) + deletion_queue.task_done() + + for _ in range(NUM_THREADS): + Thread(target=worker, args=(status_info_counter,), daemon=True).start() + + query = client.query(kind="Row", ancestor=key) while True: - query = client.query(kind="Row", ancestor=key) - entities = list(query.fetch(limit=1000)) + entities = list(query.fetch(limit=BATCH_SIZE)) if not entities: - return + break + deletion_queue.put([entity.key for entity in entities]) - for entity in entities: - client.delete(entity.key) + deletion_queue.join() def _initialize_client( From 6c09bc4e7e9245a05d47976a0b66edf08ec25b7c Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Wed, 5 Jan 2022 12:18:24 -0800 Subject: [PATCH 07/85] Add InfraDiff class for feast plan (#2190) * Add InfraDiff and InfraObjectDiff classes Signed-off-by: Felix Wang * Add support for Infra objects in the registry Signed-off-by: Felix Wang * Rename utils to property_diff Signed-off-by: Felix Wang --- protos/feast/core/Registry.proto | 2 ++ sdk/python/feast/diff/FcoDiff.py | 17 +------------- sdk/python/feast/diff/infra_diff.py | 28 +++++++++++++++++++++++ sdk/python/feast/diff/property_diff.py | 17 ++++++++++++++ sdk/python/feast/registry.py | 31 ++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 sdk/python/feast/diff/infra_diff.py create mode 100644 sdk/python/feast/diff/property_diff.py diff --git a/protos/feast/core/Registry.proto b/protos/feast/core/Registry.proto index 035e87a49f8..912fa1b90a1 100644 --- a/protos/feast/core/Registry.proto +++ b/protos/feast/core/Registry.proto @@ -25,6 +25,7 @@ import "feast/core/Entity.proto"; import "feast/core/FeatureService.proto"; import "feast/core/FeatureTable.proto"; import "feast/core/FeatureView.proto"; +import "feast/core/InfraObject.proto"; import "feast/core/OnDemandFeatureView.proto"; import "feast/core/RequestFeatureView.proto"; import "google/protobuf/timestamp.proto"; @@ -36,6 +37,7 @@ message Registry { repeated OnDemandFeatureView on_demand_feature_views = 8; repeated RequestFeatureView request_feature_views = 9; repeated FeatureService feature_services = 7; + Infra infra = 10; string registry_schema_version = 3; // to support migrations; incremented when schema is changed string version_id = 4; // version id, random string generated on each update of the data; now used only for debugging purposes diff --git a/sdk/python/feast/diff/FcoDiff.py b/sdk/python/feast/diff/FcoDiff.py index 09f76d42f10..e4b044dcc41 100644 --- a/sdk/python/feast/diff/FcoDiff.py +++ b/sdk/python/feast/diff/FcoDiff.py @@ -1,29 +1,14 @@ from dataclasses import dataclass -from enum import Enum from typing import Any, Iterable, List, Set, Tuple, TypeVar from feast.base_feature_view import BaseFeatureView +from feast.diff.property_diff import PropertyDiff, TransitionType from feast.entity import Entity from feast.feature_service import FeatureService from feast.protos.feast.core.Entity_pb2 import Entity as EntityProto from feast.protos.feast.core.FeatureView_pb2 import FeatureView as FeatureViewProto -@dataclass -class PropertyDiff: - property_name: str - val_existing: str - val_declared: str - - -class TransitionType(Enum): - UNKNOWN = 0 - CREATE = 1 - DELETE = 2 - UPDATE = 3 - UNCHANGED = 4 - - @dataclass class FcoDiff: name: str diff --git a/sdk/python/feast/diff/infra_diff.py b/sdk/python/feast/diff/infra_diff.py new file mode 100644 index 00000000000..458b7e1e01d --- /dev/null +++ b/sdk/python/feast/diff/infra_diff.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import Any, List + +from feast.diff.property_diff import PropertyDiff, TransitionType + + +@dataclass +class InfraObjectDiff: + name: str + infra_object_type: str + current_fco: Any + new_fco: Any + fco_property_diffs: List[PropertyDiff] + transition_type: TransitionType + + +@dataclass +class InfraDiff: + infra_object_diffs: List[InfraObjectDiff] + + def __init__(self): + self.infra_object_diffs = [] + + def update(self): + pass + + def to_string(self): + pass diff --git a/sdk/python/feast/diff/property_diff.py b/sdk/python/feast/diff/property_diff.py new file mode 100644 index 00000000000..9136cada500 --- /dev/null +++ b/sdk/python/feast/diff/property_diff.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from enum import Enum + + +@dataclass +class PropertyDiff: + property_name: str + val_existing: str + val_declared: str + + +class TransitionType(Enum): + UNKNOWN = 0 + CREATE = 1 + DELETE = 2 + UPDATE = 3 + UNCHANGED = 4 diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index 57eae83ac59..615de063aa9 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -42,6 +42,7 @@ ) from feast.feature_service import FeatureService from feast.feature_view import FeatureView +from feast.infra.infra_object import Infra from feast.on_demand_feature_view import OnDemandFeatureView from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.registry_store import NoopRegistryStore @@ -222,6 +223,36 @@ def _initialize_registry(self): registry_proto.registry_schema_version = REGISTRY_SCHEMA_VERSION self._registry_store.update_registry_proto(registry_proto) + def update_infra(self, infra: Infra, project: str, commit: bool = True): + """ + Updates the stored Infra object. + + Args: + infra: The new Infra object to be stored. + project: Feast project that the Infra object refers to + commit: Whether the change should be persisted immediately + """ + self._prepare_registry_for_changes() + assert self.cached_registry_proto + + self.cached_registry_proto.infra.CopyFrom(infra.to_proto()) + if commit: + self.commit() + + def get_infra(self, project: str, allow_cache: bool = False) -> Infra: + """ + Retrieves the stored Infra object. + + Args: + project: Feast project that the Infra object refers to + allow_cache: Whether to allow returning this entity from a cached registry + + Returns: + The stored Infra object. + """ + registry_proto = self._get_registry_proto(allow_cache=allow_cache) + return Infra.from_proto(registry_proto.infra) + def apply_entity(self, entity: Entity, project: str, commit: bool = True): """ Registers a single entity with Feast From ad3ea8d89c74b19e1962eed53a34a1588544cfea Mon Sep 17 00:00:00 2001 From: Amom Mendes Date: Thu, 6 Jan 2022 13:05:25 -0300 Subject: [PATCH 08/85] Add created timestamp for feature views (#1952) * Updating roadmap + hero image (#1950) * Update hero image Signed-off-by: Danny Chiao * Fix roadmap extra line Signed-off-by: Danny Chiao Signed-off-by: Amom Mendes * :sparkles: Add created timestamp for feature view Signed-off-by: Amom Mendes * :art: Adding created timestamp to base feature view and on demand feature view Signed-off-by: Amom Mendes * :rotating_light: Fix import Signed-off-by: Amom Mendes * :fire: Remove unused import Signed-off-by: Amom Mendes * :art: Add read/write created timestamp to ODFV Signed-off-by: Amom Mendes * :rotating_light: Fix isort lint Signed-off-by: Amom Mendes Co-authored-by: Danny Chiao Co-authored-by: Achal Shah --- README.md | 1 + docs/roadmap.md | 1 + protos/feast/core/OnDemandFeatureView.proto | 9 +++++++++ sdk/python/feast/base_feature_view.py | 4 +++- sdk/python/feast/feature_view.py | 1 - sdk/python/feast/on_demand_feature_view.py | 11 ++++++++++- sdk/python/feast/registry.py | 2 ++ 7 files changed, 26 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6ef49896d4c..649bb909fa5 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ The list below contains the functionality that contributors are planning to deve * We welcome contribution to all items in the roadmap! * Want to influence our roadmap and prioritization? Submit your feedback to [this form](https://docs.google.com/forms/d/e/1FAIpQLSfa1nRQ0sKz-JEFnMMCi4Jseag\_yDssO\_3nV9qMfxfrkil-wA/viewform). * Want to speak to a Feast contributor? We are more than happy to jump on a call. Please schedule a time using [Calendly](https://calendly.com/d/x2ry-g5bb/meet-with-feast-team). + * **Data Sources** * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) * [x] [BigQuery source](https://docs.feast.dev/reference/data-sources/bigquery) diff --git a/docs/roadmap.md b/docs/roadmap.md index cb67d72b48c..723bfba82a6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,7 @@ The list below contains the functionality that contributors are planning to deve * We welcome contribution to all items in the roadmap! * Want to influence our roadmap and prioritization? Submit your feedback to [this form](https://docs.google.com/forms/d/e/1FAIpQLSfa1nRQ0sKz-JEFnMMCi4Jseag\_yDssO\_3nV9qMfxfrkil-wA/viewform). * Want to speak to a Feast contributor? We are more than happy to jump on a call. Please schedule a time using [Calendly](https://calendly.com/d/x2ry-g5bb/meet-with-feast-team). + * **Data Sources** * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) * [x] [BigQuery source](https://docs.feast.dev/reference/data-sources/bigquery) diff --git a/protos/feast/core/OnDemandFeatureView.proto b/protos/feast/core/OnDemandFeatureView.proto index e1169416973..4cfac4cbd02 100644 --- a/protos/feast/core/OnDemandFeatureView.proto +++ b/protos/feast/core/OnDemandFeatureView.proto @@ -22,6 +22,7 @@ option go_package = "github.com/feast-dev/feast/sdk/go/protos/feast/core"; option java_outer_classname = "OnDemandFeatureViewProto"; option java_package = "feast.proto.core"; +import "google/protobuf/timestamp.proto"; import "feast/core/FeatureView.proto"; import "feast/core/Feature.proto"; import "feast/core/DataSource.proto"; @@ -29,6 +30,7 @@ import "feast/core/DataSource.proto"; message OnDemandFeatureView { // User-specified specifications of this feature view. OnDemandFeatureViewSpec spec = 1; + OnDemandFeatureViewMeta meta = 2; } message OnDemandFeatureViewSpec { @@ -45,6 +47,13 @@ message OnDemandFeatureViewSpec { map inputs = 4; UserDefinedFunction user_defined_function = 5; + + +} + +message OnDemandFeatureViewMeta { + // Time where this Feature View is created + google.protobuf.Timestamp created_timestamp = 1; } message OnDemandInput { diff --git a/sdk/python/feast/base_feature_view.py b/sdk/python/feast/base_feature_view.py index 10f949d9a1c..97180266d7e 100644 --- a/sdk/python/feast/base_feature_view.py +++ b/sdk/python/feast/base_feature_view.py @@ -13,7 +13,8 @@ # limitations under the License. import warnings from abc import ABC, abstractmethod -from typing import List, Type +from datetime import datetime +from typing import List, Optional, Type from google.protobuf.json_format import MessageToJson from proto import Message @@ -32,6 +33,7 @@ def __init__(self, name: str, features: List[Feature]): self._name = name self._features = features self._projection = FeatureViewProjection.from_definition(self) + self.created_timestamp: Optional[datetime] = None @property def name(self) -> str: diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index ee22ae12663..1f31b192a34 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -75,7 +75,6 @@ class FeatureView(BaseFeatureView): input: DataSource batch_source: DataSource stream_source: Optional[DataSource] = None - created_timestamp: Optional[datetime] = None last_updated_timestamp: Optional[datetime] = None materialization_intervals: List[Tuple[datetime, datetime]] diff --git a/sdk/python/feast/on_demand_feature_view.py b/sdk/python/feast/on_demand_feature_view.py index 86eece9de96..0f55f0dde58 100644 --- a/sdk/python/feast/on_demand_feature_view.py +++ b/sdk/python/feast/on_demand_feature_view.py @@ -17,6 +17,7 @@ OnDemandFeatureView as OnDemandFeatureViewProto, ) from feast.protos.feast.core.OnDemandFeatureView_pb2 import ( + OnDemandFeatureViewMeta, OnDemandFeatureViewSpec, OnDemandInput, ) @@ -90,6 +91,9 @@ def to_proto(self) -> OnDemandFeatureViewProto: Returns: A OnDemandFeatureViewProto protobuf. """ + meta = OnDemandFeatureViewMeta() + if self.created_timestamp: + meta.created_timestamp.FromDatetime(self.created_timestamp) inputs = {} for input_ref, fv in self.input_feature_views.items(): inputs[input_ref] = OnDemandInput(feature_view=fv.to_proto()) @@ -107,7 +111,7 @@ def to_proto(self) -> OnDemandFeatureViewProto: ), ) - return OnDemandFeatureViewProto(spec=spec) + return OnDemandFeatureViewProto(spec=spec, meta=meta) @classmethod def from_proto(cls, on_demand_feature_view_proto: OnDemandFeatureViewProto): @@ -155,6 +159,11 @@ def from_proto(cls, on_demand_feature_view_proto: OnDemandFeatureViewProto): on_demand_feature_view_obj ) + if on_demand_feature_view_proto.meta.HasField("created_timestamp"): + on_demand_feature_view_obj.created_timestamp = ( + on_demand_feature_view_proto.meta.created_timestamp.ToDatetime() + ) + return on_demand_feature_view_obj def get_request_data_schema(self) -> Dict[str, ValueType]: diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index 615de063aa9..e57ecdee2cd 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -405,6 +405,8 @@ def apply_feature_view( commit: Whether the change should be persisted immediately """ feature_view.ensure_valid() + if not feature_view.created_timestamp: + feature_view.created_timestamp = datetime.now() feature_view_proto = feature_view.to_proto() feature_view_proto.spec.project = project self._prepare_registry_for_changes() From 14048fd535f982eed7a3ba3af7d93557335e325a Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Thu, 6 Jan 2022 23:46:24 -0800 Subject: [PATCH 09/85] Compare only specs in integration tests (#2200) * Modify registry to_dict method to allow for returning only specs Signed-off-by: Felix Wang * Change test_cli test to keep only specs Signed-off-by: Felix Wang --- .../tests/integration/registration/test_cli.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/sdk/python/tests/integration/registration/test_cli.py b/sdk/python/tests/integration/registration/test_cli.py index b92dc52642d..f05674ea5c0 100644 --- a/sdk/python/tests/integration/registration/test_cli.py +++ b/sdk/python/tests/integration/registration/test_cli.py @@ -44,6 +44,12 @@ def test_universal_cli(test_repo_config) -> None: fs = FeatureStore(repo_path=str(repo_path)) registry_dict = fs.registry.to_dict(project=project) + # Save only the specs, not the metadata. + registry_specs = { + key: [fco["spec"] for fco in value] + for key, value in registry_dict.items() + } + # entity & feature view list commands should succeed result = runner.run(["entities", "list"], cwd=repo_path) assertpy.assert_that(result.returncode).is_equal_to(0) @@ -83,8 +89,12 @@ def test_universal_cli(test_repo_config) -> None: ) # Confirm that registry contents have not changed. - assertpy.assert_that(registry_dict).is_equal_to( - fs.registry.to_dict(project=project) + registry_dict = fs.registry.to_dict(project=project) + assertpy.assert_that(registry_specs).is_equal_to( + { + key: [fco["spec"] for fco in value] + for key, value in registry_dict.items() + } ) result = runner.run(["teardown"], cwd=repo_path) From 4c32d759a930547597575e48c69dd5cb7f3151d5 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Fri, 7 Jan 2022 13:57:25 +0000 Subject: [PATCH 10/85] Refactor `OnlineResponse.to_dict()` (#2196) Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/online_response.py | 33 +++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/sdk/python/feast/online_response.py b/sdk/python/feast/online_response.py index 359e216165f..8f30668f72a 100644 --- a/sdk/python/feast/online_response.py +++ b/sdk/python/feast/online_response.py @@ -26,7 +26,6 @@ from feast.type_map import ( _proto_value_to_value_type, _python_value_to_proto_value, - feast_value_type_to_python_type, python_values_to_feast_value_type, ) from feast.value_type import ValueType @@ -63,14 +62,34 @@ def to_dict(self) -> Dict[str, Any]: """ Converts GetOnlineFeaturesResponse features into a dictionary form. """ + # Status for every Feature should be present in every record. features_dict: Dict[str, List[Any]] = { - k: list() for row in self.field_values for k, _ in row.statuses.items() + k: list() for k in self.field_values[0].statuses.keys() } - - for row in self.field_values: - for feature in features_dict.keys(): - native_type_value = feast_value_type_to_python_type(row.fields[feature]) - features_dict[feature].append(native_type_value) + rows = [record.fields for record in self.field_values] + + # Find the first non-null instance of each Feature to determine + # which ValueType. + val_types = {k: None for k in features_dict.keys()} + for feature in features_dict.keys(): + for row in rows: + try: + val_types[feature] = row[feature].WhichOneof("val") + except KeyError: + continue + if val_types[feature] is not None: + break + + # Now we know what attribute to fetch. + for feature, val_type in val_types.items(): + if val_type is None: + features_dict[feature] = [None] * len(rows) + else: + for row in rows: + val = getattr(row[feature], val_type) + if "_list_" in val_type: + val = list(val.val) + features_dict[feature].append(val) return features_dict From 30f7bbad96651a19c698663582eafa43779d313e Mon Sep 17 00:00:00 2001 From: corentinmarek Date: Fri, 7 Jan 2022 15:28:25 +0000 Subject: [PATCH 11/85] Add on demand feature views deletion (#2203) Signed-off-by: corentinmarek --- sdk/python/feast/registry.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index e57ecdee2cd..d5c4c080484 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -667,6 +667,18 @@ def delete_feature_view(self, name: str, project: str, commit: bool = True): self.commit() return + for idx, existing_on_demand_feature_view_proto in enumerate( + self.cached_registry_proto.on_demand_feature_views + ): + if ( + existing_on_demand_feature_view_proto.spec.name == name + and existing_on_demand_feature_view_proto.spec.project == project + ): + del self.cached_registry_proto.on_demand_feature_views[idx] + if commit: + self.commit() + return + raise FeatureViewNotFoundException(name, project) def delete_entity(self, name: str, project: str, commit: bool = True): From f969e53d1eb34cdf993c852742da7ec9878f86aa Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Fri, 7 Jan 2022 16:28:25 +0000 Subject: [PATCH 12/85] Use FeatureViewProjection instead of FeatureView in ODFV (#2186) * Use FeatureViewProjection instead of FeatureView in ODFV Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Refactor `get_online_features` to allow for use of FeatureViewProjections in ODFVs. Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Refactor `get_online_features` to improve performance Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Fix linting error Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Refactor `_set_table_entity_keys` for clarity and performance Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Customer IDs should be `ValueType.STRING` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Use Entity ValueType information in `_convert_arrow_to_proto` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Only call `_augment_response_with_on_demand_transforms` if needed Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- protos/feast/core/OnDemandFeatureView.proto | 4 +- sdk/go/request.go | 10 +- sdk/go/request_test.go | 4 +- sdk/python/feast/cli.py | 5 +- sdk/python/feast/feature_store.py | 352 ++++++++---------- sdk/python/feast/feature_view.py | 2 +- .../feast/infra/passthrough_provider.py | 4 +- sdk/python/feast/infra/provider.py | 12 +- sdk/python/feast/on_demand_feature_view.py | 71 +++- sdk/python/feast/online_response.py | 77 +--- .../online_store/test_online_retrieval.py | 14 +- 11 files changed, 241 insertions(+), 314 deletions(-) diff --git a/protos/feast/core/OnDemandFeatureView.proto b/protos/feast/core/OnDemandFeatureView.proto index 4cfac4cbd02..31fe90a9ba2 100644 --- a/protos/feast/core/OnDemandFeatureView.proto +++ b/protos/feast/core/OnDemandFeatureView.proto @@ -24,6 +24,7 @@ option java_package = "feast.proto.core"; import "google/protobuf/timestamp.proto"; import "feast/core/FeatureView.proto"; +import "feast/core/FeatureViewProjection.proto"; import "feast/core/Feature.proto"; import "feast/core/DataSource.proto"; @@ -43,7 +44,7 @@ message OnDemandFeatureViewSpec { // List of features specifications for each feature defined with this feature view. repeated FeatureSpecV2 features = 3; - // List of features specifications for each feature defined with this feature view. + // Map of inputs for this feature view. map inputs = 4; UserDefinedFunction user_defined_function = 5; @@ -59,6 +60,7 @@ message OnDemandFeatureViewMeta { message OnDemandInput { oneof input { FeatureView feature_view = 1; + FeatureViewProjection feature_view_projection = 3; DataSource request_data_source = 2; } } diff --git a/sdk/go/request.go b/sdk/go/request.go index e6da10ff9bb..94fecea01ba 100644 --- a/sdk/go/request.go +++ b/sdk/go/request.go @@ -35,12 +35,12 @@ func (r OnlineFeaturesRequest) buildRequest() (*serving.GetOnlineFeaturesRequest if err != nil { return nil, err } - if len(r.Entities) == 0 { - return nil, fmt.Errorf("Entities must be provided") - } + if len(r.Entities) == 0 { + return nil, fmt.Errorf("Entities must be provided") + } - firstRow := r.Entities[0] - columnSize := len(firstRow) + firstRow := r.Entities[0] + columnSize := len(firstRow) // build request entity rows from native entities entityColumns := make(map[string][]*types.Value, columnSize) diff --git a/sdk/go/request_test.go b/sdk/go/request_test.go index 6beb15f7f71..9122c8ca401 100644 --- a/sdk/go/request_test.go +++ b/sdk/go/request_test.go @@ -37,12 +37,12 @@ func TestGetOnlineFeaturesRequest(t *testing.T) { }, }, Entities: map[string]*types.RepeatedValue{ - "entity1": &types.RepeatedValue{ + "entity1": { Val: []*types.Value{ Int64Val(1), Int64Val(1), Int64Val(1), }, }, - "entity2": &types.RepeatedValue{ + "entity2": { Val: []*types.Value{ StrVal("bob"), StrVal("annie"), StrVal("jane"), }, diff --git a/sdk/python/feast/cli.py b/sdk/python/feast/cli.py index 186d0185efc..4950977e2a3 100644 --- a/sdk/python/feast/cli.py +++ b/sdk/python/feast/cli.py @@ -284,9 +284,8 @@ def feature_view_list(ctx: click.Context): if isinstance(feature_view, FeatureView): entities.update(feature_view.entities) elif isinstance(feature_view, OnDemandFeatureView): - for backing_fv in feature_view.inputs.values(): - if isinstance(backing_fv, FeatureView): - entities.update(backing_fv.entities) + for backing_fv in feature_view.input_feature_view_projections.values(): + entities.update(store.get_feature_view(backing_fv.name).entities) table.append( [ feature_view.name, diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index d43a114b366..f1fee70336e 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -15,7 +15,7 @@ import itertools import os import warnings -from collections import Counter, OrderedDict, defaultdict +from collections import Counter, defaultdict from datetime import datetime from pathlib import Path from typing import ( @@ -62,14 +62,14 @@ ) from feast.infra.provider import Provider, RetrievalJob, get_provider from feast.on_demand_feature_view import OnDemandFeatureView -from feast.online_response import OnlineResponse, _infer_online_entity_rows +from feast.online_response import OnlineResponse from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.protos.feast.serving.ServingService_pb2 import ( FieldStatus, - GetOnlineFeaturesRequestV2, GetOnlineFeaturesResponse, ) from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto +from feast.protos.feast.types.Value_pb2 import Value from feast.registry import Registry from feast.repo_config import RepoConfig, load_repo_config from feast.request_feature_view import RequestFeatureView @@ -1073,6 +1073,15 @@ def get_online_features( set_usage_attribute("odfv", bool(grouped_odfv_refs)) set_usage_attribute("request_fv", bool(grouped_request_fv_refs)) + # All requested features should be present in the result. + requested_result_row_names = { + feat_ref.replace(":", "__") for feat_ref in _feature_refs + } + if not full_feature_names: + requested_result_row_names = { + name.rpartition("__")[-1] for name in requested_result_row_names + } + feature_views = list(view for view, _ in grouped_refs) entityless_case = DUMMY_ENTITY_NAME in [ entity_name @@ -1083,8 +1092,10 @@ def get_online_features( provider = self._get_provider() entities = self._list_entities(allow_cache=True, hide_dummy_entity=False) entity_name_to_join_key_map: Dict[str, str] = {} + join_key_to_entity_type_map: Dict[str, ValueType] = {} for entity in entities: entity_name_to_join_key_map[entity.name] = entity.join_key + join_key_to_entity_type_map[entity.join_key] = entity.value_type for feature_view in requested_feature_views: for entity_name in feature_view.entities: entity = self._registry.get_entity( @@ -1099,13 +1110,14 @@ def get_online_features( entity.join_key, entity.join_key ) entity_name_to_join_key_map[entity_name] = join_key + join_key_to_entity_type_map[join_key] = entity.value_type needed_request_data, needed_request_fv_features = self.get_needed_request_data( grouped_odfv_refs, grouped_request_fv_refs ) join_key_rows = [] - request_data_features: Dict[str, List[Any]] = {} + request_data_features: Dict[str, List[Any]] = defaultdict(list) # Entity rows may be either entities or request data. for row in entity_rows: join_key_row = {} @@ -1115,17 +1127,21 @@ def get_online_features( entity_name in needed_request_data or entity_name in needed_request_fv_features ): - if entity_name not in request_data_features: - request_data_features[entity_name] = [] + if entity_name in needed_request_fv_features: + # If the data was requested as a feature then + # make sure it appears in the result. + requested_result_row_names.add(entity_name) request_data_features[entity_name].append(entity_value) - continue - try: - join_key = entity_name_to_join_key_map[entity_name] - except KeyError: - raise EntityNotFoundException(entity_name, self.project) - join_key_row[join_key] = entity_value - if entityless_case: - join_key_row[DUMMY_ENTITY_ID] = DUMMY_ENTITY_VAL + else: + try: + join_key = entity_name_to_join_key_map[entity_name] + except KeyError: + raise EntityNotFoundException(entity_name, self.project) + # All join keys should be returned in the result. + requested_result_row_names.add(join_key) + join_key_row[join_key] = entity_value + if entityless_case: + join_key_row[DUMMY_ENTITY_ID] = DUMMY_ENTITY_VAL if len(join_key_row) > 0: # May be empty if this entity row was request data join_key_rows.append(join_key_row) @@ -1134,88 +1150,111 @@ def get_online_features( needed_request_data, needed_request_fv_features, request_data_features ) - entity_row_proto_list = _infer_online_entity_rows(join_key_rows) - - union_of_entity_keys: List[EntityKeyProto] = [] - result_rows: List[GetOnlineFeaturesResponse.FieldValues] = [] + # Convert join_key_rows from rowise to columnar. + join_key_python_values: Dict[str, List[Value]] = defaultdict(list) + for join_key_row in join_key_rows: + for join_key, value in join_key_row.items(): + join_key_python_values[join_key].append(value) - for entity_row_proto in entity_row_proto_list: - # Create a list of entity keys to filter down for each feature view at lookup time. - union_of_entity_keys.append(_entity_row_to_key(entity_row_proto)) - # Also create entity values to append to the result - result_rows.append(_entity_row_to_field_values(entity_row_proto)) + # Convert all join key values to Protobuf Values + join_key_proto_values = { + k: python_values_to_proto_values(v, join_key_to_entity_type_map[k]) + for k, v in join_key_python_values.items() + } - # Keep track of what has been requested from the OnlineStore - # to avoid requesting the same thing twice for ODFVs. - retrieved_feature_refs: Set[str] = set() + # Populate result rows with join keys + result_rows = [ + GetOnlineFeaturesResponse.FieldValues() for _ in range(len(entity_rows)) + ] + for key, values in join_key_proto_values.items(): + for row_idx, result_row in enumerate(result_rows): + result_row.fields[key].CopyFrom(values[row_idx]) + result_row.statuses[key] = FieldStatus.PRESENT + + # Initialize the set of EntityKeyProtos once and reuse them for each FeatureView + # to avoid initialization overhead. + entity_keys = [EntityKeyProto() for _ in range(len(join_key_rows))] for table, requested_features in grouped_refs: - table_join_keys = [ - entity_name_to_join_key_map[entity_name] - for entity_name in table.entities - ] + # Get the correct set of entity values with the correct join keys. + entity_values = self._get_table_entity_values( + table, entity_name_to_join_key_map, join_key_proto_values, + ) + + # Set the EntityKeyProtos inplace. + self._set_table_entity_keys( + entity_values, entity_keys, + ) + + # Populate the result_rows with the Features from the OnlineStore inplace. self._populate_result_rows_from_feature_view( - table_join_keys, + entity_keys, full_feature_names, provider, requested_features, result_rows, table, - union_of_entity_keys, ) - table_feature_names = {feature.name for feature in table.features} - retrieved_feature_refs |= { - f"{table.name}:{feature}" if feature in table_feature_names else feature - for feature in requested_features - } - requested_result_row_names = self._get_requested_result_fields( - result_rows, needed_request_fv_features - ) - self._populate_odfv_dependencies( - entity_name_to_join_key_map, - full_feature_names, - grouped_odfv_refs, - provider, - request_data_features, - result_rows, - union_of_entity_keys, - retrieved_feature_refs, + self._populate_request_data_features( + request_data_features, result_rows, ) - self._augment_response_with_on_demand_transforms( - _feature_refs, - requested_result_row_names, - requested_on_demand_feature_views, - full_feature_names, - result_rows, + if grouped_odfv_refs: + self._augment_response_with_on_demand_transforms( + _feature_refs, + requested_on_demand_feature_views, + full_feature_names, + result_rows, + ) + + self._drop_unneeded_columns( + requested_result_row_names, result_rows, ) return OnlineResponse(GetOnlineFeaturesResponse(field_values=result_rows)) - def _get_requested_result_fields( - self, - result_rows: List[GetOnlineFeaturesResponse.FieldValues], - needed_request_fv_features: Set[str], - ): - # Get requested feature values so we can drop odfv dependencies that aren't requested - requested_result_row_names: Set[str] = set() - for result_row in result_rows: - for feature_name in result_row.fields.keys(): - requested_result_row_names.add(feature_name) - # Request feature view values are also request data features that should be in the - # final output - requested_result_row_names.update(needed_request_fv_features) - return requested_result_row_names - - def _populate_odfv_dependencies( - self, + @staticmethod + def _get_table_entity_values( + table: FeatureView, entity_name_to_join_key_map: Dict[str, str], - full_feature_names: bool, - grouped_odfv_refs: List[Tuple[OnDemandFeatureView, List[str]]], - provider: Provider, + join_key_proto_values: Dict[str, List[Value]], + ) -> Dict[str, List[Value]]: + # The correct join_keys expected by the OnlineStore for this Feature View. + table_join_keys = [ + entity_name_to_join_key_map[entity_name] for entity_name in table.entities + ] + + # If the FeatureView has a Projection then the join keys may be aliased. + alias_to_join_key_map = {v: k for k, v in table.projection.join_key_map.items()} + + # Subset to columns which are relevant to this FeatureView and + # give them the correct names. + entity_values = { + alias_to_join_key_map.get(k, k): v + for k, v in join_key_proto_values.items() + if alias_to_join_key_map.get(k, k) in table_join_keys + } + return entity_values + + @staticmethod + def _set_table_entity_keys( + entity_values: Dict[str, List[Value]], entity_keys: List[EntityKeyProto], + ): + """ + This method sets the a list of EntityKeyProtos inplace. + """ + keys = entity_values.keys() + # Columar to rowise (dict keys and values are guaranteed to have the same order). + rowise_values = zip(*entity_values.values()) + for entity_key in entity_keys: + # Make sure entity_keys are empty before setting. + entity_key.Clear() + entity_key.join_keys.extend(keys) + entity_key.entity_values.extend(next(rowise_values)) + + @staticmethod + def _populate_request_data_features( request_data_features: Dict[str, List[Any]], result_rows: List[GetOnlineFeaturesResponse.FieldValues], - union_of_entity_keys: List[EntityKeyProto], - retrieved_feature_refs: Set[str], ): # Add more feature values to the existing result rows for the request data features for feature_name, feature_values in request_data_features.items(): @@ -1228,39 +1267,8 @@ def _populate_odfv_dependencies( result_row.fields[feature_name].CopyFrom(proto_value) result_row.statuses[feature_name] = FieldStatus.PRESENT - # Add data if odfv requests specific feature views as dependencies - if len(grouped_odfv_refs) > 0: - for odfv, _ in grouped_odfv_refs: - for fv in odfv.input_feature_views.values(): - # Find the set of required Features which have not yet - # been retrieved. - not_yet_retrieved = { - feature.name - for feature in fv.projection.features - if f"{fv.name}:{feature.name}" not in retrieved_feature_refs - } - # If there are required Features which have not yet been retrieved - # retrieve them. - if not_yet_retrieved: - table_join_keys = [ - entity_name_to_join_key_map[entity_name] - for entity_name in fv.entities - ] - self._populate_result_rows_from_feature_view( - table_join_keys, - full_feature_names, - provider, - list(not_yet_retrieved), - result_rows, - fv, - union_of_entity_keys, - ) - # Update the set of retrieved Features with any newly retrieved - # Features. - retrieved_feature_refs |= not_yet_retrieved - + @staticmethod def get_needed_request_data( - self, grouped_odfv_refs: List[Tuple[OnDemandFeatureView, List[str]]], grouped_request_fv_refs: List[Tuple[RequestFeatureView, List[str]]], ) -> Tuple[Set[str], Set[str]]: @@ -1274,8 +1282,8 @@ def get_needed_request_data( needed_request_fv_features.add(feature.name) return needed_request_data, needed_request_fv_features + @staticmethod def ensure_request_data_values_exist( - self, needed_request_data: Set[str], needed_request_fv_features: Set[str], request_data_features: Dict[str, List[Any]], @@ -1296,17 +1304,13 @@ def ensure_request_data_values_exist( def _populate_result_rows_from_feature_view( self, - table_join_keys: List[str], + entity_keys: List[EntityKeyProto], full_feature_names: bool, provider: Provider, requested_features: List[str], result_rows: List[GetOnlineFeaturesResponse.FieldValues], table: FeatureView, - union_of_entity_keys: List[EntityKeyProto], ): - entity_keys = _get_table_entity_keys( - table, union_of_entity_keys, table_join_keys - ) read_rows = provider.online_read( config=self.config, table=table, @@ -1339,10 +1343,9 @@ def _populate_result_rows_from_feature_view( ) result_row.statuses[feature_ref] = FieldStatus.PRESENT + @staticmethod def _augment_response_with_on_demand_transforms( - self, feature_refs: List[str], - requested_result_row_names: Set[str], requested_on_demand_feature_views: List[OnDemandFeatureView], full_feature_names: bool, result_rows: List[GetOnlineFeaturesResponse.FieldValues], @@ -1350,22 +1353,17 @@ def _augment_response_with_on_demand_transforms( """Computes on demand feature values and adds them to the result rows. Assumes that 'result_rows' already contains the necessary request data and input feature - views for the on demand feature views. Unneeded feature values such as request data and - unrequested input feature views will be removed from 'result_rows'. + views for the on demand feature views. Args: feature_refs: List of all feature references to be returned. - requested_result_row_names: Fields from 'result_rows' that have been requested, and - therefore should not be dropped. + requested_on_demand_feature_views: List of all odfvs that have been requested. full_feature_names: A boolean that provides the option to add the feature view prefixes to the feature names, changing them from the format "feature" to "feature_view__feature" (e.g., "daily_transactions" changes to "customer_fv__daily_transactions"). result_rows: List of result rows to be augmented with on demand feature values. """ - if len(requested_on_demand_feature_views) == 0: - return - requested_odfv_map = { odfv.name: odfv for odfv in requested_on_demand_feature_views } @@ -1414,11 +1412,25 @@ def _augment_response_with_on_demand_transforms( ) result_row.statuses[transformed_feature] = FieldStatus.PRESENT + @staticmethod + def _drop_unneeded_columns( + requested_result_row_names: Set[str], + result_rows: List[GetOnlineFeaturesResponse.FieldValues], + ): + """ + Unneeded feature values such as request data and unrequested input feature views will + be removed from 'result_rows'. + + Args: + requested_result_row_names: Fields from 'result_rows' that have been requested, and + therefore should not be dropped. + result_rows: List of result rows to be editted inplace. + """ # Drop values that aren't needed unneeded_features = [ val for val in result_rows[0].fields - if val not in requested_result_row_names and val not in odfv_result_names + if val not in requested_result_row_names ] for row_idx in range(len(result_rows)): result_row = result_rows[row_idx] @@ -1467,9 +1479,13 @@ def _get_feature_views_to_use( request_fvs[fv_name].with_projection(copy.copy(projection)) ) elif fv_name in od_fvs: - od_fvs_to_use.append( - od_fvs[fv_name].with_projection(copy.copy(projection)) - ) + odfv = od_fvs[fv_name].with_projection(copy.copy(projection)) + od_fvs_to_use.append(odfv) + # Let's make sure to include an FVs which the ODFV requires Features from. + for projection in odfv.input_feature_view_projections.values(): + fv = fvs[projection.name].with_projection(copy.copy(projection)) + if fv not in fvs_to_use: + fvs_to_use.append(fv) else: raise ValueError( f"The provided feature service {features.name} contains a reference to a feature view" @@ -1512,22 +1528,6 @@ def serve_transformations(self, port: int) -> None: transformation_server.start_server(self, port) -def _entity_row_to_key(row: GetOnlineFeaturesRequestV2.EntityRow) -> EntityKeyProto: - names, values = zip(*row.fields.items()) - return EntityKeyProto(join_keys=names, entity_values=values) - - -def _entity_row_to_field_values( - row: GetOnlineFeaturesRequestV2.EntityRow, -) -> GetOnlineFeaturesResponse.FieldValues: - result = GetOnlineFeaturesResponse.FieldValues() - for k in row.fields: - result.fields[k].CopyFrom(row.fields[k]) - result.statuses[k] = FieldStatus.PRESENT - - return result - - def _validate_feature_refs(feature_refs: List[str], full_feature_names: bool = False): collided_feature_refs = [] @@ -1581,21 +1581,27 @@ def _group_feature_refs( } # view name to feature names - views_features = defaultdict(list) - request_views_features = defaultdict(list) + views_features = defaultdict(set) + request_views_features = defaultdict(set) request_view_refs = set() # on demand view name to feature names - on_demand_view_features = defaultdict(list) + on_demand_view_features = defaultdict(set) for ref in features: view_name, feat_name = ref.split(":") if view_name in view_index: - views_features[view_name].append(feat_name) + views_features[view_name].add(feat_name) elif view_name in on_demand_view_index: - on_demand_view_features[view_name].append(feat_name) + on_demand_view_features[view_name].add(feat_name) + # Let's also add in any FV Feature dependencies here. + for input_fv_projection in on_demand_view_index[ + view_name + ].input_feature_view_projections.values(): + for input_feat in input_fv_projection.features: + views_features[input_fv_projection.name].add(input_feat.name) elif view_name in request_view_index: - request_views_features[view_name].append(feat_name) + request_views_features[view_name].add(feat_name) request_view_refs.add(ref) else: raise FeatureViewNotFoundException(view_name) @@ -1605,54 +1611,14 @@ def _group_feature_refs( request_fvs_result: List[Tuple[RequestFeatureView, List[str]]] = [] for view_name, feature_names in views_features.items(): - fvs_result.append((view_index[view_name], feature_names)) + fvs_result.append((view_index[view_name], list(feature_names))) for view_name, feature_names in request_views_features.items(): - request_fvs_result.append((request_view_index[view_name], feature_names)) + request_fvs_result.append((request_view_index[view_name], list(feature_names))) for view_name, feature_names in on_demand_view_features.items(): - odfvs_result.append((on_demand_view_index[view_name], feature_names)) + odfvs_result.append((on_demand_view_index[view_name], list(feature_names))) return fvs_result, odfvs_result, request_fvs_result, request_view_refs -def _get_table_entity_keys( - table: FeatureView, entity_keys: List[EntityKeyProto], table_join_keys: List[str] -) -> List[EntityKeyProto]: - reverse_join_key_map = { - alias: original for original, alias in table.projection.join_key_map.items() - } - required_entities = OrderedDict.fromkeys(sorted(table_join_keys)) - entity_key_protos = [] - for entity_key in entity_keys: - required_entities_to_values = required_entities.copy() - for i in range(len(entity_key.join_keys)): - entity_name = reverse_join_key_map.get( - entity_key.join_keys[i], entity_key.join_keys[i] - ) - entity_value = entity_key.entity_values[i] - - if entity_name in required_entities_to_values: - if required_entities_to_values[entity_name] is not None: - raise ValueError( - f"Duplicate entity keys detected. Table {table.name} expects {table_join_keys}. The entity " - f"{entity_name} was provided at least twice" - ) - required_entities_to_values[entity_name] = entity_value - - entity_names = [] - entity_values = [] - for entity_name, entity_value in required_entities_to_values.items(): - if entity_value is None: - raise ValueError( - f"Table {table.name} expects entity field {table_join_keys}. No entity value was found for " - f"{entity_name}" - ) - entity_names.append(entity_name) - entity_values.append(entity_value) - entity_key_protos.append( - EntityKeyProto(join_keys=entity_names, entity_values=entity_values) - ) - return entity_key_protos - - def _print_materialization_log( start_date, end_date, num_feature_views: int, online_store: str ): diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 1f31b192a34..57b60c0503b 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -44,7 +44,7 @@ DUMMY_ENTITY_NAME = "__dummy" DUMMY_ENTITY_VAL = "" DUMMY_ENTITY = Entity( - name=DUMMY_ENTITY_NAME, join_key=DUMMY_ENTITY_ID, value_type=ValueType.INT32, + name=DUMMY_ENTITY_NAME, join_key=DUMMY_ENTITY_ID, value_type=ValueType.STRING, ) diff --git a/sdk/python/feast/infra/passthrough_provider.py b/sdk/python/feast/infra/passthrough_provider.py index b42f5b0daf7..98937ce1fae 100644 --- a/sdk/python/feast/infra/passthrough_provider.py +++ b/sdk/python/feast/infra/passthrough_provider.py @@ -98,7 +98,7 @@ def ingest_df( if feature_view.batch_source.field_mapping is not None: table = _run_field_mapping(table, feature_view.batch_source.field_mapping) - join_keys = [entity.join_key for entity in entities] + 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( @@ -144,7 +144,7 @@ def materialize_single_feature_view( if feature_view.batch_source.field_mapping is not None: table = _run_field_mapping(table, feature_view.batch_source.field_mapping) - join_keys = [entity.join_key for entity in entities] + 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): diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index 00591725fcc..32bca8f7d7d 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -300,21 +300,21 @@ def _coerce_datetime(ts): def _convert_arrow_to_proto( table: Union[pyarrow.Table, pyarrow.RecordBatch], feature_view: FeatureView, - join_keys: List[str], + join_keys: Dict[str, ValueType], ) -> List[Tuple[EntityKeyProto, Dict[str, ValueProto], datetime, Optional[datetime]]]: # Avoid ChunkedArrays which guarentees `zero_copy_only` availiable. if isinstance(table, pyarrow.Table): table = table.to_batches()[0] - columns = [(f.name, f.dtype) for f in feature_view.features] + [ - (key, ValueType.UNKNOWN) for key in join_keys - ] + columns = [(f.name, f.dtype) for f in feature_view.features] + list( + join_keys.items() + ) proto_values_by_column = { column: python_values_to_proto_values( - table.column(column).to_numpy(zero_copy_only=False), dtype + table.column(column).to_numpy(zero_copy_only=False), value_type ) - for column, dtype in columns + for column, value_type in columns } entity_keys = [ diff --git a/sdk/python/feast/on_demand_feature_view.py b/sdk/python/feast/on_demand_feature_view.py index 0f55f0dde58..789422add4b 100644 --- a/sdk/python/feast/on_demand_feature_view.py +++ b/sdk/python/feast/on_demand_feature_view.py @@ -6,10 +6,9 @@ import dill import pandas as pd -from feast import errors from feast.base_feature_view import BaseFeatureView from feast.data_source import RequestDataSource -from feast.errors import RegistryInferenceFailure +from feast.errors import RegistryInferenceFailure, SpecifiedFeaturesNotPresentError from feast.feature import Feature from feast.feature_view import FeatureView from feast.feature_view_projection import FeatureViewProjection @@ -45,8 +44,7 @@ class OnDemandFeatureView(BaseFeatureView): """ # TODO(adchia): remove inputs from proto and declaration - inputs: Dict[str, Union[FeatureView, RequestDataSource]] - input_feature_views: Dict[str, FeatureView] + input_feature_view_projections: Dict[str, FeatureViewProjection] input_request_data_sources: Dict[str, RequestDataSource] udf: MethodType @@ -55,21 +53,22 @@ def __init__( self, name: str, features: List[Feature], - inputs: Dict[str, Union[FeatureView, RequestDataSource]], + inputs: Dict[str, Union[FeatureView, FeatureViewProjection, RequestDataSource]], udf: MethodType, ): """ Creates an OnDemandFeatureView object. """ super().__init__(name, features) - self.inputs = inputs - self.input_feature_views = {} - self.input_request_data_sources = {} + self.input_feature_view_projections: Dict[str, FeatureViewProjection] = {} + self.input_request_data_sources: Dict[str, RequestDataSource] = {} for input_ref, odfv_input in inputs.items(): if isinstance(odfv_input, RequestDataSource): self.input_request_data_sources[input_ref] = odfv_input + elif isinstance(odfv_input, FeatureViewProjection): + self.input_feature_view_projections[input_ref] = odfv_input else: - self.input_feature_views[input_ref] = odfv_input + self.input_feature_view_projections[input_ref] = odfv_input.projection self.udf = udf @@ -79,11 +78,37 @@ def proto_class(self) -> Type[OnDemandFeatureViewProto]: def __copy__(self): fv = OnDemandFeatureView( - name=self.name, features=self.features, inputs=self.inputs, udf=self.udf + name=self.name, + features=self.features, + inputs=dict( + **self.input_feature_view_projections, **self.input_request_data_sources + ), + udf=self.udf, ) fv.projection = copy.copy(self.projection) return fv + def __eq__(self, other): + if not super().__eq__(other): + return False + + if ( + not self.input_feature_view_projections + == other.input_feature_view_projections + ): + return False + + if not self.input_request_data_sources == other.input_request_data_sources: + return False + + if not self.udf.__code__.co_code == other.udf.__code__.co_code: + return False + + return True + + def __hash__(self): + return super().__hash__() + def to_proto(self) -> OnDemandFeatureViewProto: """ Converts an on demand feature view object to its protobuf representation. @@ -95,8 +120,10 @@ def to_proto(self) -> OnDemandFeatureViewProto: if self.created_timestamp: meta.created_timestamp.FromDatetime(self.created_timestamp) inputs = {} - for input_ref, fv in self.input_feature_views.items(): - inputs[input_ref] = OnDemandInput(feature_view=fv.to_proto()) + for input_ref, fv_projection in self.input_feature_view_projections.items(): + inputs[input_ref] = OnDemandInput( + feature_view_projection=fv_projection.to_proto() + ) for input_ref, request_data_source in self.input_request_data_sources.items(): inputs[input_ref] = OnDemandInput( request_data_source=request_data_source.to_proto() @@ -132,6 +159,10 @@ def from_proto(cls, on_demand_feature_view_proto: OnDemandFeatureViewProto): if on_demand_input.WhichOneof("input") == "feature_view": inputs[input_name] = FeatureView.from_proto( on_demand_input.feature_view + ).projection + elif on_demand_input.WhichOneof("input") == "feature_view_projection": + inputs[input_name] = FeatureViewProjection.from_proto( + on_demand_input.feature_view_projection ) else: inputs[input_name] = RequestDataSource.from_proto( @@ -177,9 +208,9 @@ def get_transformed_features_df( ) -> pd.DataFrame: # Apply on demand transformations columns_to_cleanup = [] - for input_fv in self.input_feature_views.values(): - for feature in input_fv.features: - full_feature_ref = f"{input_fv.name}__{feature.name}" + for input_fv_projection in self.input_feature_view_projections.values(): + for feature in input_fv_projection.features: + full_feature_ref = f"{input_fv_projection.name}__{feature.name}" if full_feature_ref in df_with_features.keys(): # Make sure the partial feature name is always present df_with_features[feature.name] = df_with_features[full_feature_ref] @@ -218,10 +249,12 @@ def infer_features(self): RegistryInferenceFailure: The set of features could not be inferred. """ df = pd.DataFrame() - for feature_view in self.input_feature_views.values(): - for feature in feature_view.features: + for feature_view_projection in self.input_feature_view_projections.values(): + for feature in feature_view_projection.features: dtype = feast_value_type_to_pandas_type(feature.dtype) - df[f"{feature_view.name}__{feature.name}"] = pd.Series(dtype=dtype) + df[f"{feature_view_projection.name}__{feature.name}"] = pd.Series( + dtype=dtype + ) df[f"{feature.name}"] = pd.Series(dtype=dtype) for request_data in self.input_request_data_sources.values(): for feature_name, feature_type in request_data.schema.items(): @@ -242,7 +275,7 @@ def infer_features(self): if specified_features not in inferred_features: missing_features.append(specified_features) if missing_features: - raise errors.SpecifiedFeaturesNotPresentError( + raise SpecifiedFeaturesNotPresentError( [f.name for f in missing_features], self.name ) else: diff --git a/sdk/python/feast/online_response.py b/sdk/python/feast/online_response.py index 8f30668f72a..e6bf6be42c9 100644 --- a/sdk/python/feast/online_response.py +++ b/sdk/python/feast/online_response.py @@ -12,23 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import defaultdict -from typing import Any, Dict, List, cast +from typing import Any, Dict, List import pandas as pd from feast.feature_view import DUMMY_ENTITY_ID -from feast.protos.feast.serving.ServingService_pb2 import ( - GetOnlineFeaturesRequestV2, - GetOnlineFeaturesResponse, -) -from feast.protos.feast.types.Value_pb2 import Value as Value -from feast.type_map import ( - _proto_value_to_value_type, - _python_value_to_proto_value, - python_values_to_feast_value_type, -) -from feast.value_type import ValueType +from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse class OnlineResponse: @@ -99,65 +88,3 @@ def to_df(self) -> pd.DataFrame: """ return pd.DataFrame(self.to_dict()) - - -def _infer_online_entity_rows( - entity_rows: List[Dict[str, Any]] -) -> List[GetOnlineFeaturesRequestV2.EntityRow]: - """ - Builds a list of EntityRow protos from Python native type format passed by user. - - Args: - entity_rows: A list of dictionaries where each key-value is an entity-name, entity-value pair. - Returns: - A list of EntityRow protos parsed from args. - """ - - entity_rows_dicts = cast(List[Dict[str, Any]], entity_rows) - entity_row_list = [] - entity_type_map: Dict[str, ValueType] = dict() - entity_python_values_map = defaultdict(list) - - # Flatten keys-value dicts into lists for type inference - for entity in entity_rows_dicts: - for key, value in entity.items(): - if isinstance(value, Value): - inferred_type = _proto_value_to_value_type(value) - # If any ProtoValues were present their types must all be the same - if key in entity_type_map and entity_type_map.get(key) != inferred_type: - raise TypeError( - f"Input entity {key} has mixed types, {entity_type_map.get(key)} and {inferred_type}. That is not allowed." - ) - entity_type_map[key] = inferred_type - else: - entity_python_values_map[key].append(value) - - # Loop over all entities to infer dtype first in case of empty lists or nulls - for key, values in entity_python_values_map.items(): - inferred_type = python_values_to_feast_value_type(key, values) - - # If any ProtoValues were present their types must match the inferred type - if key in entity_type_map and entity_type_map.get(key) != inferred_type: - raise TypeError( - f"Input entity {key} has mixed types, {entity_type_map.get(key)} and {inferred_type}. That is not allowed." - ) - - entity_type_map[key] = inferred_type - - for entity in entity_rows_dicts: - fields = {} - for key, value in entity.items(): - if key not in entity_type_map: - raise ValueError( - f"field {key} cannot have all null values for type inference." - ) - - if isinstance(value, Value): - proto_value = value - else: - proto_value = _python_value_to_proto_value( - entity_type_map[key], [value] - )[0] - fields[key] = proto_value - entity_row_list.append(GetOnlineFeaturesRequestV2.EntityRow(fields=fields)) - return entity_row_list diff --git a/sdk/python/tests/integration/online_store/test_online_retrieval.py b/sdk/python/tests/integration/online_store/test_online_retrieval.py index b94f6f1772c..265fedd2826 100644 --- a/sdk/python/tests/integration/online_store/test_online_retrieval.py +++ b/sdk/python/tests/integration/online_store/test_online_retrieval.py @@ -54,7 +54,7 @@ def test_online() -> None: ) customer_key = EntityKeyProto( - join_keys=["customer"], entity_values=[ValueProto(int64_val=5)] + join_keys=["customer"], entity_values=[ValueProto(string_val="5")] ) provider.online_write_batch( config=store.config, @@ -76,7 +76,7 @@ def test_online() -> None: customer_key = EntityKeyProto( join_keys=["customer", "driver"], - entity_values=[ValueProto(int64_val=5), ValueProto(int64_val=1)], + entity_values=[ValueProto(string_val="5"), ValueProto(int64_val=1)], ) provider.online_write_batch( config=store.config, @@ -100,7 +100,7 @@ def test_online() -> None: "customer_profile:name", "customer_driver_combined:trips", ], - entity_rows=[{"driver": 1, "customer": 5}, {"driver": 1, "customer": 5}], + entity_rows=[{"driver": 1, "customer": "5"}, {"driver": 1, "customer": 5}], full_feature_names=False, ).to_dict() @@ -108,7 +108,7 @@ def test_online() -> None: assert "avg_orders_day" in result assert "name" in result assert result["driver"] == [1, 1] - assert result["customer"] == [5, 5] + assert result["customer"] == ["5", "5"] assert result["lon"] == ["1.0", "1.0"] assert result["avg_orders_day"] == [1.0, 1.0] assert result["name"] == ["John", "John"] @@ -311,7 +311,7 @@ def test_online_to_df(): 6 6.0 foo6 60 """ customer_key = EntityKeyProto( - join_keys=["customer"], entity_values=[ValueProto(int64_val=c)] + join_keys=["customer"], entity_values=[ValueProto(string_val=str(c))] ) provider.online_write_batch( config=store.config, @@ -341,7 +341,7 @@ def test_online_to_df(): """ combo_keys = EntityKeyProto( join_keys=["customer", "driver"], - entity_values=[ValueProto(int64_val=c), ValueProto(int64_val=d)], + entity_values=[ValueProto(string_val=str(c)), ValueProto(int64_val=d)], ) provider.online_write_batch( config=store.config, @@ -382,7 +382,7 @@ def test_online_to_df(): """ df_dict = { "driver": driver_ids, - "customer": customer_ids, + "customer": [str(c) for c in customer_ids], "lon": [str(d * lon_multiply) for d in driver_ids], "lat": [d * lat_multiply for d in driver_ids], "avg_orders_day": [c * avg_order_day_multiply for c in customer_ids], From b4d12bd0444706566c2103c7f53f9efdbec32e2a Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Fri, 7 Jan 2022 10:25:25 -0800 Subject: [PATCH 13/85] Refactor all importer logic to belong in feast.importer (#2199) * Refactor all importer logic to belong in feast.importer Signed-off-by: Felix Wang * Fix unit tests error messages to match feast.errors Signed-off-by: Felix Wang * Rename get_class_from_module to import_class Signed-off-by: Felix Wang * Change class_type checking logic Signed-off-by: Felix Wang * Change error name to FeastInvalidBaseClass Signed-off-by: Felix Wang --- sdk/python/feast/errors.py | 15 +++---- sdk/python/feast/importer.py | 39 ++++++++++++++----- sdk/python/feast/infra/infra_object.py | 4 +- .../infra/offline_stores/offline_utils.py | 29 +++----------- .../feast/infra/online_stores/helpers.py | 28 +++---------- sdk/python/feast/infra/provider.py | 5 ++- sdk/python/feast/registry.py | 6 +-- sdk/python/feast/repo_config.py | 10 ++--- .../integration/registration/test_cli.py | 8 ++-- 9 files changed, 63 insertions(+), 81 deletions(-) diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py index f6a66bea5a0..615069e5797 100644 --- a/sdk/python/feast/errors.py +++ b/sdk/python/feast/errors.py @@ -103,14 +103,16 @@ def __init__(self, feature_server_type: str): class FeastModuleImportError(Exception): - def __init__(self, module_name: str, module_type: str): - super().__init__(f"Could not import {module_type} module '{module_name}'") + def __init__(self, module_name: str, class_name: str): + super().__init__( + f"Could not import module '{module_name}' while attempting to load class '{class_name}'" + ) class FeastClassImportError(Exception): - def __init__(self, module_name, class_name, class_type="provider"): + def __init__(self, module_name: str, class_name: str): super().__init__( - f"Could not import {class_type} '{class_name}' from module '{module_name}'" + f"Could not import class '{class_name}' from module '{module_name}'" ) @@ -168,11 +170,10 @@ def __init__(self, online_store_class_name: str): ) -class FeastClassInvalidName(Exception): +class FeastInvalidBaseClass(Exception): def __init__(self, class_name: str, class_type: str): super().__init__( - f"Config Class '{class_name}' " - f"should end with the string `{class_type}`.'" + f"Class '{class_name}' should have `{class_type}` as a base class." ) diff --git a/sdk/python/feast/importer.py b/sdk/python/feast/importer.py index 5dcd7c71c12..bbd592101a6 100644 --- a/sdk/python/feast/importer.py +++ b/sdk/python/feast/importer.py @@ -1,28 +1,47 @@ import importlib -from feast import errors +from feast.errors import ( + FeastClassImportError, + FeastInvalidBaseClass, + FeastModuleImportError, +) -def get_class_from_type(module_name: str, class_name: str, class_type: str): - if not class_name.endswith(class_type): - raise errors.FeastClassInvalidName(class_name, class_type) +def import_class(module_name: str, class_name: str, class_type: str = None): + """ + Dynamically loads and returns a class from a module. - # Try importing the module that contains the custom provider + Args: + module_name: The name of the module. + class_name: The name of the class. + class_type: Optional name of a base class of the class. + + Raises: + FeastInvalidBaseClass: If the class name does not end with the specified suffix. + FeastModuleImportError: If the module cannot be imported. + FeastClassImportError: If the class cannot be imported. + """ + # Try importing the module. try: module = importlib.import_module(module_name) except Exception as e: # The original exception can be anything - either module not found, # or any other kind of error happening during the module import time. # So we should include the original error as well in the stack trace. - raise errors.FeastModuleImportError(module_name, class_type) from e + raise FeastModuleImportError(module_name, class_name) from e - # Try getting the provider class definition + # Try getting the class. try: _class = getattr(module, class_name) except AttributeError: # This can only be one type of error, when class_name attribute does not exist in the module # So we don't have to include the original exception here - raise errors.FeastClassImportError( - module_name, class_name, class_type=class_type - ) from None + raise FeastClassImportError(module_name, class_name) from None + + # Check if the class is a subclass of the base class. + if class_type and not any( + base_class.__name__ == class_type for base_class in _class.mro() + ): + raise FeastInvalidBaseClass(class_name, class_type) + return _class diff --git a/sdk/python/feast/infra/infra_object.py b/sdk/python/feast/infra/infra_object.py index f1eda19581e..3cd00899fe7 100644 --- a/sdk/python/feast/infra/infra_object.py +++ b/sdk/python/feast/infra/infra_object.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field from typing import Any, List -from feast.importer import get_class_from_type +from feast.importer import import_class from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto from feast.protos.feast.core.InfraObject_pb2 import InfraObject as InfraObjectProto @@ -106,4 +106,4 @@ def from_proto(cls, infra_proto: InfraProto): def _get_infra_object_class_from_type(infra_object_class_type: str): module_name, infra_object_class_name = infra_object_class_type.rsplit(".", 1) - return get_class_from_type(module_name, infra_object_class_name, "Object") + return import_class(module_name, infra_object_class_name) diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index 6debe14ca00..0b60c3493df 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -1,4 +1,3 @@ -import importlib import uuid from dataclasses import asdict, dataclass from datetime import datetime, timedelta @@ -12,11 +11,10 @@ import feast from feast.errors import ( EntityTimestampInferenceException, - FeastClassImportError, FeastEntityDFMissingColumnsError, - FeastModuleImportError, ) from feast.feature_view import FeatureView +from feast.importer import import_class from feast.infra.offline_stores.offline_store import OfflineStore from feast.infra.provider import _get_requested_feature_views_to_features_dict from feast.registry import Registry @@ -204,27 +202,10 @@ def get_temp_entity_table_name() -> str: return "feast_entity_df_" + uuid.uuid4().hex -def get_offline_store_from_config(offline_store_config: Any,) -> OfflineStore: - """Get the offline store from offline store config""" - +def get_offline_store_from_config(offline_store_config: Any) -> OfflineStore: + """Creates an offline store corresponding to the given offline store config.""" module_name = offline_store_config.__module__ qualified_name = type(offline_store_config).__name__ - store_class_name = qualified_name.replace("Config", "") - try: - module = importlib.import_module(module_name) - except Exception as e: - # The original exception can be anything - either module not found, - # or any other kind of error happening during the module import time. - # So we should include the original error as well in the stack trace. - raise FeastModuleImportError(module_name, "OfflineStore") from e - - # Try getting the provider class definition - try: - offline_store_class = getattr(module, store_class_name) - except AttributeError: - # This can only be one type of error, when class_name attribute does not exist in the module - # So we don't have to include the original exception here - raise FeastClassImportError( - module_name, store_class_name, class_type="OfflineStore" - ) from None + class_name = qualified_name.replace("Config", "") + offline_store_class = import_class(module_name, class_name, "OfflineStore") return offline_store_class() diff --git a/sdk/python/feast/infra/online_stores/helpers.py b/sdk/python/feast/infra/online_stores/helpers.py index 5e01ddb263f..b206c08b7c4 100644 --- a/sdk/python/feast/infra/online_stores/helpers.py +++ b/sdk/python/feast/infra/online_stores/helpers.py @@ -1,10 +1,9 @@ -import importlib import struct from typing import Any, List import mmh3 -from feast import errors +from feast.importer import import_class from feast.infra.key_encoding_utils import ( serialize_entity_key, serialize_entity_key_prefix, @@ -13,29 +12,12 @@ from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto -def get_online_store_from_config(online_store_config: Any,) -> OnlineStore: - """Get the online store from online store config""" - +def get_online_store_from_config(online_store_config: Any) -> OnlineStore: + """Creates an online store corresponding to the given online store config.""" module_name = online_store_config.__module__ qualified_name = type(online_store_config).__name__ - store_class_name = qualified_name.replace("Config", "") - try: - module = importlib.import_module(module_name) - except Exception as e: - # The original exception can be anything - either module not found, - # or any other kind of error happening during the module import time. - # So we should include the original error as well in the stack trace. - raise errors.FeastModuleImportError(module_name, "OnlineStore") from e - - # Try getting the provider class definition - try: - online_store_class = getattr(module, store_class_name) - except AttributeError: - # This can only be one type of error, when class_name attribute does not exist in the module - # So we don't have to include the original exception here - raise errors.FeastClassImportError( - module_name, store_class_name, class_type="OnlineStore" - ) from None + class_name = qualified_name.replace("Config", "") + online_store_class = import_class(module_name, class_name, "OnlineStore") return online_store_class() diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index 32bca8f7d7d..3c761f1195b 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -8,9 +8,10 @@ import pyarrow from tqdm import tqdm -from feast import errors, importer +from feast import errors from feast.entity import Entity from feast.feature_view import DUMMY_ENTITY_ID, FeatureView +from feast.importer import import_class from feast.infra.offline_stores.offline_store import RetrievalJob from feast.on_demand_feature_view import OnDemandFeatureView from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto @@ -172,7 +173,7 @@ def get_provider(config: RepoConfig, repo_path: Path) -> Provider: # For example, provider 'foo.bar.MyProvider' will be parsed into 'foo.bar' and 'MyProvider' module_name, class_name = provider.rsplit(".", 1) - cls = importer.get_class_from_type(module_name, class_name, "Provider") + cls = import_class(module_name, class_name, "Provider") return cls(config) diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index d5c4c080484..0c058a0d461 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -23,7 +23,6 @@ from google.protobuf.json_format import MessageToDict from proto import Message -from feast import importer from feast.base_feature_view import BaseFeatureView from feast.diff.FcoDiff import ( FcoDiff, @@ -42,6 +41,7 @@ ) from feast.feature_service import FeatureService from feast.feature_view import FeatureView +from feast.importer import import_class from feast.infra.infra_object import Infra from feast.on_demand_feature_view import OnDemandFeatureView from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto @@ -75,9 +75,7 @@ def get_registry_store_class_from_type(registry_store_type: str): registry_store_type = REGISTRY_STORE_CLASS_FOR_TYPE[registry_store_type] module_name, registry_store_class_name = registry_store_type.rsplit(".", 1) - return importer.get_class_from_type( - module_name, registry_store_class_name, "RegistryStore" - ) + return import_class(module_name, registry_store_class_name, "RegistryStore") def get_registry_store_class_from_scheme(registry_path: str): diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 70e64c845c5..26309fe9d77 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -20,7 +20,7 @@ FeastFeatureServerTypeSetError, FeastProviderNotSetError, ) -from feast.importer import get_class_from_type +from feast.importer import import_class from feast.usage import log_exceptions # These dict exists so that: @@ -302,7 +302,7 @@ def __repr__(self) -> str: def get_data_source_class_from_type(data_source_type: str): module_name, config_class_name = data_source_type.rsplit(".", 1) - return get_class_from_type(module_name, config_class_name, "Source") + return import_class(module_name, config_class_name, "DataSource") def get_online_config_from_type(online_store_type: str): @@ -313,7 +313,7 @@ def get_online_config_from_type(online_store_type: str): module_name, online_store_class_type = online_store_type.rsplit(".", 1) config_class_name = f"{online_store_class_type}Config" - return get_class_from_type(module_name, config_class_name, config_class_name) + return import_class(module_name, config_class_name, config_class_name) def get_offline_config_from_type(offline_store_type: str): @@ -324,7 +324,7 @@ def get_offline_config_from_type(offline_store_type: str): module_name, offline_store_class_type = offline_store_type.rsplit(".", 1) config_class_name = f"{offline_store_class_type}Config" - return get_class_from_type(module_name, config_class_name, config_class_name) + return import_class(module_name, config_class_name, config_class_name) def get_feature_server_config_from_type(feature_server_type: str): @@ -334,7 +334,7 @@ def get_feature_server_config_from_type(feature_server_type: str): feature_server_type = FEATURE_SERVER_CONFIG_CLASS_FOR_TYPE[feature_server_type] module_name, config_class_name = feature_server_type.rsplit(".", 1) - return get_class_from_type(module_name, config_class_name, config_class_name) + return import_class(module_name, config_class_name, config_class_name) def load_repo_config(repo_path: Path) -> RepoConfig: diff --git a/sdk/python/tests/integration/registration/test_cli.py b/sdk/python/tests/integration/registration/test_cli.py index f05674ea5c0..0fe73316adc 100644 --- a/sdk/python/tests/integration/registration/test_cli.py +++ b/sdk/python/tests/integration/registration/test_cli.py @@ -211,14 +211,14 @@ def test_3rd_party_providers() -> None: return_code, output = runner.run_with_output(["apply"], cwd=repo_path) assertpy.assert_that(return_code).is_equal_to(1) assertpy.assert_that(output).contains( - b"Could not import Provider module 'feast_foo'" + b"Could not import module 'feast_foo' while attempting to load class 'Provider'" ) # Check with incorrect third-party provider name (with dots) with setup_third_party_provider_repo("foo.FooProvider") as repo_path: return_code, output = runner.run_with_output(["apply"], cwd=repo_path) assertpy.assert_that(return_code).is_equal_to(1) assertpy.assert_that(output).contains( - b"Could not import Provider 'FooProvider' from module 'foo'" + b"Could not import class 'FooProvider' from module 'foo'" ) # Check with correct third-party provider name with setup_third_party_provider_repo("foo.provider.FooProvider") as repo_path: @@ -243,14 +243,14 @@ def test_3rd_party_registry_store() -> None: return_code, output = runner.run_with_output(["apply"], cwd=repo_path) assertpy.assert_that(return_code).is_equal_to(1) assertpy.assert_that(output).contains( - b"Could not import RegistryStore module 'feast_foo'" + b"Could not import module 'feast_foo' while attempting to load class 'RegistryStore'" ) # Check with incorrect third-party registry store name (with dots) with setup_third_party_registry_store_repo("foo.FooRegistryStore") as repo_path: return_code, output = runner.run_with_output(["apply"], cwd=repo_path) assertpy.assert_that(return_code).is_equal_to(1) assertpy.assert_that(output).contains( - b"Could not import RegistryStore 'FooRegistryStore' from module 'foo'" + b"Could not import class 'FooRegistryStore' from module 'foo'" ) # Check with correct third-party registry store name with setup_third_party_registry_store_repo( From a16c5e475f2f86d4252aaaa7378befb89ffb81c2 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 7 Jan 2022 15:24:52 -0600 Subject: [PATCH 14/85] Modify issue templates to automatically attach labels (#2205) Signed-off-by: Danny Chiao --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index c405c1f0840..9263376d7ea 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: '' -labels: '' +labels: 'kind/bug' assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7d615..d73d6444812 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: '' +labels: 'kind/feature' assignees: '' --- From 36c1d4602969b1e1c5e9e67cf180d6bbf026d9f1 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Fri, 7 Jan 2022 16:33:52 -0600 Subject: [PATCH 15/85] Add default priority for bug reports (#2207) Signed-off-by: Danny Chiao --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9263376d7ea..2f2d0d2f5e3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: '' -labels: 'kind/bug' +labels: 'kind/bug, priority/p2' assignees: '' --- From d241a8451a3799d15297365b7bee7f3c571d2d13 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Mon, 10 Jan 2022 06:27:11 -0800 Subject: [PATCH 16/85] Implement diff_infra_protos method for feast plan (#2204) * Rename proto methods for SqliteTable, DynamoDBTable, DatastoreTable Signed-off-by: Felix Wang * Implement diff_infra Signed-off-by: Felix Wang * Add tests for diff_infra logic Signed-off-by: Felix Wang * Fix Datastore infra object Signed-off-by: Felix Wang --- sdk/python/feast/diff/infra_diff.py | 136 +++++++++++++++- sdk/python/feast/infra/infra_object.py | 17 +- .../feast/infra/online_stores/datastore.py | 49 +++--- .../feast/infra/online_stores/dynamodb.py | 19 ++- .../feast/infra/online_stores/sqlite.py | 19 ++- sdk/python/tests/unit/diff/test_infra_diff.py | 154 ++++++++++++++++++ 6 files changed, 347 insertions(+), 47 deletions(-) create mode 100644 sdk/python/tests/unit/diff/test_infra_diff.py diff --git a/sdk/python/feast/diff/infra_diff.py b/sdk/python/feast/diff/infra_diff.py index 458b7e1e01d..d7164222611 100644 --- a/sdk/python/feast/diff/infra_diff.py +++ b/sdk/python/feast/diff/infra_diff.py @@ -1,16 +1,30 @@ from dataclasses import dataclass -from typing import Any, List +from typing import Any, Iterable, List, Tuple, TypeVar from feast.diff.property_diff import PropertyDiff, TransitionType +from feast.infra.infra_object import ( + DATASTORE_INFRA_OBJECT_CLASS_TYPE, + DYNAMODB_INFRA_OBJECT_CLASS_TYPE, + SQLITE_INFRA_OBJECT_CLASS_TYPE, + InfraObject, +) +from feast.protos.feast.core.DatastoreTable_pb2 import ( + DatastoreTable as DatastoreTableProto, +) +from feast.protos.feast.core.DynamoDBTable_pb2 import ( + DynamoDBTable as DynamoDBTableProto, +) +from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto +from feast.protos.feast.core.SqliteTable_pb2 import SqliteTable as SqliteTableProto @dataclass class InfraObjectDiff: name: str infra_object_type: str - current_fco: Any - new_fco: Any - fco_property_diffs: List[PropertyDiff] + current_infra_object: Any + new_infra_object: Any + infra_object_property_diffs: List[PropertyDiff] transition_type: TransitionType @@ -26,3 +40,117 @@ def update(self): def to_string(self): pass + + +U = TypeVar("U", DatastoreTableProto, DynamoDBTableProto, SqliteTableProto) + + +def tag_infra_proto_objects_for_keep_delete_add( + existing_objs: Iterable[U], desired_objs: Iterable[U] +) -> Tuple[Iterable[U], Iterable[U], Iterable[U]]: + existing_obj_names = {e.name for e in existing_objs} + desired_obj_names = {e.name for e in desired_objs} + + objs_to_add = [e for e in desired_objs if e.name not in existing_obj_names] + objs_to_keep = [e for e in desired_objs if e.name in existing_obj_names] + objs_to_delete = [e for e in existing_objs if e.name not in desired_obj_names] + + return objs_to_keep, objs_to_delete, objs_to_add + + +def diff_infra_protos( + current_infra_proto: InfraProto, new_infra_proto: InfraProto +) -> InfraDiff: + infra_diff = InfraDiff() + + infra_object_class_types_to_str = { + DATASTORE_INFRA_OBJECT_CLASS_TYPE: "datastore table", + DYNAMODB_INFRA_OBJECT_CLASS_TYPE: "dynamodb table", + SQLITE_INFRA_OBJECT_CLASS_TYPE: "sqlite table", + } + + for infra_object_class_type in infra_object_class_types_to_str: + current_infra_objects = get_infra_object_protos_by_type( + current_infra_proto, infra_object_class_type + ) + new_infra_objects = get_infra_object_protos_by_type( + new_infra_proto, infra_object_class_type + ) + ( + infra_objects_to_keep, + infra_objects_to_delete, + infra_objects_to_add, + ) = tag_infra_proto_objects_for_keep_delete_add( + current_infra_objects, new_infra_objects, + ) + + for e in infra_objects_to_add: + infra_diff.infra_object_diffs.append( + InfraObjectDiff( + e.name, + infra_object_class_types_to_str[infra_object_class_type], + None, + e, + [], + TransitionType.CREATE, + ) + ) + for e in infra_objects_to_delete: + infra_diff.infra_object_diffs.append( + InfraObjectDiff( + e.name, + infra_object_class_types_to_str[infra_object_class_type], + e, + None, + [], + TransitionType.DELETE, + ) + ) + for e in infra_objects_to_keep: + current_infra_object = [ + _e for _e in current_infra_objects if _e.name == e.name + ][0] + infra_diff.infra_object_diffs.append( + diff_between( + current_infra_object, + e, + infra_object_class_types_to_str[infra_object_class_type], + ) + ) + + return infra_diff + + +def get_infra_object_protos_by_type( + infra_proto: InfraProto, infra_object_class_type: str +) -> List[U]: + return [ + InfraObject.from_infra_object_proto(infra_object).to_proto() + for infra_object in infra_proto.infra_objects + if infra_object.infra_object_class_type == infra_object_class_type + ] + + +FIELDS_TO_IGNORE = {"project"} + + +def diff_between(current: U, new: U, infra_object_type: str) -> InfraObjectDiff: + assert current.DESCRIPTOR.full_name == new.DESCRIPTOR.full_name + property_diffs = [] + transition: TransitionType = TransitionType.UNCHANGED + if current != new: + for _field in current.DESCRIPTOR.fields: + if _field.name in FIELDS_TO_IGNORE: + continue + if getattr(current, _field.name) != getattr(new, _field.name): + transition = TransitionType.UPDATE + property_diffs.append( + PropertyDiff( + _field.name, + getattr(current, _field.name), + getattr(new, _field.name), + ) + ) + return InfraObjectDiff( + new.name, infra_object_type, current, new, property_diffs, transition, + ) diff --git a/sdk/python/feast/infra/infra_object.py b/sdk/python/feast/infra/infra_object.py index 3cd00899fe7..282b4bcfab7 100644 --- a/sdk/python/feast/infra/infra_object.py +++ b/sdk/python/feast/infra/infra_object.py @@ -19,6 +19,10 @@ from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto from feast.protos.feast.core.InfraObject_pb2 import InfraObject as InfraObjectProto +DATASTORE_INFRA_OBJECT_CLASS_TYPE = "feast.infra.online_stores.datastore.DatastoreTable" +DYNAMODB_INFRA_OBJECT_CLASS_TYPE = "feast.infra.online_stores.dynamodb.DynamoDBTable" +SQLITE_INFRA_OBJECT_CLASS_TYPE = "feast.infra.online_store.sqlite.SqliteTable" + class InfraObject(ABC): """ @@ -26,13 +30,18 @@ class InfraObject(ABC): """ @abstractmethod - def to_proto(self) -> InfraObjectProto: + def to_infra_object_proto(self) -> InfraObjectProto: + """Converts an InfraObject to its protobuf representation, wrapped in an InfraObjectProto.""" + pass + + @abstractmethod + def to_proto(self) -> Any: """Converts an InfraObject to its protobuf representation.""" pass @staticmethod @abstractmethod - def from_proto(infra_object_proto: InfraObjectProto) -> Any: + def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: """ Returns an InfraObject created from a protobuf representation. @@ -46,7 +55,7 @@ def from_proto(infra_object_proto: InfraObjectProto) -> Any: cls = _get_infra_object_class_from_type( infra_object_proto.infra_object_class_type ) - return cls.from_proto(infra_object_proto) + return cls.from_infra_object_proto(infra_object_proto) raise ValueError("Could not identify the type of the InfraObject.") @@ -97,7 +106,7 @@ def from_proto(cls, infra_proto: InfraProto): """ infra = cls() cls.infra_objects += [ - InfraObject.from_proto(infra_object_proto) + InfraObject.from_infra_object_proto(infra_object_proto) for infra_object_proto in infra_proto.infra_objects ] diff --git a/sdk/python/feast/infra/online_stores/datastore.py b/sdk/python/feast/infra/online_stores/datastore.py index f788f1bc741..348583a202a 100644 --- a/sdk/python/feast/infra/online_stores/datastore.py +++ b/sdk/python/feast/infra/online_stores/datastore.py @@ -23,8 +23,9 @@ from pydantic.typing import Literal from feast import Entity, utils +from feast.errors import FeastProviderLoginError from feast.feature_view import FeatureView -from feast.infra.infra_object import InfraObject +from feast.infra.infra_object import DATASTORE_INFRA_OBJECT_CLASS_TYPE, InfraObject from feast.infra.online_stores.helpers import compute_entity_id from feast.infra.online_stores.online_store import OnlineStore from feast.protos.feast.core.DatastoreTable_pb2 import ( @@ -43,7 +44,7 @@ from google.cloud import datastore from google.cloud.datastore.client import Key except ImportError as e: - from feast.errors import FeastExtrasDependencyImportError, FeastProviderLoginError + from feast.errors import FeastExtrasDependencyImportError raise FeastExtrasDependencyImportError("gcp", str(e)) @@ -332,14 +333,12 @@ class DatastoreTable(InfraObject): name: The name of the table. project_id (optional): The GCP project id. namespace (optional): Datastore namespace. - client: Datastore client. """ project: str name: str project_id: Optional[str] namespace: Optional[str] - client: datastore.Client def __init__( self, @@ -352,24 +351,26 @@ def __init__( self.name = name self.project_id = project_id self.namespace = namespace - self.client = _initialize_client(self.project_id, self.namespace) - def to_proto(self) -> InfraObjectProto: + def to_infra_object_proto(self) -> InfraObjectProto: + datastore_table_proto = self.to_proto() + return InfraObjectProto( + infra_object_class_type=DATASTORE_INFRA_OBJECT_CLASS_TYPE, + datastore_table=datastore_table_proto, + ) + + def to_proto(self) -> Any: datastore_table_proto = DatastoreTableProto() datastore_table_proto.project = self.project datastore_table_proto.name = self.name if self.project_id: - datastore_table_proto.project_id.FromString(bytes(self.project_id, "utf-8")) + datastore_table_proto.project_id.value = self.project_id if self.namespace: - datastore_table_proto.namespace.FromString(bytes(self.namespace, "utf-8")) - - return InfraObjectProto( - infra_object_class_type="feast.infra.online_stores.datastore.DatastoreTable", - datastore_table=datastore_table_proto, - ) + datastore_table_proto.namespace.value = self.namespace + return datastore_table_proto @staticmethod - def from_proto(infra_object_proto: InfraObjectProto) -> Any: + def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: datastore_table = DatastoreTable( project=infra_object_proto.datastore_table.project, name=infra_object_proto.datastore_table.name, @@ -377,26 +378,28 @@ def from_proto(infra_object_proto: InfraObjectProto) -> Any: if infra_object_proto.datastore_table.HasField("project_id"): datastore_table.project_id = ( - infra_object_proto.datastore_table.project_id.SerializeToString() - ).decode("utf-8") + infra_object_proto.datastore_table.project_id.value + ) if infra_object_proto.datastore_table.HasField("namespace"): datastore_table.namespace = ( - infra_object_proto.datastore_table.namespace.SerializeToString() - ).decode("utf-8") + infra_object_proto.datastore_table.namespace.value + ) return datastore_table def update(self): - key = self.client.key("Project", self.project, "Table", self.name) + client = _initialize_client(self.project_id, self.namespace) + key = client.key("Project", self.project, "Table", self.name) entity = datastore.Entity( key=key, exclude_from_indexes=("created_ts", "event_ts", "values") ) entity.update({"created_ts": datetime.utcnow()}) - self.client.put(entity) + client.put(entity) def teardown(self): - key = self.client.key("Project", self.project, "Table", self.name) - _delete_all_values(self.client, key) + client = _initialize_client(self.project_id, self.namespace) + key = client.key("Project", self.project, "Table", self.name) + _delete_all_values(client, key) # Delete the table metadata datastore entity - self.client.delete(key) + client.delete(key) diff --git a/sdk/python/feast/infra/online_stores/dynamodb.py b/sdk/python/feast/infra/online_stores/dynamodb.py index 377e10c3081..202cfa54bb2 100644 --- a/sdk/python/feast/infra/online_stores/dynamodb.py +++ b/sdk/python/feast/infra/online_stores/dynamodb.py @@ -19,7 +19,7 @@ from pydantic.typing import Literal from feast import Entity, FeatureView, utils -from feast.infra.infra_object import InfraObject +from feast.infra.infra_object import DYNAMODB_INFRA_OBJECT_CLASS_TYPE, InfraObject from feast.infra.online_stores.helpers import compute_entity_id from feast.infra.online_stores.online_store import OnlineStore from feast.protos.feast.core.DynamoDBTable_pb2 import ( @@ -234,18 +234,21 @@ def __init__(self, name: str, region: str): self.name = name self.region = region - def to_proto(self) -> InfraObjectProto: - dynamodb_table_proto = DynamoDBTableProto() - dynamodb_table_proto.name = self.name - dynamodb_table_proto.region = self.region - + def to_infra_object_proto(self) -> InfraObjectProto: + dynamodb_table_proto = self.to_proto() return InfraObjectProto( - infra_object_class_type="feast.infra.online_stores.dynamodb.DynamoDBTable", + infra_object_class_type=DYNAMODB_INFRA_OBJECT_CLASS_TYPE, dynamodb_table=dynamodb_table_proto, ) + def to_proto(self) -> Any: + dynamodb_table_proto = DynamoDBTableProto() + dynamodb_table_proto.name = self.name + dynamodb_table_proto.region = self.region + return dynamodb_table_proto + @staticmethod - def from_proto(infra_object_proto: InfraObjectProto) -> Any: + def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: return DynamoDBTable( name=infra_object_proto.dynamodb_table.name, region=infra_object_proto.dynamodb_table.region, diff --git a/sdk/python/feast/infra/online_stores/sqlite.py b/sdk/python/feast/infra/online_stores/sqlite.py index 206e2eb0d50..2dcbf319c39 100644 --- a/sdk/python/feast/infra/online_stores/sqlite.py +++ b/sdk/python/feast/infra/online_stores/sqlite.py @@ -23,7 +23,7 @@ from feast import Entity from feast.feature_view import FeatureView -from feast.infra.infra_object import InfraObject +from feast.infra.infra_object import SQLITE_INFRA_OBJECT_CLASS_TYPE, InfraObject from feast.infra.key_encoding_utils import serialize_entity_key from feast.infra.online_stores.online_store import OnlineStore from feast.protos.feast.core.InfraObject_pb2 import InfraObject as InfraObjectProto @@ -241,18 +241,21 @@ def __init__(self, path: str, name: str): self.name = name self.conn = _initialize_conn(path) - def to_proto(self) -> InfraObjectProto: - sqlite_table_proto = SqliteTableProto() - sqlite_table_proto.path = self.path - sqlite_table_proto.name = self.name - + def to_infra_object_proto(self) -> InfraObjectProto: + sqlite_table_proto = self.to_proto() return InfraObjectProto( - infra_object_class_type="feast.infra.online_store.sqlite.SqliteTable", + infra_object_class_type=SQLITE_INFRA_OBJECT_CLASS_TYPE, sqlite_table=sqlite_table_proto, ) + def to_proto(self) -> Any: + sqlite_table_proto = SqliteTableProto() + sqlite_table_proto.path = self.path + sqlite_table_proto.name = self.name + return sqlite_table_proto + @staticmethod - def from_proto(infra_object_proto: InfraObjectProto) -> Any: + def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: return SqliteTable( path=infra_object_proto.sqlite_table.path, name=infra_object_proto.sqlite_table.name, diff --git a/sdk/python/tests/unit/diff/test_infra_diff.py b/sdk/python/tests/unit/diff/test_infra_diff.py new file mode 100644 index 00000000000..8e3d5b765f0 --- /dev/null +++ b/sdk/python/tests/unit/diff/test_infra_diff.py @@ -0,0 +1,154 @@ +from google.protobuf import wrappers_pb2 as wrappers + +from feast.diff.infra_diff import ( + diff_between, + diff_infra_protos, + tag_infra_proto_objects_for_keep_delete_add, +) +from feast.diff.property_diff import TransitionType +from feast.infra.online_stores.datastore import DatastoreTable +from feast.infra.online_stores.dynamodb import DynamoDBTable +from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto + + +def test_tag_infra_proto_objects_for_keep_delete_add(): + to_delete = DynamoDBTable(name="to_delete", region="us-west-2").to_proto() + to_add = DynamoDBTable(name="to_add", region="us-west-2").to_proto() + unchanged_table = DynamoDBTable(name="unchanged", region="us-west-2").to_proto() + pre_changed = DynamoDBTable(name="table", region="us-west-2").to_proto() + post_changed = DynamoDBTable(name="table", region="us-east-2").to_proto() + + keep, delete, add = tag_infra_proto_objects_for_keep_delete_add( + [to_delete, unchanged_table, pre_changed], + [to_add, unchanged_table, post_changed], + ) + + assert len(list(keep)) == 2 + assert unchanged_table in keep + assert post_changed in keep + assert to_add not in keep + assert len(list(delete)) == 1 + assert to_delete in delete + assert unchanged_table not in delete + assert pre_changed not in delete + assert len(list(add)) == 1 + assert to_add in add + assert unchanged_table not in add + assert post_changed not in add + + +def test_diff_between_datastore_tables(): + pre_changed = DatastoreTable( + project="test", name="table", project_id="pre", namespace="pre" + ).to_proto() + post_changed = DatastoreTable( + project="test", name="table", project_id="post", namespace="post" + ).to_proto() + + infra_object_diff = diff_between(pre_changed, pre_changed, "datastore table") + infra_object_property_diffs = infra_object_diff.infra_object_property_diffs + assert len(infra_object_property_diffs) == 0 + + infra_object_diff = diff_between(pre_changed, post_changed, "datastore table") + infra_object_property_diffs = infra_object_diff.infra_object_property_diffs + assert len(infra_object_property_diffs) == 2 + + assert infra_object_property_diffs[0].property_name == "project_id" + assert infra_object_property_diffs[0].val_existing == wrappers.StringValue( + value="pre" + ) + assert infra_object_property_diffs[0].val_declared == wrappers.StringValue( + value="post" + ) + assert infra_object_property_diffs[1].property_name == "namespace" + assert infra_object_property_diffs[1].val_existing == wrappers.StringValue( + value="pre" + ) + assert infra_object_property_diffs[1].val_declared == wrappers.StringValue( + value="post" + ) + + +def test_diff_infra_protos(): + to_delete = DynamoDBTable(name="to_delete", region="us-west-2") + to_add = DynamoDBTable(name="to_add", region="us-west-2") + unchanged_table = DynamoDBTable(name="unchanged", region="us-west-2") + pre_changed = DatastoreTable( + project="test", name="table", project_id="pre", namespace="pre" + ) + post_changed = DatastoreTable( + project="test", name="table", project_id="post", namespace="post" + ) + + infra_objects_before = [to_delete, unchanged_table, pre_changed] + infra_objects_after = [to_add, unchanged_table, post_changed] + + infra_proto_before = InfraProto() + infra_proto_before.infra_objects.extend( + [obj.to_infra_object_proto() for obj in infra_objects_before] + ) + + infra_proto_after = InfraProto() + infra_proto_after.infra_objects.extend( + [obj.to_infra_object_proto() for obj in infra_objects_after] + ) + + infra_diff = diff_infra_protos(infra_proto_before, infra_proto_after) + infra_object_diffs = infra_diff.infra_object_diffs + + # There should be one addition, one deletion, one unchanged, and one changed. + assert len(infra_object_diffs) == 4 + + additions = [ + infra_object_diff + for infra_object_diff in infra_object_diffs + if infra_object_diff.transition_type == TransitionType.CREATE + ] + assert len(additions) == 1 + assert not additions[0].current_infra_object + assert additions[0].new_infra_object == to_add.to_proto() + assert len(additions[0].infra_object_property_diffs) == 0 + + deletions = [ + infra_object_diff + for infra_object_diff in infra_object_diffs + if infra_object_diff.transition_type == TransitionType.DELETE + ] + assert len(deletions) == 1 + assert deletions[0].current_infra_object == to_delete.to_proto() + assert not deletions[0].new_infra_object + assert len(deletions[0].infra_object_property_diffs) == 0 + + unchanged = [ + infra_object_diff + for infra_object_diff in infra_object_diffs + if infra_object_diff.transition_type == TransitionType.UNCHANGED + ] + assert len(unchanged) == 1 + assert unchanged[0].current_infra_object == unchanged_table.to_proto() + assert unchanged[0].new_infra_object == unchanged_table.to_proto() + assert len(unchanged[0].infra_object_property_diffs) == 0 + + updates = [ + infra_object_diff + for infra_object_diff in infra_object_diffs + if infra_object_diff.transition_type == TransitionType.UPDATE + ] + assert len(updates) == 1 + assert updates[0].current_infra_object == pre_changed.to_proto() + assert updates[0].new_infra_object == post_changed.to_proto() + assert len(updates[0].infra_object_property_diffs) == 2 + assert updates[0].infra_object_property_diffs[0].property_name == "project_id" + assert updates[0].infra_object_property_diffs[ + 0 + ].val_existing == wrappers.StringValue(value="pre") + assert updates[0].infra_object_property_diffs[ + 0 + ].val_declared == wrappers.StringValue(value="post") + assert updates[0].infra_object_property_diffs[1].property_name == "namespace" + assert updates[0].infra_object_property_diffs[ + 1 + ].val_existing == wrappers.StringValue(value="pre") + assert updates[0].infra_object_property_diffs[ + 1 + ].val_declared == wrappers.StringValue(value="post") From 97dd41e0c0e1a9e8050f3931eb610f59789edeed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Jan 2022 06:42:12 -0800 Subject: [PATCH 17/85] Bump protobuf-java from 3.12.2 to 3.16.1 in /java (#2208) Bumps [protobuf-java](https://github.com/protocolbuffers/protobuf) from 3.12.2 to 3.16.1. - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Changelog](https://github.com/protocolbuffers/protobuf/blob/master/generate_changelog.py) - [Commits](https://github.com/protocolbuffers/protobuf/compare/v3.12.2...v3.16.1) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-java dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- java/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/pom.xml b/java/pom.xml index 38f037431c8..6857bce4c09 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -46,7 +46,7 @@ 1.30.2 3.12.2 - 3.12.2 + 3.16.1 2.3.1.RELEASE 5.2.7.RELEASE 5.3.0.RELEASE From d5cb0440e4f8df76310aed026d6c90acc0a19fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Magalh=C3=A3es=20Martins?= Date: Tue, 11 Jan 2022 01:27:35 -0300 Subject: [PATCH 18/85] Updates to click==8.* (#2210) * Updates to click==8.* Signed-off-by: diogommartins * Updates pip tools requirements to click==8.0.3 Signed-off-by: diogommartins --- sdk/python/requirements/py3.7-ci-requirements.txt | 2 +- sdk/python/requirements/py3.7-requirements.txt | 2 +- sdk/python/requirements/py3.8-ci-requirements.txt | 2 +- sdk/python/requirements/py3.8-requirements.txt | 2 +- sdk/python/requirements/py3.9-ci-requirements.txt | 2 +- sdk/python/requirements/py3.9-requirements.txt | 2 +- sdk/python/setup.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sdk/python/requirements/py3.7-ci-requirements.txt b/sdk/python/requirements/py3.7-ci-requirements.txt index 017652873a8..a4fd01a47a6 100644 --- a/sdk/python/requirements/py3.7-ci-requirements.txt +++ b/sdk/python/requirements/py3.7-ci-requirements.txt @@ -85,7 +85,7 @@ charset-normalizer==2.0.8 # via # aiohttp # requests -click==7.1.2 +click==8.0.3 # via # black # feast (setup.py) diff --git a/sdk/python/requirements/py3.7-requirements.txt b/sdk/python/requirements/py3.7-requirements.txt index b2473f1c70c..4e7408f86ee 100644 --- a/sdk/python/requirements/py3.7-requirements.txt +++ b/sdk/python/requirements/py3.7-requirements.txt @@ -18,7 +18,7 @@ certifi==2021.10.8 # via requests charset-normalizer==2.0.8 # via requests -click==7.1.2 +click==8.0.3 # via # feast (setup.py) # uvicorn diff --git a/sdk/python/requirements/py3.8-ci-requirements.txt b/sdk/python/requirements/py3.8-ci-requirements.txt index a2df153c01e..1c57df69b63 100644 --- a/sdk/python/requirements/py3.8-ci-requirements.txt +++ b/sdk/python/requirements/py3.8-ci-requirements.txt @@ -83,7 +83,7 @@ charset-normalizer==2.0.8 # via # aiohttp # requests -click==7.1.2 +click==8.0.3 # via # black # feast (setup.py) diff --git a/sdk/python/requirements/py3.8-requirements.txt b/sdk/python/requirements/py3.8-requirements.txt index e6887dea556..11b2dbc6aca 100644 --- a/sdk/python/requirements/py3.8-requirements.txt +++ b/sdk/python/requirements/py3.8-requirements.txt @@ -18,7 +18,7 @@ certifi==2021.10.8 # via requests charset-normalizer==2.0.8 # via requests -click==7.1.2 +click==8.0.3 # via # feast (setup.py) # uvicorn diff --git a/sdk/python/requirements/py3.9-ci-requirements.txt b/sdk/python/requirements/py3.9-ci-requirements.txt index aa4cee54e46..b8d33ceb853 100644 --- a/sdk/python/requirements/py3.9-ci-requirements.txt +++ b/sdk/python/requirements/py3.9-ci-requirements.txt @@ -83,7 +83,7 @@ charset-normalizer==2.0.8 # via # aiohttp # requests -click==7.1.2 +click==8.0.3 # via # black # feast (setup.py) diff --git a/sdk/python/requirements/py3.9-requirements.txt b/sdk/python/requirements/py3.9-requirements.txt index 4cb45fd8098..ce597b8eb1e 100644 --- a/sdk/python/requirements/py3.9-requirements.txt +++ b/sdk/python/requirements/py3.9-requirements.txt @@ -18,7 +18,7 @@ certifi==2021.10.8 # via requests charset-normalizer==2.0.8 # via requests -click==7.1.2 +click==8.0.3 # via # feast (setup.py) # uvicorn diff --git a/sdk/python/setup.py b/sdk/python/setup.py index e797a1216ca..ae8d167ff04 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -40,7 +40,7 @@ REQUIRES_PYTHON = ">=3.7.0" REQUIRED = [ - "Click==7.*", + "Click==8.*", "colorama>=0.3.9", "dill==0.3.*", "fastavro>=1.1.0", From 2a95629e8c4a760b282da3ccce0897d6b9c528a0 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Tue, 11 Jan 2022 13:50:51 -0800 Subject: [PATCH 19/85] Modify feature_store.plan to produce an InfraDiff (#2211) * Implement InfraObject.from_proto for easier conversion Signed-off-by: Felix Wang * Implement InfraDiff.update Signed-off-by: Felix Wang * Modify feature_store.plan to produce an InfraDiff Signed-off-by: Felix Wang * Stricter typing for FcoDiff and InfraObjectDiff Signed-off-by: Felix Wang * Small fixes Signed-off-by: Felix Wang * Fix typevar names Signed-off-by: Felix Wang * Add comment Signed-off-by: Felix Wang * Fix protos Signed-off-by: Felix Wang --- sdk/python/feast/diff/FcoDiff.py | 41 +++++++++++------ sdk/python/feast/diff/infra_diff.py | 46 ++++++++++++++----- sdk/python/feast/errors.py | 5 ++ sdk/python/feast/feature_store.py | 25 ++++++++-- sdk/python/feast/infra/infra_object.py | 39 ++++++++++++++-- sdk/python/feast/infra/local.py | 19 +++++--- .../feast/infra/online_stores/datastore.py | 15 ++++++ .../feast/infra/online_stores/dynamodb.py | 6 +++ .../feast/infra/online_stores/online_store.py | 14 ++++++ .../feast/infra/online_stores/sqlite.py | 20 ++++++++ sdk/python/feast/infra/provider.py | 14 ++++++ sdk/python/feast/repo_operations.py | 8 ++-- 12 files changed, 208 insertions(+), 44 deletions(-) diff --git a/sdk/python/feast/diff/FcoDiff.py b/sdk/python/feast/diff/FcoDiff.py index e4b044dcc41..b85897019fe 100644 --- a/sdk/python/feast/diff/FcoDiff.py +++ b/sdk/python/feast/diff/FcoDiff.py @@ -1,20 +1,38 @@ from dataclasses import dataclass -from typing import Any, Iterable, List, Set, Tuple, TypeVar +from typing import Generic, Iterable, List, Set, Tuple, TypeVar from feast.base_feature_view import BaseFeatureView from feast.diff.property_diff import PropertyDiff, TransitionType from feast.entity import Entity from feast.feature_service import FeatureService from feast.protos.feast.core.Entity_pb2 import Entity as EntityProto +from feast.protos.feast.core.FeatureService_pb2 import ( + FeatureService as FeatureServiceProto, +) from feast.protos.feast.core.FeatureView_pb2 import FeatureView as FeatureViewProto +from feast.protos.feast.core.OnDemandFeatureView_pb2 import ( + OnDemandFeatureView as OnDemandFeatureViewProto, +) +from feast.protos.feast.core.RequestFeatureView_pb2 import ( + RequestFeatureView as RequestFeatureViewProto, +) + +FcoProto = TypeVar( + "FcoProto", + EntityProto, + FeatureViewProto, + FeatureServiceProto, + OnDemandFeatureViewProto, + RequestFeatureViewProto, +) @dataclass -class FcoDiff: +class FcoDiff(Generic[FcoProto]): name: str fco_type: str - current_fco: Any - new_fco: Any + current_fco: FcoProto + new_fco: FcoProto fco_property_diffs: List[PropertyDiff] transition_type: TransitionType @@ -30,12 +48,12 @@ def add_fco_diff(self, fco_diff: FcoDiff): self.fco_diffs.append(fco_diff) -T = TypeVar("T", Entity, BaseFeatureView, FeatureService) +Fco = TypeVar("Fco", Entity, BaseFeatureView, FeatureService) def tag_objects_for_keep_delete_add( - existing_objs: Iterable[T], desired_objs: Iterable[T] -) -> Tuple[Set[T], Set[T], Set[T]]: + existing_objs: Iterable[Fco], desired_objs: Iterable[Fco] +) -> Tuple[Set[Fco], Set[Fco], Set[Fco]]: existing_obj_names = {e.name for e in existing_objs} desired_obj_names = {e.name for e in desired_objs} @@ -46,12 +64,9 @@ def tag_objects_for_keep_delete_add( return objs_to_keep, objs_to_delete, objs_to_add -U = TypeVar("U", EntityProto, FeatureViewProto) - - def tag_proto_objects_for_keep_delete_add( - existing_objs: Iterable[U], desired_objs: Iterable[U] -) -> Tuple[Iterable[U], Iterable[U], Iterable[U]]: + existing_objs: Iterable[FcoProto], desired_objs: Iterable[FcoProto] +) -> Tuple[Iterable[FcoProto], Iterable[FcoProto], Iterable[FcoProto]]: existing_obj_names = {e.spec.name for e in existing_objs} desired_obj_names = {e.spec.name for e in desired_objs} @@ -65,7 +80,7 @@ def tag_proto_objects_for_keep_delete_add( FIELDS_TO_IGNORE = {"project"} -def diff_between(current: U, new: U, object_type: str) -> FcoDiff: +def diff_between(current: FcoProto, new: FcoProto, object_type: str) -> FcoDiff: assert current.DESCRIPTOR.full_name == new.DESCRIPTOR.full_name property_diffs = [] transition: TransitionType = TransitionType.UNCHANGED diff --git a/sdk/python/feast/diff/infra_diff.py b/sdk/python/feast/diff/infra_diff.py index d7164222611..fc79a74f678 100644 --- a/sdk/python/feast/diff/infra_diff.py +++ b/sdk/python/feast/diff/infra_diff.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Iterable, List, Tuple, TypeVar +from typing import Generic, Iterable, List, Tuple, TypeVar from feast.diff.property_diff import PropertyDiff, TransitionType from feast.infra.infra_object import ( @@ -17,13 +17,17 @@ from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto from feast.protos.feast.core.SqliteTable_pb2 import SqliteTable as SqliteTableProto +InfraObjectProto = TypeVar( + "InfraObjectProto", DatastoreTableProto, DynamoDBTableProto, SqliteTableProto +) + @dataclass -class InfraObjectDiff: +class InfraObjectDiff(Generic[InfraObjectProto]): name: str infra_object_type: str - current_infra_object: Any - new_infra_object: Any + current_infra_object: InfraObjectProto + new_infra_object: InfraObjectProto infra_object_property_diffs: List[PropertyDiff] transition_type: TransitionType @@ -36,18 +40,34 @@ def __init__(self): self.infra_object_diffs = [] def update(self): - pass + """Apply the infrastructure changes specified in this object.""" + for infra_object_diff in self.infra_object_diffs: + if infra_object_diff.transition_type in [ + TransitionType.DELETE, + TransitionType.UPDATE, + ]: + infra_object = InfraObject.from_proto( + infra_object_diff.current_infra_object + ) + infra_object.teardown() + elif infra_object_diff.transition_type in [ + TransitionType.CREATE, + TransitionType.UPDATE, + ]: + infra_object = InfraObject.from_proto( + infra_object_diff.new_infra_object + ) + infra_object.update() def to_string(self): pass -U = TypeVar("U", DatastoreTableProto, DynamoDBTableProto, SqliteTableProto) - - def tag_infra_proto_objects_for_keep_delete_add( - existing_objs: Iterable[U], desired_objs: Iterable[U] -) -> Tuple[Iterable[U], Iterable[U], Iterable[U]]: + existing_objs: Iterable[InfraObjectProto], desired_objs: Iterable[InfraObjectProto] +) -> Tuple[ + Iterable[InfraObjectProto], Iterable[InfraObjectProto], Iterable[InfraObjectProto] +]: existing_obj_names = {e.name for e in existing_objs} desired_obj_names = {e.name for e in desired_objs} @@ -123,7 +143,7 @@ def diff_infra_protos( def get_infra_object_protos_by_type( infra_proto: InfraProto, infra_object_class_type: str -) -> List[U]: +) -> List[InfraObjectProto]: return [ InfraObject.from_infra_object_proto(infra_object).to_proto() for infra_object in infra_proto.infra_objects @@ -134,7 +154,9 @@ def get_infra_object_protos_by_type( FIELDS_TO_IGNORE = {"project"} -def diff_between(current: U, new: U, infra_object_type: str) -> InfraObjectDiff: +def diff_between( + current: InfraObjectProto, new: InfraObjectProto, infra_object_type: str +) -> InfraObjectDiff: assert current.DESCRIPTOR.full_name == new.DESCRIPTOR.full_name property_diffs = [] transition: TransitionType = TransitionType.UNCHANGED diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py index 615069e5797..8592960acd2 100644 --- a/sdk/python/feast/errors.py +++ b/sdk/python/feast/errors.py @@ -293,3 +293,8 @@ def __init__(self, actual_class: str, expected_class: str): super().__init__( f"The registry store class was expected to be {expected_class}, but was instead {actual_class}." ) + + +class FeastInvalidInfraObjectType(Exception): + def __init__(self): + super().__init__("Could not identify the type of the InfraObject.") diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index f1fee70336e..64bf23ebde7 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -38,6 +38,7 @@ from feast import feature_server, flags, flags_helper, utils from feast.base_feature_view import BaseFeatureView from feast.diff.FcoDiff import RegistryDiff +from feast.diff.infra_diff import InfraDiff, diff_infra_protos from feast.entity import Entity from feast.errors import ( EntityNotFoundException, @@ -63,6 +64,7 @@ from feast.infra.provider import Provider, RetrievalJob, get_provider from feast.on_demand_feature_view import OnDemandFeatureView from feast.online_response import OnlineResponse +from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.protos.feast.serving.ServingService_pb2 import ( FieldStatus, @@ -405,7 +407,9 @@ def _get_features( return _feature_refs @log_exceptions_and_usage - def plan(self, desired_repo_objects: RepoContents) -> RegistryDiff: + def plan( + self, desired_repo_objects: RepoContents + ) -> Tuple[RegistryDiff, InfraDiff]: """Dry-run registering objects to metadata store. The plan method dry-runs registering one or more definitions (e.g., Entity, FeatureView), and produces @@ -440,7 +444,7 @@ def plan(self, desired_repo_objects: RepoContents) -> RegistryDiff: ... ttl=timedelta(seconds=86400 * 1), ... batch_source=driver_hourly_stats, ... ) - >>> diff = fs.plan(RepoContents({driver_hourly_stats_view}, set(), set(), {driver}, set())) # register entity and feature view + >>> registry_diff, infra_diff = fs.plan(RepoContents({driver_hourly_stats_view}, set(), set(), {driver}, set())) # register entity and feature view """ current_registry_proto = ( @@ -450,8 +454,21 @@ def plan(self, desired_repo_objects: RepoContents) -> RegistryDiff: ) desired_registry_proto = desired_repo_objects.to_registry_proto() - diffs = Registry.diff_between(current_registry_proto, desired_registry_proto) - return diffs + registry_diff = Registry.diff_between( + current_registry_proto, desired_registry_proto + ) + + current_infra_proto = ( + self._registry.cached_registry_proto.infra.__deepcopy__() + if self._registry.cached_registry_proto + else InfraProto() + ) + new_infra_proto = self._provider.plan_infra( + self.config, desired_registry_proto + ).to_proto() + infra_diff = diff_infra_protos(current_infra_proto, new_infra_proto) + + return (registry_diff, infra_diff) @log_exceptions_and_usage def apply( diff --git a/sdk/python/feast/infra/infra_object.py b/sdk/python/feast/infra/infra_object.py index 282b4bcfab7..f21016dea54 100644 --- a/sdk/python/feast/infra/infra_object.py +++ b/sdk/python/feast/infra/infra_object.py @@ -15,13 +15,21 @@ from dataclasses import dataclass, field from typing import Any, List +from feast.errors import FeastInvalidInfraObjectType from feast.importer import import_class +from feast.protos.feast.core.DatastoreTable_pb2 import ( + DatastoreTable as DatastoreTableProto, +) +from feast.protos.feast.core.DynamoDBTable_pb2 import ( + DynamoDBTable as DynamoDBTableProto, +) from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto from feast.protos.feast.core.InfraObject_pb2 import InfraObject as InfraObjectProto +from feast.protos.feast.core.SqliteTable_pb2 import SqliteTable as SqliteTableProto DATASTORE_INFRA_OBJECT_CLASS_TYPE = "feast.infra.online_stores.datastore.DatastoreTable" DYNAMODB_INFRA_OBJECT_CLASS_TYPE = "feast.infra.online_stores.dynamodb.DynamoDBTable" -SQLITE_INFRA_OBJECT_CLASS_TYPE = "feast.infra.online_store.sqlite.SqliteTable" +SQLITE_INFRA_OBJECT_CLASS_TYPE = "feast.infra.online_stores.sqlite.SqliteTable" class InfraObject(ABC): @@ -49,7 +57,7 @@ def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: infra_object_proto: A protobuf representation of an InfraObject. Raises: - ValueError: The type of InfraObject could not be identified. + FeastInvalidInfraObjectType: The type of InfraObject could not be identified. """ if infra_object_proto.infra_object_class_type: cls = _get_infra_object_class_from_type( @@ -57,7 +65,30 @@ def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: ) return cls.from_infra_object_proto(infra_object_proto) - raise ValueError("Could not identify the type of the InfraObject.") + raise FeastInvalidInfraObjectType() + + @staticmethod + def from_proto(infra_object_proto: Any) -> Any: + """ + Converts a protobuf representation of a subclass to an object of that subclass. + + Args: + infra_object_proto: A protobuf representation of an InfraObject. + + Raises: + FeastInvalidInfraObjectType: The type of InfraObject could not be identified. + """ + if isinstance(infra_object_proto, DatastoreTableProto): + infra_object_class_type = DATASTORE_INFRA_OBJECT_CLASS_TYPE + elif isinstance(infra_object_proto, DynamoDBTableProto): + infra_object_class_type = DYNAMODB_INFRA_OBJECT_CLASS_TYPE + elif isinstance(infra_object_proto, SqliteTableProto): + infra_object_class_type = SQLITE_INFRA_OBJECT_CLASS_TYPE + else: + raise FeastInvalidInfraObjectType() + + cls = _get_infra_object_class_from_type(infra_object_class_type) + return cls.from_proto(infra_object_proto) @abstractmethod def update(self): @@ -94,7 +125,7 @@ def to_proto(self) -> InfraProto: """ infra_proto = InfraProto() for infra_object in self.infra_objects: - infra_object_proto = infra_object.to_proto() + infra_object_proto = infra_object.to_infra_object_proto() infra_proto.infra_objects.append(infra_object_proto) return infra_proto diff --git a/sdk/python/feast/infra/local.py b/sdk/python/feast/infra/local.py index 31c46cf2823..060ac64d53e 100644 --- a/sdk/python/feast/infra/local.py +++ b/sdk/python/feast/infra/local.py @@ -1,12 +1,13 @@ import uuid from datetime import datetime from pathlib import Path +from typing import List -from feast.feature_view import FeatureView +from feast.infra.infra_object import Infra, InfraObject from feast.infra.passthrough_provider import PassthroughProvider from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.registry_store import RegistryStore -from feast.repo_config import RegistryConfig +from feast.repo_config import RegistryConfig, RepoConfig from feast.usage import log_exceptions_and_usage @@ -15,11 +16,15 @@ class LocalProvider(PassthroughProvider): This class only exists for backwards compatibility. """ - pass - - -def _table_id(project: str, table: FeatureView) -> str: - return f"{project}_{table.name}" + def plan_infra( + self, config: RepoConfig, desired_registry_proto: RegistryProto + ) -> Infra: + infra_objects: List[InfraObject] = self.online_store.plan( + config, desired_registry_proto + ) + infra = Infra() + infra.infra_objects += infra_objects + return infra class LocalRegistryStore(RegistryStore): diff --git a/sdk/python/feast/infra/online_stores/datastore.py b/sdk/python/feast/infra/online_stores/datastore.py index 348583a202a..5a8d4b71803 100644 --- a/sdk/python/feast/infra/online_stores/datastore.py +++ b/sdk/python/feast/infra/online_stores/datastore.py @@ -376,6 +376,7 @@ def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: name=infra_object_proto.datastore_table.name, ) + # Distinguish between null and empty string, since project_id and namespace are StringValues. if infra_object_proto.datastore_table.HasField("project_id"): datastore_table.project_id = ( infra_object_proto.datastore_table.project_id.value @@ -387,6 +388,20 @@ def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: return datastore_table + @staticmethod + def from_proto(datastore_table_proto: DatastoreTableProto) -> Any: + datastore_table = DatastoreTable( + project=datastore_table_proto.project, name=datastore_table_proto.name, + ) + + # Distinguish between null and empty string, since project_id and namespace are StringValues. + if datastore_table_proto.HasField("project_id"): + datastore_table.project_id = datastore_table_proto.project_id.value + if datastore_table_proto.HasField("namespace"): + datastore_table.namespace = datastore_table_proto.namespace.value + + return datastore_table + def update(self): client = _initialize_client(self.project_id, self.namespace) key = client.key("Project", self.project, "Table", self.name) diff --git a/sdk/python/feast/infra/online_stores/dynamodb.py b/sdk/python/feast/infra/online_stores/dynamodb.py index 202cfa54bb2..b7f8680e1f4 100644 --- a/sdk/python/feast/infra/online_stores/dynamodb.py +++ b/sdk/python/feast/infra/online_stores/dynamodb.py @@ -254,6 +254,12 @@ def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: region=infra_object_proto.dynamodb_table.region, ) + @staticmethod + def from_proto(dynamodb_table_proto: DynamoDBTableProto) -> Any: + return DynamoDBTable( + name=dynamodb_table_proto.name, region=dynamodb_table_proto.region, + ) + def update(self): dynamodb_client = _initialize_dynamodb_client(region=self.region) dynamodb_resource = _initialize_dynamodb_resource(region=self.region) diff --git a/sdk/python/feast/infra/online_stores/online_store.py b/sdk/python/feast/infra/online_stores/online_store.py index b2aa1e46d04..1f177996dea 100644 --- a/sdk/python/feast/infra/online_stores/online_store.py +++ b/sdk/python/feast/infra/online_stores/online_store.py @@ -18,6 +18,8 @@ from feast import Entity from feast.feature_view import FeatureView +from feast.infra.infra_object import InfraObject +from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto from feast.repo_config import RepoConfig @@ -92,6 +94,18 @@ def update( ): ... + def plan( + self, config: RepoConfig, desired_registry_proto: RegistryProto + ) -> List[InfraObject]: + """ + Returns the set of InfraObjects required to support the desired registry. + + Args: + config: The RepoConfig for the current FeatureStore. + desired_registry_proto: The desired registry, in proto form. + """ + return [] + @abstractmethod def teardown( self, diff --git a/sdk/python/feast/infra/online_stores/sqlite.py b/sdk/python/feast/infra/online_stores/sqlite.py index 2dcbf319c39..1e7ecf1024a 100644 --- a/sdk/python/feast/infra/online_stores/sqlite.py +++ b/sdk/python/feast/infra/online_stores/sqlite.py @@ -27,6 +27,7 @@ from feast.infra.key_encoding_utils import serialize_entity_key from feast.infra.online_stores.online_store import OnlineStore from feast.protos.feast.core.InfraObject_pb2 import InfraObject as InfraObjectProto +from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.protos.feast.core.SqliteTable_pb2 import SqliteTable as SqliteTableProto from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto @@ -199,6 +200,21 @@ def update( for table in tables_to_delete: conn.execute(f"DROP TABLE IF EXISTS {_table_id(project, table)}") + @log_exceptions_and_usage(online_store="sqlite") + def plan( + self, config: RepoConfig, desired_registry_proto: RegistryProto + ) -> List[InfraObject]: + project = config.project + + infra_objects: List[InfraObject] = [ + SqliteTable( + path=self._get_db_path(config), + name=_table_id(project, FeatureView.from_proto(view)), + ) + for view in desired_registry_proto.feature_views + ] + return infra_objects + def teardown( self, config: RepoConfig, @@ -261,6 +277,10 @@ def from_infra_object_proto(infra_object_proto: InfraObjectProto) -> Any: name=infra_object_proto.sqlite_table.name, ) + @staticmethod + def from_proto(sqlite_table_proto: SqliteTableProto) -> Any: + return SqliteTable(path=sqlite_table_proto.path, name=sqlite_table_proto.name,) + def update(self): self.conn.execute( f"CREATE TABLE IF NOT EXISTS {self.name} (entity_key BLOB, feature_name TEXT, value BLOB, event_ts timestamp, created_ts timestamp, PRIMARY KEY(entity_key, feature_name))" diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index 3c761f1195b..8f9dda93515 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -12,8 +12,10 @@ from feast.entity import Entity from feast.feature_view import DUMMY_ENTITY_ID, FeatureView from feast.importer import import_class +from feast.infra.infra_object import Infra from feast.infra.offline_stores.offline_store import RetrievalJob from feast.on_demand_feature_view import OnDemandFeatureView +from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto 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 @@ -61,6 +63,18 @@ def update_infra( """ ... + def plan_infra( + self, config: RepoConfig, desired_registry_proto: RegistryProto + ) -> Infra: + """ + Returns the Infra required to support the desired registry. + + Args: + config: The RepoConfig for the current FeatureStore. + desired_registry_proto: The desired registry, in proto form. + """ + return Infra() + @abc.abstractmethod def teardown_infra( self, project: str, tables: Sequence[FeatureView], entities: Sequence[Entity], diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index 9299a36123f..3e9ddb6e304 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -127,20 +127,20 @@ def plan(repo_config: RepoConfig, repo_path: Path, skip_source_validation: bool) for data_source in data_sources: data_source.validate(store.config) - diff = store.plan(repo) + registry_diff, _ = store.plan(repo) views_to_delete = [ v - for v in diff.fco_diffs + for v in registry_diff.fco_diffs if v.fco_type == "feature view" and v.transition_type == TransitionType.DELETE ] views_to_keep = [ v - for v in diff.fco_diffs + for v in registry_diff.fco_diffs if v.fco_type == "feature view" and v.transition_type in {TransitionType.CREATE, TransitionType.UNCHANGED} ] - log_cli_output(diff, views_to_delete, views_to_keep) + log_cli_output(registry_diff, views_to_delete, views_to_keep) def _prepare_registry_and_repo(repo_config, repo_path): From 1b98ec94e3573991627d561d6d207126a40a21cf Mon Sep 17 00:00:00 2001 From: Tsotne Tabidze Date: Fri, 14 Jan 2022 13:52:13 -0800 Subject: [PATCH 20/85] =?UTF-8?q?replace=20GetOnlineFeaturesResponse=20wit?= =?UTF-8?q?h=20GetOnlineFeaturesResponseV2=20in=E2=80=A6=20(#2214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * replace GetOnlineFeaturesResponse with GetOnlineFeaturesResponseV2 in Python Signed-off-by: Tsotne Tabidze * Fix unit tests Signed-off-by: Tsotne Tabidze * Fix java compilation & python integration tests Signed-off-by: Tsotne Tabidze * Fix integration tests Signed-off-by: Tsotne Tabidze --- go.mod | 4 +- go.sum | 7 + .../logging/entry/AuditLogEntryTest.java | 26 +- .../src/main/java/dev/feast/FeastClient.java | 4 +- .../test/java/dev/feast/FeastClientTest.java | 14 +- .../ServingServiceGRpcController.java | 4 +- .../ServingServiceRestController.java | 2 +- .../grpc/OnlineServingGrpcServiceV2.java | 2 +- .../service/OnlineServingServiceV2.java | 12 +- .../service/OnlineTransformationService.java | 4 +- .../serving/service/ServingServiceV2.java | 6 +- .../service/TransformationService.java | 4 +- .../util/mappers/ResponseJSONMapper.java | 4 +- .../feast/serving/it/ServingBaseTests.java | 6 +- .../service/OnlineServingServiceTest.java | 32 +- protos/feast/serving/ServingService.proto | 15 +- sdk/go/client_test.go | 4 +- sdk/go/mocks/serving_mock.go | 4 +- .../protos/feast/serving/ServingService.pb.go | 415 ++++++------------ sdk/go/response.go | 2 +- sdk/go/response_test.go | 4 +- sdk/python/feast/feature_store.py | 152 ++++--- .../feast/infra/online_stores/dynamodb.py | 2 +- sdk/python/feast/online_response.py | 58 +-- .../online_store/test_e2e_local.py | 9 +- .../online_store/test_universal_online.py | 6 +- sdk/python/tests/unit/test_proto_json.py | 81 ++-- 27 files changed, 361 insertions(+), 522 deletions(-) diff --git a/go.mod b/go.mod index f4a14550566..109666b7622 100644 --- a/go.mod +++ b/go.mod @@ -25,8 +25,8 @@ require ( go.opencensus.io v0.22.3 // indirect golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect - golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d - golang.org/x/tools v0.1.7 // indirect + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f + golang.org/x/tools v0.1.8 // indirect google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect google.golang.org/grpc v1.29.1 google.golang.org/protobuf v1.27.1 // indirect diff --git a/go.sum b/go.sum index 5e87ccf6dbf..8b0c2677f3c 100644 --- a/go.sum +++ b/go.sum @@ -345,6 +345,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -386,6 +387,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d h1:g9qWBGx4puODJTMVyoPrpoxPFgVGd+z1DZwjfRu4d0I= @@ -415,6 +417,7 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -454,6 +457,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= @@ -464,6 +468,7 @@ golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20161028155119-f51c12702a4d/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -525,6 +530,8 @@ golang.org/x/tools v0.0.0-20201124005743-911501bfb504 h1:jOKV2ysikH1GANB7t2Lotmh golang.org/x/tools v0.0.0-20201124005743-911501bfb504/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.1.8 h1:P1HhGGuLW4aAclzjtmJdf0mJOjVUZUzOTqkAkWL+l6w= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= diff --git a/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java b/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java index 0c96ee9c560..bc3dcbcf748 100644 --- a/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java +++ b/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java @@ -21,11 +21,12 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.google.protobuf.Timestamp; import feast.common.logging.entry.LogResource.ResourceType; +import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; import feast.proto.types.ValueProto.Value; import io.grpc.Status; import java.util.Arrays; @@ -50,15 +51,24 @@ public List getTestAuditLogs() { GetOnlineFeaturesResponse responseSpec = GetOnlineFeaturesResponse.newBuilder() - .addAllFieldValues( + .setMetadata( + ServingAPIProto.GetOnlineFeaturesResponseMetadata.newBuilder() + .setFeatureNames( + ServingAPIProto.FeatureList.newBuilder() + .addAllVal( + Arrays.asList( + "featuretable_1:feature_1", "featuretable_1:feature2")))) + .addAllResults( Arrays.asList( - FieldValues.newBuilder() - .putFields( - "featuretable_1:feature_1", Value.newBuilder().setInt32Val(32).build()) + GetOnlineFeaturesResponse.FeatureVector.newBuilder() + .addValues(Value.newBuilder().setInt32Val(32).build()) + .addStatuses(ServingAPIProto.FieldStatus.PRESENT) + .addEventTimestamps(Timestamp.newBuilder().build()) .build(), - FieldValues.newBuilder() - .putFields( - "featuretable_1:feature2", Value.newBuilder().setInt32Val(64).build()) + GetOnlineFeaturesResponse.FeatureVector.newBuilder() + .addValues(Value.newBuilder().setInt32Val(64).build()) + .addStatuses(ServingAPIProto.FieldStatus.PRESENT) + .addEventTimestamps(Timestamp.newBuilder().build()) .build())) .build(); diff --git a/java/sdk/java/src/main/java/dev/feast/FeastClient.java b/java/sdk/java/src/main/java/dev/feast/FeastClient.java index e9aaab151a0..c10a76ecf81 100644 --- a/java/sdk/java/src/main/java/dev/feast/FeastClient.java +++ b/java/sdk/java/src/main/java/dev/feast/FeastClient.java @@ -21,7 +21,7 @@ import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; import feast.proto.serving.ServingServiceGrpc; import feast.proto.serving.ServingServiceGrpc.ServingServiceBlockingStub; import feast.proto.types.ValueProto; @@ -129,7 +129,7 @@ public List getOnlineFeatures(List featureRefs, List entities) requestBuilder.putAllEntities(getEntityValuesMap(entities)); - GetOnlineFeaturesResponseV2 response = stub.getOnlineFeatures(requestBuilder.build()); + GetOnlineFeaturesResponse response = stub.getOnlineFeatures(requestBuilder.build()); List results = Lists.newArrayList(); if (response.getResultsCount() == 0) { diff --git a/java/sdk/java/src/test/java/dev/feast/FeastClientTest.java b/java/sdk/java/src/test/java/dev/feast/FeastClientTest.java index 3de5142a85d..1dfb9989c95 100644 --- a/java/sdk/java/src/test/java/dev/feast/FeastClientTest.java +++ b/java/sdk/java/src/test/java/dev/feast/FeastClientTest.java @@ -24,7 +24,7 @@ import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.FieldStatus; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; import feast.proto.serving.ServingServiceGrpc.ServingServiceImplBase; import feast.proto.types.ValueProto; import feast.proto.types.ValueProto.Value; @@ -57,7 +57,7 @@ public class FeastClientTest { @Override public void getOnlineFeatures( GetOnlineFeaturesRequest request, - StreamObserver responseObserver) { + StreamObserver responseObserver) { if (!request.equals(FeastClientTest.getFakeRequest())) { responseObserver.onError(Status.FAILED_PRECONDITION.asRuntimeException()); } @@ -137,22 +137,22 @@ private static GetOnlineFeaturesRequest getFakeRequest() { .build(); } - private static GetOnlineFeaturesResponseV2 getFakeResponse() { - return GetOnlineFeaturesResponseV2.newBuilder() + private static GetOnlineFeaturesResponse getFakeResponse() { + return GetOnlineFeaturesResponse.newBuilder() .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(strValue("david")) .addStatuses(FieldStatus.PRESENT) .addEventTimestamps(Timestamp.newBuilder()) .build()) .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(intValue(3)) .addStatuses(FieldStatus.PRESENT) .addEventTimestamps(Timestamp.newBuilder()) .build()) .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(Value.newBuilder().build()) .addStatuses(FieldStatus.NULL_VALUE) .addEventTimestamps(Timestamp.newBuilder()) diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java index 0f4ef7b5ae5..bc6af8ecce0 100644 --- a/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java +++ b/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java @@ -60,12 +60,12 @@ public void getFeastServingInfo( @Override public void getOnlineFeatures( ServingAPIProto.GetOnlineFeaturesRequest request, - StreamObserver responseObserver) { + StreamObserver responseObserver) { try { // authorize for the project in request object. RequestHelper.validateOnlineRequest(request); Span span = tracer.buildSpan("getOnlineFeaturesV2").start(); - ServingAPIProto.GetOnlineFeaturesResponseV2 onlineFeatures = + ServingAPIProto.GetOnlineFeaturesResponse onlineFeatures = servingServiceV2.getOnlineFeatures(request); if (span != null) { span.finish(); diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java index fe8f13d8bc2..1983f3ebce3 100644 --- a/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java +++ b/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java @@ -54,7 +54,7 @@ public GetFeastServingInfoResponse getInfo() { public List> getOnlineFeatures( @RequestBody ServingAPIProto.GetOnlineFeaturesRequest request) { RequestHelper.validateOnlineRequest(request); - ServingAPIProto.GetOnlineFeaturesResponseV2 onlineFeatures = + ServingAPIProto.GetOnlineFeaturesResponse onlineFeatures = servingService.getOnlineFeatures(request); return mapGetOnlineFeaturesResponse(onlineFeatures); } diff --git a/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java b/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java index f3a35d1d0f7..8d82a1f182b 100644 --- a/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java +++ b/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java @@ -41,7 +41,7 @@ public void getFeastServingInfo( @Override public void getOnlineFeatures( ServingAPIProto.GetOnlineFeaturesRequest request, - StreamObserver responseObserver) { + StreamObserver responseObserver) { responseObserver.onNext(this.servingServiceV2.getOnlineFeatures(request)); responseObserver.onCompleted(); } diff --git a/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java index 5774dc361a9..3e62d4db75c 100644 --- a/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java +++ b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java @@ -72,7 +72,7 @@ public GetFeastServingInfoResponse getFeastServingInfo( } @Override - public ServingAPIProto.GetOnlineFeaturesResponseV2 getOnlineFeatures( + public ServingAPIProto.GetOnlineFeaturesResponse getOnlineFeatures( ServingAPIProto.GetOnlineFeaturesRequest request) { // Split all feature references into non-ODFV (e.g. batch and stream) references and ODFV. List allFeatureReferences = getFeaturesList(request); @@ -132,8 +132,8 @@ public ServingAPIProto.GetOnlineFeaturesResponseV2 getOnlineFeatures( Span postProcessingSpan = tracer.buildSpan("postProcessing").start(); - ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder = - ServingAPIProto.GetOnlineFeaturesResponseV2.newBuilder(); + ServingAPIProto.GetOnlineFeaturesResponse.Builder responseBuilder = + ServingAPIProto.GetOnlineFeaturesResponse.newBuilder(); Timestamp now = Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000).build(); Timestamp nullTimestamp = Timestamp.newBuilder().build(); @@ -147,7 +147,7 @@ public ServingAPIProto.GetOnlineFeaturesResponseV2 getOnlineFeatures( Duration maxAge = this.registryRepository.getMaxAge(featureReference); - ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector.Builder vectorBuilder = + ServingAPIProto.GetOnlineFeaturesResponse.FeatureVector.Builder vectorBuilder = responseBuilder.addResultsBuilder(); for (int rowIdx = 0; rowIdx < features.size(); rowIdx++) { @@ -262,7 +262,7 @@ private void populateOnDemandFeatures( List retrievedFeatureReferences, ServingAPIProto.GetOnlineFeaturesRequest request, List> features, - ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder) { + ServingAPIProto.GetOnlineFeaturesResponse.Builder responseBuilder) { List>> onDemandContext = request.getRequestContextMap().entrySet().stream() @@ -383,7 +383,7 @@ private void populateHistogramMetrics( */ private void populateCountMetrics( FeatureReferenceV2 featureRef, - ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVectorOrBuilder featureVector) { + ServingAPIProto.GetOnlineFeaturesResponse.FeatureVectorOrBuilder featureVector) { String featureRefString = Feature.getFeatureReference(featureRef); featureVector .getStatusesList() diff --git a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java index bfe717aa96a..a535eacb9e9 100644 --- a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java +++ b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java @@ -189,7 +189,7 @@ public void processTransformFeaturesResponse( transformFeaturesResponse, String onDemandFeatureViewName, Set onDemandFeatureStringReferences, - ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder) { + ServingAPIProto.GetOnlineFeaturesResponse.Builder responseBuilder) { try { BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); ArrowFileReader reader = @@ -219,7 +219,7 @@ public void processTransformFeaturesResponse( FieldVector fieldVector = readBatch.getVector(field); int valueCount = fieldVector.getValueCount(); - ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector.Builder vectorBuilder = + ServingAPIProto.GetOnlineFeaturesResponse.FeatureVector.Builder vectorBuilder = responseBuilder.addResultsBuilder(); List valueList = Lists.newArrayListWithExpectedSize(valueCount); diff --git a/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java b/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java index 096b155a0ec..4a44f4e09e5 100644 --- a/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java +++ b/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java @@ -40,9 +40,9 @@ ServingAPIProto.GetFeastServingInfoResponse getFeastServingInfo( * *

This request is fulfilled synchronously. * - * @return {@link feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2} with list of - * {@link feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector}. + * @return {@link feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse} with list of + * {@link feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FeatureVector}. */ - ServingAPIProto.GetOnlineFeaturesResponseV2 getOnlineFeatures( + ServingAPIProto.GetOnlineFeaturesResponse getOnlineFeatures( ServingAPIProto.GetOnlineFeaturesRequest getFeaturesRequest); } diff --git a/java/serving/src/main/java/feast/serving/service/TransformationService.java b/java/serving/src/main/java/feast/serving/service/TransformationService.java index 36cce43e0d2..e47e847f9e4 100644 --- a/java/serving/src/main/java/feast/serving/service/TransformationService.java +++ b/java/serving/src/main/java/feast/serving/service/TransformationService.java @@ -66,13 +66,13 @@ public interface TransformationService { * @param transformFeaturesResponse response to be processed * @param onDemandFeatureViewName name of ODFV to which the response corresponds * @param onDemandFeatureStringReferences set of all ODFV references that should be kept - * @param responseBuilder {@link ServingAPIProto.GetOnlineFeaturesResponseV2.Builder} + * @param responseBuilder {@link ServingAPIProto.GetOnlineFeaturesResponse.Builder} */ void processTransformFeaturesResponse( TransformFeaturesResponse transformFeaturesResponse, String onDemandFeatureViewName, Set onDemandFeatureStringReferences, - ServingAPIProto.GetOnlineFeaturesResponseV2.Builder responseBuilder); + ServingAPIProto.GetOnlineFeaturesResponse.Builder responseBuilder); /** * Serialize data into Arrow IPC format, to be sent to the Python feature transformation server. diff --git a/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java b/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java index 1e82bf864c0..3ab9f43c341 100644 --- a/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java +++ b/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java @@ -26,14 +26,14 @@ public class ResponseJSONMapper { public static List> mapGetOnlineFeaturesResponse( - ServingAPIProto.GetOnlineFeaturesResponseV2 response) { + ServingAPIProto.GetOnlineFeaturesResponse response) { return response.getResultsList().stream() .map(fieldValues -> convertFieldValuesToMap(fieldValues)) .collect(Collectors.toList()); } private static Map convertFieldValuesToMap( - ServingAPIProto.GetOnlineFeaturesResponseV2.FeatureVector vec) { + ServingAPIProto.GetOnlineFeaturesResponse.FeatureVector vec) { return Map.of( "values", vec.getValuesList().stream() diff --git a/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java b/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java index 4d4272324e2..cd732ce1dd9 100644 --- a/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java +++ b/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java @@ -74,7 +74,7 @@ private static RegistryProto.Registry readLocalRegistry() { @Test public void shouldGetOnlineFeatures() { - ServingAPIProto.GetOnlineFeaturesResponseV2 featureResponse = + ServingAPIProto.GetOnlineFeaturesResponse featureResponse = servingStub.getOnlineFeatures(buildOnlineRequest(1005)); assertEquals(2, featureResponse.getResultsCount()); @@ -96,7 +96,7 @@ public void shouldGetOnlineFeatures() { @Test public void shouldGetOnlineFeaturesWithOutsideMaxAgeStatus() { - ServingAPIProto.GetOnlineFeaturesResponseV2 featureResponse = + ServingAPIProto.GetOnlineFeaturesResponse featureResponse = servingStub.getOnlineFeatures(buildOnlineRequest(1001)); assertEquals(2, featureResponse.getResultsCount()); @@ -113,7 +113,7 @@ public void shouldGetOnlineFeaturesWithOutsideMaxAgeStatus() { @Test public void shouldGetOnlineFeaturesWithNotFoundStatus() { - ServingAPIProto.GetOnlineFeaturesResponseV2 featureResponse = + ServingAPIProto.GetOnlineFeaturesResponse featureResponse = servingStub.getOnlineFeatures(buildOnlineRequest(-1)); assertEquals(2, featureResponse.getResultsCount()); diff --git a/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java b/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java index 4234e9dce3e..64d2e20c9b3 100644 --- a/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java +++ b/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java @@ -30,7 +30,7 @@ import feast.proto.core.FeatureViewProto; import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.FieldStatus; -import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponseV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; import feast.proto.types.ValueProto; import feast.serving.registry.Registry; import feast.serving.registry.RegistryRepository; @@ -173,10 +173,10 @@ public void shouldReturnResponseWithValuesAndMetadataIfKeysPresent() { when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); - GetOnlineFeaturesResponseV2 expected = - GetOnlineFeaturesResponseV2.newBuilder() + GetOnlineFeaturesResponse expected = + GetOnlineFeaturesResponse.newBuilder() .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(createStrValue("1")) .addValues(createStrValue("3")) .addStatuses(FieldStatus.PRESENT) @@ -184,7 +184,7 @@ public void shouldReturnResponseWithValuesAndMetadataIfKeysPresent() { .addEventTimestamps(now) .addEventTimestamps(now)) .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(createStrValue("2")) .addValues(createStrValue("4")) .addStatuses(FieldStatus.PRESENT) @@ -198,7 +198,7 @@ public void shouldReturnResponseWithValuesAndMetadataIfKeysPresent() { .addVal("featureview_1:feature_1") .addVal("featureview_1:feature_2"))) .build(); - ServingAPIProto.GetOnlineFeaturesResponseV2 actual = + ServingAPIProto.GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); assertThat(actual, equalTo(expected)); } @@ -240,10 +240,10 @@ public void shouldReturnResponseWithUnsetValuesAndMetadataIfKeysNotPresent() { when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); - GetOnlineFeaturesResponseV2 expected = - GetOnlineFeaturesResponseV2.newBuilder() + GetOnlineFeaturesResponse expected = + GetOnlineFeaturesResponse.newBuilder() .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(createStrValue("1")) .addValues(createEmptyValue()) .addStatuses(FieldStatus.PRESENT) @@ -251,7 +251,7 @@ public void shouldReturnResponseWithUnsetValuesAndMetadataIfKeysNotPresent() { .addEventTimestamps(now) .addEventTimestamps(Timestamp.newBuilder().build())) .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(createStrValue("2")) .addValues(createStrValue("5")) .addStatuses(FieldStatus.PRESENT) @@ -265,7 +265,7 @@ public void shouldReturnResponseWithUnsetValuesAndMetadataIfKeysNotPresent() { .addVal("featureview_1:feature_1") .addVal("featureview_1:feature_2"))) .build(); - GetOnlineFeaturesResponseV2 actual = onlineServingServiceV2.getOnlineFeatures(request); + GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); assertThat(actual, equalTo(expected)); } @@ -317,10 +317,10 @@ public void shouldReturnResponseWithValuesAndMetadataIfMaxAgeIsExceeded() { when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); - GetOnlineFeaturesResponseV2 expected = - GetOnlineFeaturesResponseV2.newBuilder() + GetOnlineFeaturesResponse expected = + GetOnlineFeaturesResponse.newBuilder() .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(createStrValue("6")) .addValues(createStrValue("6")) .addStatuses(FieldStatus.OUTSIDE_MAX_AGE) @@ -328,7 +328,7 @@ public void shouldReturnResponseWithValuesAndMetadataIfMaxAgeIsExceeded() { .addEventTimestamps(Timestamp.newBuilder().setSeconds(1).build()) .addEventTimestamps(Timestamp.newBuilder().setSeconds(1).build())) .addResults( - GetOnlineFeaturesResponseV2.FeatureVector.newBuilder() + GetOnlineFeaturesResponse.FeatureVector.newBuilder() .addValues(createStrValue("2")) .addValues(createStrValue("2")) .addStatuses(FieldStatus.PRESENT) @@ -342,7 +342,7 @@ public void shouldReturnResponseWithValuesAndMetadataIfMaxAgeIsExceeded() { .addVal("featureview_1:feature_1") .addVal("featureview_1:feature_2"))) .build(); - GetOnlineFeaturesResponseV2 actual = onlineServingServiceV2.getOnlineFeatures(request); + GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); assertThat(actual, equalTo(expected)); } diff --git a/protos/feast/serving/ServingService.proto b/protos/feast/serving/ServingService.proto index 7d45e61a5e6..6c551a97baf 100644 --- a/protos/feast/serving/ServingService.proto +++ b/protos/feast/serving/ServingService.proto @@ -30,7 +30,7 @@ service ServingService { rpc GetFeastServingInfo (GetFeastServingInfoRequest) returns (GetFeastServingInfoResponse); // Get online features synchronously. - rpc GetOnlineFeatures (GetOnlineFeaturesRequest) returns (GetOnlineFeaturesResponseV2); + rpc GetOnlineFeatures (GetOnlineFeaturesRequest) returns (GetOnlineFeaturesResponse); } message GetFeastServingInfoRequest {} @@ -95,19 +95,6 @@ message GetOnlineFeaturesRequest { } message GetOnlineFeaturesResponse { - // Feature values retrieved from feast. - repeated FieldValues field_values = 1; - - message FieldValues { - // Map of feature or entity name to feature/entity values. - // Timestamps are not returned in this response. - map fields = 1; - // Map of feature or entity name to feature/entity statuses/metadata. - map statuses = 2; - } -} - -message GetOnlineFeaturesResponseV2 { GetOnlineFeaturesResponseMetadata metadata = 1; // Length of "results" array should match length of requested features. diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index 95be34af734..cb15f66654b 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -33,8 +33,8 @@ func TestGetOnlineFeatures(t *testing.T) { Project: "driver_project", }, want: OnlineFeaturesResponse{ - RawResponse: &serving.GetOnlineFeaturesResponseV2{ - Results: []*serving.GetOnlineFeaturesResponseV2_FeatureVector{ + RawResponse: &serving.GetOnlineFeaturesResponse{ + Results: []*serving.GetOnlineFeaturesResponse_FeatureVector{ { Values: []*types.Value{Int64Val(1)}, Statuses: []serving.FieldStatus{ diff --git a/sdk/go/mocks/serving_mock.go b/sdk/go/mocks/serving_mock.go index 57ee0c1ea40..038d49f5e53 100644 --- a/sdk/go/mocks/serving_mock.go +++ b/sdk/go/mocks/serving_mock.go @@ -57,14 +57,14 @@ func (mr *MockServingServiceClientMockRecorder) GetFeastServingInfo(arg0, arg1 i } // GetOnlineFeaturesV2 mocks base method -func (m *MockServingServiceClient) GetOnlineFeatures(arg0 context.Context, arg1 *serving.GetOnlineFeaturesRequest, arg2 ...grpc.CallOption) (*serving.GetOnlineFeaturesResponseV2, error) { +func (m *MockServingServiceClient) GetOnlineFeatures(arg0 context.Context, arg1 *serving.GetOnlineFeaturesRequest, arg2 ...grpc.CallOption) (*serving.GetOnlineFeaturesResponse, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} for _, a := range arg2 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "GetOnlineFeatures", varargs...) - ret0, _ := ret[0].(*serving.GetOnlineFeaturesResponseV2) + ret0, _ := ret[0].(*serving.GetOnlineFeaturesResponse) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/sdk/go/protos/feast/serving/ServingService.pb.go b/sdk/go/protos/feast/serving/ServingService.pb.go index 68e771a31b7..b367a307f47 100644 --- a/sdk/go/protos/feast/serving/ServingService.pb.go +++ b/sdk/go/protos/feast/serving/ServingService.pb.go @@ -380,7 +380,10 @@ type GetOnlineFeaturesRequest struct { // A map of entity name -> list of values Entities map[string]*types.RepeatedValue `protobuf:"bytes,3,rep,name=entities,proto3" json:"entities,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` FullFeatureNames bool `protobuf:"varint,4,opt,name=full_feature_names,json=fullFeatureNames,proto3" json:"full_feature_names,omitempty"` - RequestContext map[string]*types.RepeatedValue `protobuf:"bytes,5,rep,name=request_context,json=requestContext,proto3" json:"request_context,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Context for OnDemand Feature Transformation + // (was moved to dedicated parameter to avoid unnecessary separation logic on serving side) + // A map of variable name -> list of values + RequestContext map[string]*types.RepeatedValue `protobuf:"bytes,5,rep,name=request_context,json=requestContext,proto3" json:"request_context,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` } func (x *GetOnlineFeaturesRequest) Reset() { @@ -478,8 +481,10 @@ type GetOnlineFeaturesResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Feature values retrieved from feast. - FieldValues []*GetOnlineFeaturesResponse_FieldValues `protobuf:"bytes,1,rep,name=field_values,json=fieldValues,proto3" json:"field_values,omitempty"` + Metadata *GetOnlineFeaturesResponseMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Length of "results" array should match length of requested features. + // We also preserve the same order of features here as in metadata.feature_names + Results []*GetOnlineFeaturesResponse_FeatureVector `protobuf:"bytes,2,rep,name=results,proto3" json:"results,omitempty"` } func (x *GetOnlineFeaturesResponse) Reset() { @@ -514,62 +519,14 @@ func (*GetOnlineFeaturesResponse) Descriptor() ([]byte, []int) { return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{6} } -func (x *GetOnlineFeaturesResponse) GetFieldValues() []*GetOnlineFeaturesResponse_FieldValues { - if x != nil { - return x.FieldValues - } - return nil -} - -type GetOnlineFeaturesResponseV2 struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Metadata *GetOnlineFeaturesResponseMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` - Results []*GetOnlineFeaturesResponseV2_FeatureVector `protobuf:"bytes,2,rep,name=results,proto3" json:"results,omitempty"` -} - -func (x *GetOnlineFeaturesResponseV2) Reset() { - *x = GetOnlineFeaturesResponseV2{} - if protoimpl.UnsafeEnabled { - mi := &file_feast_serving_ServingService_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetOnlineFeaturesResponseV2) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetOnlineFeaturesResponseV2) ProtoMessage() {} - -func (x *GetOnlineFeaturesResponseV2) ProtoReflect() protoreflect.Message { - mi := &file_feast_serving_ServingService_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetOnlineFeaturesResponseV2.ProtoReflect.Descriptor instead. -func (*GetOnlineFeaturesResponseV2) Descriptor() ([]byte, []int) { - return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{7} -} - -func (x *GetOnlineFeaturesResponseV2) GetMetadata() *GetOnlineFeaturesResponseMetadata { +func (x *GetOnlineFeaturesResponse) GetMetadata() *GetOnlineFeaturesResponseMetadata { if x != nil { return x.Metadata } return nil } -func (x *GetOnlineFeaturesResponseV2) GetResults() []*GetOnlineFeaturesResponseV2_FeatureVector { +func (x *GetOnlineFeaturesResponse) GetResults() []*GetOnlineFeaturesResponse_FeatureVector { if x != nil { return x.Results } @@ -587,7 +544,7 @@ type GetOnlineFeaturesResponseMetadata struct { func (x *GetOnlineFeaturesResponseMetadata) Reset() { *x = GetOnlineFeaturesResponseMetadata{} if protoimpl.UnsafeEnabled { - mi := &file_feast_serving_ServingService_proto_msgTypes[8] + mi := &file_feast_serving_ServingService_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -600,7 +557,7 @@ func (x *GetOnlineFeaturesResponseMetadata) String() string { func (*GetOnlineFeaturesResponseMetadata) ProtoMessage() {} func (x *GetOnlineFeaturesResponseMetadata) ProtoReflect() protoreflect.Message { - mi := &file_feast_serving_ServingService_proto_msgTypes[8] + mi := &file_feast_serving_ServingService_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -613,7 +570,7 @@ func (x *GetOnlineFeaturesResponseMetadata) ProtoReflect() protoreflect.Message // Deprecated: Use GetOnlineFeaturesResponseMetadata.ProtoReflect.Descriptor instead. func (*GetOnlineFeaturesResponseMetadata) Descriptor() ([]byte, []int) { - return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{8} + return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{7} } func (x *GetOnlineFeaturesResponseMetadata) GetFeatureNames() *FeatureList { @@ -638,7 +595,7 @@ type GetOnlineFeaturesRequestV2_EntityRow struct { func (x *GetOnlineFeaturesRequestV2_EntityRow) Reset() { *x = GetOnlineFeaturesRequestV2_EntityRow{} if protoimpl.UnsafeEnabled { - mi := &file_feast_serving_ServingService_proto_msgTypes[9] + mi := &file_feast_serving_ServingService_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -651,7 +608,7 @@ func (x *GetOnlineFeaturesRequestV2_EntityRow) String() string { func (*GetOnlineFeaturesRequestV2_EntityRow) ProtoMessage() {} func (x *GetOnlineFeaturesRequestV2_EntityRow) ProtoReflect() protoreflect.Message { - mi := &file_feast_serving_ServingService_proto_msgTypes[9] + mi := &file_feast_serving_ServingService_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -681,65 +638,7 @@ func (x *GetOnlineFeaturesRequestV2_EntityRow) GetFields() map[string]*types.Val return nil } -type GetOnlineFeaturesResponse_FieldValues struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Map of feature or entity name to feature/entity values. - // Timestamps are not returned in this response. - Fields map[string]*types.Value `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` - // Map of feature or entity name to feature/entity statuses/metadata. - Statuses map[string]FieldStatus `protobuf:"bytes,2,rep,name=statuses,proto3" json:"statuses,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3,enum=feast.serving.FieldStatus"` -} - -func (x *GetOnlineFeaturesResponse_FieldValues) Reset() { - *x = GetOnlineFeaturesResponse_FieldValues{} - if protoimpl.UnsafeEnabled { - mi := &file_feast_serving_ServingService_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetOnlineFeaturesResponse_FieldValues) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetOnlineFeaturesResponse_FieldValues) ProtoMessage() {} - -func (x *GetOnlineFeaturesResponse_FieldValues) ProtoReflect() protoreflect.Message { - mi := &file_feast_serving_ServingService_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetOnlineFeaturesResponse_FieldValues.ProtoReflect.Descriptor instead. -func (*GetOnlineFeaturesResponse_FieldValues) Descriptor() ([]byte, []int) { - return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{6, 0} -} - -func (x *GetOnlineFeaturesResponse_FieldValues) GetFields() map[string]*types.Value { - if x != nil { - return x.Fields - } - return nil -} - -func (x *GetOnlineFeaturesResponse_FieldValues) GetStatuses() map[string]FieldStatus { - if x != nil { - return x.Statuses - } - return nil -} - -type GetOnlineFeaturesResponseV2_FeatureVector struct { +type GetOnlineFeaturesResponse_FeatureVector struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields @@ -749,23 +648,23 @@ type GetOnlineFeaturesResponseV2_FeatureVector struct { EventTimestamps []*timestamppb.Timestamp `protobuf:"bytes,3,rep,name=event_timestamps,json=eventTimestamps,proto3" json:"event_timestamps,omitempty"` } -func (x *GetOnlineFeaturesResponseV2_FeatureVector) Reset() { - *x = GetOnlineFeaturesResponseV2_FeatureVector{} +func (x *GetOnlineFeaturesResponse_FeatureVector) Reset() { + *x = GetOnlineFeaturesResponse_FeatureVector{} if protoimpl.UnsafeEnabled { - mi := &file_feast_serving_ServingService_proto_msgTypes[16] + mi := &file_feast_serving_ServingService_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *GetOnlineFeaturesResponseV2_FeatureVector) String() string { +func (x *GetOnlineFeaturesResponse_FeatureVector) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetOnlineFeaturesResponseV2_FeatureVector) ProtoMessage() {} +func (*GetOnlineFeaturesResponse_FeatureVector) ProtoMessage() {} -func (x *GetOnlineFeaturesResponseV2_FeatureVector) ProtoReflect() protoreflect.Message { - mi := &file_feast_serving_ServingService_proto_msgTypes[16] +func (x *GetOnlineFeaturesResponse_FeatureVector) ProtoReflect() protoreflect.Message { + mi := &file_feast_serving_ServingService_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -776,26 +675,26 @@ func (x *GetOnlineFeaturesResponseV2_FeatureVector) ProtoReflect() protoreflect. return mi.MessageOf(x) } -// Deprecated: Use GetOnlineFeaturesResponseV2_FeatureVector.ProtoReflect.Descriptor instead. -func (*GetOnlineFeaturesResponseV2_FeatureVector) Descriptor() ([]byte, []int) { - return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{7, 0} +// Deprecated: Use GetOnlineFeaturesResponse_FeatureVector.ProtoReflect.Descriptor instead. +func (*GetOnlineFeaturesResponse_FeatureVector) Descriptor() ([]byte, []int) { + return file_feast_serving_ServingService_proto_rawDescGZIP(), []int{6, 0} } -func (x *GetOnlineFeaturesResponseV2_FeatureVector) GetValues() []*types.Value { +func (x *GetOnlineFeaturesResponse_FeatureVector) GetValues() []*types.Value { if x != nil { return x.Values } return nil } -func (x *GetOnlineFeaturesResponseV2_FeatureVector) GetStatuses() []FieldStatus { +func (x *GetOnlineFeaturesResponse_FeatureVector) GetStatuses() []FieldStatus { if x != nil { return x.Statuses } return nil } -func (x *GetOnlineFeaturesResponseV2_FeatureVector) GetEventTimestamps() []*timestamppb.Timestamp { +func (x *GetOnlineFeaturesResponse_FeatureVector) GetEventTimestamps() []*timestamppb.Timestamp { if x != nil { return x.EventTimestamps } @@ -888,94 +787,63 @@ var file_feast_serving_ServingService_proto_rawDesc = []byte{ 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x70, 0x65, 0x61, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x6b, 0x69, 0x6e, - 0x64, 0x22, 0xe6, 0x03, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, + 0x64, 0x22, 0xf8, 0x02, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x57, 0x0a, 0x0c, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, + 0x4c, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x30, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, + 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x50, 0x0a, + 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x36, + 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, + 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x1a, + 0xba, 0x01, 0x0a, 0x0d, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x12, 0x2a, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x12, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x36, 0x0a, + 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0e, 0x32, + 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, + 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x08, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0f, 0x65, 0x76, 0x65, + 0x6e, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x22, 0x64, 0x0a, 0x21, + 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x12, 0x3f, 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x4c, 0x69, 0x73, 0x74, 0x52, 0x0c, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, + 0x65, 0x73, 0x2a, 0x5b, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, 0x00, 0x12, 0x0b, + 0x0a, 0x07, 0x50, 0x52, 0x45, 0x53, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x4e, + 0x55, 0x4c, 0x4c, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x4e, + 0x4f, 0x54, 0x5f, 0x46, 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4f, 0x55, + 0x54, 0x53, 0x49, 0x44, 0x45, 0x5f, 0x4d, 0x41, 0x58, 0x5f, 0x41, 0x47, 0x45, 0x10, 0x04, 0x32, + 0xe6, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x6c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x29, 0x2e, 0x66, 0x65, 0x61, 0x73, + 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, + 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x66, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, + 0x74, 0x75, 0x72, 0x65, 0x73, 0x12, 0x27, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, - 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, - 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x0b, 0x66, 0x69, 0x65, - 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0xef, 0x02, 0x0a, 0x0b, 0x46, 0x69, 0x65, - 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x58, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, - 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x40, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, - 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2e, 0x46, - 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, - 0x64, 0x73, 0x12, 0x5e, 0x0a, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x42, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, - 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, - 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x65, 0x73, 0x1a, 0x4d, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, - 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x1a, 0x57, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xfc, 0x02, 0x0a, 0x1b, 0x47, + 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, + 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x56, 0x32, 0x12, 0x4c, 0x0a, 0x08, 0x6d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x66, - 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, - 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, - 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x52, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x66, 0x65, 0x61, 0x73, - 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, - 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x56, 0x32, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x65, 0x63, - 0x74, 0x6f, 0x72, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x1a, 0xba, 0x01, 0x0a, - 0x0d, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x2a, - 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, - 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x56, 0x61, 0x6c, - 0x75, 0x65, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x08, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x66, - 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x69, 0x65, - 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, - 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x22, 0x64, 0x0a, 0x21, 0x47, 0x65, 0x74, - 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x3f, - 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, - 0x74, 0x52, 0x0c, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x2a, - 0x5b, 0x0a, 0x0b, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, - 0x0a, 0x07, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, - 0x52, 0x45, 0x53, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x4e, 0x55, 0x4c, 0x4c, - 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x4e, 0x4f, 0x54, 0x5f, - 0x46, 0x4f, 0x55, 0x4e, 0x44, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x4f, 0x55, 0x54, 0x53, 0x49, - 0x44, 0x45, 0x5f, 0x4d, 0x41, 0x58, 0x5f, 0x41, 0x47, 0x45, 0x10, 0x04, 0x32, 0xe8, 0x01, 0x0a, - 0x0e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, - 0x6c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x29, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x2a, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, - 0x67, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x61, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, - 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, - 0x11, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, - 0x65, 0x73, 0x12, 0x27, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, - 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x66, 0x65, - 0x61, 0x73, 0x74, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x2e, 0x47, 0x65, 0x74, 0x4f, - 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x56, 0x32, 0x42, 0x5e, 0x0a, 0x13, 0x66, 0x65, 0x61, 0x73, 0x74, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x42, 0x0f, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x41, 0x50, 0x49, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x5a, - 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66, 0x65, 0x61, 0x73, - 0x74, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x73, 0x64, 0x6b, 0x2f, - 0x67, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x5e, 0x0a, 0x13, 0x66, 0x65, 0x61, 0x73, + 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x42, + 0x0f, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x41, 0x50, 0x49, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66, 0x65, 0x61, + 0x73, 0x74, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x73, 0x64, 0x6b, + 0x2f, 0x67, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, + 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -991,7 +859,7 @@ func file_feast_serving_ServingService_proto_rawDescGZIP() []byte { } var file_feast_serving_ServingService_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_feast_serving_ServingService_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_feast_serving_ServingService_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_feast_serving_ServingService_proto_goTypes = []interface{}{ (FieldStatus)(0), // 0: feast.serving.FieldStatus (*GetFeastServingInfoRequest)(nil), // 1: feast.serving.GetFeastServingInfoRequest @@ -1001,51 +869,42 @@ var file_feast_serving_ServingService_proto_goTypes = []interface{}{ (*FeatureList)(nil), // 5: feast.serving.FeatureList (*GetOnlineFeaturesRequest)(nil), // 6: feast.serving.GetOnlineFeaturesRequest (*GetOnlineFeaturesResponse)(nil), // 7: feast.serving.GetOnlineFeaturesResponse - (*GetOnlineFeaturesResponseV2)(nil), // 8: feast.serving.GetOnlineFeaturesResponseV2 - (*GetOnlineFeaturesResponseMetadata)(nil), // 9: feast.serving.GetOnlineFeaturesResponseMetadata - (*GetOnlineFeaturesRequestV2_EntityRow)(nil), // 10: feast.serving.GetOnlineFeaturesRequestV2.EntityRow - nil, // 11: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry - nil, // 12: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry - nil, // 13: feast.serving.GetOnlineFeaturesRequest.RequestContextEntry - (*GetOnlineFeaturesResponse_FieldValues)(nil), // 14: feast.serving.GetOnlineFeaturesResponse.FieldValues - nil, // 15: feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry - nil, // 16: feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry - (*GetOnlineFeaturesResponseV2_FeatureVector)(nil), // 17: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector - (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp - (*types.Value)(nil), // 19: feast.types.Value - (*types.RepeatedValue)(nil), // 20: feast.types.RepeatedValue + (*GetOnlineFeaturesResponseMetadata)(nil), // 8: feast.serving.GetOnlineFeaturesResponseMetadata + (*GetOnlineFeaturesRequestV2_EntityRow)(nil), // 9: feast.serving.GetOnlineFeaturesRequestV2.EntityRow + nil, // 10: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry + nil, // 11: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry + nil, // 12: feast.serving.GetOnlineFeaturesRequest.RequestContextEntry + (*GetOnlineFeaturesResponse_FeatureVector)(nil), // 13: feast.serving.GetOnlineFeaturesResponse.FeatureVector + (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp + (*types.Value)(nil), // 15: feast.types.Value + (*types.RepeatedValue)(nil), // 16: feast.types.RepeatedValue } var file_feast_serving_ServingService_proto_depIdxs = []int32{ 3, // 0: feast.serving.GetOnlineFeaturesRequestV2.features:type_name -> feast.serving.FeatureReferenceV2 - 10, // 1: feast.serving.GetOnlineFeaturesRequestV2.entity_rows:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow + 9, // 1: feast.serving.GetOnlineFeaturesRequestV2.entity_rows:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow 5, // 2: feast.serving.GetOnlineFeaturesRequest.features:type_name -> feast.serving.FeatureList - 12, // 3: feast.serving.GetOnlineFeaturesRequest.entities:type_name -> feast.serving.GetOnlineFeaturesRequest.EntitiesEntry - 13, // 4: feast.serving.GetOnlineFeaturesRequest.request_context:type_name -> feast.serving.GetOnlineFeaturesRequest.RequestContextEntry - 14, // 5: feast.serving.GetOnlineFeaturesResponse.field_values:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues - 9, // 6: feast.serving.GetOnlineFeaturesResponseV2.metadata:type_name -> feast.serving.GetOnlineFeaturesResponseMetadata - 17, // 7: feast.serving.GetOnlineFeaturesResponseV2.results:type_name -> feast.serving.GetOnlineFeaturesResponseV2.FeatureVector - 5, // 8: feast.serving.GetOnlineFeaturesResponseMetadata.feature_names:type_name -> feast.serving.FeatureList - 18, // 9: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.timestamp:type_name -> google.protobuf.Timestamp - 11, // 10: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.fields:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry - 19, // 11: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry.value:type_name -> feast.types.Value - 20, // 12: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry.value:type_name -> feast.types.RepeatedValue - 20, // 13: feast.serving.GetOnlineFeaturesRequest.RequestContextEntry.value:type_name -> feast.types.RepeatedValue - 15, // 14: feast.serving.GetOnlineFeaturesResponse.FieldValues.fields:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry - 16, // 15: feast.serving.GetOnlineFeaturesResponse.FieldValues.statuses:type_name -> feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry - 19, // 16: feast.serving.GetOnlineFeaturesResponse.FieldValues.FieldsEntry.value:type_name -> feast.types.Value - 0, // 17: feast.serving.GetOnlineFeaturesResponse.FieldValues.StatusesEntry.value:type_name -> feast.serving.FieldStatus - 19, // 18: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector.values:type_name -> feast.types.Value - 0, // 19: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector.statuses:type_name -> feast.serving.FieldStatus - 18, // 20: feast.serving.GetOnlineFeaturesResponseV2.FeatureVector.event_timestamps:type_name -> google.protobuf.Timestamp - 1, // 21: feast.serving.ServingService.GetFeastServingInfo:input_type -> feast.serving.GetFeastServingInfoRequest - 6, // 22: feast.serving.ServingService.GetOnlineFeatures:input_type -> feast.serving.GetOnlineFeaturesRequest - 2, // 23: feast.serving.ServingService.GetFeastServingInfo:output_type -> feast.serving.GetFeastServingInfoResponse - 8, // 24: feast.serving.ServingService.GetOnlineFeatures:output_type -> feast.serving.GetOnlineFeaturesResponseV2 - 23, // [23:25] is the sub-list for method output_type - 21, // [21:23] is the sub-list for method input_type - 21, // [21:21] is the sub-list for extension type_name - 21, // [21:21] is the sub-list for extension extendee - 0, // [0:21] is the sub-list for field type_name + 11, // 3: feast.serving.GetOnlineFeaturesRequest.entities:type_name -> feast.serving.GetOnlineFeaturesRequest.EntitiesEntry + 12, // 4: feast.serving.GetOnlineFeaturesRequest.request_context:type_name -> feast.serving.GetOnlineFeaturesRequest.RequestContextEntry + 8, // 5: feast.serving.GetOnlineFeaturesResponse.metadata:type_name -> feast.serving.GetOnlineFeaturesResponseMetadata + 13, // 6: feast.serving.GetOnlineFeaturesResponse.results:type_name -> feast.serving.GetOnlineFeaturesResponse.FeatureVector + 5, // 7: feast.serving.GetOnlineFeaturesResponseMetadata.feature_names:type_name -> feast.serving.FeatureList + 14, // 8: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.timestamp:type_name -> google.protobuf.Timestamp + 10, // 9: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.fields:type_name -> feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry + 15, // 10: feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry.value:type_name -> feast.types.Value + 16, // 11: feast.serving.GetOnlineFeaturesRequest.EntitiesEntry.value:type_name -> feast.types.RepeatedValue + 16, // 12: feast.serving.GetOnlineFeaturesRequest.RequestContextEntry.value:type_name -> feast.types.RepeatedValue + 15, // 13: feast.serving.GetOnlineFeaturesResponse.FeatureVector.values:type_name -> feast.types.Value + 0, // 14: feast.serving.GetOnlineFeaturesResponse.FeatureVector.statuses:type_name -> feast.serving.FieldStatus + 14, // 15: feast.serving.GetOnlineFeaturesResponse.FeatureVector.event_timestamps:type_name -> google.protobuf.Timestamp + 1, // 16: feast.serving.ServingService.GetFeastServingInfo:input_type -> feast.serving.GetFeastServingInfoRequest + 6, // 17: feast.serving.ServingService.GetOnlineFeatures:input_type -> feast.serving.GetOnlineFeaturesRequest + 2, // 18: feast.serving.ServingService.GetFeastServingInfo:output_type -> feast.serving.GetFeastServingInfoResponse + 7, // 19: feast.serving.ServingService.GetOnlineFeatures:output_type -> feast.serving.GetOnlineFeaturesResponse + 18, // [18:20] is the sub-list for method output_type + 16, // [16:18] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_feast_serving_ServingService_proto_init() } @@ -1139,18 +998,6 @@ func file_feast_serving_ServingService_proto_init() { } } file_feast_serving_ServingService_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetOnlineFeaturesResponseV2); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_feast_serving_ServingService_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetOnlineFeaturesResponseMetadata); i { case 0: return &v.state @@ -1162,7 +1009,7 @@ func file_feast_serving_ServingService_proto_init() { return nil } } - file_feast_serving_ServingService_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + file_feast_serving_ServingService_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetOnlineFeaturesRequestV2_EntityRow); i { case 0: return &v.state @@ -1174,20 +1021,8 @@ func file_feast_serving_ServingService_proto_init() { return nil } } - file_feast_serving_ServingService_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetOnlineFeaturesResponse_FieldValues); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_feast_serving_ServingService_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetOnlineFeaturesResponseV2_FeatureVector); i { + file_feast_serving_ServingService_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetOnlineFeaturesResponse_FeatureVector); i { case 0: return &v.state case 1: @@ -1209,7 +1044,7 @@ func file_feast_serving_ServingService_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_feast_serving_ServingService_proto_rawDesc, NumEnums: 1, - NumMessages: 17, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, @@ -1239,7 +1074,7 @@ type ServingServiceClient interface { // Get information about this Feast serving. GetFeastServingInfo(ctx context.Context, in *GetFeastServingInfoRequest, opts ...grpc.CallOption) (*GetFeastServingInfoResponse, error) // Get online features synchronously. - GetOnlineFeatures(ctx context.Context, in *GetOnlineFeaturesRequest, opts ...grpc.CallOption) (*GetOnlineFeaturesResponseV2, error) + GetOnlineFeatures(ctx context.Context, in *GetOnlineFeaturesRequest, opts ...grpc.CallOption) (*GetOnlineFeaturesResponse, error) } type servingServiceClient struct { @@ -1259,8 +1094,8 @@ func (c *servingServiceClient) GetFeastServingInfo(ctx context.Context, in *GetF return out, nil } -func (c *servingServiceClient) GetOnlineFeatures(ctx context.Context, in *GetOnlineFeaturesRequest, opts ...grpc.CallOption) (*GetOnlineFeaturesResponseV2, error) { - out := new(GetOnlineFeaturesResponseV2) +func (c *servingServiceClient) GetOnlineFeatures(ctx context.Context, in *GetOnlineFeaturesRequest, opts ...grpc.CallOption) (*GetOnlineFeaturesResponse, error) { + out := new(GetOnlineFeaturesResponse) err := c.cc.Invoke(ctx, "/feast.serving.ServingService/GetOnlineFeatures", in, out, opts...) if err != nil { return nil, err @@ -1273,7 +1108,7 @@ type ServingServiceServer interface { // Get information about this Feast serving. GetFeastServingInfo(context.Context, *GetFeastServingInfoRequest) (*GetFeastServingInfoResponse, error) // Get online features synchronously. - GetOnlineFeatures(context.Context, *GetOnlineFeaturesRequest) (*GetOnlineFeaturesResponseV2, error) + GetOnlineFeatures(context.Context, *GetOnlineFeaturesRequest) (*GetOnlineFeaturesResponse, error) } // UnimplementedServingServiceServer can be embedded to have forward compatible implementations. @@ -1283,7 +1118,7 @@ type UnimplementedServingServiceServer struct { func (*UnimplementedServingServiceServer) GetFeastServingInfo(context.Context, *GetFeastServingInfoRequest) (*GetFeastServingInfoResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetFeastServingInfo not implemented") } -func (*UnimplementedServingServiceServer) GetOnlineFeatures(context.Context, *GetOnlineFeaturesRequest) (*GetOnlineFeaturesResponseV2, error) { +func (*UnimplementedServingServiceServer) GetOnlineFeatures(context.Context, *GetOnlineFeaturesRequest) (*GetOnlineFeaturesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetOnlineFeatures not implemented") } diff --git a/sdk/go/response.go b/sdk/go/response.go index 49c8904ab70..cdb2cbee382 100644 --- a/sdk/go/response.go +++ b/sdk/go/response.go @@ -19,7 +19,7 @@ var ( // OnlineFeaturesResponse is a wrapper around serving.GetOnlineFeaturesResponse. type OnlineFeaturesResponse struct { - RawResponse *serving.GetOnlineFeaturesResponseV2 + RawResponse *serving.GetOnlineFeaturesResponse } // Rows retrieves the result of the request as a list of Rows. diff --git a/sdk/go/response_test.go b/sdk/go/response_test.go index e9a9bc1605a..693faae7e46 100644 --- a/sdk/go/response_test.go +++ b/sdk/go/response_test.go @@ -9,8 +9,8 @@ import ( ) var response = OnlineFeaturesResponse{ - RawResponse: &serving.GetOnlineFeaturesResponseV2{ - Results: []*serving.GetOnlineFeaturesResponseV2_FeatureVector{ + RawResponse: &serving.GetOnlineFeaturesResponse{ + Results: []*serving.GetOnlineFeaturesResponse_FeatureVector{ { Values: []*types.Value{Int64Val(1), Int64Val(2)}, Statuses: []serving.FieldStatus{ diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 64bf23ebde7..438bfef6d27 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -33,6 +33,7 @@ import pandas as pd from colorama import Fore, Style +from google.protobuf.timestamp_pb2 import Timestamp from tqdm import tqdm from feast import feature_server, flags, flags_helper, utils @@ -1179,14 +1180,19 @@ def get_online_features( for k, v in join_key_python_values.items() } - # Populate result rows with join keys - result_rows = [ - GetOnlineFeaturesResponse.FieldValues() for _ in range(len(entity_rows)) - ] + # Populate online features response proto with join keys + online_features_response = GetOnlineFeaturesResponse( + results=[ + GetOnlineFeaturesResponse.FeatureVector() + for _ in range(len(entity_rows)) + ] + ) for key, values in join_key_proto_values.items(): - for row_idx, result_row in enumerate(result_rows): - result_row.fields[key].CopyFrom(values[row_idx]) - result_row.statuses[key] = FieldStatus.PRESENT + online_features_response.metadata.feature_names.val.append(key) + for row_idx, result_row in enumerate(online_features_response.results): + result_row.values.append(values[row_idx]) + result_row.statuses.append(FieldStatus.PRESENT) + result_row.event_timestamps.append(Timestamp()) # Initialize the set of EntityKeyProtos once and reuse them for each FeatureView # to avoid initialization overhead. @@ -1204,30 +1210,30 @@ def get_online_features( # Populate the result_rows with the Features from the OnlineStore inplace. self._populate_result_rows_from_feature_view( + online_features_response, entity_keys, full_feature_names, provider, requested_features, - result_rows, table, ) self._populate_request_data_features( - request_data_features, result_rows, + online_features_response, request_data_features ) if grouped_odfv_refs: self._augment_response_with_on_demand_transforms( + online_features_response, _feature_refs, requested_on_demand_feature_views, full_feature_names, - result_rows, ) self._drop_unneeded_columns( - requested_result_row_names, result_rows, + online_features_response, requested_result_row_names ) - return OnlineResponse(GetOnlineFeaturesResponse(field_values=result_rows)) + return OnlineResponse(online_features_response) @staticmethod def _get_table_entity_values( @@ -1270,8 +1276,8 @@ def _set_table_entity_keys( @staticmethod def _populate_request_data_features( + online_features_response: GetOnlineFeaturesResponse, request_data_features: Dict[str, List[Any]], - result_rows: List[GetOnlineFeaturesResponse.FieldValues], ): # Add more feature values to the existing result rows for the request data features for feature_name, feature_values in request_data_features.items(): @@ -1279,10 +1285,13 @@ def _populate_request_data_features( feature_values, ValueType.UNKNOWN ) + online_features_response.metadata.feature_names.val.append(feature_name) + for row_idx, proto_value in enumerate(proto_values): - result_row = result_rows[row_idx] - result_row.fields[feature_name].CopyFrom(proto_value) - result_row.statuses[feature_name] = FieldStatus.PRESENT + result_row = online_features_response.results[row_idx] + result_row.values.append(proto_value) + result_row.statuses.append(FieldStatus.PRESENT) + result_row.event_timestamps.append(Timestamp()) @staticmethod def get_needed_request_data( @@ -1321,11 +1330,11 @@ def ensure_request_data_values_exist( def _populate_result_rows_from_feature_view( self, + online_features_response: GetOnlineFeaturesResponse, entity_keys: List[EntityKeyProto], full_feature_names: bool, provider: Provider, requested_features: List[str], - result_rows: List[GetOnlineFeaturesResponse.FieldValues], table: FeatureView, ): read_rows = provider.online_read( @@ -1334,47 +1343,54 @@ def _populate_result_rows_from_feature_view( entity_keys=entity_keys, requested_features=requested_features, ) + requested_feature_refs = [ + f"{table.projection.name_to_use()}__{feature_name}" + if full_feature_names + else feature_name + for feature_name in requested_features + ] + online_features_response.metadata.feature_names.val.extend( + requested_feature_refs + ) # Each row is a set of features for a given entity key for row_idx, read_row in enumerate(read_rows): row_ts, feature_data = read_row - result_row = result_rows[row_idx] + result_row = online_features_response.results[row_idx] + row_ts_proto = Timestamp() + if row_ts is not None: + row_ts_proto.FromDatetime(row_ts) + result_row.event_timestamps.extend([row_ts_proto] * len(requested_features)) if feature_data is None: - for feature_name in requested_features: - feature_ref = ( - f"{table.projection.name_to_use()}__{feature_name}" - if full_feature_names - else feature_name - ) - result_row.statuses[feature_ref] = FieldStatus.NOT_FOUND + result_row.statuses.extend( + [FieldStatus.NOT_FOUND] * len(requested_features) + ) + result_row.values.extend([Value()] * len(requested_features)) else: - for feature_name in feature_data: - feature_ref = ( - f"{table.projection.name_to_use()}__{feature_name}" - if full_feature_names - else feature_name - ) - if feature_name in requested_features: - result_row.fields[feature_ref].CopyFrom( - feature_data[feature_name] - ) - result_row.statuses[feature_ref] = FieldStatus.PRESENT + for feature_name in requested_features: + if feature_name not in feature_data: + result_row.statuses.append(FieldStatus.NOT_FOUND) + result_row.values.append(Value()) + else: + result_row.statuses.append(FieldStatus.PRESENT) + result_row.values.append(feature_data[feature_name]) @staticmethod def _augment_response_with_on_demand_transforms( + online_features_response: GetOnlineFeaturesResponse, feature_refs: List[str], requested_on_demand_feature_views: List[OnDemandFeatureView], full_feature_names: bool, - result_rows: List[GetOnlineFeaturesResponse.FieldValues], ): """Computes on demand feature values and adds them to the result rows. - Assumes that 'result_rows' already contains the necessary request data and input feature - views for the on demand feature views. + Assumes that 'online_features_response' already contains the necessary request data and input feature + views for the on demand feature views. Unneeded feature values such as request data and + unrequested input feature views will be removed from 'online_features_response'. Args: + online_features_response: Protobuf object to populate feature_refs: List of all feature references to be returned. - requested_on_demand_feature_views: List of all odfvs that have been requested. full_feature_names: A boolean that provides the option to add the feature view prefixes to the feature names, changing them from the format "feature" to "feature_view__feature" (e.g., "daily_transactions" changes to @@ -1396,9 +1412,7 @@ def _augment_response_with_on_demand_transforms( else feature_name ) - initial_response = OnlineResponse( - GetOnlineFeaturesResponse(field_values=result_rows) - ) + initial_response = OnlineResponse(online_features_response) initial_response_df = initial_response.to_df() # Apply on demand transformations and augment the result rows @@ -1412,48 +1426,56 @@ def _augment_response_with_on_demand_transforms( f for f in transformed_features_df.columns if f in _feature_refs ] - proto_values_by_column = { - feature: python_values_to_proto_values( + proto_values = [ + python_values_to_proto_values( transformed_features_df[feature].values, ValueType.UNKNOWN ) for feature in selected_subset - } + ] - for row_idx in range(len(result_rows)): - result_row = result_rows[row_idx] + odfv_result_names |= set(selected_subset) - for transformed_feature in selected_subset: - odfv_result_names.add(transformed_feature) - result_row.fields[transformed_feature].CopyFrom( - proto_values_by_column[transformed_feature][row_idx] - ) - result_row.statuses[transformed_feature] = FieldStatus.PRESENT + online_features_response.metadata.feature_names.val.extend(selected_subset) + + for row_idx in range(len(online_features_response.results)): + result_row = online_features_response.results[row_idx] + for feature_idx, transformed_feature in enumerate(selected_subset): + result_row.values.append(proto_values[feature_idx][row_idx]) + result_row.statuses.append(FieldStatus.PRESENT) + result_row.event_timestamps.append(Timestamp()) @staticmethod def _drop_unneeded_columns( + online_features_response: GetOnlineFeaturesResponse, requested_result_row_names: Set[str], - result_rows: List[GetOnlineFeaturesResponse.FieldValues], ): """ Unneeded feature values such as request data and unrequested input feature views will - be removed from 'result_rows'. + be removed from 'online_features_response'. Args: + online_features_response: Protobuf object to populate requested_result_row_names: Fields from 'result_rows' that have been requested, and therefore should not be dropped. - result_rows: List of result rows to be editted inplace. """ # Drop values that aren't needed - unneeded_features = [ - val - for val in result_rows[0].fields + unneeded_feature_indices = [ + idx + for idx, val in enumerate( + online_features_response.metadata.feature_names.val + ) if val not in requested_result_row_names ] - for row_idx in range(len(result_rows)): - result_row = result_rows[row_idx] - for unneeded_feature in unneeded_features: - result_row.fields.pop(unneeded_feature) - result_row.statuses.pop(unneeded_feature) + + for idx in reversed(unneeded_feature_indices): + del online_features_response.metadata.feature_names.val[idx] + + for row_idx in range(len(online_features_response.results)): + result_row = online_features_response.results[row_idx] + for idx in reversed(unneeded_feature_indices): + del result_row.values[idx] + del result_row.statuses[idx] + del result_row.event_timestamps[idx] def _get_feature_views_to_use( self, diff --git a/sdk/python/feast/infra/online_stores/dynamodb.py b/sdk/python/feast/infra/online_stores/dynamodb.py index b7f8680e1f4..46592bf2a3d 100644 --- a/sdk/python/feast/infra/online_stores/dynamodb.py +++ b/sdk/python/feast/infra/online_stores/dynamodb.py @@ -173,7 +173,7 @@ def online_read( val = ValueProto() val.ParseFromString(value_bin.value) res[feature_name] = val - result.append((value["event_ts"], res)) + result.append((datetime.fromisoformat(value["event_ts"]), res)) else: result.append((None, None)) return result diff --git a/sdk/python/feast/online_response.py b/sdk/python/feast/online_response.py index e6bf6be42c9..bb69c6b9d95 100644 --- a/sdk/python/feast/online_response.py +++ b/sdk/python/feast/online_response.py @@ -18,6 +18,7 @@ from feast.feature_view import DUMMY_ENTITY_ID from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse +from feast.type_map import feast_value_type_to_python_type class OnlineResponse: @@ -34,53 +35,30 @@ def __init__(self, online_response_proto: GetOnlineFeaturesResponse): """ self.proto = online_response_proto # Delete DUMMY_ENTITY_ID from proto if it exists - for item in self.proto.field_values: - if DUMMY_ENTITY_ID in item.statuses: - del item.statuses[DUMMY_ENTITY_ID] - if DUMMY_ENTITY_ID in item.fields: - del item.fields[DUMMY_ENTITY_ID] - - @property - def field_values(self): - """ - Getter for GetOnlineResponse's field_values. - """ - return self.proto.field_values + for idx, val in enumerate(self.proto.metadata.feature_names.val): + if val == DUMMY_ENTITY_ID: + del self.proto.metadata.feature_names.val[idx] + for result in self.proto.results: + del result.values[idx] + del result.statuses[idx] + del result.event_timestamps[idx] + break def to_dict(self) -> Dict[str, Any]: """ Converts GetOnlineFeaturesResponse features into a dictionary form. """ - # Status for every Feature should be present in every record. - features_dict: Dict[str, List[Any]] = { - k: list() for k in self.field_values[0].statuses.keys() - } - rows = [record.fields for record in self.field_values] - - # Find the first non-null instance of each Feature to determine - # which ValueType. - val_types = {k: None for k in features_dict.keys()} - for feature in features_dict.keys(): - for row in rows: - try: - val_types[feature] = row[feature].WhichOneof("val") - except KeyError: - continue - if val_types[feature] is not None: - break + response: Dict[str, List[Any]] = {} - # Now we know what attribute to fetch. - for feature, val_type in val_types.items(): - if val_type is None: - features_dict[feature] = [None] * len(rows) - else: - for row in rows: - val = getattr(row[feature], val_type) - if "_list_" in val_type: - val = list(val.val) - features_dict[feature].append(val) + for result in self.proto.results: + for idx, feature_ref in enumerate(self.proto.metadata.feature_names.val): + native_type_value = feast_value_type_to_python_type(result.values[idx]) + if feature_ref not in response: + response[feature_ref] = [native_type_value] + else: + response[feature_ref].append(native_type_value) - return features_dict + return response def to_df(self) -> pd.DataFrame: """ diff --git a/sdk/python/tests/integration/online_store/test_e2e_local.py b/sdk/python/tests/integration/online_store/test_e2e_local.py index dd900e90dc0..79902273440 100644 --- a/sdk/python/tests/integration/online_store/test_e2e_local.py +++ b/sdk/python/tests/integration/online_store/test_e2e_local.py @@ -40,7 +40,14 @@ def _assert_online_features( # Float features should still be floats from the online store... assert ( - response.field_values[0].fields["driver_hourly_stats__conv_rate"].float_val > 0 + response.proto.results[0] + .values[ + list(response.proto.metadata.feature_names.val).index( + "driver_hourly_stats__conv_rate" + ) + ] + .float_val + > 0 ) result = response.to_dict() diff --git a/sdk/python/tests/integration/online_store/test_universal_online.py b/sdk/python/tests/integration/online_store/test_universal_online.py index c47f2bbfd07..1f1b619cd0d 100644 --- a/sdk/python/tests/integration/online_store/test_universal_online.py +++ b/sdk/python/tests/integration/online_store/test_universal_online.py @@ -212,11 +212,11 @@ def _get_online_features_dict_remotely( time.sleep(1) else: raise Exception("Failed to get online features from remote feature server") - keys = response["field_values"][0]["statuses"].keys() + keys = response["metadata"]["feature_names"] # Get rid of unnecessary structure in the response, leaving list of dicts - response = [row["fields"] for row in response["field_values"]] + response = [row["values"] for row in response["results"]] # Convert list of dicts (response) into dict of lists which is the format of the return value - return {key: [row.get(key) for row in response] for key in keys} + return {key: [row[idx] for row in response] for idx, key in enumerate(keys)} def get_online_features_dict( diff --git a/sdk/python/tests/unit/test_proto_json.py b/sdk/python/tests/unit/test_proto_json.py index 1b352ccb19f..6bfdbbbf91b 100644 --- a/sdk/python/tests/unit/test_proto_json.py +++ b/sdk/python/tests/unit/test_proto_json.py @@ -9,7 +9,7 @@ ) from feast.protos.feast.types.Value_pb2 import RepeatedValue -FieldValues = GetOnlineFeaturesResponse.FieldValues +FeatureVector = GetOnlineFeaturesResponse.FeatureVector @pytest.fixture(scope="module") @@ -17,70 +17,63 @@ def proto_json_patch(): proto_json.patch() -def test_feast_value(proto_json_patch): - # FieldValues contains "map fields" proto field. +def test_feature_vector_values(proto_json_patch): + # FeatureVector contains "repeated values" proto field. # We want to test that feast.types.Value can take different types in JSON # without using additional structure (e.g. 1 instead of {int64_val: 1}). - field_values_str = """{ - "fields": { - "a": 1, - "b": 2.0, - "c": true, - "d": "foo", - "e": [1, 2, 3], - "f": [2.0, 3.0, 4.0, null], - "g": [true, false, true], - "h": ["foo", "bar", "foobar"], - "i": null - } + feature_vector_str = """{ + "values": [ + 1, + 2.0, + true, + "foo", + [1, 2, 3], + [2.0, 3.0, 4.0, null], + [true, false, true], + ["foo", "bar", "foobar"] + ] }""" - field_values_proto = FieldValues() - Parse(field_values_str, field_values_proto) - assertpy.assert_that(field_values_proto.fields.keys()).is_equal_to( - {"a", "b", "c", "d", "e", "f", "g", "h", "i"} - ) - assertpy.assert_that(field_values_proto.fields["a"].int64_val).is_equal_to(1) - assertpy.assert_that(field_values_proto.fields["b"].double_val).is_equal_to(2.0) - assertpy.assert_that(field_values_proto.fields["c"].bool_val).is_equal_to(True) - assertpy.assert_that(field_values_proto.fields["d"].string_val).is_equal_to("foo") - assertpy.assert_that(field_values_proto.fields["e"].int64_list_val.val).is_equal_to( + feature_vector_proto = FeatureVector() + Parse(feature_vector_str, feature_vector_proto) + assertpy.assert_that(len(feature_vector_proto.values)).is_equal_to(8) + assertpy.assert_that(feature_vector_proto.values[0].int64_val).is_equal_to(1) + assertpy.assert_that(feature_vector_proto.values[1].double_val).is_equal_to(2.0) + assertpy.assert_that(feature_vector_proto.values[2].bool_val).is_equal_to(True) + assertpy.assert_that(feature_vector_proto.values[3].string_val).is_equal_to("foo") + assertpy.assert_that(feature_vector_proto.values[4].int64_list_val.val).is_equal_to( [1, 2, 3] ) # Can't directly check equality to [2.0, 3.0, 4.0, float("nan")], because float("nan") != float("nan") assertpy.assert_that( - field_values_proto.fields["f"].double_list_val.val[:3] + feature_vector_proto.values[5].double_list_val.val[:3] ).is_equal_to([2.0, 3.0, 4.0]) - assertpy.assert_that(field_values_proto.fields["f"].double_list_val.val[3]).is_nan() - assertpy.assert_that(field_values_proto.fields["g"].bool_list_val.val).is_equal_to( + assertpy.assert_that(feature_vector_proto.values[5].double_list_val.val[3]).is_nan() + assertpy.assert_that(feature_vector_proto.values[6].bool_list_val.val).is_equal_to( [True, False, True] ) assertpy.assert_that( - field_values_proto.fields["h"].string_list_val.val + feature_vector_proto.values[7].string_list_val.val ).is_equal_to(["foo", "bar", "foobar"]) - assertpy.assert_that(field_values_proto.fields["i"].null_val).is_equal_to(0) # Now convert protobuf back to json and check that - field_values_json = MessageToDict(field_values_proto) - assertpy.assert_that(field_values_json["fields"].keys()).is_equal_to( - {"a", "b", "c", "d", "e", "f", "g", "h", "i"} - ) - assertpy.assert_that(field_values_json["fields"]["a"]).is_equal_to(1) - assertpy.assert_that(field_values_json["fields"]["b"]).is_equal_to(2.0) - assertpy.assert_that(field_values_json["fields"]["c"]).is_equal_to(True) - assertpy.assert_that(field_values_json["fields"]["d"]).is_equal_to("foo") - assertpy.assert_that(field_values_json["fields"]["e"]).is_equal_to([1, 2, 3]) + feature_vector_json = MessageToDict(feature_vector_proto) + assertpy.assert_that(len(feature_vector_json["values"])).is_equal_to(8) + assertpy.assert_that(feature_vector_json["values"][0]).is_equal_to(1) + assertpy.assert_that(feature_vector_json["values"][1]).is_equal_to(2.0) + assertpy.assert_that(feature_vector_json["values"][2]).is_equal_to(True) + assertpy.assert_that(feature_vector_json["values"][3]).is_equal_to("foo") + assertpy.assert_that(feature_vector_json["values"][4]).is_equal_to([1, 2, 3]) # Can't directly check equality to [2.0, 3.0, 4.0, float("nan")], because float("nan") != float("nan") - assertpy.assert_that(field_values_json["fields"]["f"][:3]).is_equal_to( + assertpy.assert_that(feature_vector_json["values"][5][:3]).is_equal_to( [2.0, 3.0, 4.0] ) - assertpy.assert_that(field_values_json["fields"]["f"][3]).is_nan() - assertpy.assert_that(field_values_json["fields"]["g"]).is_equal_to( + assertpy.assert_that(feature_vector_json["values"][5][3]).is_nan() + assertpy.assert_that(feature_vector_json["values"][6]).is_equal_to( [True, False, True] ) - assertpy.assert_that(field_values_json["fields"]["h"]).is_equal_to( + assertpy.assert_that(feature_vector_json["values"][7]).is_equal_to( ["foo", "bar", "foobar"] ) - assertpy.assert_that(field_values_json["fields"]["i"]).is_equal_to(None) def test_feast_repeated_value(proto_json_patch): From f32b4f45d534372720f44e238590fccbb9aa2fd8 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Tue, 18 Jan 2022 10:12:32 -0500 Subject: [PATCH 21/85] Adding a local feature server test (#2217) * merge Signed-off-by: Danny Chiao * Fix port collision Signed-off-by: Danny Chiao * Fix lint Signed-off-by: Danny Chiao * fix lint Signed-off-by: Danny Chiao * fix lint Signed-off-by: Danny Chiao * fix lint Signed-off-by: Danny Chiao * fix lint Signed-off-by: Danny Chiao * change static method Signed-off-by: Danny Chiao --- sdk/python/feast/feature_server.py | 4 ++- sdk/python/tests/conftest.py | 29 ++++++++++++++++--- ..._repo_with_duplicated_featureview_names.py | 4 +-- .../feature_repos/repo_configuration.py | 22 +++++++++++++- .../feature_repos/universal/feature_views.py | 4 +-- .../online_store/test_universal_online.py | 10 +++++-- 6 files changed, 60 insertions(+), 13 deletions(-) diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index b813af1c63d..010e6b58fc0 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -1,3 +1,5 @@ +import traceback + import click import uvicorn from fastapi import FastAPI, HTTPException, Request @@ -59,7 +61,7 @@ def get_online_features(body=Depends(get_body)): ) except Exception as e: # Print the original exception on the server side - logger.exception(e) + logger.exception(traceback.format_exc()) # Raise HTTPException to return the error message to the client raise HTTPException(status_code=500, detail=str(e)) diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index 61e591f2373..49f32379a3b 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -13,7 +13,9 @@ # limitations under the License. import logging import multiprocessing +import time from datetime import datetime, timedelta +from multiprocessing import Process from sys import platform from typing import List @@ -21,6 +23,7 @@ import pytest from _pytest.nodes import Item +from feast import FeatureStore from tests.data.data_creator import create_dataset from tests.integration.feature_repos.integration_test_repo_config import ( IntegrationTestRepoConfig, @@ -137,23 +140,41 @@ def simple_dataset_2() -> pd.DataFrame: return pd.DataFrame.from_dict(data) +def start_test_local_server(repo_path: str, port: int): + fs = FeatureStore(repo_path) + fs.serve("localhost", port, no_access_log=True) + + @pytest.fixture( params=FULL_REPO_CONFIGS, scope="session", ids=[str(c) for c in FULL_REPO_CONFIGS] ) -def environment(request): - e = construct_test_environment(request.param) +def environment(request, worker_id: str): + e = construct_test_environment(request.param, worker_id=worker_id) + proc = Process( + target=start_test_local_server, + args=(e.feature_store.repo_path, e.get_local_server_port()), + daemon=True, + ) + if e.python_feature_server and e.test_repo_config.provider == "local": + proc.start() + # Wait for server to start + time.sleep(3) def cleanup(): e.feature_store.teardown() + if proc.is_alive(): + proc.kill() request.addfinalizer(cleanup) + return e @pytest.fixture() def local_redis_environment(request, worker_id): - - e = construct_test_environment(IntegrationTestRepoConfig(online_store=REDIS_CONFIG)) + e = construct_test_environment( + IntegrationTestRepoConfig(online_store=REDIS_CONFIG), worker_id=worker_id + ) def cleanup(): e.feature_store.teardown() diff --git a/sdk/python/tests/example_repos/example_feature_repo_with_duplicated_featureview_names.py b/sdk/python/tests/example_repos/example_feature_repo_with_duplicated_featureview_names.py index e4c7abed0f4..84d57bf0381 100644 --- a/sdk/python/tests/example_repos/example_feature_repo_with_duplicated_featureview_names.py +++ b/sdk/python/tests/example_repos/example_feature_repo_with_duplicated_featureview_names.py @@ -10,7 +10,7 @@ name="driver_hourly_stats", # Intentionally use the same FeatureView name entities=["driver_id"], online=False, - input=driver_hourly_stats, + batch_source=driver_hourly_stats, ttl=Duration(seconds=10), tags={}, ) @@ -19,7 +19,7 @@ name="driver_hourly_stats", # Intentionally use the same FeatureView name entities=["driver_id"], online=False, - input=driver_hourly_stats, + batch_source=driver_hourly_stats, ttl=Duration(seconds=10), tags={}, ) diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index 6dedfb63b24..63ee4fe7bce 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -1,6 +1,7 @@ import importlib import json import os +import re import tempfile import uuid from dataclasses import dataclass, field @@ -51,6 +52,7 @@ DEFAULT_FULL_REPO_CONFIGS: List[IntegrationTestRepoConfig] = [ # Local configurations IntegrationTestRepoConfig(), + IntegrationTestRepoConfig(python_feature_server=True), ] if os.getenv("FEAST_IS_LOCAL_TEST", "False") != "True": DEFAULT_FULL_REPO_CONFIGS.extend( @@ -217,6 +219,7 @@ class Environment: feature_store: FeatureStore data_source_creator: DataSourceCreator python_feature_server: bool + worker_id: str end_date: datetime = field( default=datetime.utcnow().replace(microsecond=0, second=0, minute=0) @@ -225,6 +228,20 @@ class Environment: def __post_init__(self): self.start_date: datetime = self.end_date - timedelta(days=3) + def get_feature_server_endpoint(self) -> str: + if self.python_feature_server and self.test_repo_config.provider == "local": + return f"http://localhost:{self.get_local_server_port()}" + return self.feature_store.get_feature_server_endpoint() + + def get_local_server_port(self) -> int: + # Heuristic when running with xdist to extract unique ports for each worker + parsed_worker_id = re.findall("gw(\\d+)", self.worker_id) + if len(parsed_worker_id) != 0: + worker_id_num = int(parsed_worker_id[0]) + else: + worker_id_num = 0 + return 6566 + worker_id_num + def table_name_from_data_source(ds: DataSource) -> Optional[str]: if hasattr(ds, "table_ref"): @@ -237,6 +254,7 @@ def table_name_from_data_source(ds: DataSource) -> Optional[str]: def construct_test_environment( test_repo_config: IntegrationTestRepoConfig, test_suite_name: str = "integration_test", + worker_id: str = "worker_id", ) -> Environment: _uuid = str(uuid.uuid4()).replace("-", "")[:8] @@ -254,7 +272,7 @@ def construct_test_environment( repo_dir_name = tempfile.mkdtemp() - if test_repo_config.python_feature_server: + if test_repo_config.python_feature_server and test_repo_config.provider == "aws": from feast.infra.feature_servers.aws_lambda.config import ( AwsLambdaFeatureServerConfig, ) @@ -266,6 +284,7 @@ def construct_test_environment( registry = f"s3://feast-integration-tests/registries/{project}/registry.db" else: + # Note: even if it's a local feature server, the repo config does not have this configured feature_server = None registry = str(Path(repo_dir_name) / "registry.db") @@ -293,6 +312,7 @@ def construct_test_environment( feature_store=fs, data_source_creator=offline_creator, python_feature_server=test_repo_config.python_feature_server, + worker_id=worker_id, ) return environment diff --git a/sdk/python/tests/integration/feature_repos/universal/feature_views.py b/sdk/python/tests/integration/feature_repos/universal/feature_views.py index 3d19212f485..f68add88cbb 100644 --- a/sdk/python/tests/integration/feature_repos/universal/feature_views.py +++ b/sdk/python/tests/integration/feature_repos/universal/feature_views.py @@ -20,7 +20,7 @@ def driver_feature_view( entities=["driver"], features=None if infer_features else [Feature("value", value_type)], ttl=timedelta(days=5), - input=data_source, + batch_source=data_source, ) @@ -35,7 +35,7 @@ def global_feature_view( entities=[], features=None if infer_features else [Feature("entityless_value", value_type)], ttl=timedelta(days=5), - input=data_source, + batch_source=data_source, ) diff --git a/sdk/python/tests/integration/online_store/test_universal_online.py b/sdk/python/tests/integration/online_store/test_universal_online.py index 1f1b619cd0d..b23c68033e6 100644 --- a/sdk/python/tests/integration/online_store/test_universal_online.py +++ b/sdk/python/tests/integration/online_store/test_universal_online.py @@ -185,7 +185,7 @@ def _get_online_features_dict_remotely( The output should be identical to: - >>> fs.get_online_features(features=features, entity_rows=entity_rows, full_feature_names=full_feature_names).to_dict() + fs.get_online_features(features=features, entity_rows=entity_rows, full_feature_names=full_feature_names).to_dict() This makes it easy to test the remote feature server by comparing the output to the local method. @@ -212,6 +212,10 @@ def _get_online_features_dict_remotely( time.sleep(1) else: raise Exception("Failed to get online features from remote feature server") + if "metadata" not in response: + raise Exception( + f"Failed to get online features from remote feature server {response}" + ) keys = response["metadata"]["feature_names"] # Get rid of unnecessary structure in the response, leaving list of dicts response = [row["values"] for row in response["results"]] @@ -238,8 +242,8 @@ def get_online_features_dict( assertpy.assert_that(online_features).is_not_none() dict1 = online_features.to_dict() - endpoint = environment.feature_store.get_feature_server_endpoint() - # If endpoint is None, it means that the remote feature server isn't configured + endpoint = environment.get_feature_server_endpoint() + # If endpoint is None, it means that a local / remote feature server aren't configured if endpoint is not None: dict2 = _get_online_features_dict_remotely( endpoint=endpoint, From 05f4e8f9df1b637cde412edb086a93ca86b56788 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Tue, 18 Jan 2022 16:18:32 +0000 Subject: [PATCH 22/85] Python FeatureServer optimization (#2202) * Optimize Python FeatureServer Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Handle `RepeatedValue` proto in `_get_online_features` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Only initialize `Timestamp` once Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Don't use `defaultdict` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/feature_server.py | 16 +- sdk/python/feast/feature_store.py | 257 ++++++++++++++++++----------- 2 files changed, 163 insertions(+), 110 deletions(-) diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index 010e6b58fc0..1f4513fa371 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -10,7 +10,6 @@ import feast from feast import proto_json from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesRequest -from feast.type_map import feast_value_type_to_python_type def get_app(store: "feast.FeatureStore"): @@ -43,16 +42,11 @@ def get_online_features(body=Depends(get_body)): if any(batch_size != num_entities for batch_size in batch_sizes): raise HTTPException(status_code=500, detail="Uneven number of columns") - entity_rows = [ - { - k: feast_value_type_to_python_type(v.val[idx]) - for k, v in request_proto.entities.items() - } - for idx in range(num_entities) - ] - - response_proto = store.get_online_features( - features, entity_rows, full_feature_names=full_feature_names + response_proto = store._get_online_features( + features, + request_proto.entities, + full_feature_names=full_feature_names, + native_entity_values=False, ).proto # Convert the Protobuf object to JSON and return it diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 438bfef6d27..39273b56c25 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -23,8 +23,10 @@ Dict, Iterable, List, + Mapping, NamedTuple, Optional, + Sequence, Set, Tuple, Union, @@ -72,7 +74,7 @@ GetOnlineFeaturesResponse, ) from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto -from feast.protos.feast.types.Value_pb2 import Value +from feast.protos.feast.types.Value_pb2 import RepeatedValue, Value from feast.registry import Registry from feast.repo_config import RepoConfig, load_repo_config from feast.request_feature_view import RequestFeatureView @@ -267,14 +269,18 @@ def _list_feature_views( return feature_views @log_exceptions_and_usage - def list_on_demand_feature_views(self) -> List[OnDemandFeatureView]: + def list_on_demand_feature_views( + self, allow_cache: bool = False + ) -> List[OnDemandFeatureView]: """ Retrieves the list of on demand feature views from the registry. Returns: A list of on demand feature views. """ - return self._registry.list_on_demand_feature_views(self.project) + return self._registry.list_on_demand_feature_views( + self.project, allow_cache=allow_cache + ) @log_exceptions_and_usage def get_entity(self, name: str) -> Entity: @@ -1067,6 +1073,30 @@ def get_online_features( ... ) >>> online_response_dict = online_response.to_dict() """ + columnar: Dict[str, List[Any]] = {k: [] for k in entity_rows[0].keys()} + for entity_row in entity_rows: + for key, value in entity_row.items(): + try: + columnar[key].append(value) + except KeyError as e: + raise ValueError("All entity_rows must have the same keys.") from e + + return self._get_online_features( + features=features, + entity_values=columnar, + full_feature_names=full_feature_names, + native_entity_values=True, + ) + + def _get_online_features( + self, + features: Union[List[str], FeatureService], + entity_values: Mapping[ + str, Union[Sequence[Any], Sequence[Value], RepeatedValue] + ], + full_feature_names: bool = False, + native_entity_values: bool = True, + ): _feature_refs = self._get_features(features, allow_cache=True) ( requested_feature_views, @@ -1076,6 +1106,29 @@ def get_online_features( features=features, allow_cache=True, hide_dummy_entity=False ) + entity_name_to_join_key_map, entity_type_map = self._get_entity_maps( + requested_feature_views + ) + + # Extract Sequence from RepeatedValue Protobuf. + entity_value_lists: Dict[str, Union[List[Any], List[Value]]] = { + k: list(v) if isinstance(v, Sequence) else list(v.val) + for k, v in entity_values.items() + } + + entity_proto_values: Dict[str, List[Value]] + if native_entity_values: + # Convert values to Protobuf once. + entity_proto_values = { + k: python_values_to_proto_values( + v, entity_type_map.get(k, ValueType.UNKNOWN) + ) + for k, v in entity_value_lists.items() + } + else: + entity_proto_values = entity_value_lists + + num_rows = _validate_entity_values(entity_proto_values) _validate_feature_refs(_feature_refs, full_feature_names) ( grouped_refs, @@ -1101,111 +1154,72 @@ def get_online_features( } feature_views = list(view for view, _ in grouped_refs) - entityless_case = DUMMY_ENTITY_NAME in [ - entity_name - for feature_view in feature_views - for entity_name in feature_view.entities - ] - - provider = self._get_provider() - entities = self._list_entities(allow_cache=True, hide_dummy_entity=False) - entity_name_to_join_key_map: Dict[str, str] = {} - join_key_to_entity_type_map: Dict[str, ValueType] = {} - for entity in entities: - entity_name_to_join_key_map[entity.name] = entity.join_key - join_key_to_entity_type_map[entity.join_key] = entity.value_type - for feature_view in requested_feature_views: - for entity_name in feature_view.entities: - entity = self._registry.get_entity( - entity_name, self.project, allow_cache=True - ) - # User directly uses join_key as the entity reference in the entity_rows for the - # entity mapping case. - entity_name = feature_view.projection.join_key_map.get( - entity.join_key, entity.name - ) - join_key = feature_view.projection.join_key_map.get( - entity.join_key, entity.join_key - ) - entity_name_to_join_key_map[entity_name] = join_key - join_key_to_entity_type_map[join_key] = entity.value_type needed_request_data, needed_request_fv_features = self.get_needed_request_data( grouped_odfv_refs, grouped_request_fv_refs ) - join_key_rows = [] - request_data_features: Dict[str, List[Any]] = defaultdict(list) + join_key_values: Dict[str, List[Value]] = {} + request_data_features: Dict[str, List[Value]] = {} # Entity rows may be either entities or request data. - for row in entity_rows: - join_key_row = {} - for entity_name, entity_value in row.items(): - # Found request data - if ( - entity_name in needed_request_data - or entity_name in needed_request_fv_features - ): - if entity_name in needed_request_fv_features: - # If the data was requested as a feature then - # make sure it appears in the result. - requested_result_row_names.add(entity_name) - request_data_features[entity_name].append(entity_value) - else: - try: - join_key = entity_name_to_join_key_map[entity_name] - except KeyError: - raise EntityNotFoundException(entity_name, self.project) - # All join keys should be returned in the result. - requested_result_row_names.add(join_key) - join_key_row[join_key] = entity_value - if entityless_case: - join_key_row[DUMMY_ENTITY_ID] = DUMMY_ENTITY_VAL - if len(join_key_row) > 0: - # May be empty if this entity row was request data - join_key_rows.append(join_key_row) + for entity_name, values in entity_proto_values.items(): + # Found request data + if ( + entity_name in needed_request_data + or entity_name in needed_request_fv_features + ): + if entity_name in needed_request_fv_features: + # If the data was requested as a feature then + # make sure it appears in the result. + requested_result_row_names.add(entity_name) + request_data_features[entity_name] = values + else: + try: + join_key = entity_name_to_join_key_map[entity_name] + except KeyError: + raise EntityNotFoundException(entity_name, self.project) + # All join keys should be returned in the result. + requested_result_row_names.add(join_key) + join_key_values[join_key] = values self.ensure_request_data_values_exist( needed_request_data, needed_request_fv_features, request_data_features ) - # Convert join_key_rows from rowise to columnar. - join_key_python_values: Dict[str, List[Value]] = defaultdict(list) - for join_key_row in join_key_rows: - for join_key, value in join_key_row.items(): - join_key_python_values[join_key].append(value) - - # Convert all join key values to Protobuf Values - join_key_proto_values = { - k: python_values_to_proto_values(v, join_key_to_entity_type_map[k]) - for k, v in join_key_python_values.items() - } - - # Populate online features response proto with join keys + # Populate online features response proto with join keys and request data features online_features_response = GetOnlineFeaturesResponse( - results=[ - GetOnlineFeaturesResponse.FeatureVector() - for _ in range(len(entity_rows)) - ] + results=[GetOnlineFeaturesResponse.FeatureVector() for _ in range(num_rows)] ) - for key, values in join_key_proto_values.items(): - online_features_response.metadata.feature_names.val.append(key) - for row_idx, result_row in enumerate(online_features_response.results): - result_row.values.append(values[row_idx]) - result_row.statuses.append(FieldStatus.PRESENT) - result_row.event_timestamps.append(Timestamp()) + self._populate_result_rows_from_columnar( + online_features_response=online_features_response, + data=dict(**join_key_values, **request_data_features), + ) + + # Add the Entityless case after populating result rows to avoid having to remove + # it later. + entityless_case = DUMMY_ENTITY_NAME in [ + entity_name + for feature_view in feature_views + for entity_name in feature_view.entities + ] + if entityless_case: + join_key_values[DUMMY_ENTITY_ID] = python_values_to_proto_values( + [DUMMY_ENTITY_VAL] * num_rows, DUMMY_ENTITY.value_type + ) # Initialize the set of EntityKeyProtos once and reuse them for each FeatureView # to avoid initialization overhead. - entity_keys = [EntityKeyProto() for _ in range(len(join_key_rows))] + entity_keys = [EntityKeyProto() for _ in range(num_rows)] + provider = self._get_provider() for table, requested_features in grouped_refs: # Get the correct set of entity values with the correct join keys. - entity_values = self._get_table_entity_values( - table, entity_name_to_join_key_map, join_key_proto_values, + table_entity_values = self._get_table_entity_values( + table, entity_name_to_join_key_map, join_key_values, ) # Set the EntityKeyProtos inplace. self._set_table_entity_keys( - entity_values, entity_keys, + table_entity_values, entity_keys, ) # Populate the result_rows with the Features from the OnlineStore inplace. @@ -1218,10 +1232,6 @@ def get_online_features( table, ) - self._populate_request_data_features( - online_features_response, request_data_features - ) - if grouped_odfv_refs: self._augment_response_with_on_demand_transforms( online_features_response, @@ -1235,6 +1245,50 @@ def get_online_features( ) return OnlineResponse(online_features_response) + @staticmethod + def _get_columnar_entity_values( + rowise: Optional[List[Dict[str, Any]]], columnar: Optional[Dict[str, List[Any]]] + ) -> Dict[str, List[Any]]: + if (rowise is None and columnar is None) or ( + rowise is not None and columnar is not None + ): + raise ValueError( + "Exactly one of `columnar_entity_values` and `rowise_entity_values` must be set." + ) + + if rowise is not None: + # Convert entity_rows from rowise to columnar. + res = defaultdict(list) + for entity_row in rowise: + for key, value in entity_row.items(): + res[key].append(value) + return res + return cast(Dict[str, List[Any]], columnar) + + def _get_entity_maps(self, feature_views): + entities = self._list_entities(allow_cache=True, hide_dummy_entity=False) + entity_name_to_join_key_map: Dict[str, str] = {} + entity_type_map: Dict[str, ValueType] = {} + for entity in entities: + entity_name_to_join_key_map[entity.name] = entity.join_key + entity_type_map[entity.name] = entity.value_type + for feature_view in feature_views: + for entity_name in feature_view.entities: + entity = self._registry.get_entity( + entity_name, self.project, allow_cache=True + ) + # User directly uses join_key as the entity reference in the entity_rows for the + # entity mapping case. + entity_name = feature_view.projection.join_key_map.get( + entity.join_key, entity.name + ) + join_key = feature_view.projection.join_key_map.get( + entity.join_key, entity.join_key + ) + entity_name_to_join_key_map[entity_name] = join_key + entity_type_map[join_key] = entity.value_type + return entity_name_to_join_key_map, entity_type_map + @staticmethod def _get_table_entity_values( table: FeatureView, @@ -1275,23 +1329,21 @@ def _set_table_entity_keys( entity_key.entity_values.extend(next(rowise_values)) @staticmethod - def _populate_request_data_features( + def _populate_result_rows_from_columnar( online_features_response: GetOnlineFeaturesResponse, - request_data_features: Dict[str, List[Any]], + data: Dict[str, List[Value]], ): - # Add more feature values to the existing result rows for the request data features - for feature_name, feature_values in request_data_features.items(): - proto_values = python_values_to_proto_values( - feature_values, ValueType.UNKNOWN - ) + timestamp = Timestamp() # Only initialize this timestamp once. + # Add more values to the existing result rows + for feature_name, feature_values in data.items(): online_features_response.metadata.feature_names.val.append(feature_name) - for row_idx, proto_value in enumerate(proto_values): + for row_idx, proto_value in enumerate(feature_values): result_row = online_features_response.results[row_idx] result_row.values.append(proto_value) result_row.statuses.append(FieldStatus.PRESENT) - result_row.event_timestamps.append(Timestamp()) + result_row.event_timestamps.append(timestamp) @staticmethod def get_needed_request_data( @@ -1567,6 +1619,13 @@ def serve_transformations(self, port: int) -> None: transformation_server.start_server(self, port) +def _validate_entity_values(join_key_values: Dict[str, List[Value]]): + set_of_row_lengths = {len(v) for v in join_key_values.values()} + if len(set_of_row_lengths) > 1: + raise ValueError("All entity rows must have the same columns.") + return set_of_row_lengths.pop() + + def _validate_feature_refs(feature_refs: List[str], full_feature_names: bool = False): collided_feature_refs = [] From 1f3a595ea3879e8800cf1c290db7cdbac196164d Mon Sep 17 00:00:00 2001 From: pyalex Date: Wed, 19 Jan 2022 14:33:37 +0700 Subject: [PATCH 23/85] clean up .prow.yaml Signed-off-by: pyalex --- .prow.yaml | 126 ----------------------------------------------------- 1 file changed, 126 deletions(-) diff --git a/.prow.yaml b/.prow.yaml index b03a71a475a..4c8372cc7c8 100644 --- a/.prow.yaml +++ b/.prow.yaml @@ -1,102 +1,4 @@ -presubmits: -- name: test-core-and-ingestion - decorate: true - spec: - containers: - - image: maven:3.6-jdk-11 - command: ["infra/scripts/test-java-core-ingestion.sh"] - resources: - requests: - cpu: "2000m" - memory: "1536Mi" - skip_branches: - - ^v0\.(3|4)-branch$ - -- name: test-core-and-ingestion-java-8 - decorate: true - always_run: true - spec: - containers: - - image: maven:3.6-jdk-8 - command: ["infra/scripts/test-java-core-ingestion.sh"] - resources: - requests: - cpu: "2000m" - memory: "1536Mi" - branches: - - ^v0\.(3|4)-branch$ - -- name: test-serving - decorate: true - spec: - containers: - - image: maven:3.6-jdk-11 - command: ["infra/scripts/test-java-serving.sh"] - skip_branches: - - ^v0\.(3|4)-branch$ - -- name: test-serving-java-8 - decorate: true - always_run: true - spec: - containers: - - image: maven:3.6-jdk-8 - command: ["infra/scripts/test-java-serving.sh"] - branches: - - ^v0\.(3|4)-branch$ - -- name: test-java-sdk - decorate: true - spec: - containers: - - image: maven:3.6-jdk-11 - command: ["infra/scripts/test-java-sdk.sh"] - skip_branches: - - ^v0\.(3|4)-branch$ - -- name: test-java-sdk-java-8 - decorate: true - always_run: true - spec: - containers: - - image: maven:3.6-jdk-8 - command: ["infra/scripts/test-java-sdk.sh"] - branches: - - ^v0\.(3|4)-branch$ - -- name: test-golang-sdk - decorate: true - spec: - containers: - - image: golang:1.13 - command: ["infra/scripts/test-golang-sdk.sh"] - postsubmits: -- name: publish-python-sdk - decorate: true - spec: - containers: - - image: python:3 - command: - - sh - - -c - - | - make package-protos && make compile-protos-python && infra/scripts/publish-python-sdk.sh \ - --directory-path sdk/python --repository pypi - volumeMounts: - - name: pypirc - mountPath: /root/.pypirc - subPath: .pypirc - readOnly: true - volumes: - - name: pypirc - secret: - secretName: pypirc - branches: - # Filter on tags with semantic versioning, prefixed with "v" - # https://github.com/semver/semver/issues/232 - - ^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$ - - name: publish-java-sdk decorate: true spec: @@ -128,31 +30,3 @@ postsubmits: branches: # Filter on tags with semantic versioning, prefixed with "v". - ^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$ - -- name: publish-java-8-sdk - decorate: true - spec: - containers: - - image: maven:3.6-jdk-8 - command: - - bash - - -c - - infra/scripts/publish-java-sdk.sh --revision ${PULL_BASE_REF:1} - volumeMounts: - - name: gpg-keys - mountPath: /etc/gpg - readOnly: true - - name: maven-settings - mountPath: /root/.m2/settings.xml - subPath: settings.xml - readOnly: true - volumes: - - name: gpg-keys - secret: - secretName: gpg-keys - - name: maven-settings - secret: - secretName: maven-settings - branches: - # Filter on tags with semantic versioning, prefixed with "v". v0.3 and v0.4 only. - - ^v0\.(3|4)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$ From f5912c840173dbb9fb9f465e27238554d882e5ea Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 20 Jan 2022 02:15:29 +0000 Subject: [PATCH 24/85] Update to newer `redis-py` (#2221) * Update to newer `redis-py` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Ensure all dependencies are consistent Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Relax `redis` constraint Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/infra/online_stores/redis.py | 2 +- .../requirements/py3.7-ci-requirements.txt | 130 +++++++++--------- .../requirements/py3.7-requirements.txt | 47 ++++--- .../requirements/py3.8-ci-requirements.txt | 127 +++++++++-------- .../requirements/py3.8-requirements.txt | 40 +++--- .../requirements/py3.9-ci-requirements.txt | 125 +++++++++-------- .../requirements/py3.9-requirements.txt | 38 ++--- sdk/python/setup.py | 6 +- 8 files changed, 259 insertions(+), 256 deletions(-) diff --git a/sdk/python/feast/infra/online_stores/redis.py b/sdk/python/feast/infra/online_stores/redis.py index 9f203393432..6b33dda9394 100644 --- a/sdk/python/feast/infra/online_stores/redis.py +++ b/sdk/python/feast/infra/online_stores/redis.py @@ -41,7 +41,7 @@ try: from redis import Redis - from rediscluster import RedisCluster + from redis.cluster import RedisCluster except ImportError as e: from feast.errors import FeastExtrasDependencyImportError diff --git a/sdk/python/requirements/py3.7-ci-requirements.txt b/sdk/python/requirements/py3.7-ci-requirements.txt index a4fd01a47a6..6d77880d93a 100644 --- a/sdk/python/requirements/py3.7-ci-requirements.txt +++ b/sdk/python/requirements/py3.7-ci-requirements.txt @@ -20,7 +20,7 @@ aiosignal==1.2.0 # via aiohttp alabaster==0.7.12 # via sphinx -anyio==3.4.0 +anyio==3.5.0 # via starlette appdirs==1.4.4 # via black @@ -28,11 +28,11 @@ asgiref==3.4.1 # via uvicorn assertpy==1.1 # via feast (setup.py) -async-timeout==4.0.1 +async-timeout==4.0.2 # via aiohttp asynctest==0.13.0 # via aiohttp -attrs==21.2.0 +attrs==21.4.0 # via # aiohttp # black @@ -40,7 +40,7 @@ attrs==21.2.0 # pytest avro==1.10.0 # via feast (setup.py) -azure-core==1.21.0 +azure-core==1.21.1 # via # adlfs # azure-identity @@ -53,15 +53,13 @@ azure-storage-blob==12.9.0 # via adlfs babel==2.9.1 # via sphinx -backports.entry-points-selectable==1.1.1 - # via virtualenv black==19.10b0 # via feast (setup.py) -boto3==1.17.112 +boto3==1.20.38 # via # feast (setup.py) # moto -botocore==1.20.112 +botocore==1.23.38 # via # boto3 # moto @@ -81,7 +79,7 @@ cffi==1.15.0 # cryptography cfgv==3.3.1 # via pre-commit -charset-normalizer==2.0.8 +charset-normalizer==2.0.10 # via # aiohttp # requests @@ -103,13 +101,15 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal -decorator==5.1.0 +decorator==5.1.1 # via gcsfs +deprecated==1.2.13 + # via redis deprecation==2.1.0 # via testcontainers dill==0.3.4 # via feast (setup.py) -distlib==0.3.3 +distlib==0.3.4 # via virtualenv docker==5.0.3 # via @@ -121,29 +121,29 @@ docutils==0.17.1 # sphinx-rtd-theme execnet==1.9.0 # via pytest-xdist -fastapi==0.70.0 +fastapi==0.72.0 # via feast (setup.py) -fastavro==1.4.7 +fastavro==1.4.9 # via # feast (setup.py) # pandavro -filelock==3.4.0 +filelock==3.4.2 # via virtualenv firebase-admin==4.5.2 # via feast (setup.py) flake8==4.0.1 # via feast (setup.py) -frozenlist==1.2.0 +frozenlist==1.3.0 # via # aiohttp # aiosignal -fsspec==2021.11.1 +fsspec==2022.1.0 # via # adlfs # gcsfs -gcsfs==2021.11.1 +gcsfs==2022.1.0 # via feast (setup.py) -google-api-core[grpc]==1.31.4 +google-api-core[grpc]==1.31.5 # via # feast (setup.py) # firebase-admin @@ -153,7 +153,7 @@ google-api-core[grpc]==1.31.4 # google-cloud-core # google-cloud-datastore # google-cloud-firestore -google-api-python-client==2.32.0 +google-api-python-client==2.36.0 # via firebase-admin google-auth==1.35.0 # via @@ -162,16 +162,17 @@ google-auth==1.35.0 # google-api-python-client # google-auth-httplib2 # google-auth-oauthlib + # google-cloud-core # google-cloud-storage google-auth-httplib2==0.1.0 # via google-api-python-client google-auth-oauthlib==0.4.6 # via gcsfs -google-cloud-bigquery==2.31.0 +google-cloud-bigquery==2.32.0 # via feast (setup.py) -google-cloud-bigquery-storage==2.10.1 +google-cloud-bigquery-storage==2.11.0 # via feast (setup.py) -google-cloud-core==1.4.4 +google-cloud-core==1.7.2 # via # feast (setup.py) # google-cloud-bigquery @@ -198,7 +199,7 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata -grpcio==1.42.0 +grpcio==1.43.0 # via # feast (setup.py) # google-api-core @@ -206,7 +207,7 @@ grpcio==1.42.0 # grpcio-reflection # grpcio-testing # grpcio-tools -grpcio-reflection==1.42.0 +grpcio-reflection==1.43.0 # via feast (setup.py) grpcio-testing==1.34.0 # via feast (setup.py) @@ -220,9 +221,9 @@ httplib2==0.20.2 # via # google-api-python-client # google-auth-httplib2 -httptools==0.2.0 +httptools==0.3.0 # via uvicorn -identify==2.4.0 +identify==2.4.4 # via pre-commit idna==3.3 # via @@ -233,7 +234,7 @@ imagesize==1.3.0 # via sphinx importlib-metadata==4.2.0 # via - # backports.entry-points-selectable + # click # flake8 # jsonschema # moto @@ -241,12 +242,13 @@ importlib-metadata==4.2.0 # pluggy # pre-commit # pytest + # redis # virtualenv importlib-resources==5.4.0 # via jsonschema iniconfig==1.1.1 # via pytest -isodate==0.6.0 +isodate==0.6.1 # via msrest isort==5.10.1 # via feast (setup.py) @@ -259,9 +261,9 @@ jmespath==0.10.0 # via # boto3 # botocore -jsonschema==4.2.1 +jsonschema==4.4.0 # via feast (setup.py) -libcst==0.3.23 +libcst==0.4.0 # via # google-cloud-bigquery-storage # google-cloud-datastore @@ -277,15 +279,13 @@ mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -more-itertools==8.12.0 - # via pytest -moto==2.2.17 +moto==2.3.2 # via feast (setup.py) msal==1.16.0 # via # azure-identity # msal-extensions -msal-extensions==0.3.0 +msal-extensions==0.3.1 # via azure-identity msgpack==1.0.3 # via cachecontrol @@ -309,7 +309,7 @@ mypy-protobuf==1.24 # via feast (setup.py) nodeenv==1.6.0 # via pre-commit -numpy==1.21.4 +numpy==1.21.5 # via # pandas # pandavro @@ -323,8 +323,9 @@ packaging==21.3 # google-cloud-bigquery # google-cloud-firestore # pytest + # redis # sphinx -pandas==1.3.4 +pandas==1.3.5 # via # feast (setup.py) # pandavro @@ -338,13 +339,13 @@ pep517==0.12.0 # via pip-tools pip-tools==6.4.0 # via feast (setup.py) -platformdirs==2.4.0 +platformdirs==2.4.1 # via virtualenv -pluggy==0.13.1 +pluggy==1.0.0 # via pytest -portalocker==1.7.1 +portalocker==2.3.2 # via msal-extensions -pre-commit==2.16.0 +pre-commit==2.17.0 # via feast (setup.py) proto-plus==1.19.6 # via @@ -353,7 +354,7 @@ proto-plus==1.19.6 # google-cloud-bigquery-storage # google-cloud-datastore # google-cloud-firestore -protobuf==3.19.1 +protobuf==3.19.3 # via # feast (setup.py) # google-api-core @@ -383,13 +384,13 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi -pydantic==1.8.2 +pydantic==1.9.0 # via # fastapi # feast (setup.py) pyflakes==2.4.0 # via flake8 -pygments==2.10.0 +pygments==2.11.2 # via sphinx pyjwt[crypto]==2.3.0 # via @@ -399,9 +400,9 @@ pyparsing==3.0.6 # via # httplib2 # packaging -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via jsonschema -pytest==6.0.0 +pytest==6.2.5 # via # feast (setup.py) # pytest-benchmark @@ -416,7 +417,7 @@ pytest-benchmark==3.4.1 # via feast (setup.py) pytest-cov==3.0.0 # via feast (setup.py) -pytest-forked==1.3.0 +pytest-forked==1.4.0 # via pytest-xdist pytest-lazy-fixture==0.6.3 # via feast (setup.py) @@ -426,7 +427,7 @@ pytest-ordering==0.6 # via feast (setup.py) pytest-timeout==1.4.2 # via feast (setup.py) -pytest-xdist==2.4.0 +pytest-xdist==2.5.0 # via feast (setup.py) python-dateutil==2.8.2 # via @@ -449,13 +450,11 @@ pyyaml==6.0 # libcst # pre-commit # uvicorn -redis==3.5.3 - # via redis-py-cluster -redis-py-cluster==2.1.2 +redis==4.1.1 # via feast (setup.py) -regex==2021.11.10 +regex==2022.1.18 # via black -requests==2.26.0 +requests==2.27.1 # via # adal # adlfs @@ -477,11 +476,11 @@ requests-oauthlib==1.3.0 # via # google-auth-oauthlib # msrest -responses==0.16.0 +responses==0.17.0 # via moto rsa==4.8 # via google-auth -s3transfer==0.4.2 +s3transfer==0.5.0 # via boto3 six==1.16.0 # via @@ -505,7 +504,7 @@ sniffio==1.2.0 # via anyio snowballstemmer==2.2.0 # via sphinx -sphinx==4.3.1 +sphinx==4.3.2 # via # feast (setup.py) # sphinx-rtd-theme @@ -523,7 +522,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -starlette==0.16.0 +starlette==0.17.1 # via fastapi tabulate==0.8.9 # via feast (setup.py) @@ -539,7 +538,7 @@ toml==0.10.2 # feast (setup.py) # pre-commit # pytest -tomli==1.2.2 +tomli==2.0.0 # via # coverage # pep517 @@ -556,6 +555,7 @@ typing-extensions==4.0.1 # asgiref # async-timeout # importlib-metadata + # jsonschema # libcst # mypy # pydantic @@ -567,36 +567,38 @@ typing-inspect==0.7.1 # via libcst uritemplate==4.1.1 # via google-api-python-client -urllib3==1.26.7 +urllib3==1.26.8 # via # botocore # feast (setup.py) # minio # requests # responses -uvicorn[standard]==0.15.0 +uvicorn[standard]==0.17.0 # via feast (setup.py) uvloop==0.16.0 # via uvicorn -virtualenv==20.10.0 +virtualenv==20.13.0 # via pre-commit watchgod==0.7 # via uvicorn -websocket-client==1.2.1 +websocket-client==1.2.3 # via docker websockets==10.1 # via uvicorn werkzeug==2.0.2 # via moto -wheel==0.37.0 +wheel==0.37.1 # via pip-tools wrapt==1.13.3 - # via testcontainers + # via + # deprecated + # testcontainers xmltodict==0.12.0 # via moto yarl==1.7.2 # via aiohttp -zipp==3.6.0 +zipp==3.7.0 # via # importlib-metadata # importlib-resources diff --git a/sdk/python/requirements/py3.7-requirements.txt b/sdk/python/requirements/py3.7-requirements.txt index 4e7408f86ee..03ff93459cc 100644 --- a/sdk/python/requirements/py3.7-requirements.txt +++ b/sdk/python/requirements/py3.7-requirements.txt @@ -6,17 +6,17 @@ # absl-py==0.12.0 # via tensorflow-metadata -anyio==3.4.0 +anyio==3.5.0 # via starlette asgiref==3.4.1 # via uvicorn -attrs==21.2.0 +attrs==21.4.0 # via jsonschema cachetools==4.2.4 # via google-auth certifi==2021.10.8 # via requests -charset-normalizer==2.0.8 +charset-normalizer==2.0.10 # via requests click==8.0.3 # via @@ -26,13 +26,13 @@ colorama==0.4.4 # via feast (setup.py) dill==0.3.4 # via feast (setup.py) -fastapi==0.70.0 +fastapi==0.72.0 # via feast (setup.py) -fastavro==1.4.7 +fastavro==1.4.9 # via # feast (setup.py) # pandavro -google-api-core==2.2.2 +google-api-core==2.4.0 # via feast (setup.py) google-auth==2.3.3 # via google-api-core @@ -41,38 +41,40 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata -grpcio==1.42.0 +grpcio==1.43.0 # via # feast (setup.py) # grpcio-reflection -grpcio-reflection==1.42.0 +grpcio-reflection==1.43.0 # via feast (setup.py) h11==0.12.0 # via uvicorn -httptools==0.2.0 +httptools==0.3.0 # via uvicorn idna==3.3 # via # anyio # requests -importlib-metadata==4.8.2 - # via jsonschema +importlib-metadata==4.10.1 + # via + # click + # jsonschema importlib-resources==5.4.0 # via jsonschema jinja2==3.0.3 # via feast (setup.py) -jsonschema==4.2.1 +jsonschema==4.4.0 # via feast (setup.py) markupsafe==2.0.1 # via jinja2 mmh3==3.0.0 # via feast (setup.py) -numpy==1.21.4 +numpy==1.21.5 # via # pandas # pandavro # pyarrow -pandas==1.3.4 +pandas==1.3.5 # via # feast (setup.py) # pandavro @@ -80,7 +82,7 @@ pandavro==1.5.2 # via feast (setup.py) proto-plus==1.19.6 # via feast (setup.py) -protobuf==3.19.1 +protobuf==3.19.3 # via # feast (setup.py) # google-api-core @@ -96,11 +98,11 @@ pyasn1==0.4.8 # rsa pyasn1-modules==0.2.8 # via google-auth -pydantic==1.8.2 +pydantic==1.9.0 # via # fastapi # feast (setup.py) -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via jsonschema python-dateutil==2.8.2 # via pandas @@ -112,7 +114,7 @@ pyyaml==6.0 # via # feast (setup.py) # uvicorn -requests==2.26.0 +requests==2.27.1 # via google-api-core rsa==4.8 # via google-auth @@ -125,7 +127,7 @@ six==1.16.0 # python-dateutil sniffio==1.2.0 # via anyio -starlette==0.16.0 +starlette==0.17.1 # via fastapi tabulate==0.8.9 # via feast (setup.py) @@ -142,12 +144,13 @@ typing-extensions==4.0.1 # anyio # asgiref # importlib-metadata + # jsonschema # pydantic # starlette # uvicorn -urllib3==1.26.7 +urllib3==1.26.8 # via requests -uvicorn[standard]==0.15.0 +uvicorn[standard]==0.17.0 # via feast (setup.py) uvloop==0.16.0 # via uvicorn @@ -155,7 +158,7 @@ watchgod==0.7 # via uvicorn websockets==10.1 # via uvicorn -zipp==3.6.0 +zipp==3.7.0 # via # importlib-metadata # importlib-resources diff --git a/sdk/python/requirements/py3.8-ci-requirements.txt b/sdk/python/requirements/py3.8-ci-requirements.txt index 1c57df69b63..d63b7ea3542 100644 --- a/sdk/python/requirements/py3.8-ci-requirements.txt +++ b/sdk/python/requirements/py3.8-ci-requirements.txt @@ -20,7 +20,7 @@ aiosignal==1.2.0 # via aiohttp alabaster==0.7.12 # via sphinx -anyio==3.4.0 +anyio==3.5.0 # via starlette appdirs==1.4.4 # via black @@ -28,9 +28,9 @@ asgiref==3.4.1 # via uvicorn assertpy==1.1 # via feast (setup.py) -async-timeout==4.0.1 +async-timeout==4.0.2 # via aiohttp -attrs==21.2.0 +attrs==21.4.0 # via # aiohttp # black @@ -38,7 +38,7 @@ attrs==21.2.0 # pytest avro==1.10.0 # via feast (setup.py) -azure-core==1.21.0 +azure-core==1.21.1 # via # adlfs # azure-identity @@ -51,15 +51,13 @@ azure-storage-blob==12.9.0 # via adlfs babel==2.9.1 # via sphinx -backports.entry-points-selectable==1.1.1 - # via virtualenv black==19.10b0 # via feast (setup.py) -boto3==1.17.112 +boto3==1.20.38 # via # feast (setup.py) # moto -botocore==1.20.112 +botocore==1.23.38 # via # boto3 # moto @@ -79,7 +77,7 @@ cffi==1.15.0 # cryptography cfgv==3.3.1 # via pre-commit -charset-normalizer==2.0.8 +charset-normalizer==2.0.10 # via # aiohttp # requests @@ -101,13 +99,15 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal -decorator==5.1.0 +decorator==5.1.1 # via gcsfs +deprecated==1.2.13 + # via redis deprecation==2.1.0 # via testcontainers dill==0.3.4 # via feast (setup.py) -distlib==0.3.3 +distlib==0.3.4 # via virtualenv docker==5.0.3 # via @@ -119,29 +119,29 @@ docutils==0.17.1 # sphinx-rtd-theme execnet==1.9.0 # via pytest-xdist -fastapi==0.70.0 +fastapi==0.72.0 # via feast (setup.py) -fastavro==1.4.7 +fastavro==1.4.9 # via # feast (setup.py) # pandavro -filelock==3.4.0 +filelock==3.4.2 # via virtualenv firebase-admin==4.5.2 # via feast (setup.py) flake8==4.0.1 # via feast (setup.py) -frozenlist==1.2.0 +frozenlist==1.3.0 # via # aiohttp # aiosignal -fsspec==2021.11.1 +fsspec==2022.1.0 # via # adlfs # gcsfs -gcsfs==2021.11.1 +gcsfs==2022.1.0 # via feast (setup.py) -google-api-core[grpc]==1.31.4 +google-api-core[grpc]==1.31.5 # via # feast (setup.py) # firebase-admin @@ -151,7 +151,7 @@ google-api-core[grpc]==1.31.4 # google-cloud-core # google-cloud-datastore # google-cloud-firestore -google-api-python-client==2.32.0 +google-api-python-client==2.36.0 # via firebase-admin google-auth==1.35.0 # via @@ -160,16 +160,17 @@ google-auth==1.35.0 # google-api-python-client # google-auth-httplib2 # google-auth-oauthlib + # google-cloud-core # google-cloud-storage google-auth-httplib2==0.1.0 # via google-api-python-client google-auth-oauthlib==0.4.6 # via gcsfs -google-cloud-bigquery==2.31.0 +google-cloud-bigquery==2.32.0 # via feast (setup.py) -google-cloud-bigquery-storage==2.10.1 +google-cloud-bigquery-storage==2.11.0 # via feast (setup.py) -google-cloud-core==1.4.4 +google-cloud-core==1.7.2 # via # feast (setup.py) # google-cloud-bigquery @@ -196,7 +197,7 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata -grpcio==1.42.0 +grpcio==1.43.0 # via # feast (setup.py) # google-api-core @@ -204,7 +205,7 @@ grpcio==1.42.0 # grpcio-reflection # grpcio-testing # grpcio-tools -grpcio-reflection==1.42.0 +grpcio-reflection==1.43.0 # via feast (setup.py) grpcio-testing==1.34.0 # via feast (setup.py) @@ -218,9 +219,9 @@ httplib2==0.20.2 # via # google-api-python-client # google-auth-httplib2 -httptools==0.2.0 +httptools==0.3.0 # via uvicorn -identify==2.4.0 +identify==2.4.4 # via pre-commit idna==3.3 # via @@ -233,7 +234,7 @@ importlib-resources==5.4.0 # via jsonschema iniconfig==1.1.1 # via pytest -isodate==0.6.0 +isodate==0.6.1 # via msrest isort==5.10.1 # via feast (setup.py) @@ -246,9 +247,9 @@ jmespath==0.10.0 # via # boto3 # botocore -jsonschema==4.2.1 +jsonschema==4.4.0 # via feast (setup.py) -libcst==0.3.23 +libcst==0.4.0 # via # google-cloud-bigquery-storage # google-cloud-datastore @@ -264,15 +265,13 @@ mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -more-itertools==8.12.0 - # via pytest -moto==2.2.17 +moto==2.3.2 # via feast (setup.py) msal==1.16.0 # via # azure-identity # msal-extensions -msal-extensions==0.3.0 +msal-extensions==0.3.1 # via azure-identity msgpack==1.0.3 # via cachecontrol @@ -296,7 +295,7 @@ mypy-protobuf==1.24 # via feast (setup.py) nodeenv==1.6.0 # via pre-commit -numpy==1.21.4 +numpy==1.22.1 # via # pandas # pandavro @@ -310,8 +309,9 @@ packaging==21.3 # google-cloud-bigquery # google-cloud-firestore # pytest + # redis # sphinx -pandas==1.3.4 +pandas==1.3.5 # via # feast (setup.py) # pandavro @@ -325,13 +325,13 @@ pep517==0.12.0 # via pip-tools pip-tools==6.4.0 # via feast (setup.py) -platformdirs==2.4.0 +platformdirs==2.4.1 # via virtualenv -pluggy==0.13.1 +pluggy==1.0.0 # via pytest -portalocker==1.7.1 +portalocker==2.3.2 # via msal-extensions -pre-commit==2.16.0 +pre-commit==2.17.0 # via feast (setup.py) proto-plus==1.19.6 # via @@ -340,7 +340,7 @@ proto-plus==1.19.6 # google-cloud-bigquery-storage # google-cloud-datastore # google-cloud-firestore -protobuf==3.19.1 +protobuf==3.19.3 # via # feast (setup.py) # google-api-core @@ -370,13 +370,13 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi -pydantic==1.8.2 +pydantic==1.9.0 # via # fastapi # feast (setup.py) pyflakes==2.4.0 # via flake8 -pygments==2.10.0 +pygments==2.11.2 # via sphinx pyjwt[crypto]==2.3.0 # via @@ -386,9 +386,9 @@ pyparsing==3.0.6 # via # httplib2 # packaging -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via jsonschema -pytest==6.0.0 +pytest==6.2.5 # via # feast (setup.py) # pytest-benchmark @@ -403,7 +403,7 @@ pytest-benchmark==3.4.1 # via feast (setup.py) pytest-cov==3.0.0 # via feast (setup.py) -pytest-forked==1.3.0 +pytest-forked==1.4.0 # via pytest-xdist pytest-lazy-fixture==0.6.3 # via feast (setup.py) @@ -413,7 +413,7 @@ pytest-ordering==0.6 # via feast (setup.py) pytest-timeout==1.4.2 # via feast (setup.py) -pytest-xdist==2.4.0 +pytest-xdist==2.5.0 # via feast (setup.py) python-dateutil==2.8.2 # via @@ -436,13 +436,11 @@ pyyaml==6.0 # libcst # pre-commit # uvicorn -redis==3.5.3 - # via redis-py-cluster -redis-py-cluster==2.1.2 +redis==4.1.1 # via feast (setup.py) -regex==2021.11.10 +regex==2022.1.18 # via black -requests==2.26.0 +requests==2.27.1 # via # adal # adlfs @@ -464,11 +462,11 @@ requests-oauthlib==1.3.0 # via # google-auth-oauthlib # msrest -responses==0.16.0 +responses==0.17.0 # via moto rsa==4.8 # via google-auth -s3transfer==0.4.2 +s3transfer==0.5.0 # via boto3 six==1.16.0 # via @@ -492,7 +490,7 @@ sniffio==1.2.0 # via anyio snowballstemmer==2.2.0 # via sphinx -sphinx==4.3.1 +sphinx==4.3.2 # via # feast (setup.py) # sphinx-rtd-theme @@ -510,7 +508,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -starlette==0.16.0 +starlette==0.17.1 # via fastapi tabulate==0.8.9 # via feast (setup.py) @@ -526,7 +524,7 @@ toml==0.10.2 # feast (setup.py) # pre-commit # pytest -tomli==1.2.2 +tomli==2.0.0 # via # coverage # pep517 @@ -538,7 +536,6 @@ typed-ast==1.4.3 # mypy typing-extensions==4.0.1 # via - # async-timeout # libcst # mypy # pydantic @@ -547,36 +544,38 @@ typing-inspect==0.7.1 # via libcst uritemplate==4.1.1 # via google-api-python-client -urllib3==1.26.7 +urllib3==1.26.8 # via # botocore # feast (setup.py) # minio # requests # responses -uvicorn[standard]==0.15.0 +uvicorn[standard]==0.17.0 # via feast (setup.py) uvloop==0.16.0 # via uvicorn -virtualenv==20.10.0 +virtualenv==20.13.0 # via pre-commit watchgod==0.7 # via uvicorn -websocket-client==1.2.1 +websocket-client==1.2.3 # via docker websockets==10.1 # via uvicorn werkzeug==2.0.2 # via moto -wheel==0.37.0 +wheel==0.37.1 # via pip-tools wrapt==1.13.3 - # via testcontainers + # via + # deprecated + # testcontainers xmltodict==0.12.0 # via moto yarl==1.7.2 # via aiohttp -zipp==3.6.0 +zipp==3.7.0 # via importlib-resources # The following packages are considered to be unsafe in a requirements file: diff --git a/sdk/python/requirements/py3.8-requirements.txt b/sdk/python/requirements/py3.8-requirements.txt index 11b2dbc6aca..d16a5cbe53e 100644 --- a/sdk/python/requirements/py3.8-requirements.txt +++ b/sdk/python/requirements/py3.8-requirements.txt @@ -6,17 +6,17 @@ # absl-py==0.12.0 # via tensorflow-metadata -anyio==3.4.0 +anyio==3.5.0 # via starlette asgiref==3.4.1 # via uvicorn -attrs==21.2.0 +attrs==21.4.0 # via jsonschema cachetools==4.2.4 # via google-auth certifi==2021.10.8 # via requests -charset-normalizer==2.0.8 +charset-normalizer==2.0.10 # via requests click==8.0.3 # via @@ -26,13 +26,13 @@ colorama==0.4.4 # via feast (setup.py) dill==0.3.4 # via feast (setup.py) -fastapi==0.70.0 +fastapi==0.72.0 # via feast (setup.py) -fastavro==1.4.7 +fastavro==1.4.9 # via # feast (setup.py) # pandavro -google-api-core==2.2.2 +google-api-core==2.4.0 # via feast (setup.py) google-auth==2.3.3 # via google-api-core @@ -41,15 +41,15 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata -grpcio==1.42.0 +grpcio==1.43.0 # via # feast (setup.py) # grpcio-reflection -grpcio-reflection==1.42.0 +grpcio-reflection==1.43.0 # via feast (setup.py) h11==0.12.0 # via uvicorn -httptools==0.2.0 +httptools==0.3.0 # via uvicorn idna==3.3 # via @@ -59,18 +59,18 @@ importlib-resources==5.4.0 # via jsonschema jinja2==3.0.3 # via feast (setup.py) -jsonschema==4.2.1 +jsonschema==4.4.0 # via feast (setup.py) markupsafe==2.0.1 # via jinja2 mmh3==3.0.0 # via feast (setup.py) -numpy==1.21.4 +numpy==1.22.1 # via # pandas # pandavro # pyarrow -pandas==1.3.4 +pandas==1.3.5 # via # feast (setup.py) # pandavro @@ -78,7 +78,7 @@ pandavro==1.5.2 # via feast (setup.py) proto-plus==1.19.6 # via feast (setup.py) -protobuf==3.19.1 +protobuf==3.19.3 # via # feast (setup.py) # google-api-core @@ -94,11 +94,11 @@ pyasn1==0.4.8 # rsa pyasn1-modules==0.2.8 # via google-auth -pydantic==1.8.2 +pydantic==1.9.0 # via # fastapi # feast (setup.py) -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via jsonschema python-dateutil==2.8.2 # via pandas @@ -110,7 +110,7 @@ pyyaml==6.0 # via # feast (setup.py) # uvicorn -requests==2.26.0 +requests==2.27.1 # via google-api-core rsa==4.8 # via google-auth @@ -123,7 +123,7 @@ six==1.16.0 # python-dateutil sniffio==1.2.0 # via anyio -starlette==0.16.0 +starlette==0.17.1 # via fastapi tabulate==0.8.9 # via feast (setup.py) @@ -137,9 +137,9 @@ tqdm==4.62.3 # via feast (setup.py) typing-extensions==4.0.1 # via pydantic -urllib3==1.26.7 +urllib3==1.26.8 # via requests -uvicorn[standard]==0.15.0 +uvicorn[standard]==0.17.0 # via feast (setup.py) uvloop==0.16.0 # via uvicorn @@ -147,7 +147,7 @@ watchgod==0.7 # via uvicorn websockets==10.1 # via uvicorn -zipp==3.6.0 +zipp==3.7.0 # via importlib-resources # The following packages are considered to be unsafe in a requirements file: diff --git a/sdk/python/requirements/py3.9-ci-requirements.txt b/sdk/python/requirements/py3.9-ci-requirements.txt index b8d33ceb853..49a1ac41f6e 100644 --- a/sdk/python/requirements/py3.9-ci-requirements.txt +++ b/sdk/python/requirements/py3.9-ci-requirements.txt @@ -20,7 +20,7 @@ aiosignal==1.2.0 # via aiohttp alabaster==0.7.12 # via sphinx -anyio==3.4.0 +anyio==3.5.0 # via starlette appdirs==1.4.4 # via black @@ -28,9 +28,9 @@ asgiref==3.4.1 # via uvicorn assertpy==1.1 # via feast (setup.py) -async-timeout==4.0.1 +async-timeout==4.0.2 # via aiohttp -attrs==21.2.0 +attrs==21.4.0 # via # aiohttp # black @@ -38,7 +38,7 @@ attrs==21.2.0 # pytest avro==1.10.0 # via feast (setup.py) -azure-core==1.21.0 +azure-core==1.21.1 # via # adlfs # azure-identity @@ -51,15 +51,13 @@ azure-storage-blob==12.9.0 # via adlfs babel==2.9.1 # via sphinx -backports.entry-points-selectable==1.1.1 - # via virtualenv black==19.10b0 # via feast (setup.py) -boto3==1.17.112 +boto3==1.20.38 # via # feast (setup.py) # moto -botocore==1.20.112 +botocore==1.23.38 # via # boto3 # moto @@ -79,7 +77,7 @@ cffi==1.15.0 # cryptography cfgv==3.3.1 # via pre-commit -charset-normalizer==2.0.8 +charset-normalizer==2.0.10 # via # aiohttp # requests @@ -101,13 +99,15 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal -decorator==5.1.0 +decorator==5.1.1 # via gcsfs +deprecated==1.2.13 + # via redis deprecation==2.1.0 # via testcontainers dill==0.3.4 # via feast (setup.py) -distlib==0.3.3 +distlib==0.3.4 # via virtualenv docker==5.0.3 # via @@ -119,29 +119,29 @@ docutils==0.17.1 # sphinx-rtd-theme execnet==1.9.0 # via pytest-xdist -fastapi==0.70.0 +fastapi==0.72.0 # via feast (setup.py) -fastavro==1.4.7 +fastavro==1.4.9 # via # feast (setup.py) # pandavro -filelock==3.4.0 +filelock==3.4.2 # via virtualenv firebase-admin==4.5.2 # via feast (setup.py) flake8==4.0.1 # via feast (setup.py) -frozenlist==1.2.0 +frozenlist==1.3.0 # via # aiohttp # aiosignal -fsspec==2021.11.1 +fsspec==2022.1.0 # via # adlfs # gcsfs -gcsfs==2021.11.1 +gcsfs==2022.1.0 # via feast (setup.py) -google-api-core[grpc]==1.31.4 +google-api-core[grpc]==1.31.5 # via # feast (setup.py) # firebase-admin @@ -151,7 +151,7 @@ google-api-core[grpc]==1.31.4 # google-cloud-core # google-cloud-datastore # google-cloud-firestore -google-api-python-client==2.32.0 +google-api-python-client==2.36.0 # via firebase-admin google-auth==1.35.0 # via @@ -160,16 +160,17 @@ google-auth==1.35.0 # google-api-python-client # google-auth-httplib2 # google-auth-oauthlib + # google-cloud-core # google-cloud-storage google-auth-httplib2==0.1.0 # via google-api-python-client google-auth-oauthlib==0.4.6 # via gcsfs -google-cloud-bigquery==2.31.0 +google-cloud-bigquery==2.32.0 # via feast (setup.py) -google-cloud-bigquery-storage==2.10.1 +google-cloud-bigquery-storage==2.11.0 # via feast (setup.py) -google-cloud-core==1.4.4 +google-cloud-core==1.7.2 # via # feast (setup.py) # google-cloud-bigquery @@ -196,7 +197,7 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata -grpcio==1.42.0 +grpcio==1.43.0 # via # feast (setup.py) # google-api-core @@ -204,7 +205,7 @@ grpcio==1.42.0 # grpcio-reflection # grpcio-testing # grpcio-tools -grpcio-reflection==1.42.0 +grpcio-reflection==1.43.0 # via feast (setup.py) grpcio-testing==1.34.0 # via feast (setup.py) @@ -218,9 +219,9 @@ httplib2==0.20.2 # via # google-api-python-client # google-auth-httplib2 -httptools==0.2.0 +httptools==0.3.0 # via uvicorn -identify==2.4.0 +identify==2.4.4 # via pre-commit idna==3.3 # via @@ -231,7 +232,7 @@ imagesize==1.3.0 # via sphinx iniconfig==1.1.1 # via pytest -isodate==0.6.0 +isodate==0.6.1 # via msrest isort==5.10.1 # via feast (setup.py) @@ -244,9 +245,9 @@ jmespath==0.10.0 # via # boto3 # botocore -jsonschema==4.2.1 +jsonschema==4.4.0 # via feast (setup.py) -libcst==0.3.23 +libcst==0.4.0 # via # google-cloud-bigquery-storage # google-cloud-datastore @@ -262,15 +263,13 @@ mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -more-itertools==8.12.0 - # via pytest -moto==2.2.17 +moto==2.3.2 # via feast (setup.py) msal==1.16.0 # via # azure-identity # msal-extensions -msal-extensions==0.3.0 +msal-extensions==0.3.1 # via azure-identity msgpack==1.0.3 # via cachecontrol @@ -294,7 +293,7 @@ mypy-protobuf==1.24 # via feast (setup.py) nodeenv==1.6.0 # via pre-commit -numpy==1.21.4 +numpy==1.22.1 # via # pandas # pandavro @@ -308,8 +307,9 @@ packaging==21.3 # google-cloud-bigquery # google-cloud-firestore # pytest + # redis # sphinx -pandas==1.3.4 +pandas==1.3.5 # via # feast (setup.py) # pandavro @@ -323,13 +323,13 @@ pep517==0.12.0 # via pip-tools pip-tools==6.4.0 # via feast (setup.py) -platformdirs==2.4.0 +platformdirs==2.4.1 # via virtualenv -pluggy==0.13.1 +pluggy==1.0.0 # via pytest -portalocker==1.7.1 +portalocker==2.3.2 # via msal-extensions -pre-commit==2.16.0 +pre-commit==2.17.0 # via feast (setup.py) proto-plus==1.19.6 # via @@ -338,7 +338,7 @@ proto-plus==1.19.6 # google-cloud-bigquery-storage # google-cloud-datastore # google-cloud-firestore -protobuf==3.19.1 +protobuf==3.19.3 # via # feast (setup.py) # google-api-core @@ -368,13 +368,13 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi -pydantic==1.8.2 +pydantic==1.9.0 # via # fastapi # feast (setup.py) pyflakes==2.4.0 # via flake8 -pygments==2.10.0 +pygments==2.11.2 # via sphinx pyjwt[crypto]==2.3.0 # via @@ -384,9 +384,9 @@ pyparsing==3.0.6 # via # httplib2 # packaging -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via jsonschema -pytest==6.0.0 +pytest==6.2.5 # via # feast (setup.py) # pytest-benchmark @@ -401,7 +401,7 @@ pytest-benchmark==3.4.1 # via feast (setup.py) pytest-cov==3.0.0 # via feast (setup.py) -pytest-forked==1.3.0 +pytest-forked==1.4.0 # via pytest-xdist pytest-lazy-fixture==0.6.3 # via feast (setup.py) @@ -411,7 +411,7 @@ pytest-ordering==0.6 # via feast (setup.py) pytest-timeout==1.4.2 # via feast (setup.py) -pytest-xdist==2.4.0 +pytest-xdist==2.5.0 # via feast (setup.py) python-dateutil==2.8.2 # via @@ -434,13 +434,11 @@ pyyaml==6.0 # libcst # pre-commit # uvicorn -redis==3.5.3 - # via redis-py-cluster -redis-py-cluster==2.1.2 +redis==4.1.1 # via feast (setup.py) -regex==2021.11.10 +regex==2022.1.18 # via black -requests==2.26.0 +requests==2.27.1 # via # adal # adlfs @@ -462,11 +460,11 @@ requests-oauthlib==1.3.0 # via # google-auth-oauthlib # msrest -responses==0.16.0 +responses==0.17.0 # via moto rsa==4.8 # via google-auth -s3transfer==0.4.2 +s3transfer==0.5.0 # via boto3 six==1.16.0 # via @@ -490,7 +488,7 @@ sniffio==1.2.0 # via anyio snowballstemmer==2.2.0 # via sphinx -sphinx==4.3.1 +sphinx==4.3.2 # via # feast (setup.py) # sphinx-rtd-theme @@ -508,7 +506,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -starlette==0.16.0 +starlette==0.17.1 # via fastapi tabulate==0.8.9 # via feast (setup.py) @@ -524,7 +522,7 @@ toml==0.10.2 # feast (setup.py) # pre-commit # pytest -tomli==1.2.2 +tomli==2.0.0 # via # coverage # pep517 @@ -536,7 +534,6 @@ typed-ast==1.4.3 # mypy typing-extensions==4.0.1 # via - # async-timeout # libcst # mypy # pydantic @@ -545,31 +542,33 @@ typing-inspect==0.7.1 # via libcst uritemplate==4.1.1 # via google-api-python-client -urllib3==1.26.7 +urllib3==1.26.8 # via # botocore # feast (setup.py) # minio # requests # responses -uvicorn[standard]==0.15.0 +uvicorn[standard]==0.17.0 # via feast (setup.py) uvloop==0.16.0 # via uvicorn -virtualenv==20.10.0 +virtualenv==20.13.0 # via pre-commit watchgod==0.7 # via uvicorn -websocket-client==1.2.1 +websocket-client==1.2.3 # via docker websockets==10.1 # via uvicorn werkzeug==2.0.2 # via moto -wheel==0.37.0 +wheel==0.37.1 # via pip-tools wrapt==1.13.3 - # via testcontainers + # via + # deprecated + # testcontainers xmltodict==0.12.0 # via moto yarl==1.7.2 diff --git a/sdk/python/requirements/py3.9-requirements.txt b/sdk/python/requirements/py3.9-requirements.txt index ce597b8eb1e..9a1e6e4088b 100644 --- a/sdk/python/requirements/py3.9-requirements.txt +++ b/sdk/python/requirements/py3.9-requirements.txt @@ -6,17 +6,17 @@ # absl-py==0.12.0 # via tensorflow-metadata -anyio==3.4.0 +anyio==3.5.0 # via starlette asgiref==3.4.1 # via uvicorn -attrs==21.2.0 +attrs==21.4.0 # via jsonschema cachetools==4.2.4 # via google-auth certifi==2021.10.8 # via requests -charset-normalizer==2.0.8 +charset-normalizer==2.0.10 # via requests click==8.0.3 # via @@ -26,13 +26,13 @@ colorama==0.4.4 # via feast (setup.py) dill==0.3.4 # via feast (setup.py) -fastapi==0.70.0 +fastapi==0.72.0 # via feast (setup.py) -fastavro==1.4.7 +fastavro==1.4.9 # via # feast (setup.py) # pandavro -google-api-core==2.2.2 +google-api-core==2.4.0 # via feast (setup.py) google-auth==2.3.3 # via google-api-core @@ -41,15 +41,15 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata -grpcio==1.42.0 +grpcio==1.43.0 # via # feast (setup.py) # grpcio-reflection -grpcio-reflection==1.42.0 +grpcio-reflection==1.43.0 # via feast (setup.py) h11==0.12.0 # via uvicorn -httptools==0.2.0 +httptools==0.3.0 # via uvicorn idna==3.3 # via @@ -57,18 +57,18 @@ idna==3.3 # requests jinja2==3.0.3 # via feast (setup.py) -jsonschema==4.2.1 +jsonschema==4.4.0 # via feast (setup.py) markupsafe==2.0.1 # via jinja2 mmh3==3.0.0 # via feast (setup.py) -numpy==1.21.4 +numpy==1.22.1 # via # pandas # pandavro # pyarrow -pandas==1.3.4 +pandas==1.3.5 # via # feast (setup.py) # pandavro @@ -76,7 +76,7 @@ pandavro==1.5.2 # via feast (setup.py) proto-plus==1.19.6 # via feast (setup.py) -protobuf==3.19.1 +protobuf==3.19.3 # via # feast (setup.py) # google-api-core @@ -92,11 +92,11 @@ pyasn1==0.4.8 # rsa pyasn1-modules==0.2.8 # via google-auth -pydantic==1.8.2 +pydantic==1.9.0 # via # fastapi # feast (setup.py) -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via jsonschema python-dateutil==2.8.2 # via pandas @@ -108,7 +108,7 @@ pyyaml==6.0 # via # feast (setup.py) # uvicorn -requests==2.26.0 +requests==2.27.1 # via google-api-core rsa==4.8 # via google-auth @@ -121,7 +121,7 @@ six==1.16.0 # python-dateutil sniffio==1.2.0 # via anyio -starlette==0.16.0 +starlette==0.17.1 # via fastapi tabulate==0.8.9 # via feast (setup.py) @@ -135,9 +135,9 @@ tqdm==4.62.3 # via feast (setup.py) typing-extensions==4.0.1 # via pydantic -urllib3==1.26.7 +urllib3==1.26.8 # via requests -uvicorn[standard]==0.15.0 +uvicorn[standard]==0.17.0 # via feast (setup.py) uvloop==0.16.0 # via uvicorn diff --git a/sdk/python/setup.py b/sdk/python/setup.py index ae8d167ff04..ac7b4ec6a71 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -77,7 +77,7 @@ ] REDIS_REQUIRED = [ - "redis-py-cluster==2.1.2", + "redis>=4.1.0", "hiredis>=2.0.0", ] @@ -101,7 +101,7 @@ "avro==1.10.0", "gcsfs", "urllib3>=1.25.4", - "pytest==6.0.0", + "pytest>=6.0.0", "pytest-cov", "pytest-xdist", "pytest-benchmark>=3.4.1", @@ -109,7 +109,7 @@ "pytest-timeout==1.4.2", "pytest-ordering==0.6.*", "pytest-mock==1.10.4", - "Sphinx!=4.0.0", + "Sphinx!=4.0.0,<4.4.0", "sphinx-rtd-theme", "testcontainers==3.4.2", "adlfs==0.5.9", From 428c14561bbea9730c9725417ca01b45d809c65c Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Thu, 20 Jan 2022 05:33:29 +0200 Subject: [PATCH 25/85] Publish renamed java packages to maven central (via Sonatype) (#2225) * new artifact names Signed-off-by: pyalex * clean up dependencies Signed-off-by: pyalex * better names & cleaner structure Signed-off-by: pyalex * return assertion import Signed-off-by: pyalex * bump version Signed-off-by: pyalex --- .github/workflows/release.yml | 38 +- infra/scripts/publish-java-sdk.sh | 2 +- java/common/pom.xml | 81 +--- .../feast/common/logging/AuditLogger.java | 30 +- .../interceptors/GrpcMessageInterceptor.java | 19 +- java/datatypes/{java => }/README.md | 0 java/datatypes/java/src/main/proto/feast | 1 - java/datatypes/{java => }/pom.xml | 18 +- java/datatypes/src/main/proto/feast | 1 + java/docs/coverage/{java => }/pom.xml | 4 +- java/pom.xml | 381 +----------------- java/sdk/{java => }/pom.xml | 30 +- .../src/main/java/dev/feast/FeastClient.java | 0 .../src/main/java/dev/feast/RequestUtil.java | 0 .../src/main/java/dev/feast/Row.java | 0 .../main/java/dev/feast/SecurityConfig.java | 0 .../test/java/dev/feast/FeastClientTest.java | 0 .../test/java/dev/feast/RequestUtilTest.java | 5 +- java/serving/pom.xml | 29 +- .../feast/serving/config/ServerModule.java | 14 +- .../config/ServingApiConfiguration.java | 40 -- .../serving/config/WebSecurityConfig.java | 51 --- .../controller/HealthServiceController.java | 8 +- .../ServingServiceGRpcController.java | 86 ---- .../ServingServiceRestController.java | 61 --- .../service/OnlineTransformationService.java | 2 +- java/storage/api/pom.xml | 8 +- java/storage/connectors/pom.xml | 2 +- java/storage/connectors/redis/pom.xml | 6 +- 29 files changed, 172 insertions(+), 745 deletions(-) rename java/datatypes/{java => }/README.md (100%) delete mode 120000 java/datatypes/java/src/main/proto/feast rename java/datatypes/{java => }/pom.xml (85%) create mode 120000 java/datatypes/src/main/proto/feast rename java/docs/coverage/{java => }/pom.xml (96%) rename java/sdk/{java => }/pom.xml (83%) rename java/sdk/{java => }/src/main/java/dev/feast/FeastClient.java (100%) rename java/sdk/{java => }/src/main/java/dev/feast/RequestUtil.java (100%) rename java/sdk/{java => }/src/main/java/dev/feast/Row.java (100%) rename java/sdk/{java => }/src/main/java/dev/feast/SecurityConfig.java (100%) rename java/sdk/{java => }/src/test/java/dev/feast/FeastClientTest.java (100%) rename java/sdk/{java => }/src/test/java/dev/feast/RequestUtilTest.java (96%) delete mode 100644 java/serving/src/main/java/feast/serving/config/ServingApiConfiguration.java delete mode 100644 java/serving/src/main/java/feast/serving/config/WebSecurityConfig.java delete mode 100644 java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java delete mode 100644 java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ff1139acba..bc717beab26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,5 +144,39 @@ jobs: python3 setup.py sdist bdist_wheel python3 -m twine upload --verbose dist/* - # TODO(adchia): publish java sdk once maven repo is updated - # See https://github.com/feast-dev/feast-java/blob/master/.github/workflows/release.yml#L104 \ No newline at end of file + publish-java-sdk: + container: maven:3.6-jdk-11 + runs-on: ubuntu-latest + needs: get-version + steps: + - uses: actions/checkout@v2 + with: + submodules: 'true' + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: '11' + java-package: jdk + architecture: x64 + - uses: actions/setup-python@v2 + with: + python-version: '3.7' + architecture: 'x64' + - uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-it-maven- + - name: Publish java sdk + env: + VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} + GPG_PUBLIC_KEY: ${{ secrets.GPG_PUBLIC_KEY }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + MAVEN_SETTINGS: ${{ secrets.MAVEN_SETTINGS }} + run: | + echo -n "$GPG_PUBLIC_KEY" > /root/public-key + echo -n "$GPG_PRIVATE_KEY" > /root/private-key + mkdir -p /root/.m2/ + echo -n "$MAVEN_SETTINGS" > /root/.m2/settings.xml + infra/scripts/publish-java-sdk.sh --revision ${VERSION_WITHOUT_PREFIX} --gpg-key-import-dir /root diff --git a/infra/scripts/publish-java-sdk.sh b/infra/scripts/publish-java-sdk.sh index ed00799e84a..ce1f79d2f1d 100755 --- a/infra/scripts/publish-java-sdk.sh +++ b/infra/scripts/publish-java-sdk.sh @@ -69,4 +69,4 @@ gpg --import --batch --yes $GPG_KEY_IMPORT_DIR/private-key echo "============================================================" echo "Deploying Java SDK with revision: $REVISION" echo "============================================================" -mvn -f java/pom.xml --projects datatypes/java,sdk/java -Drevision=$REVISION --batch-mode clean deploy +mvn -f java/pom.xml --projects .,datatypes/java,sdk/java -Drevision=$REVISION --batch-mode clean deploy diff --git a/java/common/pom.xml b/java/common/pom.xml index 0c5651876ea..53ce6907803 100644 --- a/java/common/pom.xml +++ b/java/common/pom.xml @@ -33,13 +33,14 @@ dev.feast - datatypes-java + feast-datatypes ${project.version} compile com.google.protobuf protobuf-java-util + ${protobuf.version} @@ -52,75 +53,34 @@ org.projectlombok lombok + ${lombok.version} com.google.auto.value auto-value-annotations + ${auto.value.version} com.google.code.gson gson + ${gson.version} io.gsonfire gson-fire + ${gson.fire.version} com.fasterxml.jackson.core jackson-databind + 2.10.1 com.fasterxml.jackson.datatype jackson-datatype-jsr310 - - - - - org.springframework - spring-context-support - - - net.devh - grpc-server-spring-boot-starter - - - org.springframework.boot - spring-boot-starter-logging - - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-web - - - org.hibernate.validator - hibernate-validator - 6.1.5.Final - - - - - org.springframework.security - spring-security-core - - - org.springframework.security - spring-security-config - - - org.springframework.security - spring-security-oauth2-resource-server - - - org.springframework.security - spring-security-oauth2-jose + 2.10.1 @@ -134,7 +94,6 @@ 0.3.1 - javax.xml.bind jaxb-api @@ -156,6 +115,7 @@ org.hamcrest hamcrest-library test + ${hamcrest.version} @@ -163,28 +123,12 @@ junit 4.13.2 - - org.springframework - spring-test - test - org.mockito mockito-core ${mockito.version} test - - org.springframework.boot - spring-boot-starter-test - test - - - org.junit.vintage - junit-vintage-engine - - - @@ -206,6 +150,13 @@ -Xms2048m -Xmx2048m -Djdk.net.URLClassPath.disableClassPathURLCheck=true + + org.sonatype.plugins + nexus-staging-maven-plugin + + true + + diff --git a/java/common/src/main/java/feast/common/logging/AuditLogger.java b/java/common/src/main/java/feast/common/logging/AuditLogger.java index 5f70fbfc97b..f3538a794b8 100644 --- a/java/common/src/main/java/feast/common/logging/AuditLogger.java +++ b/java/common/src/main/java/feast/common/logging/AuditLogger.java @@ -32,26 +32,23 @@ import org.slf4j.Marker; import org.slf4j.MarkerFactory; import org.slf4j.event.Level; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.info.BuildProperties; -import org.springframework.stereotype.Component; @Slf4j -@Component public class AuditLogger { private static final String FLUENTD_DESTINATION = "fluentd"; private static final Marker AUDIT_MARKER = MarkerFactory.getMarker("AUDIT_MARK"); private static FluentLogger fluentLogger; private static AuditLogProperties properties; - private static BuildProperties buildProperties; + private static String artifact; + private static String version; - @Autowired - public AuditLogger(LoggingProperties loggingProperties, BuildProperties buildProperties) { + public AuditLogger(LoggingProperties loggingProperties, String artifact, String version) { // Spring runs this constructor when creating the AuditLogger bean, // which allows us to populate the AuditLogger class with dependencies. // This allows us to use the dependencies in the AuditLogger's static methods AuditLogger.properties = loggingProperties.getAudit(); - AuditLogger.buildProperties = buildProperties; + AuditLogger.artifact = artifact; + AuditLogger.version = version; if (AuditLogger.properties.getMessageLogging() != null && AuditLogger.properties.getMessageLogging().isEnabled()) { AuditLogger.fluentLogger = @@ -69,12 +66,7 @@ public AuditLogger(LoggingProperties loggingProperties, BuildProperties buildPro * @param entryBuilder with all fields set except instance. */ public static void logMessage(Level level, MessageAuditLogEntry.Builder entryBuilder) { - log( - level, - entryBuilder - .setComponent(buildProperties.getArtifact()) - .setVersion(buildProperties.getVersion()) - .build()); + log(level, entryBuilder.setComponent(artifact).setVersion(version).build()); } /** @@ -90,10 +82,7 @@ public static void logAction( log( level, ActionAuditLogEntry.of( - buildProperties.getArtifact(), - buildProperties.getArtifact(), - LogResource.of(resourceType, resourceId), - action)); + artifact, version, LogResource.of(resourceType, resourceId), action)); } /** @@ -109,10 +98,7 @@ public static void logTransition( log( level, TransitionAuditLogEntry.of( - buildProperties.getArtifact(), - buildProperties.getArtifact(), - LogResource.of(resourceType, resourceId), - status)); + artifact, version, LogResource.of(resourceType, resourceId), status)); } /** diff --git a/java/common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java b/java/common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java index ffd7c6b9543..661642a89ad 100644 --- a/java/common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java +++ b/java/common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java @@ -30,10 +30,6 @@ import io.grpc.ServerInterceptor; import io.grpc.Status; import org.slf4j.event.Level; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; /** * GrpcMessageInterceptor intercepts a GRPC calls to log handling of GRPC messages to the Audit Log. @@ -41,7 +37,6 @@ * name and assumed authenticated identity (if authentication is enabled). NOTE: * GrpcMessageInterceptor assumes that all service calls are unary (ie single request/response). */ -@Component public class GrpcMessageInterceptor implements ServerInterceptor { private LoggingProperties loggingProperties; @@ -50,7 +45,6 @@ public class GrpcMessageInterceptor implements ServerInterceptor { * * @param loggingProperties properties used to configure logging interceptor. */ - @Autowired public GrpcMessageInterceptor(LoggingProperties loggingProperties) { this.loggingProperties = loggingProperties; } @@ -80,9 +74,7 @@ public Listener interceptCall( entryBuilder.setMethod(fullMethodName.substring(fullMethodName.indexOf("/") + 1)); // Attempt Extract current authenticated identity. - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - String identity = (authentication != null) ? getIdentity(authentication) : ""; - entryBuilder.setIdentity(identity); + entryBuilder.setIdentity(""); // Register forwarding call to intercept outgoing response and log to audit log call = @@ -115,13 +107,4 @@ public void onMessage(ReqT message) { } }; } - - /** - * Extract current authenticated identity from given {@link Authentication}. Extracts subject - * claim if specified in AuthorizationProperties, otherwise returns authentication subject. - */ - private String getIdentity(Authentication authentication) { - // use subject claim as identity if set in security authorization properties - return authentication.getName(); - } } diff --git a/java/datatypes/java/README.md b/java/datatypes/README.md similarity index 100% rename from java/datatypes/java/README.md rename to java/datatypes/README.md diff --git a/java/datatypes/java/src/main/proto/feast b/java/datatypes/java/src/main/proto/feast deleted file mode 120000 index 53364e5f45f..00000000000 --- a/java/datatypes/java/src/main/proto/feast +++ /dev/null @@ -1 +0,0 @@ -../../../../../../protos/feast \ No newline at end of file diff --git a/java/datatypes/java/pom.xml b/java/datatypes/pom.xml similarity index 85% rename from java/datatypes/java/pom.xml rename to java/datatypes/pom.xml index fe6c380a10e..a5c82d4c45c 100644 --- a/java/datatypes/java/pom.xml +++ b/java/datatypes/pom.xml @@ -30,13 +30,13 @@ 11 11 - datatypes-java + feast-datatypes dev.feast feast-parent ${revision} - ../.. + ../ @@ -75,6 +75,13 @@ + + org.sonatype.plugins + nexus-staging-maven-plugin + + false + + @@ -83,29 +90,34 @@ com.google.guava guava + ${guava.version} com.google.protobuf protobuf-java + ${protobuf.version} io.grpc grpc-core + ${grpc.version} io.grpc grpc-protobuf + ${grpc.version} io.grpc grpc-services + ${grpc.version} io.grpc grpc-stub + ${grpc.version} - javax.annotation javax.annotation-api diff --git a/java/datatypes/src/main/proto/feast b/java/datatypes/src/main/proto/feast new file mode 120000 index 00000000000..463e4045de1 --- /dev/null +++ b/java/datatypes/src/main/proto/feast @@ -0,0 +1 @@ +../../../../../protos/feast \ No newline at end of file diff --git a/java/docs/coverage/java/pom.xml b/java/docs/coverage/pom.xml similarity index 96% rename from java/docs/coverage/java/pom.xml rename to java/docs/coverage/pom.xml index 5f794224969..f6e08909ee6 100644 --- a/java/docs/coverage/java/pom.xml +++ b/java/docs/coverage/pom.xml @@ -30,7 +30,7 @@ dev.feast feast-parent ${revision} - ../../.. + ../.. Feast Coverage Java @@ -61,7 +61,7 @@ dev.feast - feast-sdk + feast-serving-client ${project.version} diff --git a/java/pom.xml b/java/pom.xml index 6857bce4c09..0431f881a5d 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -28,17 +28,17 @@ pom - datatypes/java + datatypes storage/api storage/connectors serving - sdk/java - docs/coverage/java + sdk + docs/coverage common - 0.15.2-SNAPSHOT + 0.17.1-SNAPSHOT https://github.com/feast-dev/feast UTF-8 @@ -47,10 +47,6 @@ 1.30.2 3.12.2 3.16.1 - 2.3.1.RELEASE - 5.2.7.RELEASE - 5.3.0.RELEASE - 2.9.0.RELEASE 1.111.1 0.8.0 1.9.10 @@ -62,7 +58,6 @@ 2.17.1 2.9.9 2.0.2 - 2.5.0.RELEASE 1.18.12 1.8.4 2.8.6 @@ -72,9 +67,9 @@ 2.3.1 1.3.2 2.0.1.Final - 2.8.0 0.20.0 1.6.6 + 29.0-jre - - org.apache.commons - commons-lang3 - ${commons.lang3.version} - - - - com.google.inject - guice - 5.0.1 - - - - - com.google.cloud - google-cloud-bigquery - ${com.google.cloud.version} - - - com.google.cloud - google-cloud-storage - ${com.google.cloud.version} - - - - - com.google.cloud - google-cloud-nio - 0.83.0-alpha - - - - io.opencensus - opencensus-api - ${opencensus.version} - - - io.opencensus - opencensus-contrib-grpc-util - ${opencensus.version} - - - io.opencensus - opencensus-contrib-http-util - ${opencensus.version} - - - - - io.grpc - grpc-core - ${grpc.version} - - - io.grpc - grpc-api - ${grpc.version} - - - io.grpc - grpc-context - ${grpc.version} - - - io.grpc - grpc-all - ${grpc.version} - - - io.grpc - grpc-okhttp - ${grpc.version} - - - io.grpc - grpc-auth - ${grpc.version} - - - io.grpc - grpc-grpclb - ${grpc.version} - - - io.grpc - grpc-alts - ${grpc.version} - - - io.grpc - grpc-netty - ${grpc.version} - - - io.grpc - grpc-netty-shaded - ${grpc.version} - - - io.grpc - grpc-protobuf - ${grpc.version} - - - io.grpc - grpc-services - ${grpc.version} - - - io.grpc - grpc-stub - ${grpc.version} - - - io.grpc - grpc-testing - ${grpc.version} - test - - - - - org.apache.arrow - arrow-java-root - 5.0.0 - pom - - - - - org.apache.arrow - arrow-vector - 5.0.0 - - - - - org.apache.arrow - arrow-memory - 5.0.0 - pom - - - - - org.apache.arrow - arrow-memory-netty - 5.0.0 - runtime - - - - - net.devh - grpc-server-spring-boot-starter - ${grpc.spring.boot.starter.version} - - - - - io.prometheus - simpleclient - ${io.prometheus.version} - - - io.prometheus - simpleclient_servlet - ${io.prometheus.version} - - - - - org.springframework.security - spring-security-core - ${spring.security.version} - - - org.springframework.security - spring-security-config - ${spring.security.version} - - - org.springframework.security - spring-security-oauth2-resource-server - ${spring.security.version} - - - org.springframework.security - spring-security-oauth2-jose - ${spring.security.version} - - - com.google.auth - google-auth-library-oauth2-http - ${google.auth.library.oauth2.http.version} - - - - - joda-time - joda-time - ${joda.time.version} - - - com.datadoghq - java-dogstatsd-client - 2.6.1 - - - com.google.guava - guava - 29.0-jre - - - com.google.protobuf - protobuf-java - ${protobuf.version} - - - com.google.protobuf - protobuf-java-util - ${protobuf.version} - - - org.projectlombok - lombok - ${lombok.version} - provided - - - com.google.auto.value - auto-value-annotations - ${auto.value.version} - - - com.google.auto.value - auto-value - ${auto.value.version} - - - com.google.code.gson - gson - ${gson.version} - - - io.gsonfire - gson-fire - ${gson.fire.version} - - - - com.github.kstyrc - embedded-redis - 0.6 - test - - - - - - net.bytebuddy - byte-buddy - ${byte-buddy.version} - - - org.mockito - mockito-core - ${mockito.version} - test - - - org.springframework.boot - spring-boot-starter-web - ${spring.boot.version} - - - org.springframework.boot - spring-boot-starter-logging - - - - - org.apache.logging.log4j - log4j-api - ${log4jVersion} - - - org.apache.logging.log4j - log4j-core - ${log4jVersion} - - - org.apache.logging.log4j - log4j-jul - ${log4jVersion} - - - org.apache.logging.log4j - log4j-web - ${log4jVersion} - org.apache.logging.log4j log4j-slf4j-impl @@ -462,26 +156,6 @@ 1.7.30 - - - - org.springframework.boot - spring-boot-dependencies - ${spring.boot.version} - pom - import - - - com.squareup.okio - okio - 1.17.2 - javax.xml.bind jaxb-api @@ -497,6 +171,19 @@ validation-api ${javax.validation.version} + + + org.junit.platform + junit-platform-engine + 1.8.2 + test + + + org.junit.platform + junit-platform-commons + 1.8.2 + test + @@ -693,22 +380,6 @@ - - org.springframework.boot - spring-boot-maven-plugin - - - true - - - - build-info - - build-info - - - - org.sonatype.plugins @@ -720,6 +391,7 @@ https://oss.sonatype.org/ true + true @@ -137,6 +142,13 @@ org.jacoco jacoco-maven-plugin + + org.sonatype.plugins + nexus-staging-maven-plugin + + false + + diff --git a/java/sdk/java/src/main/java/dev/feast/FeastClient.java b/java/sdk/src/main/java/dev/feast/FeastClient.java similarity index 100% rename from java/sdk/java/src/main/java/dev/feast/FeastClient.java rename to java/sdk/src/main/java/dev/feast/FeastClient.java diff --git a/java/sdk/java/src/main/java/dev/feast/RequestUtil.java b/java/sdk/src/main/java/dev/feast/RequestUtil.java similarity index 100% rename from java/sdk/java/src/main/java/dev/feast/RequestUtil.java rename to java/sdk/src/main/java/dev/feast/RequestUtil.java diff --git a/java/sdk/java/src/main/java/dev/feast/Row.java b/java/sdk/src/main/java/dev/feast/Row.java similarity index 100% rename from java/sdk/java/src/main/java/dev/feast/Row.java rename to java/sdk/src/main/java/dev/feast/Row.java diff --git a/java/sdk/java/src/main/java/dev/feast/SecurityConfig.java b/java/sdk/src/main/java/dev/feast/SecurityConfig.java similarity index 100% rename from java/sdk/java/src/main/java/dev/feast/SecurityConfig.java rename to java/sdk/src/main/java/dev/feast/SecurityConfig.java diff --git a/java/sdk/java/src/test/java/dev/feast/FeastClientTest.java b/java/sdk/src/test/java/dev/feast/FeastClientTest.java similarity index 100% rename from java/sdk/java/src/test/java/dev/feast/FeastClientTest.java rename to java/sdk/src/test/java/dev/feast/FeastClientTest.java diff --git a/java/sdk/java/src/test/java/dev/feast/RequestUtilTest.java b/java/sdk/src/test/java/dev/feast/RequestUtilTest.java similarity index 96% rename from java/sdk/java/src/test/java/dev/feast/RequestUtilTest.java rename to java/sdk/src/test/java/dev/feast/RequestUtilTest.java index 21fb145b248..e5684ecd18a 100644 --- a/java/sdk/java/src/test/java/dev/feast/RequestUtilTest.java +++ b/java/sdk/src/test/java/dev/feast/RequestUtilTest.java @@ -21,7 +21,6 @@ import com.google.common.collect.ImmutableList; import com.google.protobuf.TextFormat; -import feast.common.models.Feature; import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; import java.util.Arrays; import java.util.Comparator; @@ -68,7 +67,9 @@ void renderFeatureRef_ShouldReturnFeatureRefString( List expected, List input) { input = input.stream().map(ref -> ref.toBuilder().build()).collect(Collectors.toList()); List actual = - input.stream().map(ref -> Feature.getFeatureReference(ref)).collect(Collectors.toList()); + input.stream() + .map(ref -> String.format("%s:%s", ref.getFeatureViewName(), ref.getFeatureName())) + .collect(Collectors.toList()); assertEquals(expected.size(), actual.size()); for (int i = 0; i < expected.size(); i++) { assertEquals(expected.get(i), actual.get(i)); diff --git a/java/serving/pom.xml b/java/serving/pom.xml index 0da96ead956..b6f787ad305 100644 --- a/java/serving/pom.xml +++ b/java/serving/pom.xml @@ -48,6 +48,7 @@ org.apache.maven.plugins maven-jar-plugin + 3.2.2 @@ -87,7 +88,7 @@ dev.feast - datatypes-java + feast-datatypes ${project.version} @@ -119,38 +120,45 @@ org.slf4j slf4j-simple + 1.7.30 org.apache.logging.log4j log4j-web + ${log4jVersion} io.grpc grpc-services + ${grpc.version} io.grpc grpc-stub + ${grpc.version} com.google.protobuf protobuf-java-util + ${protobuf.version} com.google.guava guava + ${guava.version} joda-time joda-time + ${joda.time.version} @@ -198,7 +206,7 @@ com.google.auto.value auto-value-annotations - 1.6.6 + ${auto.value.version} @@ -231,11 +239,13 @@ io.grpc grpc-testing + ${grpc.version} org.mockito mockito-core + ${mockito.version} test @@ -281,11 +291,19 @@ com.fasterxml.jackson.dataformat jackson-dataformat-yaml + 2.11.0 + + + + com.fasterxml.jackson.core + jackson-annotations + 2.12.2 com.github.kstyrc embedded-redis + 0.6 test @@ -340,6 +358,13 @@ false + + org.sonatype.plugins + nexus-staging-maven-plugin + + true + + diff --git a/java/serving/src/main/java/feast/serving/config/ServerModule.java b/java/serving/src/main/java/feast/serving/config/ServerModule.java index cb3a18cf956..5428306f2b7 100644 --- a/java/serving/src/main/java/feast/serving/config/ServerModule.java +++ b/java/serving/src/main/java/feast/serving/config/ServerModule.java @@ -18,9 +18,12 @@ import com.google.inject.AbstractModule; import com.google.inject.Provides; +import feast.serving.controller.HealthServiceController; import feast.serving.grpc.OnlineServingGrpcServiceV2; +import feast.serving.service.ServingServiceV2; import io.grpc.Server; import io.grpc.ServerBuilder; +import io.grpc.health.v1.HealthGrpc; import io.grpc.protobuf.services.ProtoReflectionService; import io.opentracing.contrib.grpc.TracingServerInterceptor; @@ -35,13 +38,20 @@ protected void configure() { public Server provideGrpcServer( ApplicationProperties applicationProperties, OnlineServingGrpcServiceV2 onlineServingGrpcServiceV2, - TracingServerInterceptor tracingServerInterceptor) { + TracingServerInterceptor tracingServerInterceptor, + HealthGrpc.HealthImplBase healthImplBase) { ServerBuilder serverBuilder = ServerBuilder.forPort(applicationProperties.getGrpc().getServer().getPort()); serverBuilder .addService(ProtoReflectionService.newInstance()) - .addService(tracingServerInterceptor.intercept(onlineServingGrpcServiceV2)); + .addService(tracingServerInterceptor.intercept(onlineServingGrpcServiceV2)) + .addService(healthImplBase); return serverBuilder.build(); } + + @Provides + public HealthGrpc.HealthImplBase healthService(ServingServiceV2 servingServiceV2) { + return new HealthServiceController(servingServiceV2); + } } diff --git a/java/serving/src/main/java/feast/serving/config/ServingApiConfiguration.java b/java/serving/src/main/java/feast/serving/config/ServingApiConfiguration.java deleted file mode 100644 index ce4fe134373..00000000000 --- a/java/serving/src/main/java/feast/serving/config/ServingApiConfiguration.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2019 The Feast Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feast.serving.config; - -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.converter.protobuf.ProtobufJsonFormatHttpMessageConverter; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class ServingApiConfiguration implements WebMvcConfigurer { - @Autowired private ProtobufJsonFormatHttpMessageConverter protobufConverter; - - @Bean - ProtobufJsonFormatHttpMessageConverter protobufHttpMessageConverter() { - return new ProtobufJsonFormatHttpMessageConverter(); - } - - @Override - public void configureMessageConverters(List> converters) { - converters.add(protobufConverter); - } -} diff --git a/java/serving/src/main/java/feast/serving/config/WebSecurityConfig.java b/java/serving/src/main/java/feast/serving/config/WebSecurityConfig.java deleted file mode 100644 index 04d3f4b5afb..00000000000 --- a/java/serving/src/main/java/feast/serving/config/WebSecurityConfig.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2020 The Feast Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feast.serving.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; - -/** - * WebSecurityConfig disables auto configuration of Spring HTTP Security and allows security methods - * to be overridden - */ -@Configuration -@EnableWebSecurity -public class WebSecurityConfig extends WebSecurityConfigurerAdapter { - - /** - * Allows for custom web security rules to be applied. - * - * @param http {@link HttpSecurity} for configuring web based security - * @throws Exception exception - */ - @Override - protected void configure(HttpSecurity http) throws Exception { - - // Bypasses security/authentication for the following paths - http.authorizeRequests() - .antMatchers("/actuator/**", "/metrics/**") - .permitAll() - .anyRequest() - .authenticated() - .and() - .csrf() - .disable(); - } -} diff --git a/java/serving/src/main/java/feast/serving/controller/HealthServiceController.java b/java/serving/src/main/java/feast/serving/controller/HealthServiceController.java index ef675d4c157..2f98ae032f9 100644 --- a/java/serving/src/main/java/feast/serving/controller/HealthServiceController.java +++ b/java/serving/src/main/java/feast/serving/controller/HealthServiceController.java @@ -16,24 +16,20 @@ */ package feast.serving.controller; +import com.google.inject.Inject; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; -import feast.serving.interceptors.GrpcMonitoringInterceptor; import feast.serving.service.ServingServiceV2; import io.grpc.health.v1.HealthGrpc.HealthImplBase; import io.grpc.health.v1.HealthProto.HealthCheckRequest; import io.grpc.health.v1.HealthProto.HealthCheckResponse; import io.grpc.health.v1.HealthProto.ServingStatus; import io.grpc.stub.StreamObserver; -import net.devh.boot.grpc.server.service.GrpcService; -import org.springframework.beans.factory.annotation.Autowired; // Reference: https://github.com/grpc/grpc/blob/master/doc/health-checking.md - -@GrpcService(interceptors = {GrpcMonitoringInterceptor.class}) public class HealthServiceController extends HealthImplBase { private final ServingServiceV2 servingService; - @Autowired + @Inject public HealthServiceController(final ServingServiceV2 servingService) { this.servingService = servingService; } diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java deleted file mode 100644 index bc6af8ecce0..00000000000 --- a/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2019 The Feast Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feast.serving.controller; - -import feast.proto.serving.ServingAPIProto; -import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; -import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; -import feast.proto.serving.ServingServiceGrpc.ServingServiceImplBase; -import feast.serving.config.ApplicationProperties; -import feast.serving.exception.SpecRetrievalException; -import feast.serving.service.ServingServiceV2; -import feast.serving.util.RequestHelper; -import io.grpc.Status; -import io.grpc.stub.StreamObserver; -import io.opentracing.Span; -import io.opentracing.Tracer; -import org.slf4j.Logger; - -public class ServingServiceGRpcController extends ServingServiceImplBase { - - private static final Logger log = - org.slf4j.LoggerFactory.getLogger(ServingServiceGRpcController.class); - private final ServingServiceV2 servingServiceV2; - private final String version; - private final Tracer tracer; - - public ServingServiceGRpcController( - ServingServiceV2 servingServiceV2, - ApplicationProperties applicationProperties, - Tracer tracer) { - this.servingServiceV2 = servingServiceV2; - this.version = applicationProperties.getFeast().getVersion(); - this.tracer = tracer; - } - - @Override - public void getFeastServingInfo( - GetFeastServingInfoRequest request, - StreamObserver responseObserver) { - GetFeastServingInfoResponse feastServingInfo = servingServiceV2.getFeastServingInfo(request); - feastServingInfo = feastServingInfo.toBuilder().setVersion(version).build(); - responseObserver.onNext(feastServingInfo); - responseObserver.onCompleted(); - } - - @Override - public void getOnlineFeatures( - ServingAPIProto.GetOnlineFeaturesRequest request, - StreamObserver responseObserver) { - try { - // authorize for the project in request object. - RequestHelper.validateOnlineRequest(request); - Span span = tracer.buildSpan("getOnlineFeaturesV2").start(); - ServingAPIProto.GetOnlineFeaturesResponse onlineFeatures = - servingServiceV2.getOnlineFeatures(request); - if (span != null) { - span.finish(); - } - - responseObserver.onNext(onlineFeatures); - responseObserver.onCompleted(); - } catch (SpecRetrievalException e) { - log.error("Failed to retrieve specs from Registry", e); - responseObserver.onError( - Status.NOT_FOUND.withDescription(e.getMessage()).withCause(e).asException()); - } catch (Exception e) { - log.warn("Failed to get Online Features", e); - responseObserver.onError( - Status.INTERNAL.withDescription(e.getMessage()).withCause(e).asRuntimeException()); - } - } -} diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java deleted file mode 100644 index 1983f3ebce3..00000000000 --- a/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2019 The Feast Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feast.serving.controller; - -import static feast.serving.util.mappers.ResponseJSONMapper.mapGetOnlineFeaturesResponse; - -import feast.proto.serving.ServingAPIProto; -import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; -import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; -import feast.serving.config.ApplicationProperties; -import feast.serving.service.ServingServiceV2; -import feast.serving.util.RequestHelper; -import java.util.List; -import java.util.Map; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; - -public class ServingServiceRestController { - - private final ServingServiceV2 servingService; - private final String version; - - public ServingServiceRestController( - ServingServiceV2 servingService, ApplicationProperties applicationProperties) { - this.servingService = servingService; - this.version = applicationProperties.getFeast().getVersion(); - } - - @RequestMapping(value = "/api/v1/info", produces = "application/json") - public GetFeastServingInfoResponse getInfo() { - GetFeastServingInfoResponse feastServingInfo = - servingService.getFeastServingInfo(GetFeastServingInfoRequest.getDefaultInstance()); - return feastServingInfo.toBuilder().setVersion(version).build(); - } - - @RequestMapping( - value = "/api/v1/features/online", - produces = "application/json", - consumes = "application/json") - public List> getOnlineFeatures( - @RequestBody ServingAPIProto.GetOnlineFeaturesRequest request) { - RequestHelper.validateOnlineRequest(request); - ServingAPIProto.GetOnlineFeaturesResponse onlineFeatures = - servingService.getOnlineFeatures(request); - return mapGetOnlineFeaturesResponse(onlineFeatures); - } -} diff --git a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java index a535eacb9e9..ea404ff7a5e 100644 --- a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java +++ b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java @@ -34,6 +34,7 @@ import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.Status; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.channels.Channels; import java.util.*; @@ -49,7 +50,6 @@ import org.apache.arrow.vector.util.ByteArrayReadableSeekableByteChannel; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; -import org.apache.tomcat.util.http.fileupload.ByteArrayOutputStream; import org.slf4j.Logger; public class OnlineTransformationService implements TransformationService { diff --git a/java/storage/api/pom.xml b/java/storage/api/pom.xml index 583bcd06406..90f656e281e 100644 --- a/java/storage/api/pom.xml +++ b/java/storage/api/pom.xml @@ -32,16 +32,10 @@ dev.feast - datatypes-java + feast-datatypes ${project.version} - - - - - - com.google.auto.value auto-value-annotations diff --git a/java/storage/connectors/pom.xml b/java/storage/connectors/pom.xml index e896910e73d..11e32a154c2 100644 --- a/java/storage/connectors/pom.xml +++ b/java/storage/connectors/pom.xml @@ -41,7 +41,7 @@ dev.feast - datatypes-java + feast-datatypes ${project.version} diff --git a/java/storage/connectors/redis/pom.xml b/java/storage/connectors/redis/pom.xml index 7b0c944a66e..ce25f41da6c 100644 --- a/java/storage/connectors/redis/pom.xml +++ b/java/storage/connectors/redis/pom.xml @@ -48,6 +48,7 @@ com.google.guava guava + ${guava.version} @@ -61,6 +62,7 @@ com.github.kstyrc embedded-redis + 0.6 test @@ -68,12 +70,14 @@ org.hamcrest hamcrest-core test + ${hamcrest.version} org.hamcrest hamcrest-library test + ${hamcrest.version} @@ -93,7 +97,7 @@ org.slf4j slf4j-simple - 1.7.30 + 1.7.32 test From e0a36faa7d5d42972e279d7fcceb9e2161759370 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Jan 2022 03:13:46 -0800 Subject: [PATCH 26/85] Bump jackson-databind from 2.10.1 to 2.10.5.1 in /java/common (#2228) Bumps [jackson-databind](https://github.com/FasterXML/jackson) from 2.10.1 to 2.10.5.1. - [Release notes](https://github.com/FasterXML/jackson/releases) - [Commits](https://github.com/FasterXML/jackson/commits) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-databind dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- java/common/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/common/pom.xml b/java/common/pom.xml index 53ce6907803..e5a648a7f95 100644 --- a/java/common/pom.xml +++ b/java/common/pom.xml @@ -75,7 +75,7 @@ com.fasterxml.jackson.core jackson-databind - 2.10.1 + 2.10.5.1 com.fasterxml.jackson.datatype From feb923b54a9067a2b9b01acf4e96e1ed90073f5f Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Thu, 20 Jan 2022 19:45:47 +0200 Subject: [PATCH 27/85] Docker build fails for java feature server (#2230) Signed-off-by: pyalex --- java/infra/docker/feature-server/Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/java/infra/docker/feature-server/Dockerfile b/java/infra/docker/feature-server/Dockerfile index a07d3301b2c..dbd8c914724 100644 --- a/java/infra/docker/feature-server/Dockerfile +++ b/java/infra/docker/feature-server/Dockerfile @@ -7,14 +7,14 @@ FROM maven:3.6-jdk-11 as builder WORKDIR /build COPY java/pom.xml . -COPY java/datatypes/java/pom.xml datatypes/java/pom.xml +COPY java/datatypes/pom.xml datatypes/pom.xml COPY java/common/pom.xml common/pom.xml COPY java/serving/pom.xml serving/pom.xml COPY java/storage/api/pom.xml storage/api/pom.xml COPY java/storage/connectors/pom.xml storage/connectors/pom.xml COPY java/storage/connectors/redis/pom.xml storage/connectors/redis/pom.xml -COPY java/sdk/java/pom.xml sdk/java/pom.xml -COPY java/docs/coverage/java/pom.xml docs/coverage/java/pom.xml +COPY java/sdk/pom.xml sdk/pom.xml +COPY java/docs/coverage/pom.xml docs/coverage/pom.xml # Setting Maven repository .m2 directory relative to /build folder gives the # user to optionally use cached repository when building the image by copying @@ -24,7 +24,7 @@ COPY java/pom.xml .m2/* .m2/ RUN mvn dependency:go-offline -DexcludeGroupIds:dev.feast 2>/dev/null || true COPY java/ . -COPY protos/feast datatypes/java/src/main/proto/feast +COPY protos/feast datatypes/src/main/proto/feast ARG VERSION=dev RUN mvn --also-make --projects serving -Drevision=$VERSION \ From 62fae057d8c36af5f0f302c398f6134fc3c0407d Mon Sep 17 00:00:00 2001 From: mickey-liu Date: Thu, 20 Jan 2022 09:56:47 -0800 Subject: [PATCH 28/85] Split apply total parse repo (#2226) * random example commit Signed-off-by: Yun Nan Liu * random example commit Signed-off-by: Yun Nan Liu * first commit Signed-off-by: Yun Nan Liu * fix Signed-off-by: Yun Nan Liu * added method to apply with user-provided parsed_repo instance Signed-off-by: Yun Nan Liu * add apply_total_with_repo_instance to be called by apply_total Signed-off-by: Yun Nan Liu * fix styling and arg order Signed-off-by: Yun Nan Liu * fix lint issues Signed-off-by: Yun Nan Liu Co-authored-by: Yun Nan Liu --- sdk/python/feast/repo_operations.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index 3e9ddb6e304..0638ca589a8 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -237,12 +237,13 @@ def extract_objects_for_apply_delete(project, registry, repo): return all_to_apply, all_to_delete, views_to_delete, views_to_keep -@log_exceptions_and_usage -def apply_total(repo_config: RepoConfig, repo_path: Path, skip_source_validation: bool): - - os.chdir(repo_path) - project, registry, repo, store = _prepare_registry_and_repo(repo_config, repo_path) - +def apply_total_with_repo_instance( + store: FeatureStore, + project: str, + registry: Registry, + repo: RepoContents, + skip_source_validation: bool, +): if not skip_source_validation: data_sources = [t.batch_source for t in repo.feature_views] # Make sure the data source used by this feature view is supported by Feast @@ -262,6 +263,16 @@ def apply_total(repo_config: RepoConfig, repo_path: Path, skip_source_validation log_cli_output(diff, views_to_delete, views_to_keep) +@log_exceptions_and_usage +def apply_total(repo_config: RepoConfig, repo_path: Path, skip_source_validation: bool): + + os.chdir(repo_path) + project, registry, repo, store = _prepare_registry_and_repo(repo_config, repo_path) + apply_total_with_repo_instance( + store, project, registry, repo, skip_source_validation + ) + + def log_cli_output(diff, views_to_delete, views_to_keep): from colorama import Fore, Style From 3894b8da3a0f462efa2f7afc5e310d9091caec21 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Tue, 25 Jan 2022 22:58:02 +0200 Subject: [PATCH 29/85] Feature server helm chart produces invalid YAML (#2234) * Feature server helm chart has invalid syntax Signed-off-by: pyalex * return empty line Signed-off-by: pyalex --- .../feast/charts/feature-server/templates/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/charts/feast/charts/feature-server/templates/deployment.yaml b/infra/charts/feast/charts/feature-server/templates/deployment.yaml index ad0529978d9..02323cbffc6 100644 --- a/infra/charts/feast/charts/feature-server/templates/deployment.yaml +++ b/infra/charts/feast/charts/feature-server/templates/deployment.yaml @@ -89,7 +89,7 @@ spec: - java - -jar - /opt/feast/feast-serving.jar - - {{- if index .Values "application.yaml" "enabled" -}} + - {{ if index .Values "application.yaml" "enabled" -}} classpath:/application.yml {{- end }} {{- if index .Values "application-generated.yaml" "enabled" -}} From 3dcec6daf3eb89105b1cf5709003e1809e063089 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Tue, 25 Jan 2022 15:10:02 -0800 Subject: [PATCH 30/85] Compare Python objects instead of proto objects (#2227) * Compare Python objects instead of proto objects Signed-off-by: Felix Wang * Remove unnecessary helper method Signed-off-by: Felix Wang * Fix docstring test Signed-off-by: Felix Wang * Add docstring to RepoContents Signed-off-by: Felix Wang * Lint Signed-off-by: Felix Wang * Update usage test Signed-off-by: Felix Wang * Set cache ttl to 1 second in tests for local feature server tests Signed-off-by: Felix Wang * Add FCO test Signed-off-by: Felix Wang * Add properties to feature service Signed-off-by: Felix Wang * Lint Signed-off-by: Felix Wang * Remove logic that converts Registry to RepoContents Signed-off-by: Felix Wang * Always initialize registry Signed-off-by: Felix Wang * Move diffing methods from Registry into FcoDiff.py Signed-off-by: Felix Wang * Fix unit test Signed-off-by: Felix Wang * Put registry initialization back in repo_operations.py Signed-off-by: Felix Wang * Fix usage test Signed-off-by: Felix Wang * Switch from hardcoded names to enum Signed-off-by: Felix Wang --- sdk/python/feast/diff/FcoDiff.py | 202 +++++++++++++++--- sdk/python/feast/feature_service.py | 76 +++++-- sdk/python/feast/feature_store.py | 73 +------ sdk/python/feast/registry.py | 86 +------- sdk/python/feast/repo_contents.py | 50 +++++ sdk/python/feast/repo_operations.py | 116 ++++------ .../feature_repos/repo_configuration.py | 7 +- sdk/python/tests/unit/diff/test_fco_diff.py | 62 +++++- 8 files changed, 408 insertions(+), 264 deletions(-) create mode 100644 sdk/python/feast/repo_contents.py diff --git a/sdk/python/feast/diff/FcoDiff.py b/sdk/python/feast/diff/FcoDiff.py index b85897019fe..e2aac16bf5b 100644 --- a/sdk/python/feast/diff/FcoDiff.py +++ b/sdk/python/feast/diff/FcoDiff.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Generic, Iterable, List, Set, Tuple, TypeVar +from typing import Any, Dict, Generic, Iterable, List, Set, Tuple, TypeVar from feast.base_feature_view import BaseFeatureView from feast.diff.property_diff import PropertyDiff, TransitionType @@ -16,23 +16,28 @@ from feast.protos.feast.core.RequestFeatureView_pb2 import ( RequestFeatureView as RequestFeatureViewProto, ) +from feast.registry import FeastObjectType, Registry +from feast.repo_contents import RepoContents -FcoProto = TypeVar( - "FcoProto", - EntityProto, - FeatureViewProto, - FeatureServiceProto, - OnDemandFeatureViewProto, - RequestFeatureViewProto, -) +FEAST_OBJECT_TYPE_TO_STR = { + FeastObjectType.ENTITY: "entity", + FeastObjectType.FEATURE_VIEW: "feature view", + FeastObjectType.ON_DEMAND_FEATURE_VIEW: "on demand feature view", + FeastObjectType.REQUEST_FEATURE_VIEW: "request feature view", + FeastObjectType.FEATURE_SERVICE: "feature service", +} + +FEAST_OBJECT_TYPES = FEAST_OBJECT_TYPE_TO_STR.keys() + +Fco = TypeVar("Fco", Entity, BaseFeatureView, FeatureService) @dataclass -class FcoDiff(Generic[FcoProto]): +class FcoDiff(Generic[Fco]): name: str fco_type: str - current_fco: FcoProto - new_fco: FcoProto + current_fco: Fco + new_fco: Fco fco_property_diffs: List[PropertyDiff] transition_type: TransitionType @@ -48,20 +53,28 @@ def add_fco_diff(self, fco_diff: FcoDiff): self.fco_diffs.append(fco_diff) -Fco = TypeVar("Fco", Entity, BaseFeatureView, FeatureService) - - -def tag_objects_for_keep_delete_add( +def tag_objects_for_keep_delete_update_add( existing_objs: Iterable[Fco], desired_objs: Iterable[Fco] -) -> Tuple[Set[Fco], Set[Fco], Set[Fco]]: +) -> Tuple[Set[Fco], Set[Fco], Set[Fco], Set[Fco]]: existing_obj_names = {e.name for e in existing_objs} desired_obj_names = {e.name for e in desired_objs} objs_to_add = {e for e in desired_objs if e.name not in existing_obj_names} - objs_to_keep = {e for e in desired_objs if e.name in existing_obj_names} + objs_to_update = {e for e in desired_objs if e.name in existing_obj_names} + objs_to_keep = {e for e in existing_objs if e.name in desired_obj_names} objs_to_delete = {e for e in existing_objs if e.name not in desired_obj_names} - return objs_to_keep, objs_to_delete, objs_to_add + return objs_to_keep, objs_to_delete, objs_to_update, objs_to_add + + +FcoProto = TypeVar( + "FcoProto", + EntityProto, + FeatureViewProto, + FeatureServiceProto, + OnDemandFeatureViewProto, + RequestFeatureViewProto, +) def tag_proto_objects_for_keep_delete_add( @@ -80,23 +93,158 @@ def tag_proto_objects_for_keep_delete_add( FIELDS_TO_IGNORE = {"project"} -def diff_between(current: FcoProto, new: FcoProto, object_type: str) -> FcoDiff: - assert current.DESCRIPTOR.full_name == new.DESCRIPTOR.full_name +def diff_registry_objects(current: Fco, new: Fco, object_type: str) -> FcoDiff: + current_proto = current.to_proto() + new_proto = new.to_proto() + assert current_proto.DESCRIPTOR.full_name == new_proto.DESCRIPTOR.full_name property_diffs = [] transition: TransitionType = TransitionType.UNCHANGED - if current.spec != new.spec: - for _field in current.spec.DESCRIPTOR.fields: + if current_proto.spec != new_proto.spec: + for _field in current_proto.spec.DESCRIPTOR.fields: if _field.name in FIELDS_TO_IGNORE: continue - if getattr(current.spec, _field.name) != getattr(new.spec, _field.name): + if getattr(current_proto.spec, _field.name) != getattr( + new_proto.spec, _field.name + ): transition = TransitionType.UPDATE property_diffs.append( PropertyDiff( _field.name, - getattr(current.spec, _field.name), - getattr(new.spec, _field.name), + getattr(current_proto.spec, _field.name), + getattr(new_proto.spec, _field.name), ) ) return FcoDiff( - new.spec.name, object_type, current, new, property_diffs, transition, + name=new_proto.spec.name, + fco_type=object_type, + current_fco=current, + new_fco=new, + fco_property_diffs=property_diffs, + transition_type=transition, ) + + +def extract_objects_for_keep_delete_update_add( + registry: Registry, current_project: str, desired_repo_contents: RepoContents, +) -> Tuple[ + Dict[FeastObjectType, Set[Fco]], + Dict[FeastObjectType, Set[Fco]], + Dict[FeastObjectType, Set[Fco]], + Dict[FeastObjectType, Set[Fco]], +]: + """ + Returns the objects in the registry that must be modified to achieve the desired repo state. + + Args: + registry: The registry storing the current repo state. + current_project: The Feast project whose objects should be compared. + desired_repo_contents: The desired repo state. + """ + objs_to_keep = {} + objs_to_delete = {} + objs_to_update = {} + objs_to_add = {} + + registry_object_type_to_objects: Dict[FeastObjectType, List[Any]] + registry_object_type_to_objects = { + FeastObjectType.ENTITY: registry.list_entities(project=current_project), + FeastObjectType.FEATURE_VIEW: registry.list_feature_views( + project=current_project + ), + FeastObjectType.ON_DEMAND_FEATURE_VIEW: registry.list_on_demand_feature_views( + project=current_project + ), + FeastObjectType.REQUEST_FEATURE_VIEW: registry.list_request_feature_views( + project=current_project + ), + FeastObjectType.FEATURE_SERVICE: registry.list_feature_services( + project=current_project + ), + } + registry_object_type_to_repo_contents: Dict[FeastObjectType, Set[Any]] + registry_object_type_to_repo_contents = { + FeastObjectType.ENTITY: desired_repo_contents.entities, + FeastObjectType.FEATURE_VIEW: desired_repo_contents.feature_views, + FeastObjectType.ON_DEMAND_FEATURE_VIEW: desired_repo_contents.on_demand_feature_views, + FeastObjectType.REQUEST_FEATURE_VIEW: desired_repo_contents.request_feature_views, + FeastObjectType.FEATURE_SERVICE: desired_repo_contents.feature_services, + } + + for object_type in FEAST_OBJECT_TYPES: + ( + to_keep, + to_delete, + to_update, + to_add, + ) = tag_objects_for_keep_delete_update_add( + registry_object_type_to_objects[object_type], + registry_object_type_to_repo_contents[object_type], + ) + + objs_to_keep[object_type] = to_keep + objs_to_delete[object_type] = to_delete + objs_to_update[object_type] = to_update + objs_to_add[object_type] = to_add + + return objs_to_keep, objs_to_delete, objs_to_update, objs_to_add + + +def diff_between( + registry: Registry, current_project: str, desired_repo_contents: RepoContents, +) -> RegistryDiff: + """ + Returns the difference between the current and desired repo states. + + Args: + registry: The registry storing the current repo state. + current_project: The Feast project for which the diff is being computed. + desired_repo_contents: The desired repo state. + """ + diff = RegistryDiff() + + ( + objs_to_keep, + objs_to_delete, + objs_to_update, + objs_to_add, + ) = extract_objects_for_keep_delete_update_add( + registry, current_project, desired_repo_contents + ) + + for object_type in FEAST_OBJECT_TYPES: + objects_to_keep = objs_to_keep[object_type] + objects_to_delete = objs_to_delete[object_type] + objects_to_update = objs_to_update[object_type] + objects_to_add = objs_to_add[object_type] + + for e in objects_to_add: + diff.add_fco_diff( + FcoDiff( + name=e.name, + fco_type=FEAST_OBJECT_TYPE_TO_STR[object_type], + current_fco=None, + new_fco=e, + fco_property_diffs=[], + transition_type=TransitionType.CREATE, + ) + ) + for e in objects_to_delete: + diff.add_fco_diff( + FcoDiff( + name=e.name, + fco_type=FEAST_OBJECT_TYPE_TO_STR[object_type], + current_fco=e, + new_fco=None, + fco_property_diffs=[], + transition_type=TransitionType.DELETE, + ) + ) + for e in objects_to_update: + current_obj = [_e for _e in objects_to_keep if _e.name == e.name][0] + diff.add_fco_diff( + diff_registry_objects( + current_obj, e, FEAST_OBJECT_TYPE_TO_STR[object_type] + ) + ) + + return diff diff --git a/sdk/python/feast/feature_service.py b/sdk/python/feast/feature_service.py index 9bb4fb5e5df..16815531a3b 100644 --- a/sdk/python/feast/feature_service.py +++ b/sdk/python/feast/feature_service.py @@ -30,12 +30,12 @@ class FeatureService: Services. """ - name: str - feature_view_projections: List[FeatureViewProjection] - tags: Dict[str, str] - description: Optional[str] = None - created_timestamp: Optional[datetime] = None - last_updated_timestamp: Optional[datetime] = None + _name: str + _feature_view_projections: List[FeatureViewProjection] + _tags: Dict[str, str] + _description: Optional[str] = None + _created_timestamp: Optional[datetime] = None + _last_updated_timestamp: Optional[datetime] = None @log_exceptions def __init__( @@ -51,22 +51,22 @@ def __init__( Raises: ValueError: If one of the specified features is not a valid type. """ - self.name = name - self.feature_view_projections = [] + self._name = name + self._feature_view_projections = [] for feature_grouping in features: if isinstance(feature_grouping, BaseFeatureView): - self.feature_view_projections.append(feature_grouping.projection) + self._feature_view_projections.append(feature_grouping.projection) else: raise ValueError( "The FeatureService {fs_name} has been provided with an invalid type" f'{type(feature_grouping)} as part of the "features" argument.)' ) - self.tags = tags or {} - self.description = description - self.created_timestamp = None - self.last_updated_timestamp = None + self._tags = tags or {} + self._description = description + self._created_timestamp = None + self._last_updated_timestamp = None def __repr__(self): items = (f"{k} = {v}" for k, v in self.__dict__.items()) @@ -93,6 +93,56 @@ def __eq__(self, other): return True + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, name: str): + self._name = name + + @property + def feature_view_projections(self) -> List[FeatureViewProjection]: + return self._feature_view_projections + + @feature_view_projections.setter + def feature_view_projections( + self, feature_view_projections: List[FeatureViewProjection] + ): + self._feature_view_projections = feature_view_projections + + @property + def tags(self) -> Dict[str, str]: + return self._tags + + @tags.setter + def tags(self, tags: Dict[str, str]): + self._tags = tags + + @property + def description(self) -> Optional[str]: + return self._description + + @description.setter + def description(self, description: str): + self._description = description + + @property + def created_timestamp(self) -> Optional[datetime]: + return self._created_timestamp + + @created_timestamp.setter + def created_timestamp(self, created_timestamp: datetime): + self._created_timestamp = created_timestamp + + @property + def last_updated_timestamp(self) -> Optional[datetime]: + return self._last_updated_timestamp + + @last_updated_timestamp.setter + def last_updated_timestamp(self, last_updated_timestamp: datetime): + self._last_updated_timestamp = last_updated_timestamp + @staticmethod def from_proto(feature_service_proto: FeatureServiceProto): """ diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 39273b56c25..c9dae9063a6 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -24,7 +24,6 @@ Iterable, List, Mapping, - NamedTuple, Optional, Sequence, Set, @@ -40,7 +39,7 @@ from feast import feature_server, flags, flags_helper, utils from feast.base_feature_view import BaseFeatureView -from feast.diff.FcoDiff import RegistryDiff +from feast.diff.FcoDiff import RegistryDiff, diff_between from feast.diff.infra_diff import InfraDiff, diff_infra_protos from feast.entity import Entity from feast.errors import ( @@ -68,7 +67,6 @@ from feast.on_demand_feature_view import OnDemandFeatureView from feast.online_response import OnlineResponse from feast.protos.feast.core.InfraObject_pb2 import Infra as InfraProto -from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.protos.feast.serving.ServingService_pb2 import ( FieldStatus, GetOnlineFeaturesResponse, @@ -77,6 +75,7 @@ from feast.protos.feast.types.Value_pb2 import RepeatedValue, Value from feast.registry import Registry from feast.repo_config import RepoConfig, load_repo_config +from feast.repo_contents import RepoContents from feast.request_feature_view import RequestFeatureView from feast.type_map import python_values_to_proto_values from feast.usage import log_exceptions, log_exceptions_and_usage, set_usage_attribute @@ -86,31 +85,6 @@ warnings.simplefilter("once", DeprecationWarning) -class RepoContents(NamedTuple): - feature_views: Set[FeatureView] - on_demand_feature_views: Set[OnDemandFeatureView] - request_feature_views: Set[RequestFeatureView] - entities: Set[Entity] - feature_services: Set[FeatureService] - - def to_registry_proto(self) -> RegistryProto: - registry_proto = RegistryProto() - registry_proto.entities.extend([e.to_proto() for e in self.entities]) - registry_proto.feature_views.extend( - [fv.to_proto() for fv in self.feature_views] - ) - registry_proto.on_demand_feature_views.extend( - [fv.to_proto() for fv in self.on_demand_feature_views] - ) - registry_proto.request_feature_views.extend( - [fv.to_proto() for fv in self.request_feature_views] - ) - registry_proto.feature_services.extend( - [fs.to_proto() for fs in self.feature_services] - ) - return registry_proto - - class FeatureStore: """ A FeatureStore object is used to define, create, and retrieve features. @@ -415,7 +389,7 @@ def _get_features( @log_exceptions_and_usage def plan( - self, desired_repo_objects: RepoContents + self, desired_repo_contents: RepoContents ) -> Tuple[RegistryDiff, InfraDiff]: """Dry-run registering objects to metadata store. @@ -453,16 +427,8 @@ def plan( ... ) >>> registry_diff, infra_diff = fs.plan(RepoContents({driver_hourly_stats_view}, set(), set(), {driver}, set())) # register entity and feature view """ - - current_registry_proto = ( - self._registry.cached_registry_proto.__deepcopy__() - if self._registry.cached_registry_proto - else RegistryProto() - ) - - desired_registry_proto = desired_repo_objects.to_registry_proto() - registry_diff = Registry.diff_between( - current_registry_proto, desired_registry_proto + registry_diff = diff_between( + self._registry, self.project, desired_repo_contents ) current_infra_proto = ( @@ -470,6 +436,7 @@ def plan( if self._registry.cached_registry_proto else InfraProto() ) + desired_registry_proto = desired_repo_contents.to_registry_proto() new_infra_proto = self._provider.plan_infra( self.config, desired_registry_proto ).to_proto() @@ -508,7 +475,7 @@ def apply( ] ] = None, partial: bool = True, - ) -> RegistryDiff: + ): """Register objects to metadata store and update related infrastructure. The apply method registers one or more definitions (e.g., Entity, FeatureView) and registers or updates these @@ -544,7 +511,7 @@ def apply( ... ttl=timedelta(seconds=86400 * 1), ... batch_source=driver_hourly_stats, ... ) - >>> diff = fs.apply([driver_hourly_stats_view, driver]) # register entity and feature view + >>> fs.apply([driver_hourly_stats_view, driver]) # register entity and feature view """ # TODO: Add locking if not isinstance(objects, Iterable): @@ -554,12 +521,6 @@ def apply( if not objects_to_delete: objects_to_delete = [] - current_registry_proto = ( - self._registry.cached_registry_proto.__deepcopy__() - if self._registry.cached_registry_proto - else RegistryProto() - ) - # Separate all objects into entities, feature services, and different feature view types. entities_to_update = [ob for ob in objects if isinstance(ob, Entity)] views_to_update = [ob for ob in objects if isinstance(ob, FeatureView)] @@ -657,22 +618,6 @@ def apply( service.name, project=self.project, commit=False ) - new_registry_proto = ( - self._registry.cached_registry_proto - if self._registry.cached_registry_proto - else RegistryProto() - ) - - diffs = Registry.diff_between(current_registry_proto, new_registry_proto) - - entities_to_update = [ob for ob in objects if isinstance(ob, Entity)] - views_to_update = [ob for ob in objects if isinstance(ob, FeatureView)] - - entities_to_delete = [ob for ob in objects_to_delete if isinstance(ob, Entity)] - views_to_delete = [ - ob for ob in objects_to_delete if isinstance(ob, FeatureView) - ] - self._get_provider().update_infra( project=self.project, tables_to_delete=views_to_delete if not partial else [], @@ -684,8 +629,6 @@ def apply( self._registry.commit() - return diffs - @log_exceptions_and_usage def teardown(self): """Tears down all local and cloud resources for the feature store.""" diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index 0c058a0d461..f05abc6d9ae 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -14,6 +14,7 @@ import logging from collections import defaultdict from datetime import datetime, timedelta +from enum import Enum from pathlib import Path from threading import Lock from typing import Any, Dict, List, Optional @@ -24,13 +25,6 @@ from proto import Message from feast.base_feature_view import BaseFeatureView -from feast.diff.FcoDiff import ( - FcoDiff, - RegistryDiff, - TransitionType, - diff_between, - tag_proto_objects_for_keep_delete_add, -) from feast.entity import Entity from feast.errors import ( ConflictingFeatureViewNames, @@ -65,6 +59,15 @@ "": "LocalRegistryStore", } + +class FeastObjectType(Enum): + ENTITY = 0 + FEATURE_VIEW = 1 + ON_DEMAND_FEATURE_VIEW = 2 + REQUEST_FEATURE_VIEW = 3 + FEATURE_SERVICE = 4 + + logger = logging.getLogger(__name__) @@ -143,75 +146,6 @@ def clone(self) -> "Registry": new_registry._registry_store = NoopRegistryStore() return new_registry - # TODO(achals): This method needs to be filled out and used in the feast plan/apply methods. - @staticmethod - def diff_between( - current_registry: RegistryProto, new_registry: RegistryProto - ) -> RegistryDiff: - diff = RegistryDiff() - - attribute_to_object_type_str = { - "entities": "entity", - "feature_views": "feature view", - "feature_tables": "feature table", - "on_demand_feature_views": "on demand feature view", - "request_feature_views": "request feature view", - "feature_services": "feature service", - } - - for object_type in [ - "entities", - "feature_views", - "feature_tables", - "on_demand_feature_views", - "request_feature_views", - "feature_services", - ]: - ( - objects_to_keep, - objects_to_delete, - objects_to_add, - ) = tag_proto_objects_for_keep_delete_add( - getattr(current_registry, object_type), - getattr(new_registry, object_type), - ) - - for e in objects_to_add: - diff.add_fco_diff( - FcoDiff( - e.spec.name, - attribute_to_object_type_str[object_type], - None, - e, - [], - TransitionType.CREATE, - ) - ) - for e in objects_to_delete: - diff.add_fco_diff( - FcoDiff( - e.spec.name, - attribute_to_object_type_str[object_type], - e, - None, - [], - TransitionType.DELETE, - ) - ) - for e in objects_to_keep: - current_obj_proto = [ - _e - for _e in getattr(current_registry, object_type) - if _e.spec.name == e.spec.name - ][0] - diff.add_fco_diff( - diff_between( - current_obj_proto, e, attribute_to_object_type_str[object_type] - ) - ) - - return diff - def _initialize_registry(self): """Explicitly initializes the registry with an empty proto if it doesn't exist.""" try: diff --git a/sdk/python/feast/repo_contents.py b/sdk/python/feast/repo_contents.py new file mode 100644 index 00000000000..9190af11eec --- /dev/null +++ b/sdk/python/feast/repo_contents.py @@ -0,0 +1,50 @@ +# Copyright 2022 The Feast Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import NamedTuple, Set + +from feast.entity import Entity +from feast.feature_service import FeatureService +from feast.feature_view import FeatureView +from feast.on_demand_feature_view import OnDemandFeatureView +from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto +from feast.request_feature_view import RequestFeatureView + + +class RepoContents(NamedTuple): + """ + Represents the objects in a Feast feature repo. + """ + + feature_views: Set[FeatureView] + on_demand_feature_views: Set[OnDemandFeatureView] + request_feature_views: Set[RequestFeatureView] + entities: Set[Entity] + feature_services: Set[FeatureService] + + def to_registry_proto(self) -> RegistryProto: + registry_proto = RegistryProto() + registry_proto.entities.extend([e.to_proto() for e in self.entities]) + registry_proto.feature_views.extend( + [fv.to_proto() for fv in self.feature_views] + ) + registry_proto.on_demand_feature_views.extend( + [fv.to_proto() for fv in self.on_demand_feature_views] + ) + registry_proto.request_feature_views.extend( + [fv.to_proto() for fv in self.request_feature_views] + ) + registry_proto.feature_services.extend( + [fs.to_proto() for fs in self.feature_services] + ) + return registry_proto diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index 0638ca589a8..17d0530d4ec 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -6,21 +6,25 @@ import sys from importlib.abc import Loader from pathlib import Path -from typing import List, Set, Union, cast +from typing import List, Set, Union import click from click.exceptions import BadParameter -from feast.base_feature_view import BaseFeatureView -from feast.diff.FcoDiff import TransitionType, tag_objects_for_keep_delete_add +from feast.diff.FcoDiff import ( + FEAST_OBJECT_TYPES, + extract_objects_for_keep_delete_update_add, +) +from feast.diff.property_diff import TransitionType from feast.entity import Entity from feast.feature_service import FeatureService -from feast.feature_store import FeatureStore, RepoContents +from feast.feature_store import FeatureStore from feast.feature_view import DUMMY_ENTITY, DUMMY_ENTITY_NAME, FeatureView from feast.names import adjectives, animals from feast.on_demand_feature_view import OnDemandFeatureView -from feast.registry import Registry +from feast.registry import FeastObjectType, Registry from feast.repo_config import RepoConfig +from feast.repo_contents import RepoContents from feast.request_feature_view import RequestFeatureView from feast.usage import log_exceptions_and_usage @@ -160,81 +164,41 @@ def _prepare_registry_and_repo(repo_config, repo_path): def extract_objects_for_apply_delete(project, registry, repo): - ( - entities_to_keep, - entities_to_delete, - entities_to_add, - ) = tag_objects_for_keep_delete_add( - set(registry.list_entities(project=project)), repo.entities - ) # TODO(achals): This code path should be refactored to handle added & kept entities separately. - entities_to_keep = set(entities_to_keep).union(entities_to_add) - views = tag_objects_for_keep_delete_add( - set(registry.list_feature_views(project=project)), repo.feature_views - ) - views_to_keep, views_to_delete, views_to_add = ( - cast(Set[FeatureView], views[0]), - cast(Set[FeatureView], views[1]), - cast(Set[FeatureView], views[2]), - ) - request_views = tag_objects_for_keep_delete_add( - set(registry.list_request_feature_views(project=project)), - repo.request_feature_views, - ) - request_views_to_keep: Set[RequestFeatureView] - request_views_to_delete: Set[RequestFeatureView] - request_views_to_add: Set[RequestFeatureView] - request_views_to_keep, request_views_to_delete, request_views_to_add = ( - cast(Set[RequestFeatureView], request_views[0]), - cast(Set[RequestFeatureView], request_views[1]), - cast(Set[RequestFeatureView], request_views[2]), - ) - base_views_to_keep: Set[Union[RequestFeatureView, FeatureView]] = { - *views_to_keep, - *views_to_add, - *request_views_to_keep, - *request_views_to_add, - } - base_views_to_delete: Set[Union[RequestFeatureView, FeatureView]] = { - *views_to_delete, - *request_views_to_delete, - } - odfvs = tag_objects_for_keep_delete_add( - set(registry.list_on_demand_feature_views(project=project)), - repo.on_demand_feature_views, - ) - odfvs_to_keep, odfvs_to_delete, odfvs_to_add = ( - cast(Set[OnDemandFeatureView], odfvs[0]), - cast(Set[OnDemandFeatureView], odfvs[1]), - cast(Set[OnDemandFeatureView], odfvs[2]), - ) - odfvs_to_keep = odfvs_to_keep.union(odfvs_to_add) ( - services_to_keep, - services_to_delete, - services_to_add, - ) = tag_objects_for_keep_delete_add( - set(registry.list_feature_services(project=project)), repo.feature_services - ) - services_to_keep = services_to_keep.union(services_to_add) - sys.dont_write_bytecode = False - # Apply all changes to the registry and infrastructure. + _, + objs_to_delete, + objs_to_update, + objs_to_add, + ) = extract_objects_for_keep_delete_update_add(registry, project, repo) + all_to_apply: List[ - Union[Entity, BaseFeatureView, FeatureService, OnDemandFeatureView] + Union[ + Entity, FeatureView, RequestFeatureView, OnDemandFeatureView, FeatureService + ] ] = [] - all_to_apply.extend(entities_to_keep) - all_to_apply.extend(base_views_to_keep) - all_to_apply.extend(services_to_keep) - all_to_apply.extend(odfvs_to_keep) + for object_type in FEAST_OBJECT_TYPES: + to_apply = set(objs_to_add[object_type]).union(objs_to_update[object_type]) + all_to_apply.extend(to_apply) + all_to_delete: List[ - Union[Entity, BaseFeatureView, FeatureService, OnDemandFeatureView] + Union[ + Entity, FeatureView, RequestFeatureView, OnDemandFeatureView, FeatureService + ] ] = [] - all_to_delete.extend(entities_to_delete) - all_to_delete.extend(base_views_to_delete) - all_to_delete.extend(services_to_delete) - all_to_delete.extend(odfvs_to_delete) + for object_type in FEAST_OBJECT_TYPES: + all_to_delete.extend(objs_to_delete[object_type]) - return all_to_apply, all_to_delete, views_to_delete, views_to_keep + return ( + all_to_apply, + all_to_delete, + set( + objs_to_add[FeastObjectType.FEATURE_VIEW].union( + objs_to_update[FeastObjectType.FEATURE_VIEW] + ) + ), + objs_to_delete[FeastObjectType.FEATURE_VIEW], + ) def apply_total_with_repo_instance( @@ -250,6 +214,8 @@ def apply_total_with_repo_instance( for data_source in data_sources: data_source.validate(store.config) + registry_diff, _ = store.plan(repo) + # For each object in the registry, determine whether it should be kept or deleted. ( all_to_apply, @@ -258,9 +224,9 @@ def apply_total_with_repo_instance( views_to_keep, ) = extract_objects_for_apply_delete(project, registry, repo) - diff = store.apply(all_to_apply, objects_to_delete=all_to_delete, partial=False) + store.apply(all_to_apply, objects_to_delete=all_to_delete, partial=False) - log_cli_output(diff, views_to_delete, views_to_keep) + log_cli_output(registry_diff, views_to_delete, views_to_keep) @log_exceptions_and_usage diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index 63ee4fe7bce..f6f2b5d2bc6 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -12,10 +12,11 @@ import pandas as pd import yaml -from feast import FeatureStore, FeatureView, RepoConfig, driver_test_data +from feast import FeatureStore, FeatureView, driver_test_data from feast.constants import FULL_REPO_CONFIGS_MODULE_ENV_NAME from feast.data_source import DataSource from feast.errors import FeastModuleImportError +from feast.repo_config import RegistryConfig, RepoConfig from tests.integration.feature_repos.integration_test_repo_config import ( IntegrationTestRepoConfig, ) @@ -286,7 +287,9 @@ def construct_test_environment( else: # Note: even if it's a local feature server, the repo config does not have this configured feature_server = None - registry = str(Path(repo_dir_name) / "registry.db") + registry = RegistryConfig( + path=str(Path(repo_dir_name) / "registry.db"), cache_ttl_seconds=1, + ) config = RepoConfig( registry=registry, diff --git a/sdk/python/tests/unit/diff/test_fco_diff.py b/sdk/python/tests/unit/diff/test_fco_diff.py index 802a6438c3c..fa3c84d0350 100644 --- a/sdk/python/tests/unit/diff/test_fco_diff.py +++ b/sdk/python/tests/unit/diff/test_fco_diff.py @@ -1,4 +1,8 @@ -from feast.diff.FcoDiff import diff_between, tag_proto_objects_for_keep_delete_add +from feast.diff.FcoDiff import ( + diff_registry_objects, + tag_objects_for_keep_delete_update_add, + tag_proto_objects_for_keep_delete_add, +) from feast.feature_view import FeatureView from tests.utils.data_source_utils import prep_file_source @@ -45,29 +49,75 @@ def test_tag_proto_objects_for_keep_delete_add(simple_dataset_1): assert to_add in add -def test_diff_between_feature_views(simple_dataset_1): +def test_tag_objects_for_keep_delete_update_add(simple_dataset_1): with prep_file_source( df=simple_dataset_1, event_timestamp_column="ts_1" ) as file_source: + to_delete = FeatureView( + name="to_delete", entities=["id"], batch_source=file_source, ttl=None, + ) + unchanged_fv = FeatureView( + name="fv1", entities=["id"], batch_source=file_source, ttl=None, + ) pre_changed = FeatureView( name="fv2", entities=["id"], batch_source=file_source, ttl=None, tags={"when": "before"}, - ).to_proto() + ) post_changed = FeatureView( name="fv2", entities=["id"], batch_source=file_source, ttl=None, tags={"when": "after"}, - ).to_proto() + ) + to_add = FeatureView( + name="to_add", entities=["id"], batch_source=file_source, ttl=None, + ) + + keep, delete, update, add = tag_objects_for_keep_delete_update_add( + [unchanged_fv, pre_changed, to_delete], [unchanged_fv, post_changed, to_add] + ) + + assert len(list(keep)) == 2 + assert unchanged_fv in keep + assert pre_changed in keep + assert post_changed not in keep + assert len(list(delete)) == 1 + assert to_delete in delete + assert len(list(update)) == 2 + assert unchanged_fv in update + assert post_changed in update + assert pre_changed not in update + assert len(list(add)) == 1 + assert to_add in add + + +def test_diff_registry_objects_feature_views(simple_dataset_1): + with prep_file_source( + df=simple_dataset_1, event_timestamp_column="ts_1" + ) as file_source: + pre_changed = FeatureView( + name="fv2", + entities=["id"], + batch_source=file_source, + ttl=None, + tags={"when": "before"}, + ) + post_changed = FeatureView( + name="fv2", + entities=["id"], + batch_source=file_source, + ttl=None, + tags={"when": "after"}, + ) - fco_diffs = diff_between(pre_changed, pre_changed, "feature view") + fco_diffs = diff_registry_objects(pre_changed, pre_changed, "feature view") assert len(fco_diffs.fco_property_diffs) == 0 - fco_diffs = diff_between(pre_changed, post_changed, "feature view") + fco_diffs = diff_registry_objects(pre_changed, post_changed, "feature view") assert len(fco_diffs.fco_property_diffs) == 1 assert fco_diffs.fco_property_diffs[0].property_name == "tags" From b3174c9b671eac7d48b2f784f40373a953ab0fa2 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Wed, 26 Jan 2022 07:41:02 +0200 Subject: [PATCH 31/85] Tests for transformation service integration in java feature server (#2236) * IT for transformation service interop Signed-off-by: pyalex * remove unnecessary concat Signed-off-by: pyalex --- java/serving/pom.xml | 10 ++ .../serving/config/ApplicationProperties.java | 70 ++++++++---- .../feast/serving/config/RegistryConfig.java | 4 +- .../config/ServingServiceConfigV2.java | 6 +- .../grpc/OnlineServingGrpcServiceV2.java | 24 +++- .../feast/serving/modules/ServerModule.java | 19 ---- .../service/OnlineServingServiceV2.java | 12 +- .../service/OnlineTransformationService.java | 103 +++++------------- .../service/TransformationService.java | 24 +--- .../feast/serving/it/ServingEnvironment.java | 69 +++++++----- .../test/java/feast/serving/it/TestUtils.java | 16 +++ .../serving/it/TransformationServiceIT.java | 103 ++++++++++++++++++ .../docker-compose-redis-it.yml | 4 +- .../docker-compose/feast10/Dockerfile | 7 +- .../docker-compose/feast10/definitions.py | 97 +++++++++++++++++ .../docker-compose/feast10/feature_store.yaml | 1 + .../docker-compose/feast10/materialize.py | 84 +++----------- .../docker-compose/feast10/registry.db | Bin 14374 -> 14203 bytes .../docker-compose/feast10/requirements.txt | 2 - 19 files changed, 405 insertions(+), 250 deletions(-) delete mode 100644 java/serving/src/main/java/feast/serving/modules/ServerModule.java create mode 100644 java/serving/src/test/java/feast/serving/it/TransformationServiceIT.java create mode 100644 java/serving/src/test/resources/docker-compose/feast10/definitions.py diff --git a/java/serving/pom.xml b/java/serving/pom.xml index b6f787ad305..8c81dfa7e72 100644 --- a/java/serving/pom.xml +++ b/java/serving/pom.xml @@ -92,6 +92,11 @@ ${project.version} + + dev.feast + feast-common + ${project.version} + dev.feast @@ -141,6 +146,11 @@ grpc-stub ${grpc.version} + + io.grpc + grpc-netty-shaded + ${grpc.version} + com.google.protobuf diff --git a/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java b/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java index 2e2448ca902..791c871e59b 100644 --- a/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java +++ b/java/serving/src/main/java/feast/serving/config/ApplicationProperties.java @@ -147,38 +147,46 @@ public TracingProperties getTracing() { public LoggingProperties getLogging() { return logging; } - } - private FeastProperties feast; + private String gcpProject; - public void setFeast(FeastProperties feast) { - this.feast = feast; - } + public String getGcpProject() { + return gcpProject; + } - public FeastProperties getFeast() { - return feast; - } + public void setGcpProject(String gcpProject) { + this.gcpProject = gcpProject; + } - private String gcpProject; + public void setAwsRegion(String awsRegion) { + this.awsRegion = awsRegion; + } - public String getGcpProject() { - return gcpProject; - } + private String awsRegion; - public void setAwsRegion(String awsRegion) { - this.awsRegion = awsRegion; - } + public String getAwsRegion() { + return awsRegion; + } + + private String transformationServiceEndpoint; - private String awsRegion; + public String getTransformationServiceEndpoint() { + return transformationServiceEndpoint; + } - public String getAwsRegion() { - return awsRegion; + public void setTransformationServiceEndpoint(String transformationServiceEndpoint) { + this.transformationServiceEndpoint = transformationServiceEndpoint; + } } - private String transformationServiceEndpoint; + private FeastProperties feast; - public String getTransformationServiceEndpoint() { - return transformationServiceEndpoint; + public void setFeast(FeastProperties feast) { + this.feast = feast; + } + + public FeastProperties getFeast() { + return feast; } /** Store configuration class for database that this Feast Serving uses. */ @@ -263,6 +271,10 @@ public static class Server { public int getPort() { return port; } + + public void setPort(int port) { + this.port = port; + } } public static class GrpcServer { @@ -271,6 +283,10 @@ public static class GrpcServer { public Server getServer() { return server; } + + public void setServer(Server server) { + this.server = server; + } } public static class RestServer { @@ -279,6 +295,10 @@ public static class RestServer { public Server getServer() { return server; } + + public void setServer(Server server) { + this.server = server; + } } private GrpcServer grpc; @@ -288,10 +308,18 @@ public GrpcServer getGrpc() { return grpc; } + public void setGrpc(GrpcServer grpc) { + this.grpc = grpc; + } + public RestServer getRest() { return rest; } + public void setRest(RestServer rest) { + this.rest = rest; + } + public enum StoreType { REDIS, REDIS_CLUSTER; diff --git a/java/serving/src/main/java/feast/serving/config/RegistryConfig.java b/java/serving/src/main/java/feast/serving/config/RegistryConfig.java index d23ab374d85..3e7cbe3f1f9 100644 --- a/java/serving/src/main/java/feast/serving/config/RegistryConfig.java +++ b/java/serving/src/main/java/feast/serving/config/RegistryConfig.java @@ -31,7 +31,7 @@ public class RegistryConfig extends AbstractModule { @Provides Storage googleStorage(ApplicationProperties applicationProperties) { return StorageOptions.newBuilder() - .setProjectId(applicationProperties.getGcpProject()) + .setProjectId(applicationProperties.getFeast().getGcpProject()) .build() .getService(); } @@ -39,7 +39,7 @@ Storage googleStorage(ApplicationProperties applicationProperties) { @Provides public AmazonS3 awsStorage(ApplicationProperties applicationProperties) { return AmazonS3ClientBuilder.standard() - .withRegion(applicationProperties.getAwsRegion()) + .withRegion(applicationProperties.getFeast().getAwsRegion()) .build(); } diff --git a/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java b/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java index d3fe1ba116f..4ea0692ccd5 100644 --- a/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java +++ b/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java @@ -68,10 +68,10 @@ public ServingServiceV2 registryBasedServingServiceV2( log.info("Working Directory = " + System.getProperty("user.dir")); - final String transformationServiceEndpoint = - applicationProperties.getTransformationServiceEndpoint(); final OnlineTransformationService onlineTransformationService = - new OnlineTransformationService(transformationServiceEndpoint, registryRepository); + new OnlineTransformationService( + applicationProperties.getFeast().getTransformationServiceEndpoint(), + registryRepository); servingService = new OnlineServingServiceV2( diff --git a/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java b/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java index 8d82a1f182b..fe024404f33 100644 --- a/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java +++ b/java/serving/src/main/java/feast/serving/grpc/OnlineServingGrpcServiceV2.java @@ -19,11 +19,15 @@ import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingServiceGrpc; import feast.serving.service.ServingServiceV2; +import io.grpc.Status; import io.grpc.stub.StreamObserver; import javax.inject.Inject; +import org.slf4j.Logger; public class OnlineServingGrpcServiceV2 extends ServingServiceGrpc.ServingServiceImplBase { private final ServingServiceV2 servingServiceV2; + private static final Logger log = + org.slf4j.LoggerFactory.getLogger(OnlineServingGrpcServiceV2.class); @Inject OnlineServingGrpcServiceV2(ServingServiceV2 servingServiceV2) { @@ -34,15 +38,27 @@ public class OnlineServingGrpcServiceV2 extends ServingServiceGrpc.ServingServic public void getFeastServingInfo( ServingAPIProto.GetFeastServingInfoRequest request, StreamObserver responseObserver) { - responseObserver.onNext(this.servingServiceV2.getFeastServingInfo(request)); - responseObserver.onCompleted(); + try { + responseObserver.onNext(this.servingServiceV2.getFeastServingInfo(request)); + responseObserver.onCompleted(); + } catch (RuntimeException e) { + log.warn("Failed to get Serving Info", e); + responseObserver.onError( + Status.INTERNAL.withDescription(e.getMessage()).withCause(e).asRuntimeException()); + } } @Override public void getOnlineFeatures( ServingAPIProto.GetOnlineFeaturesRequest request, StreamObserver responseObserver) { - responseObserver.onNext(this.servingServiceV2.getOnlineFeatures(request)); - responseObserver.onCompleted(); + try { + responseObserver.onNext(this.servingServiceV2.getOnlineFeatures(request)); + responseObserver.onCompleted(); + } catch (RuntimeException e) { + log.warn("Failed to get Online Features", e); + responseObserver.onError( + Status.INTERNAL.withDescription(e.getMessage()).withCause(e).asRuntimeException()); + } } } diff --git a/java/serving/src/main/java/feast/serving/modules/ServerModule.java b/java/serving/src/main/java/feast/serving/modules/ServerModule.java deleted file mode 100644 index 29d1f574321..00000000000 --- a/java/serving/src/main/java/feast/serving/modules/ServerModule.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2021 The Feast Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feast.serving.modules; - -public class ServerModule {} diff --git a/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java index 3e62d4db75c..f4e330fbf73 100644 --- a/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java +++ b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java @@ -91,9 +91,8 @@ public ServingAPIProto.GetOnlineFeaturesResponse getOnlineFeatures( // Pair from extractRequestDataFeatureNamesAndOnDemandFeatureInputs. // Currently, we can retrieve context variables directly from GetOnlineFeaturesRequest. List onDemandFeatureInputs = - this.onlineTransformationService - .extractRequestDataFeatureNamesAndOnDemandFeatureInputs(onDemandFeatureReferences) - .getRight(); + this.onlineTransformationService.extractOnDemandFeaturesDependencies( + onDemandFeatureReferences); // Add on demand feature inputs to list of feature references to retrieve. for (FeatureReferenceV2 onDemandFeatureInput : onDemandFeatureInputs) { @@ -284,7 +283,12 @@ private void populateOnDemandFeatures( valueList.add(features.get(rowIdx).get(featureIdx).getFeatureValue(valueType)); } - onDemandContext.add(Pair.of(Feature.getFeatureReference(featureReference), valueList)); + onDemandContext.add( + Pair.of( + String.format( + "%s__%s", + featureReference.getFeatureViewName(), featureReference.getFeatureName()), + valueList)); } // Serialize the augmented values. ValueType transformationInput = diff --git a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java index ea404ff7a5e..d1df763f6ed 100644 --- a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java +++ b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java @@ -19,11 +19,7 @@ import com.google.common.collect.Lists; import com.google.protobuf.ByteString; import com.google.protobuf.Timestamp; -import feast.common.models.Feature; -import feast.proto.core.DataSourceProto; -import feast.proto.core.FeatureProto; -import feast.proto.core.FeatureViewProto; -import feast.proto.core.OnDemandFeatureViewProto; +import feast.proto.core.*; import feast.proto.serving.ServingAPIProto; import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesRequest; import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesResponse; @@ -48,7 +44,6 @@ import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.types.pojo.Schema; import org.apache.arrow.vector.util.ByteArrayReadableSeekableByteChannel; -import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; import org.slf4j.Logger; @@ -77,17 +72,18 @@ public OnlineTransformationService( @Override public TransformFeaturesResponse transformFeatures( TransformFeaturesRequest transformFeaturesRequest) { + if (this.stub == null) { + throw new RuntimeException( + "Transformation service endpoint must be configured to enable this functionality."); + } return this.stub.transformFeatures(transformFeaturesRequest); } /** {@inheritDoc} */ @Override - public Pair, List> - extractRequestDataFeatureNamesAndOnDemandFeatureInputs( - List onDemandFeatureReferences) { - Set requestDataFeatureNames = new HashSet(); - List onDemandFeatureInputs = - new ArrayList(); + public List extractOnDemandFeaturesDependencies( + List onDemandFeatureReferences) { + List onDemandFeatureInputs = new ArrayList<>(); for (ServingAPIProto.FeatureReferenceV2 featureReference : onDemandFeatureReferences) { OnDemandFeatureViewProto.OnDemandFeatureViewSpec onDemandFeatureViewSpec = this.registryRepository.getOnDemandFeatureViewSpec(featureReference); @@ -98,11 +94,20 @@ public TransformFeaturesResponse transformFeatures( OnDemandFeatureViewProto.OnDemandInput.InputCase inputCase = input.getInputCase(); switch (inputCase) { case REQUEST_DATA_SOURCE: - DataSourceProto.DataSource requestDataSource = input.getRequestDataSource(); - DataSourceProto.DataSource.RequestDataOptions requestDataOptions = - requestDataSource.getRequestDataOptions(); - Set requestDataNames = requestDataOptions.getSchemaMap().keySet(); - requestDataFeatureNames.addAll(requestDataNames); + // Do nothing. The value should be provided as dedicated request parameter + break; + case FEATURE_VIEW_PROJECTION: + FeatureReferenceProto.FeatureViewProjection projection = + input.getFeatureViewProjection(); + for (FeatureProto.FeatureSpecV2 featureSpec : projection.getFeatureColumnsList()) { + String featureName = featureSpec.getName(); + ServingAPIProto.FeatureReferenceV2 onDemandFeatureInput = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureViewName(projection.getFeatureViewName()) + .setFeatureName(featureName) + .build(); + onDemandFeatureInputs.add(onDemandFeatureInput); + } break; case FEATURE_VIEW: FeatureViewProto.FeatureView featureView = input.getFeatureView(); @@ -126,61 +131,7 @@ public TransformFeaturesResponse transformFeatures( } } } - Pair, List> pair = - new ImmutablePair, List>( - requestDataFeatureNames, onDemandFeatureInputs); - return pair; - } - - /** {@inheritDoc} */ - public Pair< - List, - Map>> - separateEntityRows( - Set requestDataFeatureNames, ServingAPIProto.GetOnlineFeaturesRequestV2 request) { - // Separate entity rows into entity data and request feature data. - List entityRows = - new ArrayList(); - Map> requestDataFeatures = - new HashMap>(); - - for (ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow entityRow : - request.getEntityRowsList()) { - Map fieldsMap = new HashMap(); - - for (Map.Entry entry : entityRow.getFieldsMap().entrySet()) { - String key = entry.getKey(); - ValueProto.Value value = entry.getValue(); - - if (requestDataFeatureNames.contains(key)) { - if (!requestDataFeatures.containsKey(key)) { - requestDataFeatures.put(key, new ArrayList()); - } - requestDataFeatures.get(key).add(value); - } else { - fieldsMap.put(key, value); - } - } - - // Construct new entity row containing the extracted entity data, if necessary. - if (!fieldsMap.isEmpty()) { - ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow newEntityRow = - ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow.newBuilder() - .setTimestamp(entityRow.getTimestamp()) - .putAllFields(fieldsMap) - .build(); - entityRows.add(newEntityRow); - } - } - - Pair< - List, - Map>> - pair = - new ImmutablePair< - List, - Map>>(entityRows, requestDataFeatures); - return pair; + return onDemandFeatureInputs; } /** {@inheritDoc} */ @@ -208,7 +159,7 @@ public void processTransformFeaturesResponse( for (Field field : responseFields) { String columnName = field.getName(); - String fullFeatureName = onDemandFeatureViewName + ":" + columnName; + String fullFeatureName = columnName.replace("__", ":"); ArrowType columnType = field.getType(); // The response will contain all features for the specified ODFV, so we @@ -306,7 +257,7 @@ public ValueType serializeValuesIntoArrowIPC(List> columnEntry : values) { // The Python FTS does not expect full feature names, so we extract the feature name. - String columnName = Feature.getFeatureName(columnEntry.getKey()); + String columnName = columnEntry.getKey(); List columnValues = columnEntry.getValue(); FieldVector column; @@ -332,14 +283,14 @@ public ValueType serializeValuesIntoArrowIPC(List, List> - extractRequestDataFeatureNamesAndOnDemandFeatureInputs( - List onDemandFeatureReferences); - - /** - * Separate the entity rows of a request into entity data and request feature data. - * - * @param requestDataFeatureNames set of feature names for the request data - * @param request the GetOnlineFeaturesRequestV2 containing the entity rows - * @return a pair containing the set of request data feature names and list of on demand feature - * inputs - */ - Pair, Map>> - separateEntityRows(Set requestDataFeatureNames, GetOnlineFeaturesRequestV2 request); + List extractOnDemandFeaturesDependencies( + List onDemandFeatureReferences); /** * Process a response from the feature transformation server by augmenting the given lists of diff --git a/java/serving/src/test/java/feast/serving/it/ServingEnvironment.java b/java/serving/src/test/java/feast/serving/it/ServingEnvironment.java index 0c622d7c421..c00dc7b1f31 100644 --- a/java/serving/src/test/java/feast/serving/it/ServingEnvironment.java +++ b/java/serving/src/test/java/feast/serving/it/ServingEnvironment.java @@ -16,23 +16,23 @@ */ package feast.serving.it; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; + import com.google.inject.*; import com.google.inject.Module; import com.google.inject.util.Modules; import feast.proto.serving.ServingServiceGrpc; -import feast.serving.config.ApplicationProperties; -import feast.serving.config.InstrumentationConfig; -import feast.serving.config.RegistryConfig; -import feast.serving.config.ServingServiceConfigV2; +import feast.serving.config.*; import feast.serving.grpc.OnlineServingGrpcServiceV2; import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; import io.grpc.Server; -import io.grpc.inprocess.InProcessChannelBuilder; -import io.grpc.inprocess.InProcessServerBuilder; -import io.grpc.protobuf.services.ProtoReflectionService; import io.grpc.util.MutableHandlerRegistry; import java.io.File; -import java.time.Duration; +import java.io.IOException; +import java.net.ServerSocket; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; @@ -53,17 +53,16 @@ abstract class ServingEnvironment { Server server; MutableHandlerRegistry serviceRegistry; + static int serverPort = getFreePort(); + @BeforeAll static void globalSetup() { environment = new DockerComposeContainer( new File("src/test/resources/docker-compose/docker-compose-redis-it.yml")) .withExposedService("redis", 6379) - .withOptions() - .waitingFor( - "materialize", - Wait.forLogMessage(".*Materialization finished.*\\n", 1) - .withStartupTimeout(Duration.ofMinutes(5))); + .withExposedService("feast", 8080) + .waitingFor("feast", Wait.forListeningPort()); environment.start(); } @@ -74,7 +73,6 @@ static void globalTeardown() { @BeforeEach public void envSetUp() throws Exception { - AbstractModule appPropertiesModule = new AbstractModule() { @Override @@ -85,9 +83,15 @@ protected void configure() { @Provides ApplicationProperties applicationProperties() { final ApplicationProperties p = new ApplicationProperties(); - p.setAwsRegion("us-east-1"); + + ApplicationProperties.GrpcServer grpcServer = new ApplicationProperties.GrpcServer(); + ApplicationProperties.Server server = new ApplicationProperties.Server(); + server.setPort(serverPort); + grpcServer.setServer(server); + p.setGrpc(grpcServer); final ApplicationProperties.FeastProperties feastProperties = createFeastProperties(); + feastProperties.setAwsRegion("us-east-1"); p.setFeast(feastProperties); final ApplicationProperties.TracingProperties tracingProperties = @@ -112,22 +116,13 @@ ApplicationProperties applicationProperties() { new ServingServiceConfigV2(), registryConfig, new InstrumentationConfig(), - appPropertiesModule); + appPropertiesModule, + new ServerModule()); - OnlineServingGrpcServiceV2 onlineServingGrpcServiceV2 = - injector.getInstance(OnlineServingGrpcServiceV2.class); - - serverName = InProcessServerBuilder.generateName(); - - server = - InProcessServerBuilder.forName(serverName) - .fallbackHandlerRegistry(serviceRegistry) - .addService(onlineServingGrpcServiceV2) - .addService(ProtoReflectionService.newInstance()) - .build(); + server = injector.getInstance(Server.class); server.start(); - channel = InProcessChannelBuilder.forName(serverName).usePlaintext().directExecutor().build(); + channel = ManagedChannelBuilder.forAddress("localhost", serverPort).usePlaintext().build(); servingStub = ServingServiceGrpc.newBlockingStub(channel) @@ -149,6 +144,10 @@ public void envTeardown() throws Exception { channel.shutdownNow(); server.shutdownNow(); } + + server = null; + channel = null; + servingStub = null; } abstract ApplicationProperties.FeastProperties createFeastProperties(); @@ -156,4 +155,18 @@ public void envTeardown() throws Exception { AbstractModule registryConfig() { return null; } + + private static int getFreePort() { + ServerSocket serverSocket; + try { + serverSocket = new ServerSocket(0); + } catch (IOException e) { + throw new RuntimeException("Couldn't allocate port"); + } + + assertThat(serverSocket, is(notNullValue())); + assertThat(serverSocket.getLocalPort(), greaterThan(0)); + + return serverSocket.getLocalPort(); + } } diff --git a/java/serving/src/test/java/feast/serving/it/TestUtils.java b/java/serving/src/test/java/feast/serving/it/TestUtils.java index 71d15e4a89d..867fa4afb06 100644 --- a/java/serving/src/test/java/feast/serving/it/TestUtils.java +++ b/java/serving/src/test/java/feast/serving/it/TestUtils.java @@ -38,17 +38,33 @@ public static ServingServiceGrpc.ServingServiceBlockingStub getServingServiceStu public static GetOnlineFeaturesRequest createOnlineFeatureRequest( List featureReferences, Map entityRows) { + return createOnlineFeatureRequest(featureReferences, entityRows, new HashMap<>()); + } + + public static GetOnlineFeaturesRequest createOnlineFeatureRequest( + List featureReferences, + Map entityRows, + Map requestContext) { return GetOnlineFeaturesRequest.newBuilder() .setFeatures(ServingAPIProto.FeatureList.newBuilder().addAllVal(featureReferences)) .putAllEntities(entityRows) + .putAllRequestContext(requestContext) .build(); } public static GetOnlineFeaturesRequest createOnlineFeatureRequest( String featureService, Map entityRows) { + return createOnlineFeatureRequest(featureService, entityRows, new HashMap<>()); + } + + public static GetOnlineFeaturesRequest createOnlineFeatureRequest( + String featureService, + Map entityRows, + Map requestContext) { return GetOnlineFeaturesRequest.newBuilder() .setFeatureService(featureService) .putAllEntities(entityRows) + .putAllRequestContext(requestContext) .build(); } diff --git a/java/serving/src/test/java/feast/serving/it/TransformationServiceIT.java b/java/serving/src/test/java/feast/serving/it/TransformationServiceIT.java new file mode 100644 index 00000000000..102d8515285 --- /dev/null +++ b/java/serving/src/test/java/feast/serving/it/TransformationServiceIT.java @@ -0,0 +1,103 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2022 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.serving.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import feast.proto.serving.ServingAPIProto; +import feast.proto.types.ValueProto; +import feast.serving.config.ApplicationProperties; +import feast.serving.util.DataGenerator; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class TransformationServiceIT extends ServingEnvironment { + @Override + ApplicationProperties.FeastProperties createFeastProperties() { + ApplicationProperties.FeastProperties feastProperties = + TestUtils.createBasicFeastProperties( + environment.getServiceHost("redis", 6379), environment.getServicePort("redis", 6379)); + feastProperties.setTransformationServiceEndpoint( + String.format( + "%s:%d", + environment.getServiceHost("feast", 8080), environment.getServicePort("feast", 8080))); + return feastProperties; + } + + private ServingAPIProto.GetOnlineFeaturesRequest buildOnlineRequest( + int driverId, boolean transformedFeaturesOnly) { + Map entityRows = + ImmutableMap.of( + "driver_id", + ValueProto.RepeatedValue.newBuilder() + .addVal(DataGenerator.createInt64Value(driverId)) + .build()); + + Map requestContext = + ImmutableMap.of( + "val_to_add", + ValueProto.RepeatedValue.newBuilder().addVal(DataGenerator.createInt64Value(3)).build(), + "val_to_add_2", + ValueProto.RepeatedValue.newBuilder() + .addVal(DataGenerator.createInt64Value(5)) + .build()); + + List featureReferences = + Lists.newArrayList( + "transformed_conv_rate:conv_rate_plus_val1", + "transformed_conv_rate:conv_rate_plus_val2"); + + if (!transformedFeaturesOnly) { + featureReferences.add("driver_hourly_stats:conv_rate"); + } + + return TestUtils.createOnlineFeatureRequest(featureReferences, entityRows, requestContext); + } + + @Test + public void shouldCalculateOnDemandFeatures() { + ServingAPIProto.GetOnlineFeaturesResponse featureResponse = + servingStub.getOnlineFeatures(buildOnlineRequest(1005, false)); + + for (int featureIdx : List.of(0, 1, 2)) { + assertEquals( + List.of(ServingAPIProto.FieldStatus.PRESENT), + featureResponse.getResults(featureIdx).getStatusesList()); + } + + // conv_rate + assertEquals(0.5, featureResponse.getResults(0).getValues(0).getDoubleVal(), 0.0001); + // conv_rate + val_to_add (3.0) + assertEquals(3.5, featureResponse.getResults(1).getValues(0).getDoubleVal(), 0.0001); + // conv_rate + val_to_add_2 (5.0) + assertEquals(5.5, featureResponse.getResults(2).getValues(0).getDoubleVal(), 0.0001); + } + + @Test + public void shouldCorrectlyFetchDependantFeatures() { + ServingAPIProto.GetOnlineFeaturesResponse featureResponse = + servingStub.getOnlineFeatures(buildOnlineRequest(1005, true)); + + // conv_rate + val_to_add (3.0) + assertEquals(3.5, featureResponse.getResults(0).getValues(0).getDoubleVal(), 0.0001); + // conv_rate + val_to_add_2 (5.0) + assertEquals(5.5, featureResponse.getResults(1).getValues(0).getDoubleVal(), 0.0001); + } +} diff --git a/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml b/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml index 22e054f8b11..13835e07d41 100644 --- a/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml +++ b/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml @@ -5,8 +5,10 @@ services: image: redis:6.2 ports: - "6379:6379" - materialize: + feast: build: feast10 + ports: + - "8080:8080" links: - redis diff --git a/java/serving/src/test/resources/docker-compose/feast10/Dockerfile b/java/serving/src/test/resources/docker-compose/feast10/Dockerfile index bde9f11592f..df14bb592b4 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/Dockerfile +++ b/java/serving/src/test/resources/docker-compose/feast10/Dockerfile @@ -5,6 +5,11 @@ WORKDIR /usr/src/ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt +RUN git clone https://github.com/feast-dev/feast.git /root/feast +RUN cd /root/feast/sdk/python && pip install -e '.[redis]' + +WORKDIR /app COPY . . +EXPOSE 8080 -CMD [ "python", "./materialize.py" ] +CMD ["/bin/sh", "-c", "python materialize.py && feast serve_transformations --port 8080"] diff --git a/java/serving/src/test/resources/docker-compose/feast10/definitions.py b/java/serving/src/test/resources/docker-compose/feast10/definitions.py new file mode 100644 index 00000000000..a598fe93919 --- /dev/null +++ b/java/serving/src/test/resources/docker-compose/feast10/definitions.py @@ -0,0 +1,97 @@ +import pandas as pd + +from google.protobuf.duration_pb2 import Duration + +from feast.value_type import ValueType +from feast.feature import Feature +from feast.feature_view import FeatureView +from feast.entity import Entity +from feast.feature_service import FeatureService +from feast.on_demand_feature_view import RequestDataSource, on_demand_feature_view +from feast import FileSource + + +file_path = "driver_stats.parquet" +driver_hourly_stats = FileSource( + path=file_path, + event_timestamp_column="event_timestamp", + created_timestamp_column="created", +) + +# Define an entity for the driver. You can think of entity as a primary key used to +# fetch features. +driver = Entity(name="driver_id", value_type=ValueType.INT64, description="driver id",) + +# Our parquet files contain sample data that includes a driver_id column, timestamps and +# three feature column. Here we define a Feature View that will allow us to serve this +# data to our model online. +driver_hourly_stats_view = FeatureView( + name="driver_hourly_stats", + entities=["driver_id"], + ttl=Duration(seconds=86400 * 7), + features=[ + Feature(name="conv_rate", dtype=ValueType.DOUBLE), + Feature(name="acc_rate", dtype=ValueType.FLOAT), + Feature(name="avg_daily_trips", dtype=ValueType.INT64), + ], + online=True, + batch_source=driver_hourly_stats, + tags={}, +) + + +input_request = RequestDataSource( + name="vals_to_add", + schema={ + "val_to_add": ValueType.INT64, + "val_to_add_2": ValueType.INT64 + } +) + + +@on_demand_feature_view( + inputs={ + 'driver_hourly_stats': driver_hourly_stats_view, + 'vals_to_add': input_request + }, + features=[ + Feature(name='conv_rate_plus_val1', dtype=ValueType.DOUBLE), + Feature(name='conv_rate_plus_val2', dtype=ValueType.DOUBLE) + ] +) +def transformed_conv_rate(features_df: pd.DataFrame) -> pd.DataFrame: + df = pd.DataFrame() + df['conv_rate_plus_val1'] = (features_df['conv_rate'] + features_df['val_to_add']) + df['conv_rate_plus_val2'] = (features_df['conv_rate'] + features_df['val_to_add_2']) + return df + + +generated_data_source = FileSource( + path="benchmark_data.parquet", + event_timestamp_column="event_timestamp", +) + +entity = Entity( + name="entity", + value_type=ValueType.INT64, +) + +benchmark_feature_views = [ + FeatureView( + name=f"feature_view_{i}", + entities=["entity"], + ttl=Duration(seconds=86400), + features=[ + Feature(name=f"feature_{10 * i + j}", dtype=ValueType.INT64) + for j in range(10) + ], + online=True, + batch_source=generated_data_source, + ) + for i in range(25) +] + +benchmark_feature_service = FeatureService( + name=f"benchmark_feature_service", + features=benchmark_feature_views, +) diff --git a/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml b/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml index 7554725004f..102dfdd5f4c 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml +++ b/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml @@ -8,3 +8,4 @@ offline_store: {} flags: alpha_features: true on_demand_transforms: true + python_feature_server: true diff --git a/java/serving/src/test/resources/docker-compose/feast10/materialize.py b/java/serving/src/test/resources/docker-compose/feast10/materialize.py index ca4cc98db26..9aba494169f 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/materialize.py +++ b/java/serving/src/test/resources/docker-compose/feast10/materialize.py @@ -1,12 +1,12 @@ -# This is an example feature definition file - import pandas as pd import numpy as np -from google.protobuf.duration_pb2 import Duration - from datetime import datetime, timedelta -from feast import Entity, Feature, FeatureView, FileSource, ValueType, FeatureService, FeatureStore +from feast import FeatureStore + +from definitions import driver_hourly_stats_view, driver, entity,\ + benchmark_feature_service, benchmark_feature_views, transformed_conv_rate + print("Running materialize.py") @@ -23,42 +23,14 @@ # some of rows are beyond 7 days to test OUTSIDE_MAX_AGE status df["event_timestamp"] = start + pd.Series(np.arange(0, 10)).map(lambda days: timedelta(days=days)) -df.to_parquet("driver_stats.parquet") - -# Read data from parquet files. Parquet is convenient for local development mode. For +# Store data in parquet files. Parquet is convenient for local development mode. For # production, you can use your favorite DWH, such as BigQuery. See Feast documentation # for more info. -file_path = "driver_stats.parquet" -driver_hourly_stats = FileSource( - path=file_path, - event_timestamp_column="event_timestamp", - created_timestamp_column="created", -) - -# Define an entity for the driver. You can think of entity as a primary key used to -# fetch features. -driver = Entity(name="driver_id", value_type=ValueType.INT64, description="driver id",) - -# Our parquet files contain sample data that includes a driver_id column, timestamps and -# three feature column. Here we define a Feature View that will allow us to serve this -# data to our model online. -driver_hourly_stats_view = FeatureView( - name="driver_hourly_stats", - entities=["driver_id"], - ttl=Duration(seconds=86400 * 7), - features=[ - Feature(name="conv_rate", dtype=ValueType.DOUBLE), - Feature(name="acc_rate", dtype=ValueType.FLOAT), - Feature(name="avg_daily_trips", dtype=ValueType.INT64), - ], - online=True, - batch_source=driver_hourly_stats, - tags={}, -) - +df.to_parquet("driver_stats.parquet") # For Benchmarks -# Please read more in Feast RFC-031 (link https://docs.google.com/document/d/12UuvTQnTTCJhdRgy6h10zSbInNGSyEJkIxpOcgOen1I/edit) +# Please read more in Feast RFC-031 +# (link https://docs.google.com/document/d/12UuvTQnTTCJhdRgy6h10zSbInNGSyEJkIxpOcgOen1I/edit) # about this benchmark setup def generate_data(num_rows: int, num_features: int, key_space: int, destination: str) -> pd.DataFrame: features = [f"feature_{i}" for i in range(num_features)] @@ -70,42 +42,16 @@ def generate_data(num_rows: int, num_features: int, key_space: int, destination: df.to_parquet(destination) -generate_data(10**3, 250, 10**3, "benchmark_data.parquet") - -generated_data_source = FileSource( - path="benchmark_data.parquet", - event_timestamp_column="event_timestamp", -) -entity = Entity( - name="entity", - value_type=ValueType.INT64, -) - -benchmark_feature_views = [ - FeatureView( - name=f"feature_view_{i}", - entities=["entity"], - ttl=Duration(seconds=86400), - features=[ - Feature(name=f"feature_{10 * i + j}", dtype=ValueType.INT64) - for j in range(10) - ], - online=True, - batch_source=generated_data_source, - ) - for i in range(25) -] - -benchmark_feature_service = FeatureService( - name=f"benchmark_feature_service", - features=benchmark_feature_views, -) +generate_data(10**3, 250, 10**3, "benchmark_data.parquet") fs = FeatureStore(".") -fs.apply([driver_hourly_stats_view, driver, - entity, benchmark_feature_service, *benchmark_feature_views]) +fs.apply([driver_hourly_stats_view, + transformed_conv_rate, + driver, + entity, benchmark_feature_service, + *benchmark_feature_views]) now = datetime.now() fs.materialize(start, now) diff --git a/java/serving/src/test/resources/docker-compose/feast10/registry.db b/java/serving/src/test/resources/docker-compose/feast10/registry.db index 4590c5800a67e03539018fecc910ffec3012ca6f..746934e3d0a09c348f8046087fd6144d0d237d61 100644 GIT binary patch delta 1468 zcmZ{kU1%Id9Kd()BfachuIpY(OclknLR+}3cjQxjYW(CN4;G)txt+ac+r8aoXYTYc z;p#*69u}--f<;AA#A+YJ)}v4ZV#PuWEwmz`meP`bl05ZFp~SWUXD`|02Clm>Fu&h_ zX8-e_KYP@9Y#l0!p)ahy!9pf8Iyzj)r$>g-PI6WV~EO$xq*qMbnhL=zNn$OPFmlA2Qg*JY_kk6i}?@11~(&O`2 ze0p{>N6#I->(kes;pp+t9{BVZg`aL zpXKP{!(%>u(96-~?@wyki_>s>cm3n%`dd#@`}=fY9zD;QOEVV&Q+a_m_b&%#`9;pm zUb_{T4`1R;jN?V8vEnKkOqK<%OTV>!vqAOhm9by6;*WRaVWNVLfS_SkD}cC$gXPGs zJK?8Q;e7Z&WahLeG#^hwhH3T#f25uaG*@Sp0)>*L5yQ6VG~F>Fc!_{(Hqw2T6LSK4 zL%DEnY;1rCv*Hn9N}Q|;NbD6R1r+KPdW1<4gV+Icz0Nlvfp596sMAlW7iU*bO0JFpDzE@HFql{0$GVGErvgp= zBXzAWu-a@BbssE4mHDz^?Nza>sYJ&_Wm~W-({!vNTidU@Y0WM=HrAVqswDG@iu8hE z8GiL}zf+-J`(~uMfw7nnkBQyvr8{>L`W~HHO`R1y2@JNYOF+>^CDSjt*Wo}5K}-*_ eKs=EeAD^P*{-S;*DJ5d(n}6A8LV!jz zf@9*x-_whj@Eh@QvLdhW=08j-)G)@Gg(}8$u~NmDi)>UeMuLMX#>8_{#h7JWR59i) zH>JjGR^&COEY17@mnAiPdekuH96wczQ4pkxF-1a@8nbzWuoE@HMNE`Zmw;m_Lrjm+ Q8d6k|RnBZSQSuN10M{Lah5!Hn diff --git a/java/serving/src/test/resources/docker-compose/feast10/requirements.txt b/java/serving/src/test/resources/docker-compose/feast10/requirements.txt index 447f126392b..94e4771de2a 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/requirements.txt +++ b/java/serving/src/test/resources/docker-compose/feast10/requirements.txt @@ -1,5 +1,3 @@ -feast[redis]>=0.13,<1 - # for source generation pyarrow==6.0.0 From 396f7294425169d69445d31863c5d34d6f71389f Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 26 Jan 2022 07:48:45 +0000 Subject: [PATCH 32/85] Optimize `_populate_result_rows_from_feature_view` (#2223) * Optimize `_populate_result_rows_from_feature_view` This commit optimizes the fetching of features by only fetching the features for each unique Entity once and then expands the result to the shape of input EntityKeys. Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Initialize the minimum number of EntityKeyProtos per FeatureView Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Refactor for readability Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Update `mypy` and `mypy-protobuf` to allow Enum typing Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Fix newly detected linting errors Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Fix test failure Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Remove accidentally committed typo Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Define Entity type Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Ensure entities are sorted for `itertools.groupby` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Add simple unittest for `_get_unique_entities` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/feature_store.py | 190 +++++++++++++----- .../feature_servers/aws_lambda/__init__.py | 0 .../feature_servers/gcp_cloudrun/__init__.py | 0 sdk/python/feast/infra/online_stores/redis.py | 4 +- sdk/python/feast/repo_operations.py | 8 +- sdk/python/feast/templates/aws/__init__.py | 0 sdk/python/feast/templates/gcp/__init__.py | 0 sdk/python/feast/templates/local/__init__.py | 0 sdk/python/feast/type_map.py | 7 +- sdk/python/feast/value_type.py | 22 ++ .../requirements/py3.7-ci-requirements.txt | 44 +++- .../requirements/py3.7-requirements.txt | 7 +- .../requirements/py3.8-ci-requirements.txt | 45 +++-- .../requirements/py3.8-requirements.txt | 6 +- .../requirements/py3.9-ci-requirements.txt | 45 +++-- .../requirements/py3.9-requirements.txt | 6 +- sdk/python/setup.py | 77 ++++--- sdk/python/tests/data/data_creator.py | 8 +- .../feature_repos/repo_configuration.py | 4 +- .../universal/data_sources/bigquery.py | 6 +- .../universal/data_sources/redshift.py | 4 +- .../test_universal_historical_retrieval.py | 2 +- .../integration/registration/test_cli.py | 8 +- .../registration/test_universal_types.py | 8 +- .../tests/unit/test_unit_feature_store.py | 50 +++++ sdk/python/tests/utils/data_source_utils.py | 3 +- 26 files changed, 398 insertions(+), 156 deletions(-) create mode 100644 sdk/python/feast/infra/feature_servers/aws_lambda/__init__.py create mode 100644 sdk/python/feast/infra/feature_servers/gcp_cloudrun/__init__.py create mode 100644 sdk/python/feast/templates/aws/__init__.py create mode 100644 sdk/python/feast/templates/gcp/__init__.py create mode 100644 sdk/python/feast/templates/local/__init__.py create mode 100644 sdk/python/tests/unit/test_unit_feature_store.py diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index c9dae9063a6..c4d03fd01cc 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -1150,27 +1150,24 @@ def _get_online_features( [DUMMY_ENTITY_VAL] * num_rows, DUMMY_ENTITY.value_type ) - # Initialize the set of EntityKeyProtos once and reuse them for each FeatureView - # to avoid initialization overhead. - entity_keys = [EntityKeyProto() for _ in range(num_rows)] provider = self._get_provider() for table, requested_features in grouped_refs: # Get the correct set of entity values with the correct join keys. - table_entity_values = self._get_table_entity_values( - table, entity_name_to_join_key_map, join_key_values, + table_entity_values, idxs = self._get_unique_entities( + table, join_key_values, entity_name_to_join_key_map, ) - # Set the EntityKeyProtos inplace. - self._set_table_entity_keys( - table_entity_values, entity_keys, + # Fetch feature data for the minimum set of Entities. + feature_data = self._read_from_online_store( + table_entity_values, provider, requested_features, table, ) # Populate the result_rows with the Features from the OnlineStore inplace. - self._populate_result_rows_from_feature_view( + self._populate_response_from_feature_data( + feature_data, + idxs, online_features_response, - entity_keys, full_feature_names, - provider, requested_features, table, ) @@ -1255,22 +1252,6 @@ def _get_table_entity_values( } return entity_values - @staticmethod - def _set_table_entity_keys( - entity_values: Dict[str, List[Value]], entity_keys: List[EntityKeyProto], - ): - """ - This method sets the a list of EntityKeyProtos inplace. - """ - keys = entity_values.keys() - # Columar to rowise (dict keys and values are guaranteed to have the same order). - rowise_values = zip(*entity_values.values()) - for entity_key in entity_keys: - # Make sure entity_keys are empty before setting. - entity_key.Clear() - entity_key.join_keys.extend(keys) - entity_key.entity_values.extend(next(rowise_values)) - @staticmethod def _populate_result_rows_from_columnar( online_features_response: GetOnlineFeaturesResponse, @@ -1323,21 +1304,134 @@ def ensure_request_data_values_exist( feature_names=missing_features ) - def _populate_result_rows_from_feature_view( + def _get_unique_entities( self, - online_features_response: GetOnlineFeaturesResponse, - entity_keys: List[EntityKeyProto], - full_feature_names: bool, + table: FeatureView, + join_key_values: Dict[str, List[Value]], + entity_name_to_join_key_map: Dict[str, str], + ) -> Tuple[Tuple[Dict[str, Value], ...], Tuple[List[int], ...]]: + """ Return the set of unique composite Entities for a Feature View and the indexes at which they appear. + + This method allows us to query the OnlineStore for data we need only once + rather than requesting and processing data for the same combination of + Entities multiple times. + """ + # Get the correct set of entity values with the correct join keys. + table_entity_values = self._get_table_entity_values( + table, entity_name_to_join_key_map, join_key_values, + ) + + # Convert back to rowise. + keys = table_entity_values.keys() + # Sort the rowise data to allow for grouping but keep original index. This lambda is + # sufficient as Entity types cannot be complex (ie. lists). + rowise = list(enumerate(zip(*table_entity_values.values()))) + rowise.sort( + key=lambda row: tuple(getattr(x, x.WhichOneof("val")) for x in row[1]) + ) + + # Identify unique entities and the indexes at which they occur. + unique_entities: Tuple[Dict[str, Value], ...] + indexes: Tuple[List[int], ...] + unique_entities, indexes = tuple( + zip( + *[ + (dict(zip(keys, k)), [_[0] for _ in g]) + for k, g in itertools.groupby(rowise, key=lambda x: x[1]) + ] + ) + ) + return unique_entities, indexes + + def _read_from_online_store( + self, + entity_rows: Iterable[Mapping[str, Value]], provider: Provider, requested_features: List[str], table: FeatureView, - ): + ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[Value]]]: + """ Read and process data from the OnlineStore for a given FeatureView. + + This method guarentees that the order of the data in each element of the + List returned is the same as the order of `requested_features`. + + This method assumes that `provider.online_read` returns data for each + combination of Entities in `entity_rows` in the same order as they + are provided. + """ + # Instantiate one EntityKeyProto per Entity. + entity_key_protos = [ + EntityKeyProto(join_keys=row.keys(), entity_values=row.values()) + for row in entity_rows + ] + + # Fetch data for Entities. read_rows = provider.online_read( config=self.config, table=table, - entity_keys=entity_keys, + entity_keys=entity_key_protos, requested_features=requested_features, ) + + # Each row is a set of features for a given entity key. We only need to convert + # the data to Protobuf once. + row_ts_proto = Timestamp() + null_value = Value() + read_row_protos = [] + for read_row in read_rows: + row_ts, feature_data = read_row + if row_ts is not None: + row_ts_proto.FromDatetime(row_ts) + event_timestamps = [row_ts_proto] * len(requested_features) + if feature_data is None: + statuses = [FieldStatus.NOT_FOUND] * len(requested_features) + values = [null_value] * len(requested_features) + else: + statuses = [] + values = [] + for feature_name in requested_features: + # Make sure order of data is the same as requested_features. + if feature_name not in feature_data: + statuses.append(FieldStatus.NOT_FOUND) + values.append(null_value) + else: + statuses.append(FieldStatus.PRESENT) + values.append(feature_data[feature_name]) + read_row_protos.append((event_timestamps, statuses, values)) + return read_row_protos + + @staticmethod + def _populate_response_from_feature_data( + feature_data: Iterable[ + Tuple[ + Iterable[Timestamp], Iterable["FieldStatus.ValueType"], Iterable[Value] + ] + ], + indexes: Iterable[Iterable[int]], + online_features_response: GetOnlineFeaturesResponse, + full_feature_names: bool, + requested_features: Iterable[str], + table: FeatureView, + ): + """ Populate the GetOnlineFeaturesReponse with feature data. + + This method assumes that `_read_from_online_store` returns data for each + combination of Entities in `entity_rows` in the same order as they + are provided. + + Args: + feature_data: A list of data in Protobuf form which was retrieved from the OnlineStore. + indexes: A list of indexes which should be the same length as `feature_data`. Each list + of indexes corresponds to a set of result rows in `online_features_response`. + online_features_response: The object to populate. + full_feature_names: A boolean that provides the option to add the feature view prefixes to the feature names, + changing them from the format "feature" to "feature_view__feature" (e.g., "daily_transactions" changes to + "customer_fv__daily_transactions"). + requested_features: The names of the features in `feature_data`. This should be ordered in the same way as the + data in `feature_data`. + table: The FeatureView that `feature_data` was retrieved from. + """ + # Add the feature names to the response. requested_feature_refs = [ f"{table.projection.name_to_use()}__{feature_name}" if full_feature_names @@ -1347,28 +1441,16 @@ def _populate_result_rows_from_feature_view( online_features_response.metadata.feature_names.val.extend( requested_feature_refs ) - # Each row is a set of features for a given entity key - for row_idx, read_row in enumerate(read_rows): - row_ts, feature_data = read_row - result_row = online_features_response.results[row_idx] - row_ts_proto = Timestamp() - if row_ts is not None: - row_ts_proto.FromDatetime(row_ts) - result_row.event_timestamps.extend([row_ts_proto] * len(requested_features)) - if feature_data is None: - result_row.statuses.extend( - [FieldStatus.NOT_FOUND] * len(requested_features) - ) - result_row.values.extend([Value()] * len(requested_features)) - else: - for feature_name in requested_features: - if feature_name not in feature_data: - result_row.statuses.append(FieldStatus.NOT_FOUND) - result_row.values.append(Value()) - else: - result_row.statuses.append(FieldStatus.PRESENT) - result_row.values.append(feature_data[feature_name]) + # Populate the result with data fetched from the OnlineStore + # which is guarenteed to be aligned with `requested_features`. + for feature_row, dest_idxs in zip(feature_data, indexes): + event_timestamps, statuses, values = feature_row + for dest_idx in dest_idxs: + result_row = online_features_response.results[dest_idx] + result_row.event_timestamps.extend(event_timestamps) + result_row.statuses.extend(statuses) + result_row.values.extend(values) @staticmethod def _augment_response_with_on_demand_transforms( diff --git a/sdk/python/feast/infra/feature_servers/aws_lambda/__init__.py b/sdk/python/feast/infra/feature_servers/aws_lambda/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/infra/feature_servers/gcp_cloudrun/__init__.py b/sdk/python/feast/infra/feature_servers/gcp_cloudrun/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/infra/online_stores/redis.py b/sdk/python/feast/infra/online_stores/redis.py index 6b33dda9394..493c6ab4626 100644 --- a/sdk/python/feast/infra/online_stores/redis.py +++ b/sdk/python/feast/infra/online_stores/redis.py @@ -277,13 +277,13 @@ def _get_features_for_entity( res_ts = Timestamp() ts_val = res_val.pop(f"_ts:{feature_view}") if ts_val: - res_ts.ParseFromString(ts_val) + res_ts.ParseFromString(bytes(ts_val)) res = {} for feature_name, val_bin in res_val.items(): val = ValueProto() if val_bin: - val.ParseFromString(val_bin) + val.ParseFromString(bytes(val_bin)) res[feature_name] = val if not res: diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index 17d0530d4ec..16f444a2c26 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -5,6 +5,7 @@ import re import sys from importlib.abc import Loader +from importlib.machinery import ModuleSpec from pathlib import Path from typing import List, Set, Union @@ -82,7 +83,11 @@ def get_repo_files(repo_root: Path) -> List[Path]: ignore_files = get_ignore_files(repo_root, ignore_paths) # List all Python files in the root directory (recursively) - repo_files = {p.resolve() for p in repo_root.glob("**/*.py") if p.is_file()} + repo_files = { + p.resolve() + for p in repo_root.glob("**/*.py") + if p.is_file() and "__init__.py" != p.name + } # Ignore all files that match any of the ignore paths in .feastignore repo_files -= ignore_files @@ -352,6 +357,7 @@ def init_repo(repo_name: str, template: str): import importlib.util spec = importlib.util.spec_from_file_location("bootstrap", str(bootstrap_path)) + assert isinstance(spec, ModuleSpec) bootstrap = importlib.util.module_from_spec(spec) assert isinstance(spec.loader, Loader) spec.loader.exec_module(bootstrap) diff --git a/sdk/python/feast/templates/aws/__init__.py b/sdk/python/feast/templates/aws/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/templates/gcp/__init__.py b/sdk/python/feast/templates/gcp/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/templates/local/__init__.py b/sdk/python/feast/templates/local/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 969ca658625..715da87c51a 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -19,7 +19,6 @@ import numpy as np import pandas as pd import pyarrow -from google.protobuf.pyext.cpp_message import GeneratedProtocolMessageType from google.protobuf.timestamp_pb2 import Timestamp from feast.protos.feast.types.Value_pb2 import ( @@ -32,7 +31,7 @@ StringList, ) from feast.protos.feast.types.Value_pb2 import Value as ProtoValue -from feast.value_type import ValueType +from feast.value_type import ListType, ValueType def feast_value_type_to_python_type(field_value_proto: ProtoValue) -> Any: @@ -195,7 +194,7 @@ def _type_err(item, dtype): PYTHON_LIST_VALUE_TYPE_TO_PROTO_VALUE: Dict[ - ValueType, Tuple[GeneratedProtocolMessageType, str, List[Type]] + ValueType, Tuple[ListType, str, List[Type]] ] = { ValueType.FLOAT_LIST: ( FloatList, @@ -273,7 +272,7 @@ def _python_value_to_proto_value( raise _type_err(first_invalid, valid_types[0]) return [ - ProtoValue(**{field_name: proto_type(val=value)}) + ProtoValue(**{field_name: proto_type(val=value)}) # type: ignore if value is not None else ProtoValue() for value in values diff --git a/sdk/python/feast/value_type.py b/sdk/python/feast/value_type.py index 3d1817421a2..1904baf7bbb 100644 --- a/sdk/python/feast/value_type.py +++ b/sdk/python/feast/value_type.py @@ -12,6 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. import enum +from typing import Type, Union + +from feast.protos.feast.types.Value_pb2 import ( + BoolList, + BytesList, + DoubleList, + FloatList, + Int32List, + Int64List, + StringList, +) class ValueType(enum.Enum): @@ -37,3 +48,14 @@ class ValueType(enum.Enum): BOOL_LIST = 17 UNIX_TIMESTAMP_LIST = 18 NULL = 19 + + +ListType = Union[ + Type[BoolList], + Type[BytesList], + Type[DoubleList], + Type[FloatList], + Type[Int32List], + Type[Int64List], + Type[StringList], +] diff --git a/sdk/python/requirements/py3.7-ci-requirements.txt b/sdk/python/requirements/py3.7-ci-requirements.txt index 6d77880d93a..87ab9f9813b 100644 --- a/sdk/python/requirements/py3.7-ci-requirements.txt +++ b/sdk/python/requirements/py3.7-ci-requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --extra=ci --output-file=requirements/py3.7-ci-requirements.txt # -absl-py==0.12.0 +absl-py==1.0.0 # via tensorflow-metadata adal==1.2.7 # via @@ -55,11 +55,11 @@ babel==2.9.1 # via sphinx black==19.10b0 # via feast (setup.py) -boto3==1.20.38 +boto3==1.20.40 # via # feast (setup.py) # moto -botocore==1.23.38 +botocore==1.23.40 # via # boto3 # moto @@ -213,7 +213,7 @@ grpcio-testing==1.34.0 # via feast (setup.py) grpcio-tools==1.34.0 # via feast (setup.py) -h11==0.12.0 +h11==0.13.0 # via uvicorn hiredis==2.0.0 # via feast (setup.py) @@ -279,7 +279,7 @@ mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -moto==2.3.2 +moto==3.0.0 # via feast (setup.py) msal==1.16.0 # via @@ -299,13 +299,13 @@ multidict==5.2.0 # via # aiohttp # yarl -mypy==0.790 +mypy==0.931 # via feast (setup.py) mypy-extensions==0.4.3 # via # mypy # typing-inspect -mypy-protobuf==1.24 +mypy-protobuf==3.1.0 # via feast (setup.py) nodeenv==1.6.0 # via pre-commit @@ -396,7 +396,7 @@ pyjwt[crypto]==2.3.0 # via # adal # msal -pyparsing==3.0.6 +pyparsing==3.0.7 # via # httplib2 # packaging @@ -528,7 +528,7 @@ tabulate==0.8.9 # via feast (setup.py) tenacity==8.0.1 # via feast (setup.py) -tensorflow-metadata==1.5.0 +tensorflow-metadata==1.6.0 # via feast (setup.py) testcontainers==3.4.2 # via feast (setup.py) @@ -541,19 +541,43 @@ toml==0.10.2 tomli==2.0.0 # via # coverage + # mypy # pep517 tqdm==4.62.3 # via feast (setup.py) -typed-ast==1.4.3 +typed-ast==1.5.1 # via # black # mypy +types-futures==3.3.7 + # via types-protobuf +types-protobuf==3.19.5 + # via + # feast (setup.py) + # mypy-protobuf +types-python-dateutil==2.8.8 + # via feast (setup.py) +types-pytz==2021.3.4 + # via feast (setup.py) +types-pyyaml==6.0.3 + # via feast (setup.py) +types-redis==4.1.10 + # via feast (setup.py) +types-requests==2.27.7 + # via feast (setup.py) +types-setuptools==57.4.7 + # via feast (setup.py) +types-tabulate==0.8.5 + # via feast (setup.py) +types-urllib3==1.26.7 + # via types-requests typing-extensions==4.0.1 # via # aiohttp # anyio # asgiref # async-timeout + # h11 # importlib-metadata # jsonschema # libcst diff --git a/sdk/python/requirements/py3.7-requirements.txt b/sdk/python/requirements/py3.7-requirements.txt index 03ff93459cc..c2ad63fdea3 100644 --- a/sdk/python/requirements/py3.7-requirements.txt +++ b/sdk/python/requirements/py3.7-requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements/py3.7-requirements.txt # -absl-py==0.12.0 +absl-py==1.0.0 # via tensorflow-metadata anyio==3.5.0 # via starlette @@ -47,7 +47,7 @@ grpcio==1.43.0 # grpcio-reflection grpcio-reflection==1.43.0 # via feast (setup.py) -h11==0.12.0 +h11==0.13.0 # via uvicorn httptools==0.3.0 # via uvicorn @@ -133,7 +133,7 @@ tabulate==0.8.9 # via feast (setup.py) tenacity==8.0.1 # via feast (setup.py) -tensorflow-metadata==1.5.0 +tensorflow-metadata==1.6.0 # via feast (setup.py) toml==0.10.2 # via feast (setup.py) @@ -143,6 +143,7 @@ typing-extensions==4.0.1 # via # anyio # asgiref + # h11 # importlib-metadata # jsonschema # pydantic diff --git a/sdk/python/requirements/py3.8-ci-requirements.txt b/sdk/python/requirements/py3.8-ci-requirements.txt index d63b7ea3542..851a0b70548 100644 --- a/sdk/python/requirements/py3.8-ci-requirements.txt +++ b/sdk/python/requirements/py3.8-ci-requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --extra=ci --output-file=requirements/py3.8-ci-requirements.txt # -absl-py==0.12.0 +absl-py==1.0.0 # via tensorflow-metadata adal==1.2.7 # via @@ -53,11 +53,11 @@ babel==2.9.1 # via sphinx black==19.10b0 # via feast (setup.py) -boto3==1.20.38 +boto3==1.20.40 # via # feast (setup.py) # moto -botocore==1.23.38 +botocore==1.23.40 # via # boto3 # moto @@ -211,7 +211,7 @@ grpcio-testing==1.34.0 # via feast (setup.py) grpcio-tools==1.34.0 # via feast (setup.py) -h11==0.12.0 +h11==0.13.0 # via uvicorn hiredis==2.0.0 # via feast (setup.py) @@ -265,7 +265,7 @@ mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -moto==2.3.2 +moto==3.0.0 # via feast (setup.py) msal==1.16.0 # via @@ -285,13 +285,13 @@ multidict==5.2.0 # via # aiohttp # yarl -mypy==0.790 +mypy==0.931 # via feast (setup.py) mypy-extensions==0.4.3 # via # mypy # typing-inspect -mypy-protobuf==1.24 +mypy-protobuf==3.1.0 # via feast (setup.py) nodeenv==1.6.0 # via pre-commit @@ -382,7 +382,7 @@ pyjwt[crypto]==2.3.0 # via # adal # msal -pyparsing==3.0.6 +pyparsing==3.0.7 # via # httplib2 # packaging @@ -514,7 +514,7 @@ tabulate==0.8.9 # via feast (setup.py) tenacity==8.0.1 # via feast (setup.py) -tensorflow-metadata==1.5.0 +tensorflow-metadata==1.6.0 # via feast (setup.py) testcontainers==3.4.2 # via feast (setup.py) @@ -527,13 +527,34 @@ toml==0.10.2 tomli==2.0.0 # via # coverage + # mypy # pep517 tqdm==4.62.3 # via feast (setup.py) -typed-ast==1.4.3 +typed-ast==1.5.1 + # via black +types-futures==3.3.7 + # via types-protobuf +types-protobuf==3.19.5 # via - # black - # mypy + # feast (setup.py) + # mypy-protobuf +types-python-dateutil==2.8.8 + # via feast (setup.py) +types-pytz==2021.3.4 + # via feast (setup.py) +types-pyyaml==6.0.3 + # via feast (setup.py) +types-redis==4.1.10 + # via feast (setup.py) +types-requests==2.27.7 + # via feast (setup.py) +types-setuptools==57.4.7 + # via feast (setup.py) +types-tabulate==0.8.5 + # via feast (setup.py) +types-urllib3==1.26.7 + # via types-requests typing-extensions==4.0.1 # via # libcst diff --git a/sdk/python/requirements/py3.8-requirements.txt b/sdk/python/requirements/py3.8-requirements.txt index d16a5cbe53e..e94fe117b4a 100644 --- a/sdk/python/requirements/py3.8-requirements.txt +++ b/sdk/python/requirements/py3.8-requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements/py3.8-requirements.txt # -absl-py==0.12.0 +absl-py==1.0.0 # via tensorflow-metadata anyio==3.5.0 # via starlette @@ -47,7 +47,7 @@ grpcio==1.43.0 # grpcio-reflection grpcio-reflection==1.43.0 # via feast (setup.py) -h11==0.12.0 +h11==0.13.0 # via uvicorn httptools==0.3.0 # via uvicorn @@ -129,7 +129,7 @@ tabulate==0.8.9 # via feast (setup.py) tenacity==8.0.1 # via feast (setup.py) -tensorflow-metadata==1.5.0 +tensorflow-metadata==1.6.0 # via feast (setup.py) toml==0.10.2 # via feast (setup.py) diff --git a/sdk/python/requirements/py3.9-ci-requirements.txt b/sdk/python/requirements/py3.9-ci-requirements.txt index 49a1ac41f6e..76ed9f1237c 100644 --- a/sdk/python/requirements/py3.9-ci-requirements.txt +++ b/sdk/python/requirements/py3.9-ci-requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --extra=ci --output-file=requirements/py3.9-ci-requirements.txt # -absl-py==0.12.0 +absl-py==1.0.0 # via tensorflow-metadata adal==1.2.7 # via @@ -53,11 +53,11 @@ babel==2.9.1 # via sphinx black==19.10b0 # via feast (setup.py) -boto3==1.20.38 +boto3==1.20.40 # via # feast (setup.py) # moto -botocore==1.23.38 +botocore==1.23.40 # via # boto3 # moto @@ -211,7 +211,7 @@ grpcio-testing==1.34.0 # via feast (setup.py) grpcio-tools==1.34.0 # via feast (setup.py) -h11==0.12.0 +h11==0.13.0 # via uvicorn hiredis==2.0.0 # via feast (setup.py) @@ -263,7 +263,7 @@ mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -moto==2.3.2 +moto==3.0.0 # via feast (setup.py) msal==1.16.0 # via @@ -283,13 +283,13 @@ multidict==5.2.0 # via # aiohttp # yarl -mypy==0.790 +mypy==0.931 # via feast (setup.py) mypy-extensions==0.4.3 # via # mypy # typing-inspect -mypy-protobuf==1.24 +mypy-protobuf==3.1.0 # via feast (setup.py) nodeenv==1.6.0 # via pre-commit @@ -380,7 +380,7 @@ pyjwt[crypto]==2.3.0 # via # adal # msal -pyparsing==3.0.6 +pyparsing==3.0.7 # via # httplib2 # packaging @@ -512,7 +512,7 @@ tabulate==0.8.9 # via feast (setup.py) tenacity==8.0.1 # via feast (setup.py) -tensorflow-metadata==1.5.0 +tensorflow-metadata==1.6.0 # via feast (setup.py) testcontainers==3.4.2 # via feast (setup.py) @@ -525,13 +525,34 @@ toml==0.10.2 tomli==2.0.0 # via # coverage + # mypy # pep517 tqdm==4.62.3 # via feast (setup.py) -typed-ast==1.4.3 +typed-ast==1.5.1 + # via black +types-futures==3.3.7 + # via types-protobuf +types-protobuf==3.19.5 # via - # black - # mypy + # feast (setup.py) + # mypy-protobuf +types-python-dateutil==2.8.8 + # via feast (setup.py) +types-pytz==2021.3.4 + # via feast (setup.py) +types-pyyaml==6.0.3 + # via feast (setup.py) +types-redis==4.1.10 + # via feast (setup.py) +types-requests==2.27.7 + # via feast (setup.py) +types-setuptools==57.4.7 + # via feast (setup.py) +types-tabulate==0.8.5 + # via feast (setup.py) +types-urllib3==1.26.7 + # via types-requests typing-extensions==4.0.1 # via # libcst diff --git a/sdk/python/requirements/py3.9-requirements.txt b/sdk/python/requirements/py3.9-requirements.txt index 9a1e6e4088b..187cb02154b 100644 --- a/sdk/python/requirements/py3.9-requirements.txt +++ b/sdk/python/requirements/py3.9-requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements/py3.9-requirements.txt # -absl-py==0.12.0 +absl-py==1.0.0 # via tensorflow-metadata anyio==3.5.0 # via starlette @@ -47,7 +47,7 @@ grpcio==1.43.0 # grpcio-reflection grpcio-reflection==1.43.0 # via feast (setup.py) -h11==0.12.0 +h11==0.13.0 # via uvicorn httptools==0.3.0 # via uvicorn @@ -127,7 +127,7 @@ tabulate==0.8.9 # via feast (setup.py) tenacity==8.0.1 # via feast (setup.py) -tensorflow-metadata==1.5.0 +tensorflow-metadata==1.6.0 # via feast (setup.py) toml==0.10.2 # via feast (setup.py) diff --git a/sdk/python/setup.py b/sdk/python/setup.py index ac7b4ec6a71..d35ee9de11b 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -86,38 +86,51 @@ "docker>=5.0.2", ] -CI_REQUIRED = [ - "cryptography==3.3.2", - "flake8", - "black==19.10b0", - "isort>=5", - "grpcio-tools==1.34.0", - "grpcio-testing==1.34.0", - "minio==7.1.0", - "mock==2.0.0", - "moto", - "mypy==0.790", - "mypy-protobuf==1.24", - "avro==1.10.0", - "gcsfs", - "urllib3>=1.25.4", - "pytest>=6.0.0", - "pytest-cov", - "pytest-xdist", - "pytest-benchmark>=3.4.1", - "pytest-lazy-fixture==0.6.3", - "pytest-timeout==1.4.2", - "pytest-ordering==0.6.*", - "pytest-mock==1.10.4", - "Sphinx!=4.0.0,<4.4.0", - "sphinx-rtd-theme", - "testcontainers==3.4.2", - "adlfs==0.5.9", - "firebase-admin==4.5.2", - "pre-commit", - "assertpy==1.1", - "pip-tools", -] + GCP_REQUIRED + REDIS_REQUIRED + AWS_REQUIRED +CI_REQUIRED = ( + [ + "cryptography==3.3.2", + "flake8", + "black==19.10b0", + "isort>=5", + "grpcio-tools==1.34.0", + "grpcio-testing==1.34.0", + "minio==7.1.0", + "mock==2.0.0", + "moto", + "mypy==0.931", + "mypy-protobuf==3.1.0", + "avro==1.10.0", + "gcsfs", + "urllib3>=1.25.4", + "pytest>=6.0.0", + "pytest-cov", + "pytest-xdist", + "pytest-benchmark>=3.4.1", + "pytest-lazy-fixture==0.6.3", + "pytest-timeout==1.4.2", + "pytest-ordering==0.6.*", + "pytest-mock==1.10.4", + "Sphinx!=4.0.0,<4.4.0", + "sphinx-rtd-theme", + "testcontainers==3.4.2", + "adlfs==0.5.9", + "firebase-admin==4.5.2", + "pre-commit", + "assertpy==1.1", + "pip-tools", + "types-protobuf", + "types-python-dateutil", + "types-pytz", + "types-PyYAML", + "types-redis", + "types-requests", + "types-setuptools", + "types-tabulate", + ] + + GCP_REQUIRED + + REDIS_REQUIRED + + AWS_REQUIRED +) DEV_REQUIRED = ["mypy-protobuf==1.*", "grpcio-testing==1.*"] + CI_REQUIRED diff --git a/sdk/python/tests/data/data_creator.py b/sdk/python/tests/data/data_creator.py index e5355b40bbc..1145f95c073 100644 --- a/sdk/python/tests/data/data_creator.py +++ b/sdk/python/tests/data/data_creator.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import List +from typing import Dict, List, Optional import pandas as pd from pytz import timezone, utc @@ -38,7 +38,7 @@ def create_dataset( def get_entities_for_value_type(value_type: ValueType) -> List: - value_type_map = { + value_type_map: Dict[ValueType, List] = { ValueType.INT32: [1, 2, 1, 3, 3], ValueType.INT64: [1, 2, 1, 3, 3], ValueType.FLOAT: [1.0, 2.0, 1.0, 3.0, 3.0], @@ -48,13 +48,13 @@ def get_entities_for_value_type(value_type: ValueType) -> List: def get_feature_values_for_dtype( - dtype: str, is_list: bool, has_empty_list: bool + dtype: Optional[str], is_list: bool, has_empty_list: bool ) -> List: if dtype is None: return [0.1, None, 0.3, 4, 5] # TODO(adchia): for int columns, consider having a better error when dealing with None values (pandas int dfs can't # have na) - dtype_map = { + dtype_map: Dict[str, List] = { "int32": [1, 2, 3, 4, 5], "int64": [1, 2, 3, 4, 5], "float": [1.0, None, 3.0, 4.0, 5.0], diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index f6f2b5d2bc6..45044574e01 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -246,9 +246,9 @@ def get_local_server_port(self) -> int: def table_name_from_data_source(ds: DataSource) -> Optional[str]: if hasattr(ds, "table_ref"): - return ds.table_ref + return ds.table_ref # type: ignore elif hasattr(ds, "table"): - return ds.table + return ds.table # type: ignore return None diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py index 766c31150e1..4085ef9d067 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Dict, List, Optional import pandas as pd from google.cloud import bigquery @@ -21,7 +21,7 @@ def __init__(self, project_name: str): self.gcp_project = self.client.project self.dataset_id = f"{self.gcp_project}.{project_name}" - self.tables = [] + self.tables: List[str] = [] def create_dataset(self): if not self.dataset: @@ -50,7 +50,7 @@ def create_offline_store_config(self): def create_data_source( self, df: pd.DataFrame, - destination_name: Optional[str] = None, + destination_name: str, event_timestamp_column="ts", created_timestamp_column="created_ts", field_mapping: Dict[str, str] = None, diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py index 88780f07a07..f7839da5288 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Dict, List, Optional import pandas as pd @@ -14,7 +14,7 @@ class RedshiftDataSourceCreator(DataSourceCreator): - tables = [] + tables: List[str] = [] def __init__(self, project_name: str): super().__init__() 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 dad14ac5aad..99f111a3462 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 @@ -55,7 +55,7 @@ def find_asof_record( filter_keys = filter_keys or [] filter_values = filter_values or [] assert len(filter_keys) == len(filter_values) - found_record = {} + found_record: Dict[str, Any] = {} for record in records: if ( all( diff --git a/sdk/python/tests/integration/registration/test_cli.py b/sdk/python/tests/integration/registration/test_cli.py index 0fe73316adc..5dc3772265a 100644 --- a/sdk/python/tests/integration/registration/test_cli.py +++ b/sdk/python/tests/integration/registration/test_cli.py @@ -1,7 +1,7 @@ import tempfile import uuid from contextlib import contextmanager -from pathlib import Path, PosixPath +from pathlib import Path from textwrap import dedent import pytest @@ -26,10 +26,10 @@ def test_universal_cli(test_repo_config) -> None: with tempfile.TemporaryDirectory() as repo_dir_name: try: + repo_path = Path(repo_dir_name) feature_store_yaml = make_feature_store_yaml( - project, test_repo_config, repo_dir_name + project, test_repo_config, repo_path ) - repo_path = Path(repo_dir_name) repo_config = repo_path / "feature_store.yaml" @@ -103,7 +103,7 @@ def test_universal_cli(test_repo_config) -> None: runner.run(["teardown"], cwd=repo_path) -def make_feature_store_yaml(project, test_repo_config, repo_dir_name: PosixPath): +def make_feature_store_yaml(project, test_repo_config, repo_dir_name: Path): offline_creator: DataSourceCreator = test_repo_config.offline_store_creator(project) offline_store_config = offline_creator.create_offline_store_config() diff --git a/sdk/python/tests/integration/registration/test_universal_types.py b/sdk/python/tests/integration/registration/test_universal_types.py index c007d56c35d..bb6261313d2 100644 --- a/sdk/python/tests/integration/registration/test_universal_types.py +++ b/sdk/python/tests/integration/registration/test_universal_types.py @@ -1,7 +1,7 @@ import logging from dataclasses import dataclass from datetime import datetime, timedelta -from typing import List +from typing import Any, Dict, List, Tuple, Union import numpy as np import pandas as pd @@ -217,7 +217,7 @@ def test_feature_get_online_features_types_match(online_types_test_fixtures): ) fs = environment.feature_store features = [fv.name + ":value"] - entity = driver(value_type=ValueType.UNKNOWN) + entity = driver(value_type=config.entity_type) fs.apply([fv, entity]) fs.materialize(environment.start_date, environment.end_date) @@ -292,7 +292,9 @@ def assert_feature_list_types( provider: str, feature_dtype: str, historical_features_df: pd.DataFrame ): print("Asserting historical feature list types") - feature_list_dtype_to_expected_historical_feature_list_dtype = { + feature_list_dtype_to_expected_historical_feature_list_dtype: Dict[ + str, Union[type, Tuple[Union[type, Tuple[Any, ...]], ...]] + ] = { "int32": ( int, np.int64, diff --git a/sdk/python/tests/unit/test_unit_feature_store.py b/sdk/python/tests/unit/test_unit_feature_store.py new file mode 100644 index 00000000000..6f9dd6acb08 --- /dev/null +++ b/sdk/python/tests/unit/test_unit_feature_store.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Dict, List + +from feast import FeatureStore +from feast.protos.feast.types.Value_pb2 import Value + + +@dataclass +class MockFeatureViewProjection: + join_key_map: Dict[str, str] + + +@dataclass +class MockFeatureView: + name: str + entities: List[str] + projection: MockFeatureViewProjection + + +def test__get_unique_entities(): + entity_values = { + "entity_1": [Value(int64_val=1), Value(int64_val=2), Value(int64_val=1)], + "entity_2": [ + Value(string_val="1"), + Value(string_val="2"), + Value(string_val="1"), + ], + "entity_3": [Value(int64_val=8), Value(int64_val=9), Value(int64_val=10)], + } + + entity_name_to_join_key_map = {"entity_1": "entity_1", "entity_2": "entity_2"} + + fv = MockFeatureView( + name="fv_1", + entities=["entity_1", "entity_2"], + projection=MockFeatureViewProjection(join_key_map={}), + ) + + unique_entities, indexes = FeatureStore._get_unique_entities( + FeatureStore, + table=fv, + join_key_values=entity_values, + entity_name_to_join_key_map=entity_name_to_join_key_map, + ) + + assert unique_entities == ( + {"entity_1": Value(int64_val=1), "entity_2": Value(string_val="1")}, + {"entity_1": Value(int64_val=2), "entity_2": Value(string_val="2")}, + ) + assert indexes == ([0, 2], [1]) diff --git a/sdk/python/tests/utils/data_source_utils.py b/sdk/python/tests/utils/data_source_utils.py index 6e3d77ead0b..12870186bfc 100644 --- a/sdk/python/tests/utils/data_source_utils.py +++ b/sdk/python/tests/utils/data_source_utils.py @@ -2,6 +2,7 @@ import random import tempfile import time +from typing import Iterator from google.cloud import bigquery @@ -10,7 +11,7 @@ @contextlib.contextmanager -def prep_file_source(df, event_timestamp_column=None) -> FileSource: +def prep_file_source(df, event_timestamp_column=None) -> Iterator[FileSource]: with tempfile.NamedTemporaryFile(suffix=".parquet") as f: f.close() df.to_parquet(f.name) From d7707c11fbf32a9475d52d6f6cfcfa1a9ece987b Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Wed, 26 Jan 2022 20:49:45 +0200 Subject: [PATCH 33/85] Allow using pandas.StringDtype (#2229) Signed-off-by: pyalex --- sdk/python/feast/type_map.py | 77 ++++++++++--------- .../registration/test_inference.py | 41 +++++++++- 2 files changed, 78 insertions(+), 40 deletions(-) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 715da87c51a..acf049e928e 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -96,6 +96,7 @@ def python_type_to_feast_value_type( type_map = { "int": ValueType.INT64, "str": ValueType.STRING, + "string": ValueType.STRING, # pandas.StringDtype "float": ValueType.DOUBLE, "bytes": ValueType.BYTES, "float64": ValueType.DOUBLE, @@ -118,48 +119,50 @@ def python_type_to_feast_value_type( if type_name in type_map: return type_map[type_name] - if type_name == "ndarray" or isinstance(value, list): - if recurse: - - # Convert to list type - list_items = pd.core.series.Series(value) - - # This is the final type which we infer from the list - common_item_value_type = None - for item in list_items: - if isinstance(item, ProtoValue): - current_item_value_type: ValueType = _proto_value_to_value_type( - item - ) - else: - # Get the type from the current item, only one level deep - current_item_value_type = python_type_to_feast_value_type( - name=name, value=item, recurse=False - ) - # Validate whether the type stays consistent - if ( - common_item_value_type - and not common_item_value_type == current_item_value_type - ): - raise ValueError( - f"List value type for field {name} is inconsistent. " - f"{common_item_value_type} different from " - f"{current_item_value_type}." - ) - common_item_value_type = current_item_value_type - if common_item_value_type is None: - return ValueType.UNKNOWN - return ValueType[common_item_value_type.name + "_LIST"] - else: - assert value + if isinstance(value, np.ndarray) and str(value.dtype) in type_map: + item_type = type_map[str(value.dtype)] + return ValueType[item_type.name + "_LIST"] + + if isinstance(value, (list, np.ndarray)): + # if the value's type is "ndarray" and we couldn't infer from "value.dtype" + # this is most probably array of "object", + # so we need to iterate over objects and try to infer type of each item + if not recurse: raise ValueError( - f"Value type for field {name} is {value.dtype.__str__()} but " + f"Value type for field {name} is {type(value)} but " f"recursion is not allowed. Array types can only be one level " f"deep." ) - assert value - return type_map[value.dtype.__str__()] + # This is the final type which we infer from the list + common_item_value_type = None + for item in value: + if isinstance(item, ProtoValue): + current_item_value_type: ValueType = _proto_value_to_value_type(item) + else: + # Get the type from the current item, only one level deep + current_item_value_type = python_type_to_feast_value_type( + name=name, value=item, recurse=False + ) + # Validate whether the type stays consistent + if ( + common_item_value_type + and not common_item_value_type == current_item_value_type + ): + raise ValueError( + f"List value type for field {name} is inconsistent. " + f"{common_item_value_type} different from " + f"{current_item_value_type}." + ) + common_item_value_type = current_item_value_type + if common_item_value_type is None: + return ValueType.UNKNOWN + return ValueType[common_item_value_type.name + "_LIST"] + + raise ValueError( + f"Value with native type {type_name} " + f"cannot be converted into Feast value type" + ) def python_values_to_feast_value_type( diff --git a/sdk/python/tests/integration/registration/test_inference.py b/sdk/python/tests/integration/registration/test_inference.py index 14aa1e13ad5..ca5f56c435e 100644 --- a/sdk/python/tests/integration/registration/test_inference.py +++ b/sdk/python/tests/integration/registration/test_inference.py @@ -3,7 +3,7 @@ from feast import Entity, Feature, RepoConfig, ValueType from feast.data_source import RequestDataSource -from feast.errors import RegistryInferenceFailure +from feast.errors import RegistryInferenceFailure, SpecifiedFeaturesNotPresentError from feast.feature_view import FeatureView from feast.inference import ( update_data_sources_with_inferred_event_timestamp_col, @@ -86,7 +86,7 @@ def test_update_data_sources_with_inferred_event_timestamp_col(simple_dataset_1) ) -def test_modify_feature_views_success(): +def test_on_demand_features_type_inference(): # Create Feature Views date_request = RequestDataSource( name="date_request", schema={"some_date": ValueType.UNIX_TIMESTAMP} @@ -94,11 +94,46 @@ def test_modify_feature_views_success(): @on_demand_feature_view( inputs={"date_request": date_request}, - features=[Feature("output", ValueType.UNIX_TIMESTAMP)], + features=[ + Feature("output", ValueType.UNIX_TIMESTAMP), + Feature("string_output", ValueType.STRING), + ], ) def test_view(features_df: pd.DataFrame) -> pd.DataFrame: data = pd.DataFrame() data["output"] = features_df["some_date"] + data["string_output"] = features_df["some_date"].astype(pd.StringDtype()) return data test_view.infer_features() + + @on_demand_feature_view( + inputs={"date_request": date_request}, + features=[ + Feature("output", ValueType.UNIX_TIMESTAMP), + Feature("object_output", ValueType.STRING), + ], + ) + def invalid_test_view(features_df: pd.DataFrame) -> pd.DataFrame: + data = pd.DataFrame() + data["output"] = features_df["some_date"] + data["object_output"] = features_df["some_date"].astype(str) + return data + + with pytest.raises(ValueError, match="Value with native type object"): + invalid_test_view.infer_features() + + @on_demand_feature_view( + inputs={"date_request": date_request}, + features=[ + Feature("output", ValueType.UNIX_TIMESTAMP), + Feature("missing", ValueType.STRING), + ], + ) + def test_view_with_missing_feature(features_df: pd.DataFrame) -> pd.DataFrame: + data = pd.DataFrame() + data["output"] = features_df["some_date"] + return data + + with pytest.raises(SpecifiedFeaturesNotPresentError): + test_view_with_missing_feature.infer_features() From 6f1174ad754088c0cb004949afe1766651a1cce7 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Wed, 26 Jan 2022 20:54:45 +0200 Subject: [PATCH 34/85] Persisting results of historical retrieval (#2197) * persisting results of historical retrieval Signed-off-by: pyalex * fix after rebase Signed-off-by: pyalex --- docs/SUMMARY.md | 1 + docs/getting-started/concepts/README.md | 1 + docs/getting-started/concepts/dataset.md | 46 ++ protos/feast/core/Registry.proto | 2 + protos/feast/core/SavedDataset.proto | 76 +++ sdk/python/feast/errors.py | 5 + sdk/python/feast/feature_store.py | 88 ++++ .../feast/infra/offline_stores/bigquery.py | 121 +++-- .../infra/offline_stores/bigquery_source.py | 29 ++ sdk/python/feast/infra/offline_stores/file.py | 86 +++- .../feast/infra/offline_stores/file_source.py | 43 +- .../infra/offline_stores/offline_store.py | 57 +++ .../feast/infra/offline_stores/redshift.py | 128 +++-- .../infra/offline_stores/redshift_source.py | 30 ++ .../feast/infra/passthrough_provider.py | 28 +- sdk/python/feast/infra/provider.py | 16 + sdk/python/feast/registry.py | 82 ++++ sdk/python/feast/saved_dataset.py | 185 ++++++++ sdk/python/tests/foo_provider.py | 4 + .../feature_repos/repo_configuration.py | 6 +- .../universal/data_source_creator.py | 5 + .../universal/data_sources/bigquery.py | 8 + .../universal/data_sources/file.py | 18 + .../universal/data_sources/redshift.py | 10 + .../test_universal_historical_retrieval.py | 449 ++++++++++-------- 25 files changed, 1262 insertions(+), 262 deletions(-) create mode 100644 docs/getting-started/concepts/dataset.md create mode 100644 protos/feast/core/SavedDataset.proto create mode 100644 sdk/python/feast/saved_dataset.py diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 987a432ac9a..ae23cd5d406 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ * [Feature service](getting-started/concepts/feature-service.md) * [Feature retrieval](getting-started/concepts/feature-retrieval.md) * [Point-in-time joins](getting-started/concepts/point-in-time-joins.md) + * [Dataset](getting-started/concepts/dataset.md) * [Architecture](getting-started/architecture-and-components/README.md) * [Overview](getting-started/architecture-and-components/overview.md) * [Feature repository](getting-started/architecture-and-components/feature-repository.md) diff --git a/docs/getting-started/concepts/README.md b/docs/getting-started/concepts/README.md index 99ff5861867..7ad0115a72b 100644 --- a/docs/getting-started/concepts/README.md +++ b/docs/getting-started/concepts/README.md @@ -14,3 +14,4 @@ {% page-ref page="point-in-time-joins.md" %} +{% page-ref page="dataset.md" %} diff --git a/docs/getting-started/concepts/dataset.md b/docs/getting-started/concepts/dataset.md new file mode 100644 index 00000000000..9bdbbfffdfe --- /dev/null +++ b/docs/getting-started/concepts/dataset.md @@ -0,0 +1,46 @@ +# Dataset + +Feast datasets allow for conveniently saving dataframes that include both features and entities to be subsequently used for data analysis and model training. +[Data Quality Monitoring](https://docs.google.com/document/d/110F72d4NTv80p35wDSONxhhPBqWRwbZXG4f9mNEMd98) was the primary motivation for creating dataset concept. + +Dataset's metadata is stored in the Feast registry and raw data (features, entities, additional input keys and timestamp) is stored in the [offline store](../architecture-and-components/offline-store.md). + +Dataset can be created from: +1. Results of historical retrieval +2. [planned] Logging request (including input for [on demand transformation](../../reference/alpha-on-demand-feature-view.md)) and response during feature serving +3. [planned] Logging features during writing to online store (from batch source or stream) + + +### Creating Saved Dataset from Historical Retrieval + +To create a saved dataset from historical features for later retrieval or analysis, a user needs to call `get_historical_features` method first and then pass the returned retrieval job to `create_saved_dataset` method. +`create_saved_dataset` will trigger provided retrieval job (by calling `.persist()` on it) to store the data using specified `storage`. +Storage type must be the same as globally configured offline store (eg, it's impossible to persist data to Redshift with BigQuery source). +`create_saved_dataset` will also create SavedDataset object with all related metadata and will write it to the registry. + +```python +from feast import FeatureStore +from feast.infra.offline_stores.bigquery_source import SavedDatasetBigQueryStorage + +store = FeatureStore() + +historical_job = store.get_historical_features( + features=["driver:avg_trip"], + entity_df=..., +) + +dataset = store.create_saved_dataset( + from_=historical_job, + name='my_training_dataset', + storage=SavedDatasetBigQueryStorage(table_ref='..my_training_dataset'), + tags={'author': 'oleksii'} +) + +dataset.to_df() +``` + +Saved dataset can be later retrieved using `get_saved_dataset` method: +```python +dataset = store.get_saved_dataset('my_training_dataset') +dataset.to_df() +``` \ No newline at end of file diff --git a/protos/feast/core/Registry.proto b/protos/feast/core/Registry.proto index 912fa1b90a1..3deeb972385 100644 --- a/protos/feast/core/Registry.proto +++ b/protos/feast/core/Registry.proto @@ -28,6 +28,7 @@ import "feast/core/FeatureView.proto"; import "feast/core/InfraObject.proto"; import "feast/core/OnDemandFeatureView.proto"; import "feast/core/RequestFeatureView.proto"; +import "feast/core/SavedDataset.proto"; import "google/protobuf/timestamp.proto"; message Registry { @@ -37,6 +38,7 @@ message Registry { repeated OnDemandFeatureView on_demand_feature_views = 8; repeated RequestFeatureView request_feature_views = 9; repeated FeatureService feature_services = 7; + repeated SavedDataset saved_datasets = 11; Infra infra = 10; string registry_schema_version = 3; // to support migrations; incremented when schema is changed diff --git a/protos/feast/core/SavedDataset.proto b/protos/feast/core/SavedDataset.proto new file mode 100644 index 00000000000..6ec9df08356 --- /dev/null +++ b/protos/feast/core/SavedDataset.proto @@ -0,0 +1,76 @@ +// +// Copyright 2021 The Feast Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +syntax = "proto3"; + +package feast.core; +option java_package = "feast.proto.core"; +option java_outer_classname = "SavedDatasetProto"; +option go_package = "github.com/feast-dev/feast/sdk/go/protos/feast/core"; + +import "google/protobuf/timestamp.proto"; +import "feast/core/FeatureViewProjection.proto"; +import "feast/core/DataSource.proto"; + +message SavedDatasetSpec { + // Name of the dataset. Must be unique since it's possible to overwrite dataset by name + string name = 1; + + // Name of Feast project that this Dataset belongs to. + string project = 2; + + // list of feature references with format ":" + repeated string features = 3; + + // entity columns + request columns from all feature views used during retrieval + repeated string join_keys = 4; + + // Whether full feature names are used in stored data + bool full_feature_names = 5; + + SavedDatasetStorage storage = 6; + + // User defined metadata + map tags = 7; +} + +message SavedDatasetStorage { + oneof kind { + DataSource.FileOptions file_storage = 4; + DataSource.BigQueryOptions bigquery_storage = 5; + DataSource.RedshiftOptions redshift_storage = 6; + } +} + +message SavedDatasetMeta { + // Time when this saved dataset is created + google.protobuf.Timestamp created_timestamp = 1; + + // Time when this saved dataset is last updated + google.protobuf.Timestamp last_updated_timestamp = 2; + + // Min timestamp in the dataset (needed for retrieval) + google.protobuf.Timestamp min_event_timestamp = 3; + + // Max timestamp in the dataset (needed for retrieval) + google.protobuf.Timestamp max_event_timestamp = 4; +} + +message SavedDataset { + SavedDatasetSpec spec = 1; + SavedDatasetMeta meta = 2; +} diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py index 8592960acd2..8a4f365b446 100644 --- a/sdk/python/feast/errors.py +++ b/sdk/python/feast/errors.py @@ -74,6 +74,11 @@ def __init__(self, bucket): super().__init__(f"S3 bucket {bucket} for the Feast registry can't be accessed") +class SavedDatasetNotFound(FeastObjectNotFoundException): + def __init__(self, name: str, project: str): + super().__init__(f"Saved dataset {name} does not exist in project {project}") + + class FeastProviderLoginError(Exception): """Error class that indicates a user has not authenticated with their provider.""" diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index c4d03fd01cc..0024b368fe0 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -77,6 +77,7 @@ from feast.repo_config import RepoConfig, load_repo_config from feast.repo_contents import RepoContents from feast.request_feature_view import RequestFeatureView +from feast.saved_dataset import SavedDataset, SavedDatasetStorage from feast.type_map import python_values_to_proto_values from feast.usage import log_exceptions, log_exceptions_and_usage, set_usage_attribute from feast.value_type import ValueType @@ -764,6 +765,93 @@ def get_historical_features( return job + @log_exceptions_and_usage + def create_saved_dataset( + self, + from_: RetrievalJob, + name: str, + storage: SavedDatasetStorage, + tags: Optional[Dict[str, str]] = None, + ) -> SavedDataset: + """ + Execute provided retrieval job and persist its outcome in given storage. + Storage type (eg, BigQuery or Redshift) must be the same as globally configured offline store. + After data successfully persisted saved dataset object with dataset metadata is committed to the registry. + Name for the saved dataset should be unique within project, since it's possible to overwrite previously stored dataset + with the same name. + + Returns: + SavedDataset object with attached RetrievalJob + + Raises: + ValueError if given retrieval job doesn't have metadata + """ + warnings.warn( + "Saving dataset is an experimental feature. " + "This API is unstable and it could and most probably will be changed in the future. " + "We do not guarantee that future changes will maintain backward compatibility.", + RuntimeWarning, + ) + + if not from_.metadata: + raise ValueError( + "RetrievalJob must contains metadata. " + "Use RetrievalJob produced by get_historical_features" + ) + + dataset = SavedDataset( + name=name, + features=from_.metadata.features, + join_keys=from_.metadata.keys, + full_feature_names=from_.full_feature_names, + storage=storage, + tags=tags, + ) + + dataset.min_event_timestamp = from_.metadata.min_event_timestamp + dataset.max_event_timestamp = from_.metadata.max_event_timestamp + + from_.persist(storage) + + self._registry.apply_saved_dataset(dataset, self.project, commit=True) + + return dataset.with_retrieval_job( + self._get_provider().retrieve_saved_dataset( + config=self.config, dataset=dataset + ) + ) + + @log_exceptions_and_usage + def get_saved_dataset(self, name: str) -> SavedDataset: + """ + Find a saved dataset in the registry by provided name and + create a retrieval job to pull whole dataset from storage (offline store). + + If dataset couldn't be found by provided name SavedDatasetNotFound exception will be raised. + + Data will be retrieved from globally configured offline store. + + Returns: + SavedDataset with RetrievalJob attached + + Raises: + SavedDatasetNotFound + """ + warnings.warn( + "Retrieving datasets is an experimental feature. " + "This API is unstable and it could and most probably will be changed in the future. " + "We do not guarantee that future changes will maintain backward compatibility.", + RuntimeWarning, + ) + + dataset = self._registry.get_saved_dataset(name, self.project) + provider = self._get_provider() + + retrieval_job = provider.retrieve_saved_dataset( + config=self.config, dataset=dataset + ) + return dataset.with_retrieval_job(retrieval_job) + @log_exceptions_and_usage def materialize_incremental( self, end_date: datetime, feature_views: Optional[List[str]] = None, diff --git a/sdk/python/feast/infra/offline_stores/bigquery.py b/sdk/python/feast/infra/offline_stores/bigquery.py index 34dde7aa7b9..42a1a839073 100644 --- a/sdk/python/feast/infra/offline_stores/bigquery.py +++ b/sdk/python/feast/infra/offline_stores/bigquery.py @@ -30,19 +30,24 @@ ) from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_VAL, FeatureView from feast.infra.offline_stores import offline_utils -from feast.infra.offline_stores.offline_store import OfflineStore, RetrievalJob +from feast.infra.offline_stores.offline_store import ( + OfflineStore, + RetrievalJob, + RetrievalMetadata, +) from feast.on_demand_feature_view import OnDemandFeatureView from feast.registry import Registry from feast.repo_config import FeastConfigBaseModel, RepoConfig +from ...saved_dataset import SavedDatasetStorage from ...usage import log_exceptions_and_usage -from .bigquery_source import BigQuerySource +from .bigquery_source import BigQuerySource, SavedDatasetBigQueryStorage try: from google.api_core.exceptions import NotFound from google.auth.exceptions import DefaultCredentialsError from google.cloud import bigquery - from google.cloud.bigquery import Client + from google.cloud.bigquery import Client, Table except ImportError as e: from feast.errors import FeastExtrasDependencyImportError @@ -119,6 +124,36 @@ def pull_latest_from_table_or_query( query=query, client=client, config=config, full_feature_names=False, ) + @staticmethod + @log_exceptions_and_usage(offline_store="bigquery") + def pull_all_from_table_or_query( + 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 isinstance(data_source, BigQuerySource) + from_expression = data_source.get_table_query_string() + + client = _get_bigquery_client( + project=config.offline_store.project_id, + location=config.offline_store.location, + ) + field_string = ", ".join( + join_key_columns + feature_name_columns + [event_timestamp_column] + ) + query = f""" + SELECT {field_string} + FROM {from_expression} + WHERE {event_timestamp_column} BETWEEN TIMESTAMP('{start_date}') AND TIMESTAMP('{end_date}') + """ + return BigQueryRetrievalJob( + query=query, client=client, config=config, full_feature_names=False, + ) + @staticmethod @log_exceptions_and_usage(offline_store="bigquery") def get_historical_features( @@ -147,16 +182,22 @@ def get_historical_features( config.offline_store.location, ) + entity_schema = _get_entity_schema(client=client, entity_df=entity_df,) + + entity_df_event_timestamp_col = offline_utils.infer_event_timestamp_from_entity_df( + entity_schema + ) + + entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( + entity_df, entity_df_event_timestamp_col, client, + ) + @contextlib.contextmanager def query_generator() -> Iterator[str]: - entity_schema = _upload_entity_df_and_get_entity_schema( + _upload_entity_df( client=client, table_name=table_reference, entity_df=entity_df, ) - entity_df_event_timestamp_col = offline_utils.infer_event_timestamp_from_entity_df( - entity_schema - ) - expected_join_keys = offline_utils.get_expected_join_keys( project, feature_views, registry ) @@ -165,10 +206,6 @@ def query_generator() -> Iterator[str]: entity_schema, expected_join_keys, entity_df_event_timestamp_col ) - entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( - entity_df, entity_df_event_timestamp_col, client, table_reference, - ) - # Build a query context containing all information required to template the BigQuery SQL query query_context = offline_utils.get_feature_view_query_context( feature_refs, @@ -203,6 +240,12 @@ def query_generator() -> Iterator[str]: on_demand_feature_views=OnDemandFeatureView.get_requested_odfvs( feature_refs, project, registry ), + metadata=RetrievalMetadata( + features=feature_refs, + keys=list(entity_schema.keys() - {entity_df_event_timestamp_col}), + min_event_timestamp=entity_df_event_timestamp_range[0], + max_event_timestamp=entity_df_event_timestamp_range[1], + ), ) @@ -214,6 +257,7 @@ def __init__( config: RepoConfig, full_feature_names: bool, on_demand_feature_views: Optional[List[OnDemandFeatureView]] = None, + metadata: Optional[RetrievalMetadata] = None, ): if not isinstance(query, str): self._query_generator = query @@ -231,6 +275,7 @@ def query_generator() -> Iterator[str]: self._on_demand_feature_views = ( on_demand_feature_views if on_demand_feature_views else [] ) + self._metadata = metadata @property def full_feature_names(self) -> bool: @@ -310,6 +355,17 @@ def _execute_query( block_until_done(client=self.client, bq_job=bq_job, timeout=timeout) return bq_job + def persist(self, storage: SavedDatasetStorage): + assert isinstance(storage, SavedDatasetBigQueryStorage) + + self.to_bigquery( + bigquery.QueryJobConfig(destination=storage.bigquery_options.table_ref) + ) + + @property + def metadata(self) -> Optional[RetrievalMetadata]: + return self._metadata + def block_until_done( client: Client, @@ -380,34 +436,45 @@ def _get_table_reference_for_new_entity( return f"{dataset_project}.{dataset_name}.{table_name}" -def _upload_entity_df_and_get_entity_schema( +def _upload_entity_df( client: Client, table_name: str, entity_df: Union[pd.DataFrame, str], -) -> Dict[str, np.dtype]: +) -> Table: """Uploads a Pandas entity dataframe into a BigQuery table and returns the resulting table""" if isinstance(entity_df, str): job = client.query(f"CREATE TABLE {table_name} AS ({entity_df})") - block_until_done(client, job) - - limited_entity_df = ( - client.query(f"SELECT * FROM {table_name} LIMIT 1").result().to_dataframe() - ) - entity_schema = dict(zip(limited_entity_df.columns, limited_entity_df.dtypes)) elif isinstance(entity_df, pd.DataFrame): - # Drop the index so that we dont have unnecessary columns + # Drop the index so that we don't have unnecessary columns entity_df.reset_index(drop=True, inplace=True) job = client.load_table_from_dataframe(entity_df, table_name) - block_until_done(client, job) - entity_schema = dict(zip(entity_df.columns, entity_df.dtypes)) else: raise InvalidEntityType(type(entity_df)) + block_until_done(client, job) + # Ensure that the table expires after some time table = client.get_table(table=table_name) table.expires = datetime.utcnow() + timedelta(minutes=30) client.update_table(table, ["expires"]) + return table + + +def _get_entity_schema( + client: Client, entity_df: Union[pd.DataFrame, str] +) -> Dict[str, np.dtype]: + if isinstance(entity_df, str): + entity_df_sample = ( + client.query(f"SELECT * FROM ({entity_df}) LIMIT 1").result().to_dataframe() + ) + + entity_schema = dict(zip(entity_df_sample.columns, entity_df_sample.dtypes)) + elif isinstance(entity_df, pd.DataFrame): + entity_schema = dict(zip(entity_df.columns, entity_df.dtypes)) + else: + raise InvalidEntityType(type(entity_df)) + return entity_schema @@ -415,11 +482,11 @@ def _get_entity_df_event_timestamp_range( entity_df: Union[pd.DataFrame, str], entity_df_event_timestamp_col: str, client: Client, - table_name: str, ) -> Tuple[datetime, datetime]: if type(entity_df) is str: job = client.query( - f"SELECT MIN({entity_df_event_timestamp_col}) AS min, MAX({entity_df_event_timestamp_col}) AS max FROM {table_name}" + f"SELECT MIN({entity_df_event_timestamp_col}) AS min, MAX({entity_df_event_timestamp_col}) AS max " + f"FROM ({entity_df})" ) res = next(job.result()) entity_df_event_timestamp_range = ( @@ -435,8 +502,8 @@ def _get_entity_df_event_timestamp_range( entity_df_event_timestamp, utc=True ) entity_df_event_timestamp_range = ( - entity_df_event_timestamp.min(), - entity_df_event_timestamp.max(), + entity_df_event_timestamp.min().to_pydatetime(), + entity_df_event_timestamp.max().to_pydatetime(), ) else: raise InvalidEntityType(type(entity_df)) diff --git a/sdk/python/feast/infra/offline_stores/bigquery_source.py b/sdk/python/feast/infra/offline_stores/bigquery_source.py index a5c1afa3e02..30385e5b290 100644 --- a/sdk/python/feast/infra/offline_stores/bigquery_source.py +++ b/sdk/python/feast/infra/offline_stores/bigquery_source.py @@ -4,7 +4,11 @@ from feast.data_source import DataSource from feast.errors import DataSourceNotFoundException from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto +from feast.protos.feast.core.SavedDataset_pb2 import ( + SavedDatasetStorage as SavedDatasetStorageProto, +) from feast.repo_config import RepoConfig +from feast.saved_dataset import SavedDatasetStorage from feast.value_type import ValueType @@ -204,3 +208,28 @@ def to_proto(self) -> DataSourceProto.BigQueryOptions: ) return bigquery_options_proto + + +class SavedDatasetBigQueryStorage(SavedDatasetStorage): + _proto_attr_name = "bigquery_storage" + + bigquery_options: BigQueryOptions + + def __init__(self, table_ref: str): + self.bigquery_options = BigQueryOptions(table_ref=table_ref, query=None) + + @staticmethod + def from_proto(storage_proto: SavedDatasetStorageProto) -> SavedDatasetStorage: + return SavedDatasetBigQueryStorage( + table_ref=BigQueryOptions.from_proto( + storage_proto.bigquery_storage + ).table_ref + ) + + def to_proto(self) -> SavedDatasetStorageProto: + return SavedDatasetStorageProto( + bigquery_storage=self.bigquery_options.to_proto() + ) + + def to_data_source(self) -> DataSource: + return BigQuerySource(table_ref=self.bigquery_options.table_ref) diff --git a/sdk/python/feast/infra/offline_stores/file.py b/sdk/python/feast/infra/offline_stores/file.py index 723e9eb5335..6bc8b825389 100644 --- a/sdk/python/feast/infra/offline_stores/file.py +++ b/sdk/python/feast/infra/offline_stores/file.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Callable, List, Optional, Union +from typing import Callable, List, Optional, Tuple, Union import pandas as pd import pyarrow @@ -10,7 +10,12 @@ from feast.data_source import DataSource from feast.errors import FeastJoinKeysDuringMaterialization from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_VAL, FeatureView -from feast.infra.offline_stores.offline_store import OfflineStore, RetrievalJob +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, ) @@ -20,6 +25,7 @@ ) from feast.registry import Registry from feast.repo_config import FeastConfigBaseModel, RepoConfig +from feast.saved_dataset import SavedDatasetStorage from feast.usage import log_exceptions_and_usage @@ -36,6 +42,7 @@ def __init__( evaluation_function: Callable, full_feature_names: bool, on_demand_feature_views: Optional[List[OnDemandFeatureView]] = None, + metadata: Optional[RetrievalMetadata] = None, ): """Initialize a lazy historical retrieval job""" @@ -45,6 +52,7 @@ def __init__( self._on_demand_feature_views = ( on_demand_feature_views if on_demand_feature_views else [] ) + self._metadata = metadata @property def full_feature_names(self) -> bool: @@ -66,6 +74,27 @@ def _to_arrow_internal(self): df = self.evaluation_function() return pyarrow.Table.from_pandas(df) + 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_internal(), where=path, filesystem=filesystem + ) + else: + # otherwise assume destination is directory + pyarrow.parquet.write_to_dataset( + self._to_arrow_internal(), root_path=path, filesystem=filesystem + ) + + @property + def metadata(self) -> Optional[RetrievalMetadata]: + return self._metadata + class FileOfflineStore(OfflineStore): @staticmethod @@ -106,6 +135,10 @@ def get_historical_features( registry.list_on_demand_feature_views(config.project), ) + entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( + entity_df, entity_df_event_timestamp_col + ) + # Create lazy function that is only called from the RetrievalJob object def evaluate_historical_retrieval(): @@ -266,6 +299,12 @@ def evaluate_historical_retrieval(): on_demand_feature_views=OnDemandFeatureView.get_requested_odfvs( feature_refs, project, registry ), + metadata=RetrievalMetadata( + features=feature_refs, + keys=list(set(entity_df.columns) - {entity_df_event_timestamp_col}), + min_event_timestamp=entity_df_event_timestamp_range[0], + max_event_timestamp=entity_df_event_timestamp_range[1], + ), ) return job @@ -337,3 +376,46 @@ def evaluate_offline_job(): return FileRetrievalJob( evaluation_function=evaluate_offline_job, full_feature_names=False, ) + + @staticmethod + @log_exceptions_and_usage(offline_store="file") + def pull_all_from_table_or_query( + 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: + return FileOfflineStore.pull_latest_from_table_or_query( + config=config, + data_source=data_source, + join_key_columns=join_key_columns + + [event_timestamp_column], # avoid deduplication + feature_name_columns=feature_name_columns, + event_timestamp_column=event_timestamp_column, + created_timestamp_column=None, + start_date=start_date, + end_date=end_date, + ) + + +def _get_entity_df_event_timestamp_range( + entity_df: Union[pd.DataFrame, str], entity_df_event_timestamp_col: str, +) -> Tuple[datetime, datetime]: + if not isinstance(entity_df, pd.DataFrame): + raise ValueError( + f"Please provide an entity_df of type {type(pd.DataFrame)} instead of type {type(entity_df)}" + ) + + entity_df_event_timestamp = entity_df.loc[ + :, entity_df_event_timestamp_col + ].infer_objects() + if pd.api.types.is_string_dtype(entity_df_event_timestamp): + entity_df_event_timestamp = pd.to_datetime(entity_df_event_timestamp, utc=True) + + return ( + entity_df_event_timestamp.min().to_pydatetime(), + entity_df_event_timestamp.max().to_pydatetime(), + ) diff --git a/sdk/python/feast/infra/offline_stores/file_source.py b/sdk/python/feast/infra/offline_stores/file_source.py index 31eb5f037f0..7d52110985a 100644 --- a/sdk/python/feast/infra/offline_stores/file_source.py +++ b/sdk/python/feast/infra/offline_stores/file_source.py @@ -5,10 +5,14 @@ from pyarrow.parquet import ParquetFile from feast import type_map -from feast.data_format import FileFormat +from feast.data_format import FileFormat, ParquetFormat from feast.data_source import DataSource from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto +from feast.protos.feast.core.SavedDataset_pb2 import ( + SavedDatasetStorage as SavedDatasetStorageProto, +) from feast.repo_config import RepoConfig +from feast.saved_dataset import SavedDatasetStorage from feast.value_type import ValueType @@ -260,3 +264,40 @@ def to_proto(self) -> DataSourceProto.FileOptions: ) return file_options_proto + + +class SavedDatasetFileStorage(SavedDatasetStorage): + _proto_attr_name = "file_storage" + + file_options: FileOptions + + def __init__( + self, + path: str, + file_format: FileFormat = ParquetFormat(), + s3_endpoint_override: Optional[str] = None, + ): + self.file_options = FileOptions( + file_url=path, + file_format=file_format, + s3_endpoint_override=s3_endpoint_override, + ) + + @staticmethod + def from_proto(storage_proto: SavedDatasetStorageProto) -> SavedDatasetStorage: + file_options = FileOptions.from_proto(storage_proto.file_storage) + return SavedDatasetFileStorage( + path=file_options.file_url, + file_format=file_options.file_format, + s3_endpoint_override=file_options.s3_endpoint_override, + ) + + def to_proto(self) -> SavedDatasetStorageProto: + return SavedDatasetStorageProto(file_storage=self.file_options.to_proto()) + + def to_data_source(self) -> DataSource: + return FileSource( + path=self.file_options.file_url, + file_format=self.file_options.file_format, + s3_endpoint_override=self.file_options.s3_endpoint_override, + ) diff --git a/sdk/python/feast/infra/offline_stores/offline_store.py b/sdk/python/feast/infra/offline_stores/offline_store.py index 0ba81971543..6a1372b39b3 100644 --- a/sdk/python/feast/infra/offline_stores/offline_store.py +++ b/sdk/python/feast/infra/offline_stores/offline_store.py @@ -23,6 +23,29 @@ from feast.on_demand_feature_view import OnDemandFeatureView from feast.registry import Registry from feast.repo_config import RepoConfig +from feast.saved_dataset import SavedDatasetStorage + + +class RetrievalMetadata: + min_event_timestamp: Optional[datetime] + max_event_timestamp: Optional[datetime] + + # List of feature references + features: List[str] + # List of entity keys + ODFV inputs + keys: List[str] + + def __init__( + self, + features: List[str], + keys: List[str], + min_event_timestamp: Optional[datetime] = None, + max_event_timestamp: Optional[datetime] = None, + ): + self.features = features + self.keys = keys + self.min_event_timestamp = min_event_timestamp + self.max_event_timestamp = max_event_timestamp class RetrievalJob(ABC): @@ -73,6 +96,22 @@ def to_arrow(self) -> pyarrow.Table: ) return pyarrow.Table.from_pandas(features_df) + @abstractmethod + def persist(self, storage: SavedDatasetStorage): + """ + Run the retrieval and persist the results in the same offline store used for read. + """ + pass + + @property + @abstractmethod + def metadata(self) -> Optional[RetrievalMetadata]: + """ + Return metadata information about retrieval. + Should be available even before materializing the dataset itself. + """ + pass + class OfflineStore(ABC): """ @@ -111,3 +150,21 @@ def get_historical_features( full_feature_names: bool = False, ) -> RetrievalJob: pass + + @staticmethod + @abstractmethod + def pull_all_from_table_or_query( + 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: + """ + Note that join_key_columns, feature_name_columns, event_timestamp_column, and created_timestamp_column + have all already been mapped to column names of the source table and those column names are the values passed + into this function. + """ + pass diff --git a/sdk/python/feast/infra/offline_stores/redshift.py b/sdk/python/feast/infra/offline_stores/redshift.py index df363967d6e..2aa3d5c41c6 100644 --- a/sdk/python/feast/infra/offline_stores/redshift.py +++ b/sdk/python/feast/infra/offline_stores/redshift.py @@ -25,10 +25,16 @@ from feast.errors import InvalidEntityType from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_VAL, FeatureView from feast.infra.offline_stores import offline_utils -from feast.infra.offline_stores.offline_store import OfflineStore, RetrievalJob +from feast.infra.offline_stores.offline_store import ( + OfflineStore, + RetrievalJob, + RetrievalMetadata, +) +from feast.infra.offline_stores.redshift_source import SavedDatasetRedshiftStorage from feast.infra.utils import aws_utils from feast.registry import Registry from feast.repo_config import FeastConfigBaseModel, RepoConfig +from feast.saved_dataset import SavedDatasetStorage from feast.usage import log_exceptions_and_usage @@ -117,6 +123,46 @@ def pull_latest_from_table_or_query( full_feature_names=False, ) + @staticmethod + @log_exceptions_and_usage(offline_store="redshift") + def pull_all_from_table_or_query( + 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 isinstance(data_source, RedshiftSource) + from_expression = data_source.get_table_query_string() + + field_string = ", ".join( + join_key_columns + feature_name_columns + [event_timestamp_column] + ) + + redshift_client = aws_utils.get_redshift_data_client( + config.offline_store.region + ) + s3_resource = aws_utils.get_s3_resource(config.offline_store.region) + + start_date = start_date.astimezone(tz=utc) + end_date = end_date.astimezone(tz=utc) + + query = f""" + SELECT {field_string} + FROM {from_expression} + WHERE {event_timestamp_column} BETWEEN TIMESTAMP '{start_date}' AND TIMESTAMP '{end_date}' + """ + + return RedshiftRetrievalJob( + query=query, + redshift_client=redshift_client, + s3_resource=s3_resource, + config=config, + full_feature_names=False, + ) + @staticmethod @log_exceptions_and_usage(offline_store="redshift") def get_historical_features( @@ -135,18 +181,26 @@ def get_historical_features( ) s3_resource = aws_utils.get_s3_resource(config.offline_store.region) + entity_schema = _get_entity_schema( + entity_df, redshift_client, config, s3_resource + ) + + entity_df_event_timestamp_col = offline_utils.infer_event_timestamp_from_entity_df( + entity_schema + ) + + entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( + entity_df, entity_df_event_timestamp_col, redshift_client, config, + ) + @contextlib.contextmanager def query_generator() -> Iterator[str]: table_name = offline_utils.get_temp_entity_table_name() - entity_schema = _upload_entity_df_and_get_entity_schema( + _upload_entity_df( entity_df, redshift_client, config, s3_resource, table_name ) - entity_df_event_timestamp_col = offline_utils.infer_event_timestamp_from_entity_df( - entity_schema - ) - expected_join_keys = offline_utils.get_expected_join_keys( project, feature_views, registry ) @@ -155,14 +209,6 @@ def query_generator() -> Iterator[str]: entity_schema, expected_join_keys, entity_df_event_timestamp_col ) - entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( - entity_df, - entity_df_event_timestamp_col, - redshift_client, - config, - table_name, - ) - # Build a query context containing all information required to template the Redshift SQL query query_context = offline_utils.get_feature_view_query_context( feature_refs, @@ -203,6 +249,12 @@ def query_generator() -> Iterator[str]: on_demand_feature_views=OnDemandFeatureView.get_requested_odfvs( feature_refs, project, registry ), + metadata=RetrievalMetadata( + features=feature_refs, + keys=list(entity_schema.keys() - {entity_df_event_timestamp_col}), + min_event_timestamp=entity_df_event_timestamp_range[0], + max_event_timestamp=entity_df_event_timestamp_range[1], + ), ) @@ -215,6 +267,7 @@ def __init__( config: RepoConfig, full_feature_names: bool, on_demand_feature_views: Optional[List[OnDemandFeatureView]] = None, + metadata: Optional[RetrievalMetadata] = None, ): """Initialize RedshiftRetrievalJob object. @@ -248,6 +301,7 @@ def query_generator() -> Iterator[str]: self._on_demand_feature_views = ( on_demand_feature_views if on_demand_feature_views else [] ) + self._metadata = metadata @property def full_feature_names(self) -> bool: @@ -334,17 +388,24 @@ def to_redshift(self, table_name: str) -> None: query, ) + def persist(self, storage: SavedDatasetStorage): + assert isinstance(storage, SavedDatasetRedshiftStorage) + self.to_redshift(table_name=storage.redshift_options.table) -def _upload_entity_df_and_get_entity_schema( + @property + def metadata(self) -> Optional[RetrievalMetadata]: + return self._metadata + + +def _upload_entity_df( entity_df: Union[pd.DataFrame, str], redshift_client, config: RepoConfig, s3_resource, table_name: str, -) -> Dict[str, np.dtype]: +): if isinstance(entity_df, pd.DataFrame): # If the entity_df is a pandas dataframe, upload it to Redshift - # and construct the schema from the original entity_df dataframe aws_utils.upload_df_to_redshift( redshift_client, config.offline_store.cluster_id, @@ -356,10 +417,8 @@ def _upload_entity_df_and_get_entity_schema( table_name, entity_df, ) - return dict(zip(entity_df.columns, entity_df.dtypes)) elif isinstance(entity_df, str): - # If the entity_df is a string (SQL query), create a Redshift table out of it, - # get pandas dataframe consisting of 1 row (LIMIT 1) and generate the schema out of it + # If the entity_df is a string (SQL query), create a Redshift table out of it aws_utils.execute_redshift_statement( redshift_client, config.offline_store.cluster_id, @@ -367,14 +426,29 @@ def _upload_entity_df_and_get_entity_schema( config.offline_store.user, f"CREATE TABLE {table_name} AS ({entity_df})", ) - limited_entity_df = RedshiftRetrievalJob( - f"SELECT * FROM {table_name} LIMIT 1", + else: + raise InvalidEntityType(type(entity_df)) + + +def _get_entity_schema( + entity_df: Union[pd.DataFrame, str], + redshift_client, + config: RepoConfig, + s3_resource, +) -> Dict[str, np.dtype]: + if isinstance(entity_df, pd.DataFrame): + return dict(zip(entity_df.columns, entity_df.dtypes)) + + elif isinstance(entity_df, str): + # get pandas dataframe consisting of 1 row (LIMIT 1) and generate the schema out of it + entity_df_sample = RedshiftRetrievalJob( + f"SELECT * FROM ({entity_df}) LIMIT 1", redshift_client, s3_resource, config, full_feature_names=False, ).to_df() - return dict(zip(limited_entity_df.columns, limited_entity_df.dtypes)) + return dict(zip(entity_df_sample.columns, entity_df_sample.dtypes)) else: raise InvalidEntityType(type(entity_df)) @@ -384,7 +458,6 @@ def _get_entity_df_event_timestamp_range( entity_df_event_timestamp_col: str, redshift_client, config: RepoConfig, - table_name: str, ) -> Tuple[datetime, datetime]: if isinstance(entity_df, pd.DataFrame): entity_df_event_timestamp = entity_df.loc[ @@ -395,8 +468,8 @@ def _get_entity_df_event_timestamp_range( entity_df_event_timestamp, utc=True ) entity_df_event_timestamp_range = ( - entity_df_event_timestamp.min(), - entity_df_event_timestamp.max(), + entity_df_event_timestamp.min().to_pydatetime(), + entity_df_event_timestamp.max().to_pydatetime(), ) elif isinstance(entity_df, str): # If the entity_df is a string (SQL query), determine range @@ -406,7 +479,8 @@ def _get_entity_df_event_timestamp_range( config.offline_store.cluster_id, config.offline_store.database, config.offline_store.user, - f"SELECT MIN({entity_df_event_timestamp_col}) AS min, MAX({entity_df_event_timestamp_col}) AS max FROM {table_name}", + f"SELECT MIN({entity_df_event_timestamp_col}) AS min, MAX({entity_df_event_timestamp_col}) AS max " + f"FROM ({entity_df})", ) res = aws_utils.get_redshift_statement_result(redshift_client, statement_id)[ "Records" diff --git a/sdk/python/feast/infra/offline_stores/redshift_source.py b/sdk/python/feast/infra/offline_stores/redshift_source.py index e7e88a54ef7..949f1c9221c 100644 --- a/sdk/python/feast/infra/offline_stores/redshift_source.py +++ b/sdk/python/feast/infra/offline_stores/redshift_source.py @@ -4,7 +4,11 @@ from feast.data_source import DataSource from feast.errors import DataSourceNotFoundException, RedshiftCredentialsError from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto +from feast.protos.feast.core.SavedDataset_pb2 import ( + SavedDatasetStorage as SavedDatasetStorageProto, +) from feast.repo_config import RepoConfig +from feast.saved_dataset import SavedDatasetStorage from feast.value_type import ValueType @@ -269,3 +273,29 @@ def to_proto(self) -> DataSourceProto.RedshiftOptions: ) return redshift_options_proto + + +class SavedDatasetRedshiftStorage(SavedDatasetStorage): + _proto_attr_name = "redshift_storage" + + redshift_options: RedshiftOptions + + def __init__(self, table_ref: str): + self.redshift_options = RedshiftOptions( + table=table_ref, schema=None, query=None + ) + + @staticmethod + def from_proto(storage_proto: SavedDatasetStorageProto) -> SavedDatasetStorage: + + return SavedDatasetRedshiftStorage( + table_ref=RedshiftOptions.from_proto(storage_proto.redshift_storage).table + ) + + def to_proto(self) -> SavedDatasetStorageProto: + return SavedDatasetStorageProto( + redshift_storage=self.redshift_options.to_proto() + ) + + def to_data_source(self) -> DataSource: + return RedshiftSource(table=self.redshift_options.table) diff --git a/sdk/python/feast/infra/passthrough_provider.py b/sdk/python/feast/infra/passthrough_provider.py index 98937ce1fae..de2aca7cc16 100644 --- a/sdk/python/feast/infra/passthrough_provider.py +++ b/sdk/python/feast/infra/passthrough_provider.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union import pandas @@ -20,7 +20,9 @@ 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 DEFAULT_BATCH_SIZE = 10_000 @@ -177,4 +179,28 @@ def get_historical_features( project=project, full_feature_names=full_feature_names, ) + return job + + def retrieve_saved_dataset( + self, config: RepoConfig, dataset: SavedDataset + ) -> RetrievalJob: + set_usage_attribute("provider", self.__class__.__name__) + + 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 + ) diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index 8f9dda93515..a53030b74f9 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -20,6 +20,7 @@ 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.type_map import python_values_to_proto_values from feast.value_type import ValueType @@ -169,6 +170,21 @@ def online_read( """ ... + @abc.abstractmethod + def retrieve_saved_dataset( + self, config: RepoConfig, dataset: SavedDataset + ) -> RetrievalJob: + """ + Read saved dataset from offline store. + All parameters for retrieval (like path, datetime boundaries, column names for both keys and features, etc) + are determined from SavedDataset object. + + Returns: + RetrievalJob object, which is lazy wrapper for actual query performed under the hood. + + """ + ... + def get_feature_server_endpoint(self) -> Optional[str]: """Returns endpoint for the feature server, if it exists.""" return None diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index f05abc6d9ae..714b096e23f 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -32,6 +32,7 @@ FeatureServiceNotFoundException, FeatureViewNotFoundException, OnDemandFeatureViewNotFoundException, + SavedDatasetNotFound, ) from feast.feature_service import FeatureService from feast.feature_view import FeatureView @@ -42,6 +43,7 @@ from feast.registry_store import NoopRegistryStore from feast.repo_config import RegistryConfig from feast.request_feature_view import RequestFeatureView +from feast.saved_dataset import SavedDataset REGISTRY_SCHEMA_VERSION = "1" @@ -639,6 +641,80 @@ def delete_entity(self, name: str, project: str, commit: bool = True): raise EntityNotFoundException(name, project) + def apply_saved_dataset( + self, saved_dataset: SavedDataset, project: str, commit: bool = True + ): + """ + Registers a single entity with Feast + + Args: + saved_dataset: SavedDataset that will be added / updated to registry + project: Feast project that this dataset belongs to + commit: Whether the change should be persisted immediately + """ + saved_dataset_proto = saved_dataset.to_proto() + saved_dataset_proto.spec.project = project + self._prepare_registry_for_changes() + assert self.cached_registry_proto + + for idx, existing_saved_dataset_proto in enumerate( + self.cached_registry_proto.saved_datasets + ): + if ( + existing_saved_dataset_proto.spec.name == saved_dataset_proto.spec.name + and existing_saved_dataset_proto.spec.project == project + ): + del self.cached_registry_proto.saved_datasets[idx] + break + + self.cached_registry_proto.saved_datasets.append(saved_dataset_proto) + if commit: + self.commit() + + def get_saved_dataset( + self, name: str, project: str, allow_cache: bool = False + ) -> SavedDataset: + """ + Retrieves a saved dataset. + + Args: + name: Name of dataset + project: Feast project that this dataset belongs to + allow_cache: Whether to allow returning this dataset from a cached registry + + Returns: + Returns either the specified SavedDataset, or raises an exception if + none is found + """ + registry_proto = self._get_registry_proto(allow_cache=allow_cache) + for saved_dataset in registry_proto.saved_datasets: + if ( + saved_dataset.spec.name == name + and saved_dataset.spec.project == project + ): + return SavedDataset.from_proto(saved_dataset) + raise SavedDatasetNotFound(name, project=project) + + def list_saved_datasets( + self, project: str, allow_cache: bool = False + ) -> List[SavedDataset]: + """ + Retrieves a list of all saved datasets in specified project + + Args: + project: Feast project + allow_cache: Whether to allow returning this dataset from a cached registry + + Returns: + Returns the list of SavedDatasets + """ + registry_proto = self._get_registry_proto(allow_cache=allow_cache) + return [ + SavedDataset.from_proto(saved_dataset) + for saved_dataset in registry_proto.saved_datasets + if saved_dataset.spec.project == project + ] + def commit(self): """Commits the state of the registry cache to the remote registry store.""" if self.cached_registry_proto: @@ -693,6 +769,12 @@ def to_dict(self, project: str) -> Dict[str, List[Any]]: registry_dict["requestFeatureViews"].append( MessageToDict(request_feature_view.to_proto()) ) + for saved_dataset in sorted( + self.list_saved_datasets(project=project), key=lambda item: item.name + ): + registry_dict["savedDatasets"].append( + MessageToDict(saved_dataset.to_proto()) + ) return registry_dict def _prepare_registry_for_changes(self): diff --git a/sdk/python/feast/saved_dataset.py b/sdk/python/feast/saved_dataset.py new file mode 100644 index 00000000000..39708685795 --- /dev/null +++ b/sdk/python/feast/saved_dataset.py @@ -0,0 +1,185 @@ +from abc import abstractmethod +from datetime import datetime +from typing import TYPE_CHECKING, Dict, List, Optional, Type, cast + +import pandas as pd +import pyarrow +from google.protobuf.json_format import MessageToJson + +from feast.data_source import DataSource +from feast.protos.feast.core.SavedDataset_pb2 import SavedDataset as SavedDatasetProto +from feast.protos.feast.core.SavedDataset_pb2 import SavedDatasetMeta, SavedDatasetSpec +from feast.protos.feast.core.SavedDataset_pb2 import ( + SavedDatasetStorage as SavedDatasetStorageProto, +) + +if TYPE_CHECKING: + from feast.infra.offline_stores.offline_store import RetrievalJob + + +class _StorageRegistry(type): + classes_by_proto_attr_name: Dict[str, Type["SavedDatasetStorage"]] = {} + + def __new__(cls, name, bases, dct): + kls = type.__new__(cls, name, bases, dct) + if dct.get("_proto_attr_name"): + cls.classes_by_proto_attr_name[dct["_proto_attr_name"]] = kls + return kls + + +class SavedDatasetStorage(metaclass=_StorageRegistry): + _proto_attr_name: str + + @staticmethod + def from_proto(storage_proto: SavedDatasetStorageProto) -> "SavedDatasetStorage": + proto_attr_name = cast(str, storage_proto.WhichOneof("kind")) + return _StorageRegistry.classes_by_proto_attr_name[proto_attr_name].from_proto( + storage_proto + ) + + @abstractmethod + def to_proto(self) -> SavedDatasetStorageProto: + ... + + @abstractmethod + def to_data_source(self) -> DataSource: + ... + + +class SavedDataset: + name: str + features: List[str] + join_keys: List[str] + full_feature_names: bool + storage: SavedDatasetStorage + tags: Dict[str, str] + + created_timestamp: Optional[datetime] = None + last_updated_timestamp: Optional[datetime] = None + + min_event_timestamp: Optional[datetime] = None + max_event_timestamp: Optional[datetime] = None + + def __init__( + self, + name: str, + features: List[str], + join_keys: List[str], + storage: SavedDatasetStorage, + full_feature_names: bool = False, + tags: Optional[Dict[str, str]] = None, + ): + self.name = name + self.features = features + self.join_keys = join_keys + self.storage = storage + self.full_feature_names = full_feature_names + self.tags = tags or {} + + def __repr__(self): + items = (f"{k} = {v}" for k, v in self.__dict__.items()) + return f"<{self.__class__.__name__}({', '.join(items)})>" + + def __str__(self): + return str(MessageToJson(self.to_proto())) + + def __hash__(self): + return hash((id(self), self.name)) + + def __eq__(self, other): + if not isinstance(other, SavedDataset): + raise TypeError( + "Comparisons should only involve FeatureService class objects." + ) + if self.name != other.name: + return False + + if sorted(self.features) != sorted(other.features): + return False + + return True + + @staticmethod + def from_proto(saved_dataset_proto: SavedDatasetProto): + """ + Converts a SavedDatasetProto to a SavedDataset object. + + Args: + saved_dataset_proto: A protobuf representation of a SavedDataset. + """ + ds = SavedDataset( + name=saved_dataset_proto.spec.name, + features=list(saved_dataset_proto.spec.features), + join_keys=list(saved_dataset_proto.spec.join_keys), + full_feature_names=saved_dataset_proto.spec.full_feature_names, + storage=SavedDatasetStorage.from_proto(saved_dataset_proto.spec.storage), + tags=dict(saved_dataset_proto.spec.tags.items()), + ) + + if saved_dataset_proto.meta.HasField("created_timestamp"): + ds.created_timestamp = ( + saved_dataset_proto.meta.created_timestamp.ToDatetime() + ) + if saved_dataset_proto.meta.HasField("last_updated_timestamp"): + ds.last_updated_timestamp = ( + saved_dataset_proto.meta.last_updated_timestamp.ToDatetime() + ) + if saved_dataset_proto.meta.HasField("min_event_timestamp"): + ds.min_event_timestamp = ( + saved_dataset_proto.meta.min_event_timestamp.ToDatetime() + ) + if saved_dataset_proto.meta.HasField("max_event_timestamp"): + ds.max_event_timestamp = ( + saved_dataset_proto.meta.max_event_timestamp.ToDatetime() + ) + + return ds + + def to_proto(self) -> SavedDatasetProto: + """ + Converts a SavedDataset to its protobuf representation. + + Returns: + A SavedDatasetProto protobuf. + """ + meta = SavedDatasetMeta() + if self.created_timestamp: + meta.created_timestamp.FromDatetime(self.created_timestamp) + if self.min_event_timestamp: + meta.min_event_timestamp.FromDatetime(self.min_event_timestamp) + if self.max_event_timestamp: + meta.max_event_timestamp.FromDatetime(self.max_event_timestamp) + + spec = SavedDatasetSpec( + name=self.name, + features=self.features, + join_keys=self.join_keys, + full_feature_names=self.full_feature_names, + storage=self.storage.to_proto(), + tags=self.tags, + ) + + feature_service_proto = SavedDatasetProto(spec=spec, meta=meta) + return feature_service_proto + + def with_retrieval_job(self, retrieval_job: "RetrievalJob") -> "SavedDataset": + self._retrieval_job = retrieval_job + return self + + def to_df(self) -> pd.DataFrame: + if not self._retrieval_job: + raise RuntimeError( + "To load this dataset use FeatureStore.get_saved_dataset() " + "instead of instantiating it directly." + ) + + return self._retrieval_job.to_df() + + def to_arrow(self) -> pyarrow.Table: + if not self._retrieval_job: + raise RuntimeError( + "To load this dataset use FeatureStore.get_saved_dataset() " + "instead of instantiating it directly." + ) + + return self._retrieval_job.to_arrow() diff --git a/sdk/python/tests/foo_provider.py b/sdk/python/tests/foo_provider.py index 8e9254cd3d0..1d4ce7d6cb6 100644 --- a/sdk/python/tests/foo_provider.py +++ b/sdk/python/tests/foo_provider.py @@ -10,6 +10,7 @@ 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.saved_dataset import SavedDataset class FooProvider(Provider): @@ -75,3 +76,6 @@ def online_read( requested_features: List[str] = None, ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: pass + + def retrieve_saved_dataset(self, config: RepoConfig, dataset: SavedDataset): + pass diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index 45044574e01..e1f4f0317c2 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union import pandas as pd import yaml @@ -283,7 +283,9 @@ def construct_test_environment( execution_role_name="arn:aws:iam::402087665549:role/lambda_execution_role", ) - registry = f"s3://feast-integration-tests/registries/{project}/registry.db" + registry = ( + f"s3://feast-integration-tests/registries/{project}/registry.db" + ) # type: Union[str, RegistryConfig] else: # Note: even if it's a local feature server, the repo config does not have this configured feature_server = None diff --git a/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py b/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py index e0d6983bf15..dcefa29df1e 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py @@ -5,6 +5,7 @@ from feast.data_source import DataSource from feast.repo_config import FeastConfigBaseModel +from feast.saved_dataset import SavedDatasetStorage class DataSourceCreator(ABC): @@ -40,6 +41,10 @@ def create_data_source( def create_offline_store_config(self) -> FeastConfigBaseModel: ... + @abstractmethod + def create_saved_dataset_destination(self) -> SavedDatasetStorage: + ... + @abstractmethod def teardown(self): ... diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py index 4085ef9d067..e0ac2050ea5 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py @@ -1,3 +1,4 @@ +import uuid from typing import Dict, List, Optional import pandas as pd @@ -7,6 +8,7 @@ from feast import BigQuerySource from feast.data_source import DataSource from feast.infra.offline_stores.bigquery import BigQueryOfflineStoreConfig +from feast.infra.offline_stores.bigquery_source import SavedDatasetBigQueryStorage from tests.integration.feature_repos.universal.data_source_creator import ( DataSourceCreator, ) @@ -79,5 +81,11 @@ def create_data_source( field_mapping=field_mapping or {"ts_1": "ts"}, ) + def create_saved_dataset_destination(self) -> SavedDatasetBigQueryStorage: + table = self.get_prefixed_table_name( + f"persisted_{str(uuid.uuid4()).replace('-', '_')}" + ) + return SavedDatasetBigQueryStorage(table_ref=table) + def get_prefixed_table_name(self, suffix: str) -> str: return f"{self.client.project}.{self.project_name}.{suffix}" diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py index 0d402b23149..baa3db6afc1 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py @@ -1,4 +1,5 @@ import tempfile +import uuid from typing import Any, Dict, List, Optional import pandas as pd @@ -10,6 +11,7 @@ from feast.data_format import ParquetFormat from feast.data_source import DataSource from feast.infra.offline_stores.file import FileOfflineStoreConfig +from feast.infra.offline_stores.file_source import SavedDatasetFileStorage from feast.repo_config import FeastConfigBaseModel from tests.integration.feature_repos.universal.data_source_creator import ( DataSourceCreator, @@ -50,6 +52,12 @@ def create_data_source( field_mapping=field_mapping or {"ts_1": "ts"}, ) + def create_saved_dataset_destination(self) -> SavedDatasetFileStorage: + d = tempfile.mkdtemp(prefix=self.project_name) + return SavedDatasetFileStorage( + path=d, file_format=ParquetFormat(), s3_endpoint_override=None + ) + def get_prefixed_table_name(self, suffix: str) -> str: return f"{self.project_name}.{suffix}" @@ -127,6 +135,16 @@ def create_data_source( s3_endpoint_override=f"http://{host}:{port}", ) + def create_saved_dataset_destination(self) -> SavedDatasetFileStorage: + port = self.minio.get_exposed_port("9000") + host = self.minio.get_container_host_ip() + + return SavedDatasetFileStorage( + path=f"s3://{self.bucket}/persisted/{str(uuid.uuid4())}", + file_format=ParquetFormat(), + s3_endpoint_override=f"http://{host}:{port}", + ) + def get_prefixed_table_name(self, suffix: str) -> str: return f"{suffix}" diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py index f7839da5288..49b31263cf9 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py @@ -1,3 +1,4 @@ +import uuid from typing import Dict, List, Optional import pandas as pd @@ -5,6 +6,7 @@ from feast import RedshiftSource from feast.data_source import DataSource from feast.infra.offline_stores.redshift import RedshiftOfflineStoreConfig +from feast.infra.offline_stores.redshift_source import SavedDatasetRedshiftStorage from feast.infra.utils import aws_utils from feast.repo_config import FeastConfigBaseModel from tests.integration.feature_repos.universal.data_source_creator import ( @@ -65,6 +67,14 @@ def create_data_source( field_mapping=field_mapping or {"ts_1": "ts"}, ) + def create_saved_dataset_destination(self) -> SavedDatasetRedshiftStorage: + table = self.get_prefixed_table_name( + f"persisted_ds_{str(uuid.uuid4()).replace('-', '_')}" + ) + self.tables.append(table) + + return SavedDatasetRedshiftStorage(table_ref=table) + def create_offline_store_config(self) -> FeastConfigBaseModel: return self.offline_store_config 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 99f111a3462..5e4bd004604 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 @@ -6,7 +6,7 @@ import numpy as np import pandas as pd import pytest -from pandas.testing import assert_frame_equal +from pandas.testing import assert_frame_equal as pd_assert_frame_equal from pytz import utc from feast import utils @@ -239,9 +239,10 @@ def get_expected_training_df( .round() .astype(pd.Int32Dtype()) ) - expected_df[ - response_feature_name("conv_rate_plus_val_to_add", full_feature_names) - ] = (expected_df[conv_feature_name] + expected_df["val_to_add"]) + if "val_to_add" in expected_df.columns: + expected_df[ + response_feature_name("conv_rate_plus_val_to_add", full_feature_names) + ] = (expected_df[conv_feature_name] + expected_df["val_to_add"]) return expected_df @@ -255,15 +256,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n (entities, datasets, data_sources) = universal_data_sources feature_views = construct_universal_feature_views(data_sources) - customer_df, driver_df, location_df, orders_df, global_df, entity_df = ( - datasets["customer"], - datasets["driver"], - datasets["location"], - datasets["orders"], - datasets["global"], - datasets["entity"], - ) - entity_df_with_request_data = entity_df.copy(deep=True) + entity_df_with_request_data = datasets["entity"].copy(deep=True) entity_df_with_request_data["val_to_add"] = [ i for i in range(len(entity_df_with_request_data)) ] @@ -271,84 +264,53 @@ def test_historical_features(environment, universal_data_sources, full_feature_n i + 100 for i in range(len(entity_df_with_request_data)) ] - ( - customer_fv, - driver_fv, - driver_odfv, - location_fv, - order_fv, - global_fv, - driver_age_request_fv, - ) = ( - feature_views["customer"], - feature_views["driver"], - feature_views["driver_odfv"], - feature_views["location"], - feature_views["order"], - feature_views["global"], - feature_views["driver_age_request_fv"], - ) - feature_service = FeatureService( name="convrate_plus100", features=[ feature_views["driver"][["conv_rate"]], - driver_odfv, - driver_age_request_fv, + feature_views["driver_odfv"], + feature_views["driver_age_request_fv"], ], ) feature_service_entity_mapping = FeatureService( name="entity_mapping", features=[ - location_fv.with_name("origin").with_join_key_map( - {"location_id": "origin_id"} - ), - location_fv.with_name("destination").with_join_key_map( - {"location_id": "destination_id"} - ), + feature_views["location"] + .with_name("origin") + .with_join_key_map({"location_id": "origin_id"}), + feature_views["location"] + .with_name("destination") + .with_join_key_map({"location_id": "destination_id"}), ], ) - feast_objects = [] - feast_objects.extend( + store.apply( [ - customer_fv, - driver_fv, - driver_odfv, - location_fv, - order_fv, - global_fv, - driver_age_request_fv, driver(), customer(), location(), feature_service, feature_service_entity_mapping, + *feature_views.values(), ] ) - store.apply(feast_objects) - - entity_df_query = None - orders_table = table_name_from_data_source(data_sources["orders"]) - if orders_table: - entity_df_query = f"SELECT customer_id, driver_id, order_id, origin_id, destination_id, event_timestamp FROM {orders_table}" event_timestamp = ( DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL - if DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL in orders_df.columns + if DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL in datasets["orders"].columns else "e_ts" ) full_expected_df = get_expected_training_df( - customer_df, - customer_fv, - driver_df, - driver_fv, - orders_df, - order_fv, - location_df, - location_fv, - global_df, - global_fv, + datasets["customer"], + feature_views["customer"], + datasets["driver"], + feature_views["driver"], + datasets["orders"], + feature_views["order"], + datasets["location"], + feature_views["location"], + datasets["global"], + feature_views["global"], entity_df_with_request_data, event_timestamp, full_feature_names, @@ -359,76 +321,6 @@ def test_historical_features(environment, universal_data_sources, full_feature_n columns=["origin__temperature", "destination__temperature"], ) - if entity_df_query: - job_from_sql = store.get_historical_features( - entity_df=entity_df_query, - features=[ - "driver_stats:conv_rate", - "driver_stats:avg_daily_trips", - "customer_profile:current_balance", - "customer_profile:avg_passenger_count", - "customer_profile:lifetime_trip_count", - "order:order_is_success", - "global_stats:num_rides", - "global_stats:avg_ride_length", - ], - full_feature_names=full_feature_names, - ) - - start_time = datetime.utcnow() - actual_df_from_sql_entities = job_from_sql.to_df() - end_time = datetime.utcnow() - print( - str(f"\nTime to execute job_from_sql.to_df() = '{(end_time - start_time)}'") - ) - - # Not requesting the on demand transform with an entity_df query (can't add request data in them) - expected_df_query = expected_df.drop( - columns=[ - response_feature_name("conv_rate_plus_100", full_feature_names), - response_feature_name("conv_rate_plus_100_rounded", full_feature_names), - response_feature_name("conv_rate_plus_val_to_add", full_feature_names), - "val_to_add", - "driver_age", - ] - ) - assert sorted(expected_df_query.columns) == sorted( - actual_df_from_sql_entities.columns - ) - - actual_df_from_sql_entities = ( - actual_df_from_sql_entities[expected_df_query.columns] - .sort_values(by=[event_timestamp, "order_id", "driver_id", "customer_id"]) - .drop_duplicates() - .reset_index(drop=True) - ) - expected_df_query = ( - expected_df_query.sort_values( - by=[event_timestamp, "order_id", "driver_id", "customer_id"] - ) - .drop_duplicates() - .reset_index(drop=True) - ) - - assert_frame_equal( - actual_df_from_sql_entities, expected_df_query, check_dtype=False, - ) - - table_from_sql_entities = job_from_sql.to_arrow() - df_from_sql_entities = ( - table_from_sql_entities.to_pandas()[expected_df_query.columns] - .sort_values(by=[event_timestamp, "order_id", "driver_id", "customer_id"]) - .drop_duplicates() - .reset_index(drop=True) - ) - - for col in df_from_sql_entities.columns: - expected_df_query[col] = expected_df_query[col].astype( - df_from_sql_entities[col].dtype - ) - - assert_frame_equal(expected_df_query, df_from_sql_entities) - job_from_df = store.get_historical_features( entity_df=entity_df_with_request_data, features=[ @@ -456,23 +348,12 @@ def test_historical_features(environment, universal_data_sources, full_feature_n print(str(f"Time to execute job_from_df.to_df() = '{(end_time - start_time)}'\n")) assert sorted(expected_df.columns) == sorted(actual_df_from_df_entities.columns) - expected_df: pd.DataFrame = ( - expected_df.sort_values( - by=[event_timestamp, "order_id", "driver_id", "customer_id"] - ) - .drop_duplicates() - .reset_index(drop=True) - ) - actual_df_from_df_entities = ( - actual_df_from_df_entities[expected_df.columns] - .sort_values(by=[event_timestamp, "order_id", "driver_id", "customer_id"]) - .drop_duplicates() - .reset_index(drop=True) - ) - assert_frame_equal( - expected_df, actual_df_from_df_entities, check_dtype=False, + expected_df, + actual_df_from_df_entities, + keys=[event_timestamp, "order_id", "driver_id", "customer_id"], ) + assert_feature_service_correctness( store, feature_service, @@ -489,26 +370,33 @@ def test_historical_features(environment, universal_data_sources, full_feature_n full_expected_df, event_timestamp, ) - table_from_df_entities: pd.DataFrame = job_from_df.to_arrow().to_pandas() - columns_expected_in_table = expected_df.columns.tolist() - - table_from_df_entities = ( - table_from_df_entities[columns_expected_in_table] - .sort_values(by=[event_timestamp, "order_id", "driver_id", "customer_id"]) - .drop_duplicates() - .reset_index(drop=True) + assert_frame_equal( + expected_df, + table_from_df_entities, + keys=[event_timestamp, "order_id", "driver_id", "customer_id"], ) - assert_frame_equal(actual_df_from_df_entities, table_from_df_entities) + + +@pytest.mark.integration +@pytest.mark.universal +@pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: str(v)) +def test_historical_features_with_missing_request_data( + environment, universal_data_sources, full_feature_names +): + store = environment.feature_store + + (_, datasets, data_sources) = universal_data_sources + feature_views = construct_universal_feature_views(data_sources) + + store.apply([driver(), customer(), location(), *feature_views.values()]) # If request data is missing that's needed for on demand transform, throw an error with pytest.raises(RequestDataNotFoundInEntityDfException): store.get_historical_features( - entity_df=entity_df, + entity_df=datasets["entity"], features=[ - "driver_stats:conv_rate", - "driver_stats:avg_daily_trips", "customer_profile:current_balance", "customer_profile:avg_passenger_count", "customer_profile:lifetime_trip_count", @@ -519,13 +407,12 @@ def test_historical_features(environment, universal_data_sources, full_feature_n ], full_feature_names=full_feature_names, ) + # If request data is missing that's needed for a request feature view, throw an error with pytest.raises(RequestDataNotFoundInEntityDfException): store.get_historical_features( - entity_df=entity_df, + entity_df=datasets["entity"], features=[ - "driver_stats:conv_rate", - "driver_stats:avg_daily_trips", "customer_profile:current_balance", "customer_profile:avg_passenger_count", "customer_profile:lifetime_trip_count", @@ -537,6 +424,170 @@ def test_historical_features(environment, universal_data_sources, full_feature_n ) +@pytest.mark.integration +@pytest.mark.universal +@pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: str(v)) +def test_historical_features_with_entities_from_query( + environment, universal_data_sources, full_feature_names +): + store = environment.feature_store + + (entities, datasets, data_sources) = universal_data_sources + feature_views = construct_universal_feature_views(data_sources) + + orders_table = table_name_from_data_source(data_sources["orders"]) + if not orders_table: + raise pytest.skip("Offline source is not sql-based") + + entity_df_query = f"SELECT customer_id, driver_id, order_id, origin_id, destination_id, event_timestamp FROM {orders_table}" + + store.apply([driver(), customer(), location(), *feature_views.values()]) + + job_from_sql = store.get_historical_features( + entity_df=entity_df_query, + features=[ + "customer_profile:current_balance", + "customer_profile:avg_passenger_count", + "customer_profile:lifetime_trip_count", + "order:order_is_success", + "global_stats:num_rides", + "global_stats:avg_ride_length", + ], + full_feature_names=full_feature_names, + ) + + start_time = datetime.utcnow() + actual_df_from_sql_entities = job_from_sql.to_df() + end_time = datetime.utcnow() + print(str(f"\nTime to execute job_from_sql.to_df() = '{(end_time - start_time)}'")) + + event_timestamp = ( + DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL + if DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL in datasets["orders"].columns + else "e_ts" + ) + full_expected_df = get_expected_training_df( + datasets["customer"], + feature_views["customer"], + datasets["driver"], + feature_views["driver"], + datasets["orders"], + feature_views["order"], + datasets["location"], + feature_views["location"], + datasets["global"], + feature_views["global"], + datasets["entity"], + event_timestamp, + full_feature_names, + ) + + # Not requesting the on demand transform with an entity_df query (can't add request data in them) + expected_df_query = full_expected_df.drop( + columns=[ + response_feature_name("conv_rate_plus_100", full_feature_names), + response_feature_name("conv_rate_plus_100_rounded", full_feature_names), + response_feature_name("avg_daily_trips", full_feature_names), + response_feature_name("conv_rate", full_feature_names), + "origin__temperature", + "destination__temperature", + ] + ) + assert_frame_equal( + expected_df_query, + actual_df_from_sql_entities, + keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + ) + + table_from_sql_entities = job_from_sql.to_arrow().to_pandas() + for col in table_from_sql_entities.columns: + expected_df_query[col] = expected_df_query[col].astype( + table_from_sql_entities[col].dtype + ) + + assert_frame_equal( + expected_df_query, + table_from_sql_entities, + keys=[event_timestamp, "order_id", "driver_id", "customer_id"], + ) + + +@pytest.mark.integration +@pytest.mark.universal +@pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: str(v)) +def test_historical_features_persisting( + environment, universal_data_sources, full_feature_names +): + store = environment.feature_store + + (entities, datasets, data_sources) = universal_data_sources + feature_views = construct_universal_feature_views(data_sources) + + store.apply([driver(), customer(), location(), *feature_views.values()]) + + entity_df = datasets["entity"].drop( + columns=["order_id", "origin_id", "destination_id"] + ) + + job = store.get_historical_features( + entity_df=entity_df, + features=[ + "customer_profile:current_balance", + "customer_profile:avg_passenger_count", + "customer_profile:lifetime_trip_count", + "order:order_is_success", + "global_stats:num_rides", + "global_stats:avg_ride_length", + ], + full_feature_names=full_feature_names, + ) + + saved_dataset = store.create_saved_dataset( + from_=job, + name="saved_dataset", + storage=environment.data_source_creator.create_saved_dataset_destination(), + tags={"env": "test"}, + ) + + event_timestamp = DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL + expected_df = get_expected_training_df( + datasets["customer"], + feature_views["customer"], + datasets["driver"], + feature_views["driver"], + datasets["orders"], + feature_views["order"], + datasets["location"], + feature_views["location"], + datasets["global"], + feature_views["global"], + entity_df, + event_timestamp, + full_feature_names, + ).drop( + columns=[ + response_feature_name("conv_rate_plus_100", full_feature_names), + response_feature_name("conv_rate_plus_100_rounded", full_feature_names), + response_feature_name("avg_daily_trips", full_feature_names), + response_feature_name("conv_rate", full_feature_names), + "origin__temperature", + "destination__temperature", + ] + ) + + assert_frame_equal( + expected_df, + saved_dataset.to_df(), + keys=[event_timestamp, "driver_id", "customer_id"], + ) + + assert_frame_equal( + job.to_df(), + saved_dataset.to_df(), + keys=[event_timestamp, "driver_id", "customer_id"], + ) + + @pytest.mark.integration @pytest.mark.universal def test_historical_features_from_bigquery_sources_containing_backfills(environment): @@ -630,13 +681,7 @@ 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) - assert_frame_equal( - expected_df.sort_values(by=["driver_id"]).reset_index(drop=True), - actual_df[expected_df.columns] - .sort_values(by=["driver_id"]) - .reset_index(drop=True), - check_dtype=False, - ) + assert_frame_equal(expected_df, actual_df, keys=["driver_id"]) def response_feature_name(feature: str, full_feature_names: bool) -> str: @@ -669,13 +714,6 @@ def assert_feature_service_correctness( actual_df_from_df_entities = job_from_df.to_df() - expected_df: pd.DataFrame = ( - expected_df.sort_values( - by=[event_timestamp, "order_id", "driver_id", "customer_id"] - ) - .drop_duplicates() - .reset_index(drop=True) - ) expected_df = expected_df[ [ event_timestamp, @@ -687,15 +725,11 @@ def assert_feature_service_correctness( "driver_age", ] ] - actual_df_from_df_entities = ( - actual_df_from_df_entities[expected_df.columns] - .sort_values(by=[event_timestamp, "order_id", "driver_id", "customer_id"]) - .drop_duplicates() - .reset_index(drop=True) - ) assert_frame_equal( - expected_df, actual_df_from_df_entities, check_dtype=False, + expected_df, + actual_df_from_df_entities, + keys=[event_timestamp, "order_id", "driver_id", "customer_id"], ) @@ -736,24 +770,18 @@ def assert_feature_service_entity_mapping_correctness( "destination__temperature", ] ] - actual_df_from_df_entities = ( - actual_df_from_df_entities[expected_df.columns] - .sort_values( - by=[ - event_timestamp, - "order_id", - "driver_id", - "customer_id", - "origin_id", - "destination_id", - ] - ) - .drop_duplicates() - .reset_index(drop=True) - ) assert_frame_equal( - expected_df, actual_df_from_df_entities, check_dtype=False, + expected_df, + actual_df_from_df_entities, + keys=[ + event_timestamp, + "order_id", + "driver_id", + "customer_id", + "origin_id", + "destination_id", + ], ) else: # using 2 of the same FeatureView without full_feature_names=True will result in collision @@ -763,3 +791,20 @@ def assert_feature_service_entity_mapping_correctness( features=feature_service, full_feature_names=full_feature_names, ) + + +def assert_frame_equal(expected_df, actual_df, keys): + expected_df: pd.DataFrame = ( + expected_df.sort_values(by=keys).drop_duplicates().reset_index(drop=True) + ) + + actual_df = ( + actual_df[expected_df.columns] + .sort_values(by=keys) + .drop_duplicates() + .reset_index(drop=True) + ) + + pd_assert_frame_equal( + expected_df, actual_df, check_dtype=False, + ) From ef1884fab2ef417e7f826f8e10f028aa95446f86 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 26 Jan 2022 21:50:46 +0000 Subject: [PATCH 35/85] Fix ValueType.UNIX_TIMESTAMP conversions (#2219) * Handle `np.datetime64` to `ValueType.UNIX_TIMESTAMP` conversion Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Add `datetime` feature to tests Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Fix `datetime` features in `type_map.py` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/type_map.py | 25 ++++++++++++++- sdk/python/tests/data/data_creator.py | 7 ++++ .../registration/test_universal_types.py | 32 ++++++++++++------- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index acf049e928e..74c4cb17edd 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -214,7 +214,7 @@ def _type_err(item, dtype): ValueType.UNIX_TIMESTAMP_LIST: ( Int64List, "int64_list_val", - [np.int64, np.int32, int], + [np.datetime64, np.int64, np.int32, int, datetime, Timestamp], ), ValueType.STRING_LIST: (StringList, "string_list_val", [np.str_, str]), ValueType.BOOL_LIST: (BoolList, "bool_list_val", [np.bool_, bool]), @@ -274,6 +274,24 @@ def _python_value_to_proto_value( ) raise _type_err(first_invalid, valid_types[0]) + if feast_value_type == ValueType.UNIX_TIMESTAMP_LIST: + converted_values = [] + for value in values: + converted_sub_values = [] + for sub_value in value: + if isinstance(sub_value, datetime): + converted_sub_values.append(int(sub_value.timestamp())) + elif isinstance(sub_value, Timestamp): + converted_sub_values.append(int(sub_value.ToSeconds())) + elif isinstance(sub_value, np.datetime64): + converted_sub_values.append( + sub_value.astype("datetime64[s]").astype("int") + ) + else: + converted_sub_values.append(sub_value) + converted_values.append(converted_sub_values) + values = converted_values + return [ ProtoValue(**{field_name: proto_type(val=value)}) # type: ignore if value is not None @@ -292,6 +310,11 @@ def _python_value_to_proto_value( return [ ProtoValue(int64_val=int(value.ToSeconds())) for value in values ] + elif isinstance(sample, np.datetime64): + return [ + ProtoValue(int64_val=value.astype("datetime64[s]").astype("int")) + for value in values + ] return [ProtoValue(int64_val=int(value)) for value in values] if feast_value_type in PYTHON_SCALAR_VALUE_TYPE_TO_PROTO_VALUE: diff --git a/sdk/python/tests/data/data_creator.py b/sdk/python/tests/data/data_creator.py index 1145f95c073..e08597b67b2 100644 --- a/sdk/python/tests/data/data_creator.py +++ b/sdk/python/tests/data/data_creator.py @@ -60,6 +60,13 @@ def get_feature_values_for_dtype( "float": [1.0, None, 3.0, 4.0, 5.0], "string": ["1", None, "3", "4", "5"], "bool": [True, None, False, True, False], + "datetime": [ + datetime(1980, 1, 1), + None, + datetime(1981, 1, 1), + datetime(1982, 1, 1), + datetime(1982, 1, 1), + ], } non_list_val = dtype_map[dtype] if is_list: diff --git a/sdk/python/tests/integration/registration/test_universal_types.py b/sdk/python/tests/integration/registration/test_universal_types.py index bb6261313d2..8cb21e63847 100644 --- a/sdk/python/tests/integration/registration/test_universal_types.py +++ b/sdk/python/tests/integration/registration/test_universal_types.py @@ -1,4 +1,5 @@ import logging +import re from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Dict, List, Tuple, Union @@ -28,6 +29,7 @@ def populate_test_configs(offline: bool): (ValueType.INT64, "int64"), (ValueType.STRING, "float"), (ValueType.STRING, "bool"), + (ValueType.INT32, "datetime"), ] configs: List[TypeTestConfig] = [] for test_repo_config in FULL_REPO_CONFIGS: @@ -232,6 +234,7 @@ def test_feature_get_online_features_types_match(online_types_test_fixtures): "float": float, "string": str, "bool": bool, + "datetime": int, } expected_dtype = feature_list_dtype_to_expected_online_response_value_type[ config.feature_dtype @@ -258,6 +261,8 @@ def create_feature_view( value_type = ValueType.FLOAT_LIST elif feature_dtype == "bool": value_type = ValueType.BOOL_LIST + elif feature_dtype == "datetime": + value_type = ValueType.UNIX_TIMESTAMP_LIST else: if feature_dtype == "int32": value_type = ValueType.INT32 @@ -267,6 +272,8 @@ def create_feature_view( value_type = ValueType.FLOAT elif feature_dtype == "bool": value_type = ValueType.BOOL + elif feature_dtype == "datetime": + value_type = ValueType.UNIX_TIMESTAMP return driver_feature_view(data_source, name=name, value_type=value_type,) @@ -281,6 +288,7 @@ def assert_expected_historical_feature_types( "float": (pd.api.types.is_float_dtype,), "string": (pd.api.types.is_string_dtype,), "bool": (pd.api.types.is_bool_dtype, pd.api.types.is_object_dtype), + "datetime": (pd.api.types.is_datetime64_any_dtype,), } dtype_checkers = feature_dtype_to_expected_historical_feature_dtype[feature_dtype] assert any( @@ -309,6 +317,7 @@ def assert_feature_list_types( bool, np.bool_, ), # Can be `np.bool_` if from `np.array` rather that `list` + "datetime": np.datetime64, } expected_dtype = feature_list_dtype_to_expected_historical_feature_list_dtype[ feature_dtype @@ -330,22 +339,23 @@ def assert_expected_arrow_types( historical_features_arrow = historical_features.to_arrow() print(historical_features_arrow) feature_list_dtype_to_expected_historical_feature_arrow_type = { - "int32": "int64", - "int64": "int64", - "float": "double", - "string": "string", - "bool": "bool", + "int32": r"int64", + "int64": r"int64", + "float": r"double", + "string": r"string", + "bool": r"bool", + "datetime": r"timestamp\[.+\]", } arrow_type = feature_list_dtype_to_expected_historical_feature_arrow_type[ feature_dtype ] if feature_is_list: - assert ( - str(historical_features_arrow.schema.field_by_name("value").type) - == f"list" + assert re.match( + f"list", + str(historical_features_arrow.schema.field_by_name("value").type), ) else: - assert ( - str(historical_features_arrow.schema.field_by_name("value").type) - == arrow_type + assert re.match( + arrow_type, + str(historical_features_arrow.schema.field_by_name("value").type), ) From 88fac8b9f7adf463e480b15dec2eb03c7de7f739 Mon Sep 17 00:00:00 2001 From: mirayyuce Date: Wed, 26 Jan 2022 14:27:45 -0800 Subject: [PATCH 36/85] Make online store nullable (#2224) * make online_store optional Signed-off-by: Miray Yuce * make online store optional Signed-off-by: Miray Yuce * make default online store sqlite Signed-off-by: Miray Yuce * remove unsused import Signed-off-by: Miray Yuce * remove unsused condition Signed-off-by: Miray Yuce * make online store nullable Signed-off-by: Miray Yuce * delete dummy values Signed-off-by: Miray Yuce * adding testing, addressing comments Signed-off-by: Miray Yuce * removed return type in helpers Signed-off-by: Miray Yuce * cleaned repo_configuration, changed test_cli Signed-off-by: Miray Yuce * updates after review Signed-off-by: Miray Yuce * fixing broken integration test Signed-off-by: Miray Yuce * fix integration test Signed-off-by: Miray Yuce * updates after review Signed-off-by: Miray Yuce * reformat imports Signed-off-by: Miray Yuce Co-authored-by: Miray Yuce --- sdk/python/feast/infra/aws.py | 21 +++--- sdk/python/feast/infra/local.py | 9 +-- .../feast/infra/passthrough_provider.py | 42 +++++++----- sdk/python/feast/repo_config.py | 6 +- .../integration/registration/test_cli.py | 65 ++++++++++++++++++- .../scaffolding/test_repo_config.py | 43 ++++++++++++ 6 files changed, 155 insertions(+), 31 deletions(-) diff --git a/sdk/python/feast/infra/aws.py b/sdk/python/feast/infra/aws.py index 735b2f62e72..104e20388a2 100644 --- a/sdk/python/feast/infra/aws.py +++ b/sdk/python/feast/infra/aws.py @@ -62,14 +62,16 @@ def update_infra( entities_to_keep: Sequence[Entity], partial: bool, ): - 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, - ) + # 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, + ) if self.repo_config.feature_server and self.repo_config.feature_server.enabled: if not enable_aws_lambda_feature_server(self.repo_config): @@ -194,7 +196,8 @@ def _deploy_feature_server(self, project: str, image_uri: str): def teardown_infra( self, project: str, tables: Sequence[FeatureView], entities: Sequence[Entity], ) -> None: - self.online_store.teardown(self.repo_config, tables, entities) + if self.online_store: + self.online_store.teardown(self.repo_config, tables, entities) if ( self.repo_config.feature_server is not None diff --git a/sdk/python/feast/infra/local.py b/sdk/python/feast/infra/local.py index 060ac64d53e..c5a15c8a91b 100644 --- a/sdk/python/feast/infra/local.py +++ b/sdk/python/feast/infra/local.py @@ -19,11 +19,12 @@ class LocalProvider(PassthroughProvider): def plan_infra( self, config: RepoConfig, desired_registry_proto: RegistryProto ) -> Infra: - infra_objects: List[InfraObject] = self.online_store.plan( - config, desired_registry_proto - ) infra = Infra() - infra.infra_objects += infra_objects + if self.online_store: + infra_objects: List[InfraObject] = self.online_store.plan( + config, desired_registry_proto + ) + infra.infra_objects += infra_objects return infra diff --git a/sdk/python/feast/infra/passthrough_provider.py b/sdk/python/feast/infra/passthrough_provider.py index de2aca7cc16..3468b9dc927 100644 --- a/sdk/python/feast/infra/passthrough_provider.py +++ b/sdk/python/feast/infra/passthrough_provider.py @@ -37,7 +37,11 @@ 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) + self.online_store = ( + get_online_store_from_config(config.online_store) + if config.online_store + else None + ) def update_infra( self, @@ -49,20 +53,24 @@ def update_infra( partial: bool, ): set_usage_attribute("provider", self.__class__.__name__) - 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, - ) + + # 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, + ) def teardown_infra( self, project: str, tables: Sequence[FeatureView], entities: Sequence[Entity], ) -> None: set_usage_attribute("provider", self.__class__.__name__) - self.online_store.teardown(self.repo_config, tables, entities) + if self.online_store: + self.online_store.teardown(self.repo_config, tables, entities) def online_write_batch( self, @@ -74,7 +82,8 @@ def online_write_batch( progress: Optional[Callable[[int], Any]], ) -> None: set_usage_attribute("provider", self.__class__.__name__) - self.online_store.online_write_batch(config, table, data, progress) + if self.online_store: + self.online_store.online_write_batch(config, table, data, progress) @log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001)) def online_read( @@ -83,12 +92,13 @@ def online_read( table: FeatureView, entity_keys: List[EntityKeyProto], requested_features: List[str] = None, - ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: + ) -> List: set_usage_attribute("provider", self.__class__.__name__) - result = self.online_store.online_read( - config, table, entity_keys, requested_features - ) - + result = [] + if self.online_store: + result = self.online_store.online_read( + config, table, entity_keys, requested_features + ) return result def ingest_df( diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 26309fe9d77..e8ba1805681 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -152,8 +152,12 @@ def _validate_online_store_config(cls, values): if "online_store" not in values: values["online_store"] = dict() - # Skip if we aren't creating the configuration from a dict + # Skip if we aren't creating the configuration from a dict or online store is null or it is a string like "None" or "null" if not isinstance(values["online_store"], Dict): + if isinstance(values["online_store"], str) and values[ + "online_store" + ].lower() in {"none", "null"}: + values["online_store"] = None return values # Make sure that the provider configuration is set. We need it to set the defaults diff --git a/sdk/python/tests/integration/registration/test_cli.py b/sdk/python/tests/integration/registration/test_cli.py index 5dc3772265a..2cf5ccd6724 100644 --- a/sdk/python/tests/integration/registration/test_cli.py +++ b/sdk/python/tests/integration/registration/test_cli.py @@ -1,18 +1,32 @@ +import os import tempfile import uuid from contextlib import contextmanager from pathlib import Path from textwrap import dedent +from typing import List import pytest import yaml from assertpy import assertpy from feast import FeatureStore, RepoConfig +from tests.integration.feature_repos.integration_test_repo_config import ( + IntegrationTestRepoConfig, +) from tests.integration.feature_repos.repo_configuration import FULL_REPO_CONFIGS from tests.integration.feature_repos.universal.data_source_creator import ( DataSourceCreator, ) +from tests.integration.feature_repos.universal.data_sources.bigquery import ( + BigQueryDataSourceCreator, +) +from tests.integration.feature_repos.universal.data_sources.file import ( + FileDataSourceCreator, +) +from tests.integration.feature_repos.universal.data_sources.redshift import ( + RedshiftDataSourceCreator, +) from tests.utils.cli_utils import CliRunner, get_example_repo from tests.utils.online_read_write_test import basic_rw_test @@ -21,7 +35,6 @@ @pytest.mark.parametrize("test_repo_config", FULL_REPO_CONFIGS) def test_universal_cli(test_repo_config) -> None: project = f"test_universal_cli_{str(uuid.uuid4()).replace('-', '')[:8]}" - runner = CliRunner() with tempfile.TemporaryDirectory() as repo_dir_name: @@ -128,6 +141,56 @@ def make_feature_store_yaml(project, test_repo_config, repo_dir_name: Path): return yaml.safe_dump(config_dict) +NULLABLE_ONLINE_STORE_CONFIGS: List[IntegrationTestRepoConfig] = [ + IntegrationTestRepoConfig( + provider="local", + offline_store_creator=FileDataSourceCreator, + online_store=None, + ), +] + +if os.getenv("FEAST_IS_LOCAL_TEST", "False") == "True": + NULLABLE_ONLINE_STORE_CONFIGS.extend( + [ + IntegrationTestRepoConfig( + provider="gcp", + offline_store_creator=BigQueryDataSourceCreator, + online_store=None, + ), + IntegrationTestRepoConfig( + provider="aws", + offline_store_creator=RedshiftDataSourceCreator, + online_store=None, + ), + ] + ) + + +@pytest.mark.integration +@pytest.mark.parametrize("test_nullable_online_store", NULLABLE_ONLINE_STORE_CONFIGS) +def test_nullable_online_store(test_nullable_online_store) -> None: + project = f"test_nullable_online_store{str(uuid.uuid4()).replace('-', '')[:8]}" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as repo_dir_name: + try: + feature_store_yaml = make_feature_store_yaml( + project, test_nullable_online_store, repo_dir_name + ) + repo_path = Path(repo_dir_name) + + repo_config = repo_path / "feature_store.yaml" + + repo_config.write_text(dedent(feature_store_yaml)) + + repo_example = repo_path / "example.py" + repo_example.write_text(get_example_repo("example_feature_repo_1.py")) + result = runner.run(["apply"], cwd=repo_path) + assertpy.assert_that(result.returncode).is_equal_to(0) + finally: + runner.run(["teardown"], cwd=repo_path) + + @contextmanager def setup_third_party_provider_repo(provider_name: str): with tempfile.TemporaryDirectory() as repo_dir_name: diff --git a/sdk/python/tests/integration/scaffolding/test_repo_config.py b/sdk/python/tests/integration/scaffolding/test_repo_config.py index dfa80cb6186..3ec91c0044c 100644 --- a/sdk/python/tests/integration/scaffolding/test_repo_config.py +++ b/sdk/python/tests/integration/scaffolding/test_repo_config.py @@ -34,6 +34,49 @@ def _test_config(config_text, expect_error: Optional[str]): return rc +def test_nullable_online_store_aws(): + _test_config( + dedent( + """ + project: foo + registry: "registry.db" + provider: aws + online_store: null + """ + ), + expect_error="__root__ -> offline_store -> cluster_id\n" + " field required (type=value_error.missing)", + ) + + +def test_nullable_online_store_gcp(): + _test_config( + dedent( + """ + project: foo + registry: "registry.db" + provider: gcp + online_store: null + """ + ), + expect_error=None, + ) + + +def test_nullable_online_store_local(): + _test_config( + dedent( + """ + project: foo + registry: "registry.db" + provider: local + online_store: null + """ + ), + expect_error=None, + ) + + def test_local_config(): _test_config( dedent( From 46c4722a1035e0dd098ac2f3b6f39330a25632e8 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Wed, 26 Jan 2022 23:41:21 -0800 Subject: [PATCH 37/85] Fix Redshift data creator (#2242) * Raise error for Redshift table names with >127 characters Signed-off-by: Felix Wang * Fix lint error Signed-off-by: Felix Wang --- sdk/python/feast/errors.py | 7 +++++++ sdk/python/feast/infra/utils/aws_utils.py | 18 ++++++++++++++---- .../feature_repos/repo_configuration.py | 2 +- .../tests/integration/registration/test_cli.py | 4 ++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py index 8a4f365b446..3fc8c7571ea 100644 --- a/sdk/python/feast/errors.py +++ b/sdk/python/feast/errors.py @@ -243,6 +243,13 @@ def __init__(self, details): super().__init__(f"Redshift SQL Query failed to finish. Details: {details}") +class RedshiftTableNameTooLong(Exception): + def __init__(self, table_name: str): + super().__init__( + f"Redshift table names have a maximum length of 127 characters, but the table name {table_name} has length {len(table_name)} characters." + ) + + class EntityTimestampInferenceException(Exception): def __init__(self, expected_column_name: str): super().__init__( diff --git a/sdk/python/feast/infra/utils/aws_utils.py b/sdk/python/feast/infra/utils/aws_utils.py index 6211c75e375..b25454ca6ad 100644 --- a/sdk/python/feast/infra/utils/aws_utils.py +++ b/sdk/python/feast/infra/utils/aws_utils.py @@ -15,7 +15,11 @@ wait_exponential, ) -from feast.errors import RedshiftCredentialsError, RedshiftQueryError +from feast.errors import ( + RedshiftCredentialsError, + RedshiftQueryError, + RedshiftTableNameTooLong, +) from feast.type_map import pa_to_redshift_value_type try: @@ -28,6 +32,9 @@ raise FeastExtrasDependencyImportError("aws", str(e)) +REDSHIFT_TABLE_NAME_MAX_LENGTH = 127 + + def get_redshift_data_client(aws_region: str): """ Get the Redshift Data API Service client for the given AWS region. @@ -184,7 +191,7 @@ def upload_df_to_redshift( iam_role: str, table_name: str, df: pd.DataFrame, -) -> None: +): """Uploads a Pandas DataFrame to Redshift as a new table. The caller is responsible for deleting the table when no longer necessary. @@ -208,9 +215,12 @@ def upload_df_to_redshift( table_name: The name of the new Redshift table where we copy the dataframe df: The Pandas DataFrame to upload - Returns: None - + Raises: + RedshiftTableNameTooLong: The specified table name is too long. """ + if len(table_name) > REDSHIFT_TABLE_NAME_MAX_LENGTH: + raise RedshiftTableNameTooLong(table_name) + bucket, key = get_bucket_and_key(s3_path) # Drop the index so that we dont have unnecessary columns diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index e1f4f0317c2..f66a92c9d69 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -258,7 +258,7 @@ def construct_test_environment( worker_id: str = "worker_id", ) -> Environment: - _uuid = str(uuid.uuid4()).replace("-", "")[:8] + _uuid = str(uuid.uuid4()).replace("-", "")[:6] run_id = os.getenv("GITHUB_RUN_ID", default=None) run_id = f"gh_run_{run_id}_{_uuid}" if run_id else _uuid diff --git a/sdk/python/tests/integration/registration/test_cli.py b/sdk/python/tests/integration/registration/test_cli.py index 2cf5ccd6724..bba12056ce8 100644 --- a/sdk/python/tests/integration/registration/test_cli.py +++ b/sdk/python/tests/integration/registration/test_cli.py @@ -174,10 +174,10 @@ def test_nullable_online_store(test_nullable_online_store) -> None: with tempfile.TemporaryDirectory() as repo_dir_name: try: + repo_path = Path(repo_dir_name) feature_store_yaml = make_feature_store_yaml( - project, test_nullable_online_store, repo_dir_name + project, test_nullable_online_store, repo_path ) - repo_path = Path(repo_dir_name) repo_config = repo_path / "feature_store.yaml" From 7bff5ed84fea5f4da9cd271735812ef6f2168737 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Thu, 27 Jan 2022 14:23:21 -0800 Subject: [PATCH 38/85] Implement feature_store._apply_diffs to handle registry and infra diffs (#2238) * Implement feature_store._apply_diffs to handle registry and infra diffs Signed-off-by: Felix Wang * Add logging Signed-off-by: Felix Wang * Factor out validation and inference logic Signed-off-by: Felix Wang * Clean up FeastObjectType enum Signed-off-by: Felix Wang * Move logic into enum Signed-off-by: Felix Wang * Add TODO Signed-off-by: Felix Wang * Add positional argument name Signed-off-by: Felix Wang --- sdk/python/feast/diff/FcoDiff.py | 131 ++++++++++++++++-------- sdk/python/feast/diff/infra_diff.py | 19 +++- sdk/python/feast/feature_store.py | 151 +++++++++++++++++++++------- sdk/python/feast/registry.py | 46 +++++++-- sdk/python/feast/repo_operations.py | 91 ++++++----------- 5 files changed, 289 insertions(+), 149 deletions(-) diff --git a/sdk/python/feast/diff/FcoDiff.py b/sdk/python/feast/diff/FcoDiff.py index e2aac16bf5b..1ea66ec6598 100644 --- a/sdk/python/feast/diff/FcoDiff.py +++ b/sdk/python/feast/diff/FcoDiff.py @@ -5,6 +5,7 @@ from feast.diff.property_diff import PropertyDiff, TransitionType from feast.entity import Entity from feast.feature_service import FeatureService +from feast.feature_view import DUMMY_ENTITY_NAME from feast.protos.feast.core.Entity_pb2 import Entity as EntityProto from feast.protos.feast.core.FeatureService_pb2 import ( FeatureService as FeatureServiceProto, @@ -16,26 +17,16 @@ from feast.protos.feast.core.RequestFeatureView_pb2 import ( RequestFeatureView as RequestFeatureViewProto, ) -from feast.registry import FeastObjectType, Registry +from feast.registry import FEAST_OBJECT_TYPES, FeastObjectType, Registry from feast.repo_contents import RepoContents -FEAST_OBJECT_TYPE_TO_STR = { - FeastObjectType.ENTITY: "entity", - FeastObjectType.FEATURE_VIEW: "feature view", - FeastObjectType.ON_DEMAND_FEATURE_VIEW: "on demand feature view", - FeastObjectType.REQUEST_FEATURE_VIEW: "request feature view", - FeastObjectType.FEATURE_SERVICE: "feature service", -} - -FEAST_OBJECT_TYPES = FEAST_OBJECT_TYPE_TO_STR.keys() - Fco = TypeVar("Fco", Entity, BaseFeatureView, FeatureService) @dataclass class FcoDiff(Generic[Fco]): name: str - fco_type: str + fco_type: FeastObjectType current_fco: Fco new_fco: Fco fco_property_diffs: List[PropertyDiff] @@ -52,6 +43,28 @@ def __init__(self): def add_fco_diff(self, fco_diff: FcoDiff): self.fco_diffs.append(fco_diff) + def to_string(self): + from colorama import Fore, Style + + log_string = "" + + message_action_map = { + TransitionType.CREATE: ("Created", Fore.GREEN), + TransitionType.DELETE: ("Deleted", Fore.RED), + TransitionType.UNCHANGED: ("Unchanged", Fore.LIGHTBLUE_EX), + TransitionType.UPDATE: ("Updated", Fore.YELLOW), + } + for fco_diff in self.fco_diffs: + if fco_diff.name == DUMMY_ENTITY_NAME: + continue + action, color = message_action_map[fco_diff.transition_type] + log_string += f"{action} {fco_diff.fco_type.value} {Style.BRIGHT + color}{fco_diff.name}{Style.RESET_ALL}\n" + if fco_diff.transition_type == TransitionType.UPDATE: + for _p in fco_diff.fco_property_diffs: + log_string += f"\t{_p.property_name}: {Style.BRIGHT + color}{_p.val_existing}{Style.RESET_ALL} -> {Style.BRIGHT + Fore.LIGHTGREEN_EX}{_p.val_declared}{Style.RESET_ALL}\n" + + return log_string + def tag_objects_for_keep_delete_update_add( existing_objs: Iterable[Fco], desired_objs: Iterable[Fco] @@ -93,7 +106,9 @@ def tag_proto_objects_for_keep_delete_add( FIELDS_TO_IGNORE = {"project"} -def diff_registry_objects(current: Fco, new: Fco, object_type: str) -> FcoDiff: +def diff_registry_objects( + current: Fco, new: Fco, object_type: FeastObjectType +) -> FcoDiff: current_proto = current.to_proto() new_proto = new.to_proto() assert current_proto.DESCRIPTOR.full_name == new_proto.DESCRIPTOR.full_name @@ -145,30 +160,12 @@ def extract_objects_for_keep_delete_update_add( objs_to_update = {} objs_to_add = {} - registry_object_type_to_objects: Dict[FeastObjectType, List[Any]] - registry_object_type_to_objects = { - FeastObjectType.ENTITY: registry.list_entities(project=current_project), - FeastObjectType.FEATURE_VIEW: registry.list_feature_views( - project=current_project - ), - FeastObjectType.ON_DEMAND_FEATURE_VIEW: registry.list_on_demand_feature_views( - project=current_project - ), - FeastObjectType.REQUEST_FEATURE_VIEW: registry.list_request_feature_views( - project=current_project - ), - FeastObjectType.FEATURE_SERVICE: registry.list_feature_services( - project=current_project - ), - } - registry_object_type_to_repo_contents: Dict[FeastObjectType, Set[Any]] - registry_object_type_to_repo_contents = { - FeastObjectType.ENTITY: desired_repo_contents.entities, - FeastObjectType.FEATURE_VIEW: desired_repo_contents.feature_views, - FeastObjectType.ON_DEMAND_FEATURE_VIEW: desired_repo_contents.on_demand_feature_views, - FeastObjectType.REQUEST_FEATURE_VIEW: desired_repo_contents.request_feature_views, - FeastObjectType.FEATURE_SERVICE: desired_repo_contents.feature_services, - } + registry_object_type_to_objects: Dict[ + FeastObjectType, List[Any] + ] = FeastObjectType.get_objects_from_registry(registry, current_project) + registry_object_type_to_repo_contents: Dict[ + FeastObjectType, Set[Any] + ] = FeastObjectType.get_objects_from_repo_contents(desired_repo_contents) for object_type in FEAST_OBJECT_TYPES: ( @@ -221,7 +218,7 @@ def diff_between( diff.add_fco_diff( FcoDiff( name=e.name, - fco_type=FEAST_OBJECT_TYPE_TO_STR[object_type], + fco_type=object_type, current_fco=None, new_fco=e, fco_property_diffs=[], @@ -232,7 +229,7 @@ def diff_between( diff.add_fco_diff( FcoDiff( name=e.name, - fco_type=FEAST_OBJECT_TYPE_TO_STR[object_type], + fco_type=object_type, current_fco=e, new_fco=None, fco_property_diffs=[], @@ -241,10 +238,56 @@ def diff_between( ) for e in objects_to_update: current_obj = [_e for _e in objects_to_keep if _e.name == e.name][0] - diff.add_fco_diff( - diff_registry_objects( - current_obj, e, FEAST_OBJECT_TYPE_TO_STR[object_type] - ) - ) + diff.add_fco_diff(diff_registry_objects(current_obj, e, object_type)) return diff + + +def apply_diff_to_registry( + registry: Registry, registry_diff: RegistryDiff, project: str, commit: bool = True +): + """ + Applies the given diff to the given Feast project in the registry. + + Args: + registry: The registry to be updated. + registry_diff: The diff to apply. + project: Feast project to be updated. + commit: Whether the change should be persisted immediately + """ + for fco_diff in registry_diff.fco_diffs: + # There is no need to delete the FCO on an update, since applying the new FCO + # will automatically delete the existing FCO. + if fco_diff.transition_type == TransitionType.DELETE: + if fco_diff.fco_type == FeastObjectType.ENTITY: + registry.delete_entity(fco_diff.current_fco.name, project, commit=False) + elif fco_diff.fco_type == FeastObjectType.FEATURE_SERVICE: + registry.delete_feature_service( + fco_diff.current_fco.name, project, commit=False + ) + elif fco_diff.fco_type in [ + FeastObjectType.FEATURE_VIEW, + FeastObjectType.ON_DEMAND_FEATURE_VIEW, + FeastObjectType.REQUEST_FEATURE_VIEW, + ]: + registry.delete_feature_view( + fco_diff.current_fco.name, project, commit=False, + ) + + if fco_diff.transition_type in [ + TransitionType.CREATE, + TransitionType.UPDATE, + ]: + if fco_diff.fco_type == FeastObjectType.ENTITY: + registry.apply_entity(fco_diff.new_fco, project, commit=False) + elif fco_diff.fco_type == FeastObjectType.FEATURE_SERVICE: + registry.apply_feature_service(fco_diff.new_fco, project, commit=False) + elif fco_diff.fco_type in [ + FeastObjectType.FEATURE_VIEW, + FeastObjectType.ON_DEMAND_FEATURE_VIEW, + FeastObjectType.REQUEST_FEATURE_VIEW, + ]: + registry.apply_feature_view(fco_diff.new_fco, project, commit=False) + + if commit: + registry.commit() diff --git a/sdk/python/feast/diff/infra_diff.py b/sdk/python/feast/diff/infra_diff.py index fc79a74f678..d5bcbbc44a7 100644 --- a/sdk/python/feast/diff/infra_diff.py +++ b/sdk/python/feast/diff/infra_diff.py @@ -60,7 +60,24 @@ def update(self): infra_object.update() def to_string(self): - pass + from colorama import Fore, Style + + log_string = "" + + message_action_map = { + TransitionType.CREATE: ("Created", Fore.GREEN), + TransitionType.DELETE: ("Deleted", Fore.RED), + TransitionType.UNCHANGED: ("Unchanged", Fore.LIGHTBLUE_EX), + TransitionType.UPDATE: ("Updated", Fore.YELLOW), + } + for infra_object_diff in self.infra_object_diffs: + action, color = message_action_map[infra_object_diff.transition_type] + log_string += f"{action} {infra_object_diff.infra_object_type} {Style.BRIGHT + color}{infra_object_diff.name}{Style.RESET_ALL}\n" + if infra_object_diff.transition_type == TransitionType.UPDATE: + for _p in infra_object_diff.infra_object_property_diffs: + log_string += f"\t{_p.property_name}: {Style.BRIGHT + color}{_p.val_existing}{Style.RESET_ALL} -> {Style.BRIGHT + Fore.LIGHTGREEN_EX}{_p.val_declared}{Style.RESET_ALL}\n" + + return log_string def tag_infra_proto_objects_for_keep_delete_add( diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 0024b368fe0..01b1dc0f0c5 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -39,8 +39,9 @@ from feast import feature_server, flags, flags_helper, utils from feast.base_feature_view import BaseFeatureView -from feast.diff.FcoDiff import RegistryDiff, diff_between +from feast.diff.FcoDiff import RegistryDiff, apply_diff_to_registry, diff_between from feast.diff.infra_diff import InfraDiff, diff_infra_protos +from feast.diff.property_diff import TransitionType from feast.entity import Entity from feast.errors import ( EntityNotFoundException, @@ -63,6 +64,7 @@ update_entities_with_inferred_types_from_feature_views, update_feature_views_with_inferred_features, ) +from feast.infra.infra_object import Infra from feast.infra.provider import Provider, RetrievalJob, get_provider from feast.on_demand_feature_view import OnDemandFeatureView from feast.online_response import OnlineResponse @@ -73,7 +75,7 @@ ) from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import RepeatedValue, Value -from feast.registry import Registry +from feast.registry import FeastObjectType, Registry from feast.repo_config import RepoConfig, load_repo_config from feast.repo_contents import RepoContents from feast.request_feature_view import RequestFeatureView @@ -388,10 +390,56 @@ def _get_features( _feature_refs = _features return _feature_refs + def _should_use_plan(self): + """Returns True if _plan and _apply_diffs should be used, False otherwise.""" + # Currently only the local provider supports _plan and _apply_diffs. + return self.config.provider == "local" + + def _validate_all_feature_views( + self, + views_to_update: List[FeatureView], + odfvs_to_update: List[OnDemandFeatureView], + request_views_to_update: List[RequestFeatureView], + ): + """Validates all feature views.""" + if ( + not flags_helper.enable_on_demand_feature_views(self.config) + and len(odfvs_to_update) > 0 + ): + raise ExperimentalFeatureNotEnabled(flags.FLAG_ON_DEMAND_TRANSFORM_NAME) + + set_usage_attribute("odfv", bool(odfvs_to_update)) + + _validate_feature_views( + [*views_to_update, *odfvs_to_update, *request_views_to_update] + ) + + def _make_inferences( + self, + entities_to_update: List[Entity], + views_to_update: List[FeatureView], + odfvs_to_update: List[OnDemandFeatureView], + ): + """Makes inferences for entities, feature views, and odfvs.""" + update_entities_with_inferred_types_from_feature_views( + entities_to_update, views_to_update, self.config + ) + + update_data_sources_with_inferred_event_timestamp_col( + [view.batch_source for view in views_to_update], self.config + ) + + update_feature_views_with_inferred_features( + views_to_update, entities_to_update, self.config + ) + + for odfv in odfvs_to_update: + odfv.infer_features() + @log_exceptions_and_usage - def plan( + def _plan( self, desired_repo_contents: RepoContents - ) -> Tuple[RegistryDiff, InfraDiff]: + ) -> Tuple[RegistryDiff, InfraDiff, Infra]: """Dry-run registering objects to metadata store. The plan method dry-runs registering one or more definitions (e.g., Entity, FeatureView), and produces @@ -426,24 +474,78 @@ def plan( ... ttl=timedelta(seconds=86400 * 1), ... batch_source=driver_hourly_stats, ... ) - >>> registry_diff, infra_diff = fs.plan(RepoContents({driver_hourly_stats_view}, set(), set(), {driver}, set())) # register entity and feature view + >>> registry_diff, infra_diff, new_infra = fs._plan(RepoContents({driver_hourly_stats_view}, set(), set(), {driver}, set())) # register entity and feature view """ registry_diff = diff_between( self._registry, self.project, desired_repo_contents ) + self._registry.refresh() current_infra_proto = ( self._registry.cached_registry_proto.infra.__deepcopy__() if self._registry.cached_registry_proto else InfraProto() ) desired_registry_proto = desired_repo_contents.to_registry_proto() - new_infra_proto = self._provider.plan_infra( - self.config, desired_registry_proto - ).to_proto() + new_infra = self._provider.plan_infra(self.config, desired_registry_proto) + new_infra_proto = new_infra.to_proto() infra_diff = diff_infra_protos(current_infra_proto, new_infra_proto) - return (registry_diff, infra_diff) + return (registry_diff, infra_diff, new_infra) + + @log_exceptions_and_usage + def _apply_diffs( + self, registry_diff: RegistryDiff, infra_diff: InfraDiff, new_infra: Infra + ): + """Applies the given diffs to the metadata store and infrastructure. + + Args: + registry_diff: The diff between the current registry and the desired registry. + infra_diff: The diff between the current infra and the desired infra. + new_infra: The desired infra. + """ + entities_to_update = [ + fco_diff.new_fco + for fco_diff in registry_diff.fco_diffs + if fco_diff.fco_type == FeastObjectType.ENTITY + and fco_diff.transition_type + in [TransitionType.CREATE, TransitionType.UPDATE] + ] + views_to_update = [ + fco_diff.new_fco + for fco_diff in registry_diff.fco_diffs + if fco_diff.fco_type == FeastObjectType.FEATURE_VIEW + and fco_diff.transition_type + in [TransitionType.CREATE, TransitionType.UPDATE] + ] + odfvs_to_update = [ + fco_diff.new_fco + for fco_diff in registry_diff.fco_diffs + if fco_diff.fco_type == FeastObjectType.ON_DEMAND_FEATURE_VIEW + and fco_diff.transition_type + in [TransitionType.CREATE, TransitionType.UPDATE] + ] + request_views_to_update = [ + fco_diff.new_fco + for fco_diff in registry_diff.fco_diffs + if fco_diff.fco_type == FeastObjectType.REQUEST_FEATURE_VIEW + and fco_diff.transition_type + in [TransitionType.CREATE, TransitionType.UPDATE] + ] + + # TODO(felixwang9817): move validation logic into _plan. + # Validate all feature views and make inferences. + self._validate_all_feature_views( + views_to_update, odfvs_to_update, request_views_to_update + ) + self._make_inferences(entities_to_update, views_to_update, odfvs_to_update) + + # Apply infra and registry changes. + infra_diff.update() + apply_diff_to_registry( + self._registry, registry_diff, self.project, commit=False + ) + self._registry.update_infra(new_infra, self.project, commit=True) @log_exceptions_and_usage def apply( @@ -536,34 +638,11 @@ def apply( ) + len(odfvs_to_update) + len(services_to_update) != len(objects): raise ValueError("Unknown object type provided as part of apply() call") - # Validate all types of feature views. - if ( - not flags_helper.enable_on_demand_feature_views(self.config) - and len(odfvs_to_update) > 0 - ): - raise ExperimentalFeatureNotEnabled(flags.FLAG_ON_DEMAND_TRANSFORM_NAME) - - set_usage_attribute("odfv", bool(odfvs_to_update)) - - _validate_feature_views( - [*views_to_update, *odfvs_to_update, *request_views_to_update] - ) - - # Make inferences - update_entities_with_inferred_types_from_feature_views( - entities_to_update, views_to_update, self.config - ) - - update_data_sources_with_inferred_event_timestamp_col( - [view.batch_source for view in views_to_update], self.config - ) - - update_feature_views_with_inferred_features( - views_to_update, entities_to_update, self.config + # Validate all feature views and make inferences. + self._validate_all_feature_views( + views_to_update, odfvs_to_update, request_views_to_update ) - - for odfv in odfvs_to_update: - odfv.infer_features() + self._make_inferences(entities_to_update, views_to_update, odfvs_to_update) # Handle all entityless feature views by using DUMMY_ENTITY as a placeholder entity. entities_to_update.append(DUMMY_ENTITY) diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index 714b096e23f..3352c54ffd7 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -17,7 +17,7 @@ from enum import Enum from pathlib import Path from threading import Lock -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from urllib.parse import urlparse from google.protobuf.internal.containers import RepeatedCompositeFieldContainer @@ -42,6 +42,7 @@ from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto from feast.registry_store import NoopRegistryStore from feast.repo_config import RegistryConfig +from feast.repo_contents import RepoContents from feast.request_feature_view import RequestFeatureView from feast.saved_dataset import SavedDataset @@ -63,11 +64,44 @@ class FeastObjectType(Enum): - ENTITY = 0 - FEATURE_VIEW = 1 - ON_DEMAND_FEATURE_VIEW = 2 - REQUEST_FEATURE_VIEW = 3 - FEATURE_SERVICE = 4 + ENTITY = "entity" + FEATURE_VIEW = "feature view" + ON_DEMAND_FEATURE_VIEW = "on demand feature view" + REQUEST_FEATURE_VIEW = "request feature view" + FEATURE_SERVICE = "feature service" + + @staticmethod + def get_objects_from_registry( + registry: "Registry", project: str + ) -> Dict["FeastObjectType", List[Any]]: + return { + FeastObjectType.ENTITY: registry.list_entities(project=project), + FeastObjectType.FEATURE_VIEW: registry.list_feature_views(project=project), + FeastObjectType.ON_DEMAND_FEATURE_VIEW: registry.list_on_demand_feature_views( + project=project + ), + FeastObjectType.REQUEST_FEATURE_VIEW: registry.list_request_feature_views( + project=project + ), + FeastObjectType.FEATURE_SERVICE: registry.list_feature_services( + project=project + ), + } + + @staticmethod + def get_objects_from_repo_contents( + repo_contents: RepoContents, + ) -> Dict["FeastObjectType", Set[Any]]: + return { + FeastObjectType.ENTITY: repo_contents.entities, + FeastObjectType.FEATURE_VIEW: repo_contents.feature_views, + FeastObjectType.ON_DEMAND_FEATURE_VIEW: repo_contents.on_demand_feature_views, + FeastObjectType.REQUEST_FEATURE_VIEW: repo_contents.request_feature_views, + FeastObjectType.FEATURE_SERVICE: repo_contents.feature_services, + } + + +FEAST_OBJECT_TYPES = [feast_object_type for feast_object_type in FeastObjectType] logger = logging.getLogger(__name__) diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index 16f444a2c26..f34346871de 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -12,18 +12,14 @@ import click from click.exceptions import BadParameter -from feast.diff.FcoDiff import ( - FEAST_OBJECT_TYPES, - extract_objects_for_keep_delete_update_add, -) -from feast.diff.property_diff import TransitionType +from feast.diff.FcoDiff import extract_objects_for_keep_delete_update_add from feast.entity import Entity from feast.feature_service import FeatureService from feast.feature_store import FeatureStore -from feast.feature_view import DUMMY_ENTITY, DUMMY_ENTITY_NAME, FeatureView +from feast.feature_view import DUMMY_ENTITY, FeatureView from feast.names import adjectives, animals from feast.on_demand_feature_view import OnDemandFeatureView -from feast.registry import FeastObjectType, Registry +from feast.registry import FEAST_OBJECT_TYPES, FeastObjectType, Registry from feast.repo_config import RepoConfig from feast.repo_contents import RepoContents from feast.request_feature_view import RequestFeatureView @@ -136,20 +132,9 @@ def plan(repo_config: RepoConfig, repo_path: Path, skip_source_validation: bool) for data_source in data_sources: data_source.validate(store.config) - registry_diff, _ = store.plan(repo) - views_to_delete = [ - v - for v in registry_diff.fco_diffs - if v.fco_type == "feature view" and v.transition_type == TransitionType.DELETE - ] - views_to_keep = [ - v - for v in registry_diff.fco_diffs - if v.fco_type == "feature view" - and v.transition_type in {TransitionType.CREATE, TransitionType.UNCHANGED} - ] - - log_cli_output(registry_diff, views_to_delete, views_to_keep) + registry_diff, infra_diff, _ = store._plan(repo) + click.echo(registry_diff.to_string()) + click.echo(infra_diff.to_string()) def _prepare_registry_and_repo(repo_config, repo_path): @@ -219,7 +204,7 @@ def apply_total_with_repo_instance( for data_source in data_sources: data_source.validate(store.config) - registry_diff, _ = store.plan(repo) + registry_diff, infra_diff, new_infra = store._plan(repo) # For each object in the registry, determine whether it should be kept or deleted. ( @@ -229,9 +214,29 @@ def apply_total_with_repo_instance( views_to_keep, ) = extract_objects_for_apply_delete(project, registry, repo) - store.apply(all_to_apply, objects_to_delete=all_to_delete, partial=False) + click.echo(registry_diff.to_string()) + + if store._should_use_plan(): + store._apply_diffs(registry_diff, infra_diff, new_infra) + click.echo(infra_diff.to_string()) + else: + store.apply(all_to_apply, objects_to_delete=all_to_delete, partial=False) + log_infra_changes(views_to_keep, views_to_delete) - log_cli_output(registry_diff, views_to_delete, views_to_keep) + +def log_infra_changes( + views_to_keep: List[FeatureView], views_to_delete: List[FeatureView] +): + from colorama import Fore, Style + + for view in views_to_keep: + click.echo( + f"Deploying infrastructure for {Style.BRIGHT + Fore.GREEN}{view.name}{Style.RESET_ALL}" + ) + for view in views_to_delete: + click.echo( + f"Removing infrastructure for {Style.BRIGHT + Fore.RED}{view.name}{Style.RESET_ALL}" + ) @log_exceptions_and_usage @@ -244,44 +249,6 @@ def apply_total(repo_config: RepoConfig, repo_path: Path, skip_source_validation ) -def log_cli_output(diff, views_to_delete, views_to_keep): - from colorama import Fore, Style - - message_action_map = { - TransitionType.CREATE: ("Created", Fore.GREEN), - TransitionType.DELETE: ("Deleted", Fore.RED), - TransitionType.UNCHANGED: ("Unchanged", Fore.LIGHTBLUE_EX), - TransitionType.UPDATE: ("Updated", Fore.YELLOW), - } - for fco_diff in diff.fco_diffs: - if fco_diff.name == DUMMY_ENTITY_NAME: - continue - action, color = message_action_map[fco_diff.transition_type] - click.echo( - f"{action} {fco_diff.fco_type} {Style.BRIGHT + color}{fco_diff.name}{Style.RESET_ALL}" - ) - if fco_diff.transition_type == TransitionType.UPDATE: - for _p in fco_diff.fco_property_diffs: - click.echo( - f"\t{_p.property_name}: {Style.BRIGHT + color}{_p.val_existing}{Style.RESET_ALL} -> {Style.BRIGHT + Fore.LIGHTGREEN_EX}{_p.val_declared}{Style.RESET_ALL}" - ) - - views_to_keep_in_infra = [ - view for view in views_to_keep if isinstance(view, FeatureView) - ] - for name in [view.name for view in views_to_keep_in_infra]: - click.echo( - f"Deploying infrastructure for {Style.BRIGHT + Fore.GREEN}{name}{Style.RESET_ALL}" - ) - views_to_delete_from_infra = [ - view for view in views_to_delete if isinstance(view, FeatureView) - ] - for name in [view.name for view in views_to_delete_from_infra]: - click.echo( - f"Removing infrastructure for {Style.BRIGHT + Fore.RED}{name}{Style.RESET_ALL}" - ) - - @log_exceptions_and_usage def teardown(repo_config: RepoConfig, repo_path: Path): # Cannot pass in both repo_path and repo_config to FeatureStore. From 9f2c6d6168bac1b11be2c76c6b266a7444fc11c7 Mon Sep 17 00:00:00 2001 From: David Miller Date: Thu, 27 Jan 2022 14:33:21 -0800 Subject: [PATCH 39/85] Add backticks to left_table_query_string (#2250) Signed-off-by: david --- sdk/python/feast/infra/offline_stores/bigquery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/feast/infra/offline_stores/bigquery.py b/sdk/python/feast/infra/offline_stores/bigquery.py index 42a1a839073..f34a9977183 100644 --- a/sdk/python/feast/infra/offline_stores/bigquery.py +++ b/sdk/python/feast/infra/offline_stores/bigquery.py @@ -558,7 +558,7 @@ def _get_bigquery_client(project: Optional[str] = None, location: Optional[str] ,CAST({{entity_df_event_timestamp_col}} AS STRING) AS {{featureview.name}}__entity_row_unique_id {% endif %} {% endfor %} - FROM {{ left_table_query_string }} + FROM `{{ left_table_query_string }}` ), {% for featureview in featureviews %} From 5fc0b52f4674df019be3452571f60f9876154b0c Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Fri, 28 Jan 2022 09:25:54 +0200 Subject: [PATCH 40/85] Delete entity key from Redis only when all attached feature views are gone (#2240) * Delete entity from redis when the last attached feature view is deleted Signed-off-by: pyalex * Delete entity key from Redis only when all attached feature views are gone Signed-off-by: pyalex * make lint happy Signed-off-by: pyalex * make lint happy Signed-off-by: pyalex * one more try with mypy Signed-off-by: pyalex --- sdk/python/feast/infra/online_stores/redis.py | 24 ++++-- .../online_store/test_universal_online.py | 74 +++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/sdk/python/feast/infra/online_stores/redis.py b/sdk/python/feast/infra/online_stores/redis.py index 493c6ab4626..752ed7d009d 100644 --- a/sdk/python/feast/infra/online_stores/redis.py +++ b/sdk/python/feast/infra/online_stores/redis.py @@ -72,11 +72,11 @@ class RedisOnlineStoreConfig(FeastConfigBaseModel): class RedisOnlineStore(OnlineStore): _client: Optional[Union[Redis, RedisCluster]] = None - def delete_table_values(self, config: RepoConfig, table: FeatureView): + def delete_entity_values(self, config: RepoConfig, join_keys: List[str]): client = self._get_client(config.online_store) deleted_count = 0 pipeline = client.pipeline() - prefix = _redis_key_prefix(table.entities) + prefix = _redis_key_prefix(join_keys) for _k in client.scan_iter( b"".join([prefix, b"*", config.project.encode("utf8")]) @@ -85,7 +85,7 @@ def delete_table_values(self, config: RepoConfig, table: FeatureView): deleted_count += 1 pipeline.execute() - logger.debug(f"Deleted {deleted_count} keys for {table.name}") + logger.debug(f"Deleted {deleted_count} rows for entity {', '.join(join_keys)}") @log_exceptions_and_usage(online_store="redis") def update( @@ -98,10 +98,16 @@ def update( partial: bool, ): """ - We delete the keys in redis for tables/views being removed. + Look for join_keys (list of entities) that are not in use anymore + (usually this happens when the last feature view that was using specific compound key is deleted) + and remove all features attached to this "join_keys". """ - for table in tables_to_delete: - self.delete_table_values(config, table) + join_keys_to_keep = set(tuple(table.entities) for table in tables_to_keep) + + join_keys_to_delete = set(tuple(table.entities) for table in tables_to_delete) + + for join_keys in join_keys_to_delete - join_keys_to_keep: + self.delete_entity_values(config, list(join_keys)) def teardown( self, @@ -112,8 +118,10 @@ def teardown( """ We delete the keys in redis for tables/views being removed. """ - for table in tables: - self.delete_table_values(config, table) + join_keys_to_delete = set(tuple(table.entities) for table in tables) + + for join_keys in join_keys_to_delete: + self.delete_entity_values(config, list(join_keys)) @staticmethod def _parse_connection_string(connection_string: str): diff --git a/sdk/python/tests/integration/online_store/test_universal_online.py b/sdk/python/tests/integration/online_store/test_universal_online.py index b23c68033e6..f483d54f6b3 100644 --- a/sdk/python/tests/integration/online_store/test_universal_online.py +++ b/sdk/python/tests/integration/online_store/test_universal_online.py @@ -28,6 +28,7 @@ ) from tests.integration.feature_repos.universal.feature_views import ( create_driver_hourly_stats_feature_view, + driver_feature_view, ) from tests.utils.data_source_utils import prep_file_source @@ -503,6 +504,79 @@ def test_online_retrieval(environment, universal_data_sources, full_feature_name ) +@pytest.mark.integration +@pytest.mark.universal +def test_online_store_cleanup(environment, universal_data_sources): + """ + Some online store implementations (like Redis) keep features from different features views + but with common entities together. + This might end up with deletion of all features attached to the entity, + when only one feature view was deletion target (see https://github.com/feast-dev/feast/issues/2150). + + Plan: + 1. Register two feature views with common entity "driver" + 2. Materialize data + 3. Check if features are available (via online retrieval) + 4. Delete one feature view + 5. Check that features for other are still available + 6. Delete another feature view (and create again) + 7. Verify that features for both feature view were deleted + """ + fs = environment.feature_store + entities, datasets, data_sources = universal_data_sources + driver_stats_fv = construct_universal_feature_views(data_sources)["driver"] + + df = pd.DataFrame( + { + "ts_1": [environment.end_date] * len(entities["driver"]), + "created_ts": [environment.end_date] * len(entities["driver"]), + "driver_id": entities["driver"], + "value": np.random.random(size=len(entities["driver"])), + } + ) + + ds = environment.data_source_creator.create_data_source( + df, destination_name="simple_driver_dataset" + ) + + simple_driver_fv = driver_feature_view( + data_source=ds, name="test_universal_online_simple_driver" + ) + + fs.apply([driver(), simple_driver_fv, driver_stats_fv]) + + fs.materialize( + environment.start_date - timedelta(days=1), + environment.end_date + timedelta(days=1), + ) + expected_values = df.sort_values(by="driver_id") + + features = [f"{simple_driver_fv.name}:value"] + entity_rows = [{"driver": driver_id} for driver_id in sorted(entities["driver"])] + + online_features = fs.get_online_features( + features=features, entity_rows=entity_rows + ).to_dict() + assert np.allclose(expected_values["value"], online_features["value"]) + + fs.apply( + objects=[simple_driver_fv], objects_to_delete=[driver_stats_fv], partial=False + ) + + online_features = fs.get_online_features( + features=features, entity_rows=entity_rows + ).to_dict() + assert np.allclose(expected_values["value"], online_features["value"]) + + fs.apply(objects=[], objects_to_delete=[simple_driver_fv], partial=False) + fs.apply([simple_driver_fv]) + + online_features = fs.get_online_features( + features=features, entity_rows=entity_rows + ).to_dict() + assert all(v is None for v in online_features["value"]) + + def response_feature_name(feature: str, full_feature_names: bool) -> str: if ( feature in {"current_balance", "avg_passenger_count", "lifetime_trip_count"} From 592af75e63766a380673b2dc8a879e70d2df5818 Mon Sep 17 00:00:00 2001 From: Michelle Rascati <44408275+michelle-rascati-sp@users.noreply.github.com> Date: Fri, 28 Jan 2022 01:57:54 -0600 Subject: [PATCH 41/85] historical_field_mappings2 merge for one sign off commit (#2252) Signed-off-by: Michelle Rascati --- CONTRIBUTING.md | 2 +- sdk/python/feast/driver_test_data.py | 26 ++++++++++++++ .../feast/infra/offline_stores/bigquery.py | 4 +-- .../infra/offline_stores/offline_utils.py | 12 +++++-- .../feast/infra/offline_stores/redshift.py | 4 +-- sdk/python/setup.py | 2 +- .../feature_repos/repo_configuration.py | 14 ++++++++ .../feature_repos/universal/feature_views.py | 10 ++++++ .../test_universal_historical_retrieval.py | 36 +++++++++++++++++++ 9 files changed, 102 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6918d7f1de9..dbf44d4bef9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ Setting up your development environment for Feast Python SDK / CLI: 3. _Recommended:_ Create a virtual environment to isolate development dependencies to be installed ```sh # create & activate a virtual environment -python -v venv venv/ +python -m venv venv/ source venv/bin/activate ``` diff --git a/sdk/python/feast/driver_test_data.py b/sdk/python/feast/driver_test_data.py index 1c9a1dd20bc..117bfcbd9cb 100644 --- a/sdk/python/feast/driver_test_data.py +++ b/sdk/python/feast/driver_test_data.py @@ -264,3 +264,29 @@ def create_global_daily_stats_df(start_date, end_date) -> pd.DataFrame: # TODO: Remove created timestamp in order to test whether its really optional df_daily["created"] = pd.to_datetime(pd.Timestamp.now(tz=None).round("ms")) return df_daily + + +def create_field_mapping_df(start_date, end_date) -> pd.DataFrame: + """ + Example df generated by this function: + | event_timestamp | column_name | created | + |------------------+-------------+------------------| + | 2021-03-17 19:00 | 99 | 2021-03-24 19:38 | + | 2021-03-17 19:00 | 22 | 2021-03-24 19:38 | + | 2021-03-17 19:00 | 7 | 2021-03-24 19:38 | + | 2021-03-17 19:00 | 45 | 2021-03-24 19:38 | + """ + size = 10 + df = pd.DataFrame() + df["column_name"] = np.random.randint(1, 100, size=size).astype(np.int32) + 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=size) + ) + ] + df["created"] = pd.to_datetime(pd.Timestamp.now(tz=None).round("ms")) + return df diff --git a/sdk/python/feast/infra/offline_stores/bigquery.py b/sdk/python/feast/infra/offline_stores/bigquery.py index f34a9977183..44e62d6ad1a 100644 --- a/sdk/python/feast/infra/offline_stores/bigquery.py +++ b/sdk/python/feast/infra/offline_stores/bigquery.py @@ -598,7 +598,7 @@ def _get_bigquery_client(project: Optional[str] = None, location: Optional[str] {{ 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 }} WHERE {{ featureview.event_timestamp_column }} <= '{{ featureview.max_event_timestamp }}' @@ -699,7 +699,7 @@ def _get_bigquery_client(project: Optional[str] = None, location: Optional[str] 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 ) USING ({{featureview.name}}__entity_row_unique_id) diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index 0b60c3493df..eaf4925266d 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -85,6 +85,7 @@ class FeatureViewQueryContext: ttl: int entities: List[str] features: List[str] # feature reference format + field_mapping: Dict[str, str] event_timestamp_column: str created_timestamp_column: Optional[str] table_subquery: str @@ -144,7 +145,10 @@ def get_feature_view_query_context( name=feature_view.projection.name_to_use(), ttl=ttl_seconds, entities=join_keys, - features=features, + features=[ + reverse_field_mapping.get(feature, feature) for feature in features + ], + field_mapping=feature_view.input.field_mapping, event_timestamp_column=reverse_field_mapping.get( event_timestamp_column, event_timestamp_column ), @@ -175,7 +179,11 @@ def build_point_in_time_query( final_output_feature_names = list(entity_df_columns) final_output_feature_names.extend( [ - (f"{fv.name}__{feature}" if full_feature_names else feature) + ( + f"{fv.name}__{fv.field_mapping.get(feature, feature)}" + if full_feature_names + else fv.field_mapping.get(feature, feature) + ) for fv in feature_view_query_contexts for feature in fv.features ] diff --git a/sdk/python/feast/infra/offline_stores/redshift.py b/sdk/python/feast/infra/offline_stores/redshift.py index 2aa3d5c41c6..3efd45bc741 100644 --- a/sdk/python/feast/infra/offline_stores/redshift.py +++ b/sdk/python/feast/infra/offline_stores/redshift.py @@ -563,7 +563,7 @@ def _get_entity_df_event_timestamp_range( {{ 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 }} WHERE {{ featureview.event_timestamp_column }} <= '{{ featureview.max_event_timestamp }}' @@ -664,7 +664,7 @@ def _get_entity_df_event_timestamp_range( 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 ) USING ({{featureview.name}}__entity_row_unique_id) diff --git a/sdk/python/setup.py b/sdk/python/setup.py index d35ee9de11b..4f01c7b4e01 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -132,7 +132,7 @@ + AWS_REQUIRED ) -DEV_REQUIRED = ["mypy-protobuf==1.*", "grpcio-testing==1.*"] + CI_REQUIRED +DEV_REQUIRED = ["mypy-protobuf>=1.*", "grpcio-testing==1.*"] + CI_REQUIRED # Get git repo root directory repo_root = str(pathlib.Path(__file__).resolve().parent.parent.parent) diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index f66a92c9d69..f0fb0b28fda 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -35,6 +35,7 @@ create_customer_daily_profile_feature_view, create_driver_age_request_feature_view, create_driver_hourly_stats_feature_view, + create_field_mapping_feature_view, create_global_stats_feature_view, create_location_stats_feature_view, create_order_feature_view, @@ -126,6 +127,7 @@ def construct_universal_datasets( order_count=20, ) global_df = driver_test_data.create_global_daily_stats_df(start_time, end_time) + field_mapping_df = driver_test_data.create_field_mapping_df(start_time, end_time) entity_df = orders_df[ [ "customer_id", @@ -143,6 +145,7 @@ def construct_universal_datasets( "location": location_df, "orders": orders_df, "global": global_df, + "field_mapping": field_mapping_df, "entity": entity_df, } @@ -180,12 +183,20 @@ def construct_universal_data_sources( event_timestamp_column="event_timestamp", created_timestamp_column="created", ) + field_mapping_ds = data_source_creator.create_data_source( + datasets["field_mapping"], + destination_name="field_mapping", + event_timestamp_column="event_timestamp", + created_timestamp_column="created", + field_mapping={"column_name": "feature_name"}, + ) return { "customer": customer_ds, "driver": driver_ds, "location": location_ds, "orders": orders_ds, "global": global_ds, + "field_mapping": field_mapping_ds, } @@ -210,6 +221,9 @@ def construct_universal_feature_views( "driver_age_request_fv": create_driver_age_request_feature_view(), "order": create_order_feature_view(data_sources["orders"]), "location": create_location_stats_feature_view(data_sources["location"]), + "field_mapping": create_field_mapping_feature_view( + data_sources["field_mapping"] + ), } diff --git a/sdk/python/tests/integration/feature_repos/universal/feature_views.py b/sdk/python/tests/integration/feature_repos/universal/feature_views.py index f68add88cbb..b0dc34197f3 100644 --- a/sdk/python/tests/integration/feature_repos/universal/feature_views.py +++ b/sdk/python/tests/integration/feature_repos/universal/feature_views.py @@ -217,3 +217,13 @@ def create_location_stats_feature_view(source, infer_features: bool = False): ttl=timedelta(days=2), ) return location_stats_feature_view + + +def create_field_mapping_feature_view(source): + return FeatureView( + name="field_mapping", + entities=[], + features=[Feature(name="feature_name", dtype=ValueType.INT32)], + batch_source=source, + ttl=timedelta(days=2), + ) 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 5e4bd004604..147e20aee1f 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 @@ -82,6 +82,8 @@ def get_expected_training_df( location_fv: FeatureView, global_df: pd.DataFrame, global_fv: FeatureView, + field_mapping_df: pd.DataFrame, + field_mapping_fv: FeatureView, entity_df: pd.DataFrame, event_timestamp: str, full_feature_names: bool = False, @@ -102,6 +104,10 @@ def get_expected_training_df( global_records = convert_timestamp_records_to_utc( global_df.to_dict("records"), global_fv.batch_source.event_timestamp_column ) + field_mapping_records = convert_timestamp_records_to_utc( + field_mapping_df.to_dict("records"), + field_mapping_fv.batch_source.event_timestamp_column, + ) entity_rows = convert_timestamp_records_to_utc( entity_df.to_dict("records"), event_timestamp ) @@ -156,6 +162,13 @@ def get_expected_training_df( ts_end=order_record[event_timestamp], ) + field_mapping_record = find_asof_record( + field_mapping_records, + ts_key=field_mapping_fv.batch_source.event_timestamp_column, + ts_start=order_record[event_timestamp] - field_mapping_fv.ttl, + ts_end=order_record[event_timestamp], + ) + entity_row.update( { ( @@ -197,6 +210,16 @@ def get_expected_training_df( } ) + # get field_mapping_record by column name, but label by feature name + entity_row.update( + { + ( + f"field_mapping__{feature}" if full_feature_names else feature + ): field_mapping_record.get(column, None) + for (column, feature) in field_mapping_fv.input.field_mapping.items() + } + ) + # Convert records back to pandas dataframe expected_df = pd.DataFrame(entity_rows) @@ -213,6 +236,7 @@ def get_expected_training_df( "customer_profile__current_balance": "float32", "customer_profile__avg_passenger_count": "float32", "global_stats__avg_ride_length": "float32", + "field_mapping__feature_name": "int32", } else: expected_column_types = { @@ -221,6 +245,7 @@ def get_expected_training_df( "current_balance": "float32", "avg_passenger_count": "float32", "avg_ride_length": "float32", + "feature_name": "int32", } for col, typ in expected_column_types.items(): @@ -311,6 +336,8 @@ def test_historical_features(environment, universal_data_sources, full_feature_n feature_views["location"], datasets["global"], feature_views["global"], + datasets["field_mapping"], + feature_views["field_mapping"], entity_df_with_request_data, event_timestamp, full_feature_names, @@ -336,6 +363,7 @@ def test_historical_features(environment, universal_data_sources, full_feature_n "global_stats:num_rides", "global_stats:avg_ride_length", "driver_age:driver_age", + "field_mapping:feature_name", ], full_feature_names=full_feature_names, ) @@ -404,6 +432,7 @@ def test_historical_features_with_missing_request_data( "conv_rate_plus_100:conv_rate_plus_val_to_add", "global_stats:num_rides", "global_stats:avg_ride_length", + "field_mapping:feature_name", ], full_feature_names=full_feature_names, ) @@ -419,6 +448,7 @@ def test_historical_features_with_missing_request_data( "driver_age:driver_age", "global_stats:num_rides", "global_stats:avg_ride_length", + "field_mapping:feature_name", ], full_feature_names=full_feature_names, ) @@ -452,6 +482,7 @@ def test_historical_features_with_entities_from_query( "order:order_is_success", "global_stats:num_rides", "global_stats:avg_ride_length", + "field_mapping:feature_name", ], full_feature_names=full_feature_names, ) @@ -477,6 +508,8 @@ def test_historical_features_with_entities_from_query( feature_views["location"], datasets["global"], feature_views["global"], + datasets["field_mapping"], + feature_views["field_mapping"], datasets["entity"], event_timestamp, full_feature_names, @@ -538,6 +571,7 @@ def test_historical_features_persisting( "order:order_is_success", "global_stats:num_rides", "global_stats:avg_ride_length", + "field_mapping:feature_name", ], full_feature_names=full_feature_names, ) @@ -561,6 +595,8 @@ def test_historical_features_persisting( feature_views["location"], datasets["global"], feature_views["global"], + datasets["field_mapping"], + feature_views["field_mapping"], entity_df, event_timestamp, full_feature_names, From 12e2130a95ae473ec20436212784ba4351cac4b5 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Fri, 28 Jan 2022 16:21:54 +0000 Subject: [PATCH 42/85] Correct inconsistent dependency (#2255) Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/setup.py b/sdk/python/setup.py index 4f01c7b4e01..bae1695bf1a 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -132,7 +132,7 @@ + AWS_REQUIRED ) -DEV_REQUIRED = ["mypy-protobuf>=1.*", "grpcio-testing==1.*"] + CI_REQUIRED +DEV_REQUIRED = ["mypy-protobuf>=3.1.0", "grpcio-testing==1.*"] + CI_REQUIRED # Get git repo root directory repo_root = str(pathlib.Path(__file__).resolve().parent.parent.parent) @@ -244,7 +244,7 @@ def run(self): ], entry_points={"console_scripts": ["feast=feast.cli:cli"]}, use_scm_version=use_scm_version, - setup_requires=["setuptools_scm", "grpcio", "grpcio-tools==1.34.0", "mypy-protobuf==1.*", "sphinx!=4.0.0"], + setup_requires=["setuptools_scm", "grpcio", "grpcio-tools==1.34.0", "mypy-protobuf==3.1.0", "sphinx!=4.0.0"], package_data={ "": [ "protos/feast/**/*.proto", From f6cc618a788172dace5176fef33182cbd8f1eb00 Mon Sep 17 00:00:00 2001 From: sfc-gh-madkins <82121043+sfc-gh-madkins@users.noreply.github.com> Date: Sat, 29 Jan 2022 09:01:31 -0600 Subject: [PATCH 43/85] Add snowflake environment variables to allow testing on snowflake infra (#2258) * add snowflake environment vars to test framework Signed-off-by: sfc-gh-madkins * add snowflake environment vars to test framework Signed-off-by: sfc-gh-madkins --- .github/workflows/master_only.yml | 5 +++++ .github/workflows/pr_integration_tests.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/master_only.yml b/.github/workflows/master_only.yml index 42f0383832a..66b5c620739 100644 --- a/.github/workflows/master_only.yml +++ b/.github/workflows/master_only.yml @@ -125,6 +125,11 @@ jobs: FEAST_SERVER_DOCKER_IMAGE_TAG: ${{ needs.build-lambda-docker-image.outputs.DOCKER_IMAGE_TAG }} FEAST_USAGE: "False" IS_TEST: "True" + SNOWFLAKE_CI_DEPLOYMENT: ${{ secrets.SNOWFLAKE_CI_DEPLOYMENT }} + SNOWFLAKE_CI_USER: ${{ secrets.SNOWFLAKE_CI_USER }} + SNOWFLAKE_CI_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }} + SNOWFLAKE_CI_ROLE: ${{ secrets.SNOWFLAKE_CI_ROLE }} + SNOWFLAKE_CI_WAREHOUSE: ${{ secrets.SNOWFLAKE_CI_WAREHOUSE }} run: pytest -n 8 --cov=./ --cov-report=xml --verbose --color=yes sdk/python/tests --integration --durations=5 - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index 8a910f943c6..e04b78ec320 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -151,6 +151,11 @@ jobs: FEAST_SERVER_DOCKER_IMAGE_TAG: ${{ needs.build-docker-image.outputs.DOCKER_IMAGE_TAG }} FEAST_USAGE: "False" IS_TEST: "True" + SNOWFLAKE_CI_DEPLOYMENT: ${{ secrets.SNOWFLAKE_CI_DEPLOYMENT }} + SNOWFLAKE_CI_USER: ${{ secrets.SNOWFLAKE_CI_USER }} + SNOWFLAKE_CI_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }} + SNOWFLAKE_CI_ROLE: ${{ secrets.SNOWFLAKE_CI_ROLE }} + SNOWFLAKE_CI_WAREHOUSE: ${{ secrets.SNOWFLAKE_CI_WAREHOUSE }} run: pytest -n 8 --cov=./ --cov-report=xml --verbose --color=yes sdk/python/tests --integration --durations=5 - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 From 08d6881efdfaca4033b62f640915f716ee6b85df Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Sat, 29 Jan 2022 21:38:32 +0000 Subject: [PATCH 44/85] Return `UNIX_TIMESTAMP` as Python `datetime` (#2244) * Refactor `UNIX_TIMESTAMP` conversion Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Return `UNIX_TIMESTAMP` types as `datetime` to user Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Fix linting errors Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Rename variable to something more sensible Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/type_map.py | 88 ++++++++++++------- .../registration/test_universal_types.py | 2 +- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 74c4cb17edd..599be85fdff 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -13,8 +13,20 @@ # limitations under the License. import re -from datetime import datetime -from typing import Any, Dict, List, Optional, Set, Sized, Tuple, Type +from datetime import datetime, timezone +from typing import ( + Any, + Dict, + List, + Optional, + Sequence, + Set, + Sized, + Tuple, + Type, + Union, + cast, +) import numpy as np import pandas as pd @@ -49,8 +61,17 @@ def feast_value_type_to_python_type(field_value_proto: ProtoValue) -> Any: if val_attr is None: return None val = getattr(field_value_proto, val_attr) + + # If it's a _LIST type extract the list. if hasattr(val, "val"): val = list(val.val) + + # Convert UNIX_TIMESTAMP values to `datetime` + if val_attr == "unix_timestamp_list_val": + val = [datetime.fromtimestamp(v, tz=timezone.utc) for v in val] + elif val_attr == "unix_timestamp_val": + val = datetime.fromtimestamp(val, tz=timezone.utc) + return val @@ -240,6 +261,28 @@ def _type_err(item, dtype): } +def _python_datetime_to_int_timestamp( + values: Sequence[Any], +) -> Sequence[Union[int, np.int_]]: + # Fast path for Numpy array. + if isinstance(values, np.ndarray) and isinstance(values.dtype, np.datetime64): + if values.ndim != 1: + raise ValueError("Only 1 dimensional arrays are supported.") + return cast(Sequence[np.int_], values.astype("datetime64[s]").astype(np.int_)) + + int_timestamps = [] + for value in values: + if isinstance(value, datetime): + int_timestamps.append(int(value.timestamp())) + elif isinstance(value, Timestamp): + int_timestamps.append(int(value.ToSeconds())) + elif isinstance(value, np.datetime64): + int_timestamps.append(value.astype("datetime64[s]").astype(np.int_)) + else: + int_timestamps.append(int(value)) + return int_timestamps + + def _python_value_to_proto_value( feast_value_type: ValueType, values: List[Any] ) -> List[ProtoValue]: @@ -275,22 +318,14 @@ def _python_value_to_proto_value( raise _type_err(first_invalid, valid_types[0]) if feast_value_type == ValueType.UNIX_TIMESTAMP_LIST: - converted_values = [] - for value in values: - converted_sub_values = [] - for sub_value in value: - if isinstance(sub_value, datetime): - converted_sub_values.append(int(sub_value.timestamp())) - elif isinstance(sub_value, Timestamp): - converted_sub_values.append(int(sub_value.ToSeconds())) - elif isinstance(sub_value, np.datetime64): - converted_sub_values.append( - sub_value.astype("datetime64[s]").astype("int") - ) - else: - converted_sub_values.append(sub_value) - converted_values.append(converted_sub_values) - values = converted_values + int_timestamps_lists = ( + _python_datetime_to_int_timestamp(value) for value in values + ) + return [ + # ProtoValue does actually accept `np.int_` but the typing complains. + ProtoValue(unix_timestamp_list_val=Int64List(val=ts)) # type: ignore + for ts in int_timestamps_lists + ] return [ ProtoValue(**{field_name: proto_type(val=value)}) # type: ignore @@ -302,20 +337,9 @@ def _python_value_to_proto_value( # Handle scalar types below else: if feast_value_type == ValueType.UNIX_TIMESTAMP: - if isinstance(sample, datetime): - return [ - ProtoValue(int64_val=int(value.timestamp())) for value in values - ] - elif isinstance(sample, Timestamp): - return [ - ProtoValue(int64_val=int(value.ToSeconds())) for value in values - ] - elif isinstance(sample, np.datetime64): - return [ - ProtoValue(int64_val=value.astype("datetime64[s]").astype("int")) - for value in values - ] - return [ProtoValue(int64_val=int(value)) for value in values] + int_timestamps = _python_datetime_to_int_timestamp(values) + # ProtoValue does actually accept `np.int_` but the typing complains. + return [ProtoValue(unix_timestamp_val=ts) for ts in int_timestamps] # type: ignore if feast_value_type in PYTHON_SCALAR_VALUE_TYPE_TO_PROTO_VALUE: ( diff --git a/sdk/python/tests/integration/registration/test_universal_types.py b/sdk/python/tests/integration/registration/test_universal_types.py index 8cb21e63847..5c782306e64 100644 --- a/sdk/python/tests/integration/registration/test_universal_types.py +++ b/sdk/python/tests/integration/registration/test_universal_types.py @@ -234,7 +234,7 @@ def test_feature_get_online_features_types_match(online_types_test_fixtures): "float": float, "string": str, "bool": bool, - "datetime": int, + "datetime": datetime, } expected_dtype = feature_list_dtype_to_expected_online_response_value_type[ config.feature_dtype From 895589a8de6734bfebe3af7e0ae53205d9157594 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Sun, 30 Jan 2022 09:02:52 -0800 Subject: [PATCH 45/85] Feast plan clean up (#2256) * Run validation and inference on views and entities during plan Signed-off-by: Felix Wang * Do not log objects that are unchanged Signed-off-by: Felix Wang * Rename Fco to FeastObject Signed-off-by: Felix Wang * Remove useless method Signed-off-by: Felix Wang * Lint Signed-off-by: Felix Wang * Always initialize registry during feature store initialization Signed-off-by: Felix Wang * Fix usage test Signed-off-by: Felix Wang * Remove print statements Signed-off-by: Felix Wang --- sdk/python/feast/diff/infra_diff.py | 8 + .../diff/{FcoDiff.py => registry_diff.py} | 153 +++++++++--------- sdk/python/feast/feature_store.py | 63 +++----- sdk/python/feast/inference.py | 12 +- sdk/python/feast/repo_operations.py | 3 +- .../tests/integration/e2e/test_usage_e2e.py | 10 +- ...test_fco_diff.py => test_registry_diff.py} | 67 ++------ 7 files changed, 145 insertions(+), 171 deletions(-) rename sdk/python/feast/diff/{FcoDiff.py => registry_diff.py} (63%) rename sdk/python/tests/unit/diff/{test_fco_diff.py => test_registry_diff.py} (53%) diff --git a/sdk/python/feast/diff/infra_diff.py b/sdk/python/feast/diff/infra_diff.py index d5bcbbc44a7..a09eaf39ebe 100644 --- a/sdk/python/feast/diff/infra_diff.py +++ b/sdk/python/feast/diff/infra_diff.py @@ -71,12 +71,20 @@ def to_string(self): TransitionType.UPDATE: ("Updated", Fore.YELLOW), } for infra_object_diff in self.infra_object_diffs: + if infra_object_diff.transition_type == TransitionType.UNCHANGED: + continue action, color = message_action_map[infra_object_diff.transition_type] log_string += f"{action} {infra_object_diff.infra_object_type} {Style.BRIGHT + color}{infra_object_diff.name}{Style.RESET_ALL}\n" if infra_object_diff.transition_type == TransitionType.UPDATE: for _p in infra_object_diff.infra_object_property_diffs: log_string += f"\t{_p.property_name}: {Style.BRIGHT + color}{_p.val_existing}{Style.RESET_ALL} -> {Style.BRIGHT + Fore.LIGHTGREEN_EX}{_p.val_declared}{Style.RESET_ALL}\n" + log_string = ( + f"{Style.BRIGHT + Fore.LIGHTBLUE_EX}No changes to infrastructure" + if not log_string + else log_string + ) + return log_string diff --git a/sdk/python/feast/diff/FcoDiff.py b/sdk/python/feast/diff/registry_diff.py similarity index 63% rename from sdk/python/feast/diff/FcoDiff.py rename to sdk/python/feast/diff/registry_diff.py index 1ea66ec6598..1f68d3ff65c 100644 --- a/sdk/python/feast/diff/FcoDiff.py +++ b/sdk/python/feast/diff/registry_diff.py @@ -20,28 +20,28 @@ from feast.registry import FEAST_OBJECT_TYPES, FeastObjectType, Registry from feast.repo_contents import RepoContents -Fco = TypeVar("Fco", Entity, BaseFeatureView, FeatureService) +FeastObject = TypeVar("FeastObject", Entity, BaseFeatureView, FeatureService) @dataclass -class FcoDiff(Generic[Fco]): +class FeastObjectDiff(Generic[FeastObject]): name: str - fco_type: FeastObjectType - current_fco: Fco - new_fco: Fco - fco_property_diffs: List[PropertyDiff] + feast_object_type: FeastObjectType + current_feast_object: FeastObject + new_feast_object: FeastObject + feast_object_property_diffs: List[PropertyDiff] transition_type: TransitionType @dataclass class RegistryDiff: - fco_diffs: List[FcoDiff] + feast_object_diffs: List[FeastObjectDiff] def __init__(self): - self.fco_diffs = [] + self.feast_object_diffs = [] - def add_fco_diff(self, fco_diff: FcoDiff): - self.fco_diffs.append(fco_diff) + def add_feast_object_diff(self, feast_object_diff: FeastObjectDiff): + self.feast_object_diffs.append(feast_object_diff) def to_string(self): from colorama import Fore, Style @@ -54,21 +54,29 @@ def to_string(self): TransitionType.UNCHANGED: ("Unchanged", Fore.LIGHTBLUE_EX), TransitionType.UPDATE: ("Updated", Fore.YELLOW), } - for fco_diff in self.fco_diffs: - if fco_diff.name == DUMMY_ENTITY_NAME: + for feast_object_diff in self.feast_object_diffs: + if feast_object_diff.name == DUMMY_ENTITY_NAME: continue - action, color = message_action_map[fco_diff.transition_type] - log_string += f"{action} {fco_diff.fco_type.value} {Style.BRIGHT + color}{fco_diff.name}{Style.RESET_ALL}\n" - if fco_diff.transition_type == TransitionType.UPDATE: - for _p in fco_diff.fco_property_diffs: + if feast_object_diff.transition_type == TransitionType.UNCHANGED: + continue + action, color = message_action_map[feast_object_diff.transition_type] + log_string += f"{action} {feast_object_diff.feast_object_type.value} {Style.BRIGHT + color}{feast_object_diff.name}{Style.RESET_ALL}\n" + if feast_object_diff.transition_type == TransitionType.UPDATE: + for _p in feast_object_diff.feast_object_property_diffs: log_string += f"\t{_p.property_name}: {Style.BRIGHT + color}{_p.val_existing}{Style.RESET_ALL} -> {Style.BRIGHT + Fore.LIGHTGREEN_EX}{_p.val_declared}{Style.RESET_ALL}\n" + log_string = ( + f"{Style.BRIGHT + Fore.LIGHTBLUE_EX}No changes to registry" + if not log_string + else log_string + ) + return log_string def tag_objects_for_keep_delete_update_add( - existing_objs: Iterable[Fco], desired_objs: Iterable[Fco] -) -> Tuple[Set[Fco], Set[Fco], Set[Fco], Set[Fco]]: + existing_objs: Iterable[FeastObject], desired_objs: Iterable[FeastObject] +) -> Tuple[Set[FeastObject], Set[FeastObject], Set[FeastObject], Set[FeastObject]]: existing_obj_names = {e.name for e in existing_objs} desired_obj_names = {e.name for e in desired_objs} @@ -80,8 +88,8 @@ def tag_objects_for_keep_delete_update_add( return objs_to_keep, objs_to_delete, objs_to_update, objs_to_add -FcoProto = TypeVar( - "FcoProto", +FeastObjectProto = TypeVar( + "FeastObjectProto", EntityProto, FeatureViewProto, FeatureServiceProto, @@ -90,25 +98,12 @@ def tag_objects_for_keep_delete_update_add( ) -def tag_proto_objects_for_keep_delete_add( - existing_objs: Iterable[FcoProto], desired_objs: Iterable[FcoProto] -) -> Tuple[Iterable[FcoProto], Iterable[FcoProto], Iterable[FcoProto]]: - existing_obj_names = {e.spec.name for e in existing_objs} - desired_obj_names = {e.spec.name for e in desired_objs} - - objs_to_add = [e for e in desired_objs if e.spec.name not in existing_obj_names] - objs_to_keep = [e for e in desired_objs if e.spec.name in existing_obj_names] - objs_to_delete = [e for e in existing_objs if e.spec.name not in desired_obj_names] - - return objs_to_keep, objs_to_delete, objs_to_add - - FIELDS_TO_IGNORE = {"project"} def diff_registry_objects( - current: Fco, new: Fco, object_type: FeastObjectType -) -> FcoDiff: + current: FeastObject, new: FeastObject, object_type: FeastObjectType +) -> FeastObjectDiff: current_proto = current.to_proto() new_proto = new.to_proto() assert current_proto.DESCRIPTOR.full_name == new_proto.DESCRIPTOR.full_name @@ -129,12 +124,12 @@ def diff_registry_objects( getattr(new_proto.spec, _field.name), ) ) - return FcoDiff( + return FeastObjectDiff( name=new_proto.spec.name, - fco_type=object_type, - current_fco=current, - new_fco=new, - fco_property_diffs=property_diffs, + feast_object_type=object_type, + current_feast_object=current, + new_feast_object=new, + feast_object_property_diffs=property_diffs, transition_type=transition, ) @@ -142,10 +137,10 @@ def diff_registry_objects( def extract_objects_for_keep_delete_update_add( registry: Registry, current_project: str, desired_repo_contents: RepoContents, ) -> Tuple[ - Dict[FeastObjectType, Set[Fco]], - Dict[FeastObjectType, Set[Fco]], - Dict[FeastObjectType, Set[Fco]], - Dict[FeastObjectType, Set[Fco]], + Dict[FeastObjectType, Set[FeastObject]], + Dict[FeastObjectType, Set[FeastObject]], + Dict[FeastObjectType, Set[FeastObject]], + Dict[FeastObjectType, Set[FeastObject]], ]: """ Returns the objects in the registry that must be modified to achieve the desired repo state. @@ -215,30 +210,32 @@ def diff_between( objects_to_add = objs_to_add[object_type] for e in objects_to_add: - diff.add_fco_diff( - FcoDiff( + diff.add_feast_object_diff( + FeastObjectDiff( name=e.name, - fco_type=object_type, - current_fco=None, - new_fco=e, - fco_property_diffs=[], + feast_object_type=object_type, + current_feast_object=None, + new_feast_object=e, + feast_object_property_diffs=[], transition_type=TransitionType.CREATE, ) ) for e in objects_to_delete: - diff.add_fco_diff( - FcoDiff( + diff.add_feast_object_diff( + FeastObjectDiff( name=e.name, - fco_type=object_type, - current_fco=e, - new_fco=None, - fco_property_diffs=[], + feast_object_type=object_type, + current_feast_object=e, + new_feast_object=None, + feast_object_property_diffs=[], transition_type=TransitionType.DELETE, ) ) for e in objects_to_update: current_obj = [_e for _e in objects_to_keep if _e.name == e.name][0] - diff.add_fco_diff(diff_registry_objects(current_obj, e, object_type)) + diff.add_feast_object_diff( + diff_registry_objects(current_obj, e, object_type) + ) return diff @@ -255,39 +252,47 @@ def apply_diff_to_registry( project: Feast project to be updated. commit: Whether the change should be persisted immediately """ - for fco_diff in registry_diff.fco_diffs: - # There is no need to delete the FCO on an update, since applying the new FCO - # will automatically delete the existing FCO. - if fco_diff.transition_type == TransitionType.DELETE: - if fco_diff.fco_type == FeastObjectType.ENTITY: - registry.delete_entity(fco_diff.current_fco.name, project, commit=False) - elif fco_diff.fco_type == FeastObjectType.FEATURE_SERVICE: + for feast_object_diff in registry_diff.feast_object_diffs: + # There is no need to delete the object on an update, since applying the new object + # will automatically delete the existing object. + if feast_object_diff.transition_type == TransitionType.DELETE: + if feast_object_diff.feast_object_type == FeastObjectType.ENTITY: + registry.delete_entity( + feast_object_diff.current_feast_object.name, project, commit=False + ) + elif feast_object_diff.feast_object_type == FeastObjectType.FEATURE_SERVICE: registry.delete_feature_service( - fco_diff.current_fco.name, project, commit=False + feast_object_diff.current_feast_object.name, project, commit=False ) - elif fco_diff.fco_type in [ + elif feast_object_diff.feast_object_type in [ FeastObjectType.FEATURE_VIEW, FeastObjectType.ON_DEMAND_FEATURE_VIEW, FeastObjectType.REQUEST_FEATURE_VIEW, ]: registry.delete_feature_view( - fco_diff.current_fco.name, project, commit=False, + feast_object_diff.current_feast_object.name, project, commit=False, ) - if fco_diff.transition_type in [ + if feast_object_diff.transition_type in [ TransitionType.CREATE, TransitionType.UPDATE, ]: - if fco_diff.fco_type == FeastObjectType.ENTITY: - registry.apply_entity(fco_diff.new_fco, project, commit=False) - elif fco_diff.fco_type == FeastObjectType.FEATURE_SERVICE: - registry.apply_feature_service(fco_diff.new_fco, project, commit=False) - elif fco_diff.fco_type in [ + if feast_object_diff.feast_object_type == FeastObjectType.ENTITY: + registry.apply_entity( + feast_object_diff.new_feast_object, project, commit=False + ) + elif feast_object_diff.feast_object_type == FeastObjectType.FEATURE_SERVICE: + registry.apply_feature_service( + feast_object_diff.new_feast_object, project, commit=False + ) + elif feast_object_diff.feast_object_type in [ FeastObjectType.FEATURE_VIEW, FeastObjectType.ON_DEMAND_FEATURE_VIEW, FeastObjectType.REQUEST_FEATURE_VIEW, ]: - registry.apply_feature_view(fco_diff.new_fco, project, commit=False) + registry.apply_feature_view( + feast_object_diff.new_feast_object, project, commit=False + ) if commit: registry.commit() diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 01b1dc0f0c5..6b1dadde5c9 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -39,9 +39,8 @@ from feast import feature_server, flags, flags_helper, utils from feast.base_feature_view import BaseFeatureView -from feast.diff.FcoDiff import RegistryDiff, apply_diff_to_registry, diff_between from feast.diff.infra_diff import InfraDiff, diff_infra_protos -from feast.diff.property_diff import TransitionType +from feast.diff.registry_diff import RegistryDiff, apply_diff_to_registry, diff_between from feast.entity import Entity from feast.errors import ( EntityNotFoundException, @@ -75,7 +74,7 @@ ) from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import RepeatedValue, Value -from feast.registry import FeastObjectType, Registry +from feast.registry import Registry from feast.repo_config import RepoConfig, load_repo_config from feast.repo_contents import RepoContents from feast.request_feature_view import RequestFeatureView @@ -126,6 +125,7 @@ def __init__( registry_config = self.config.get_registry_config() self._registry = Registry(registry_config, repo_path=self.repo_path) + self._registry._initialize_registry() self._provider = get_provider(self.config, self.repo_path) @log_exceptions @@ -429,8 +429,10 @@ def _make_inferences( [view.batch_source for view in views_to_update], self.config ) + # New feature views may reference previously applied entities. + entities = self._list_entities() update_feature_views_with_inferred_features( - views_to_update, entities_to_update, self.config + views_to_update, entities + entities_to_update, self.config ) for odfv in odfvs_to_update: @@ -476,10 +478,26 @@ def _plan( ... ) >>> registry_diff, infra_diff, new_infra = fs._plan(RepoContents({driver_hourly_stats_view}, set(), set(), {driver}, set())) # register entity and feature view """ + # Validate and run inference on all the objects to be registered. + self._validate_all_feature_views( + list(desired_repo_contents.feature_views), + list(desired_repo_contents.on_demand_feature_views), + list(desired_repo_contents.request_feature_views), + ) + self._make_inferences( + list(desired_repo_contents.entities), + list(desired_repo_contents.feature_views), + list(desired_repo_contents.on_demand_feature_views), + ) + + # Compute the desired difference between the current objects in the registry and + # the desired repo state. registry_diff = diff_between( self._registry, self.project, desired_repo_contents ) + # Compute the desired difference between the current infra, as stored in the registry, + # and the desired infra. self._registry.refresh() current_infra_proto = ( self._registry.cached_registry_proto.infra.__deepcopy__() @@ -504,43 +522,6 @@ def _apply_diffs( infra_diff: The diff between the current infra and the desired infra. new_infra: The desired infra. """ - entities_to_update = [ - fco_diff.new_fco - for fco_diff in registry_diff.fco_diffs - if fco_diff.fco_type == FeastObjectType.ENTITY - and fco_diff.transition_type - in [TransitionType.CREATE, TransitionType.UPDATE] - ] - views_to_update = [ - fco_diff.new_fco - for fco_diff in registry_diff.fco_diffs - if fco_diff.fco_type == FeastObjectType.FEATURE_VIEW - and fco_diff.transition_type - in [TransitionType.CREATE, TransitionType.UPDATE] - ] - odfvs_to_update = [ - fco_diff.new_fco - for fco_diff in registry_diff.fco_diffs - if fco_diff.fco_type == FeastObjectType.ON_DEMAND_FEATURE_VIEW - and fco_diff.transition_type - in [TransitionType.CREATE, TransitionType.UPDATE] - ] - request_views_to_update = [ - fco_diff.new_fco - for fco_diff in registry_diff.fco_diffs - if fco_diff.fco_type == FeastObjectType.REQUEST_FEATURE_VIEW - and fco_diff.transition_type - in [TransitionType.CREATE, TransitionType.UPDATE] - ] - - # TODO(felixwang9817): move validation logic into _plan. - # Validate all feature views and make inferences. - self._validate_all_feature_views( - views_to_update, odfvs_to_update, request_views_to_update - ) - self._make_inferences(entities_to_update, views_to_update, odfvs_to_update) - - # Apply infra and registry changes. infra_diff.update() apply_diff_to_registry( self._registry, registry_diff, self.project, commit=False diff --git a/sdk/python/feast/inference.py b/sdk/python/feast/inference.py index 39a77264bcb..642a3c6442d 100644 --- a/sdk/python/feast/inference.py +++ b/sdk/python/feast/inference.py @@ -13,7 +13,12 @@ def update_entities_with_inferred_types_from_feature_views( entities: List[Entity], feature_views: List[FeatureView], config: RepoConfig ) -> None: """ - Infer entity value type by examining schema of feature view batch sources + Infers the types of the entities by examining the schemas of feature view batch sources. + + Args: + entities: The entities to be updated. + feature_views: A list containing feature views associated with the entities. + config: The config for the current feature store. """ incomplete_entities = { entity.name: entity @@ -127,6 +132,11 @@ def update_feature_views_with_inferred_features( Infers the set of features associated to each FeatureView and updates the FeatureView with those features. Inference occurs through considering each column of the underlying data source as a feature except columns that are associated with the data source's timestamp columns and the FeatureView's entity columns. + + Args: + fvs: The feature views to be updated. + entities: A list containing entities associated with the feature views. + config: The config for the current feature store. """ entity_name_to_join_key_map = {entity.name: entity.join_key for entity in entities} diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index f34346871de..8a3a202c6dd 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -12,7 +12,7 @@ import click from click.exceptions import BadParameter -from feast.diff.FcoDiff import extract_objects_for_keep_delete_update_add +from feast.diff.registry_diff import extract_objects_for_keep_delete_update_add from feast.entity import Entity from feast.feature_service import FeatureService from feast.feature_store import FeatureStore @@ -147,7 +147,6 @@ def _prepare_registry_and_repo(repo_config, repo_path): ) sys.exit(1) registry = store.registry - registry._initialize_registry() sys.dont_write_bytecode = True repo = parse_repo(repo_path) return project, registry, repo, store diff --git a/sdk/python/tests/integration/e2e/test_usage_e2e.py b/sdk/python/tests/integration/e2e/test_usage_e2e.py index f55fbce55cf..0bae9730632 100644 --- a/sdk/python/tests/integration/e2e/test_usage_e2e.py +++ b/sdk/python/tests/integration/e2e/test_usage_e2e.py @@ -66,10 +66,16 @@ def test_usage_on(dummy_exporter, enabling_toggle): test_feature_store.apply([entity]) - assert len(dummy_exporter) == 1 + assert len(dummy_exporter) == 3 assert { - "entrypoint": "feast.feature_store.FeatureStore.apply" + "entrypoint": "feast.infra.local.LocalRegistryStore.get_registry_proto" }.items() <= dummy_exporter[0].items() + assert { + "entrypoint": "feast.infra.local.LocalRegistryStore.update_registry_proto" + }.items() <= dummy_exporter[1].items() + assert { + "entrypoint": "feast.feature_store.FeatureStore.apply" + }.items() <= dummy_exporter[2].items() @pytest.mark.integration diff --git a/sdk/python/tests/unit/diff/test_fco_diff.py b/sdk/python/tests/unit/diff/test_registry_diff.py similarity index 53% rename from sdk/python/tests/unit/diff/test_fco_diff.py rename to sdk/python/tests/unit/diff/test_registry_diff.py index fa3c84d0350..0322ab47abf 100644 --- a/sdk/python/tests/unit/diff/test_fco_diff.py +++ b/sdk/python/tests/unit/diff/test_registry_diff.py @@ -1,54 +1,11 @@ -from feast.diff.FcoDiff import ( +from feast.diff.registry_diff import ( diff_registry_objects, tag_objects_for_keep_delete_update_add, - tag_proto_objects_for_keep_delete_add, ) from feast.feature_view import FeatureView from tests.utils.data_source_utils import prep_file_source -def test_tag_proto_objects_for_keep_delete_add(simple_dataset_1): - with prep_file_source( - df=simple_dataset_1, event_timestamp_column="ts_1" - ) as file_source: - to_delete = FeatureView( - name="to_delete", entities=["id"], batch_source=file_source, ttl=None, - ).to_proto() - unchanged_fv = FeatureView( - name="fv1", entities=["id"], batch_source=file_source, ttl=None, - ).to_proto() - pre_changed = FeatureView( - name="fv2", - entities=["id"], - batch_source=file_source, - ttl=None, - tags={"when": "before"}, - ).to_proto() - post_changed = FeatureView( - name="fv2", - entities=["id"], - batch_source=file_source, - ttl=None, - tags={"when": "after"}, - ).to_proto() - to_add = FeatureView( - name="to_add", entities=["id"], batch_source=file_source, ttl=None, - ).to_proto() - - keep, delete, add = tag_proto_objects_for_keep_delete_add( - [unchanged_fv, pre_changed, to_delete], [unchanged_fv, post_changed, to_add] - ) - - assert len(list(keep)) == 2 - assert unchanged_fv in keep - assert post_changed in keep - assert pre_changed not in keep - assert len(list(delete)) == 1 - assert to_delete in delete - assert len(list(add)) == 1 - assert to_add in add - - def test_tag_objects_for_keep_delete_update_add(simple_dataset_1): with prep_file_source( df=simple_dataset_1, event_timestamp_column="ts_1" @@ -114,12 +71,20 @@ def test_diff_registry_objects_feature_views(simple_dataset_1): tags={"when": "after"}, ) - fco_diffs = diff_registry_objects(pre_changed, pre_changed, "feature view") - assert len(fco_diffs.fco_property_diffs) == 0 + feast_object_diffs = diff_registry_objects( + pre_changed, pre_changed, "feature view" + ) + assert len(feast_object_diffs.feast_object_property_diffs) == 0 - fco_diffs = diff_registry_objects(pre_changed, post_changed, "feature view") - assert len(fco_diffs.fco_property_diffs) == 1 + feast_object_diffs = diff_registry_objects( + pre_changed, post_changed, "feature view" + ) + assert len(feast_object_diffs.feast_object_property_diffs) == 1 - assert fco_diffs.fco_property_diffs[0].property_name == "tags" - assert fco_diffs.fco_property_diffs[0].val_existing == {"when": "before"} - assert fco_diffs.fco_property_diffs[0].val_declared == {"when": "after"} + assert feast_object_diffs.feast_object_property_diffs[0].property_name == "tags" + assert feast_object_diffs.feast_object_property_diffs[0].val_existing == { + "when": "before" + } + assert feast_object_diffs.feast_object_property_diffs[0].val_declared == { + "when": "after" + } From 2e4f3e55e6f7ca13c18328d7d91b8446b1b46128 Mon Sep 17 00:00:00 2001 From: Nalin Mehra <37969183+NalinGHub@users.noreply.github.com> Date: Sun, 30 Jan 2022 17:52:52 -0500 Subject: [PATCH 46/85] modify registry.db s3 object initialization to work in S3 subdirectory with Java Feast Server (#2259) Signed-off-by: NalinGHub --- .../src/main/java/feast/serving/registry/S3RegistryFile.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/java/serving/src/main/java/feast/serving/registry/S3RegistryFile.java b/java/serving/src/main/java/feast/serving/registry/S3RegistryFile.java index 486e2ca39c6..4b122a5de03 100644 --- a/java/serving/src/main/java/feast/serving/registry/S3RegistryFile.java +++ b/java/serving/src/main/java/feast/serving/registry/S3RegistryFile.java @@ -33,7 +33,8 @@ public S3RegistryFile(AmazonS3 s3Client, String url) { this.s3Client = s3Client; String[] split = url.replace("s3://", "").split("/"); - this.s3Object = this.s3Client.getObject(split[0], split[1]); + String objectPath = String.join("/", java.util.Arrays.copyOfRange(split, 1, split.length)); + this.s3Object = this.s3Client.getObject(split[0], objectPath); } @Override From f2bc4119b44fd35ea6739118273cfa48aa243cbe Mon Sep 17 00:00:00 2001 From: sfc-gh-madkins <82121043+sfc-gh-madkins@users.noreply.github.com> Date: Mon, 31 Jan 2022 10:06:11 -0600 Subject: [PATCH 47/85] Merge feast-snowflake plugin into main repo with documentation (#2193) * Add backticks to left_table_query_string (#2250) Signed-off-by: david Signed-off-by: sfc-gh-madkins * Delete entity key from Redis only when all attached feature views are gone (#2240) * Delete entity from redis when the last attached feature view is deleted Signed-off-by: pyalex * Delete entity key from Redis only when all attached feature views are gone Signed-off-by: pyalex * make lint happy Signed-off-by: pyalex * make lint happy Signed-off-by: pyalex * one more try with mypy Signed-off-by: pyalex Signed-off-by: sfc-gh-madkins * historical_field_mappings2 merge for one sign off commit (#2252) Signed-off-by: Michelle Rascati Signed-off-by: sfc-gh-madkins * Correct inconsistent dependency (#2255) Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> Signed-off-by: sfc-gh-madkins * Add snowflake environment variables to allow testing on snowflake infra (#2258) * add snowflake environment vars to test framework Signed-off-by: sfc-gh-madkins * add snowflake environment vars to test framework Signed-off-by: sfc-gh-madkins * Return `UNIX_TIMESTAMP` as Python `datetime` (#2244) * Refactor `UNIX_TIMESTAMP` conversion Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Return `UNIX_TIMESTAMP` types as `datetime` to user Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Fix linting errors Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Rename variable to something more sensible Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> Signed-off-by: sfc-gh-madkins * Feast plan clean up (#2256) * Run validation and inference on views and entities during plan Signed-off-by: Felix Wang * Do not log objects that are unchanged Signed-off-by: Felix Wang * Rename Fco to FeastObject Signed-off-by: Felix Wang * Remove useless method Signed-off-by: Felix Wang * Lint Signed-off-by: Felix Wang * Always initialize registry during feature store initialization Signed-off-by: Felix Wang * Fix usage test Signed-off-by: Felix Wang * Remove print statements Signed-off-by: Felix Wang Signed-off-by: sfc-gh-madkins * Squash commits Signed-off-by: Danny Chiao Signed-off-by: sfc-gh-madkins * Add error type and refactor query execution to have retries Signed-off-by: Danny Chiao Signed-off-by: sfc-gh-madkins * Handle more snowflake errors Signed-off-by: Danny Chiao Signed-off-by: sfc-gh-madkins * Fix lint errors Signed-off-by: Danny Chiao Signed-off-by: sfc-gh-madkins * Fix lint errors Signed-off-by: Danny Chiao Signed-off-by: sfc-gh-madkins * Fix lint errors Signed-off-by: Danny Chiao Signed-off-by: sfc-gh-madkins * Fix wrong import Signed-off-by: Danny Chiao Signed-off-by: sfc-gh-madkins * modify registry.db s3 object initialization to work in S3 subdirectory with Java Feast Server (#2259) Signed-off-by: NalinGHub Signed-off-by: sfc-gh-madkins * clean up docs Signed-off-by: sfc-gh-madkins * lint-python Signed-off-by: sfc-gh-madkins * fixed historical test Signed-off-by: sfc-gh-madkins * fixed historical test Signed-off-by: sfc-gh-madkins Co-authored-by: David Miller Co-authored-by: Oleksii Moskalenko Co-authored-by: Michelle Rascati <44408275+michelle-rascati-sp@users.noreply.github.com> Co-authored-by: Judah Rand <17158624+judahrand@users.noreply.github.com> Co-authored-by: Felix Wang Co-authored-by: Danny Chiao Co-authored-by: Nalin Mehra <37969183+NalinGHub@users.noreply.github.com> --- README.md | 8 +- docs/README.md | 2 +- docs/SUMMARY.md | 5 +- .../third-party-integrations.md | 4 +- .../README.md | 0 .../build-a-training-dataset.md | 0 .../create-a-feature-repository.md | 18 +- .../deploy-a-feature-store.md | 0 .../install-feast.md | 6 + .../load-data-into-the-online-store.md | 0 .../read-features-from-the-online-store.md | 0 docs/reference/data-sources/README.md | 3 +- docs/reference/data-sources/snowflake.md | 44 ++ docs/reference/offline-stores/README.md | 3 +- docs/reference/offline-stores/snowflake.md | 30 + docs/reference/offline-stores/untitled.md | 26 - docs/reference/online-stores/README.md | 1 - docs/reference/providers/README.md | 1 - docs/roadmap.md | 2 + docs/specs/offline_store_format.md | 22 +- .../tutorials/driver-stats-using-snowflake.md | 140 ++++ docs/tutorials/tutorials-overview.md | 1 + protos/feast/core/DataSource.proto | 24 +- protos/feast/core/SavedDataset.proto | 1 + sdk/python/feast/__init__.py | 2 + sdk/python/feast/cli.py | 2 +- sdk/python/feast/data_source.py | 6 + sdk/python/feast/errors.py | 20 + sdk/python/feast/inference.py | 17 +- .../feast/infra/offline_stores/snowflake.py | 632 ++++++++++++++++++ .../infra/offline_stores/snowflake_source.py | 315 +++++++++ .../feast/infra/utils/snowflake_utils.py | 279 ++++++++ sdk/python/feast/repo_config.py | 2 + .../feast/templates/snowflake/bootstrap.py | 91 +++ .../feast/templates/snowflake/driver_repo.py | 64 ++ .../templates/snowflake/feature_store.yaml | 11 + sdk/python/feast/templates/snowflake/test.py | 65 ++ sdk/python/feast/type_map.py | 24 + .../requirements/py3.7-ci-requirements.txt | 27 +- .../requirements/py3.8-ci-requirements.txt | 27 +- .../requirements/py3.9-ci-requirements.txt | 27 +- sdk/python/setup.py | 6 + .../feature_repos/repo_configuration.py | 9 + .../universal/data_sources/snowflake.py | 81 +++ .../test_universal_historical_retrieval.py | 11 +- 45 files changed, 2005 insertions(+), 54 deletions(-) rename docs/how-to-guides/{feast-gcp-aws => feast-snowflake-gcp-aws}/README.md (100%) rename docs/how-to-guides/{feast-gcp-aws => feast-snowflake-gcp-aws}/build-a-training-dataset.md (100%) rename docs/how-to-guides/{feast-gcp-aws => feast-snowflake-gcp-aws}/create-a-feature-repository.md (84%) rename docs/how-to-guides/{feast-gcp-aws => feast-snowflake-gcp-aws}/deploy-a-feature-store.md (100%) rename docs/how-to-guides/{feast-gcp-aws => feast-snowflake-gcp-aws}/install-feast.md (80%) rename docs/how-to-guides/{feast-gcp-aws => feast-snowflake-gcp-aws}/load-data-into-the-online-store.md (100%) rename docs/how-to-guides/{feast-gcp-aws => feast-snowflake-gcp-aws}/read-features-from-the-online-store.md (100%) create mode 100644 docs/reference/data-sources/snowflake.md create mode 100644 docs/reference/offline-stores/snowflake.md delete mode 100644 docs/reference/offline-stores/untitled.md create mode 100644 docs/tutorials/driver-stats-using-snowflake.md create mode 100644 sdk/python/feast/infra/offline_stores/snowflake.py create mode 100644 sdk/python/feast/infra/offline_stores/snowflake_source.py create mode 100644 sdk/python/feast/infra/utils/snowflake_utils.py create mode 100644 sdk/python/feast/templates/snowflake/bootstrap.py create mode 100644 sdk/python/feast/templates/snowflake/driver_repo.py create mode 100644 sdk/python/feast/templates/snowflake/feature_store.yaml create mode 100644 sdk/python/feast/templates/snowflake/test.py create mode 100644 sdk/python/tests/integration/feature_repos/universal/data_sources/snowflake.py diff --git a/README.md b/README.md index 649bb909fa5..7ede0c612a1 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ The list below contains the functionality that contributors are planning to deve * Want to speak to a Feast contributor? We are more than happy to jump on a call. Please schedule a time using [Calendly](https://calendly.com/d/x2ry-g5bb/meet-with-feast-team). * **Data Sources** + * [x] [Snowflake source](https://docs.feast.dev/reference/data-sources/snowflake) * [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) @@ -143,9 +144,9 @@ The list below contains the functionality that contributors are planning to deve * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] Kafka source (with [push support into the online store](reference/alpha-stream-ingestion.md)) - * [x] [Snowflake source (community plugin)](https://github.com/sfc-gh-madkins/feast-snowflake) * [ ] HTTP source * **Offline Stores** + * [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) @@ -153,7 +154,6 @@ The list below contains the functionality that contributors are planning to deve * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/adding-a-new-offline-store) - * [x] [Snowflake (community plugin)](https://github.com/sfc-gh-madkins/feast-snowflake) * [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) * **Online Stores** * [x] [DynamoDB](https://docs.feast.dev/reference/online-stores/dynamodb) @@ -208,7 +208,7 @@ The list below contains the functionality that contributors are planning to deve Please refer to the official documentation at [Documentation](https://docs.feast.dev/) * [Quickstart](https://docs.feast.dev/getting-started/quickstart) * [Tutorials](https://docs.feast.dev/tutorials/tutorials-overview) - * [Running Feast with GCP/AWS](https://docs.feast.dev/how-to-guides/feast-gcp-aws) + * [Running Feast with Snowflake/GCP/AWS](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws) * [Change Log](https://github.com/feast-dev/feast/blob/master/CHANGELOG.md) * [Slack (#Feast)](https://slack.feast.dev/) @@ -224,4 +224,4 @@ Thanks goes to these incredible people: - \ No newline at end of file + diff --git a/docs/README.md b/docs/README.md index 1a76adbde3d..d5c5177a18f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,6 @@ Explore the following resources to get started with Feast: * [Concepts](getting-started/concepts/) describes all important Feast API concepts * [Architecture](getting-started/architecture-and-components/) describes Feast's overall architecture. * [Tutorials](tutorials/tutorials-overview.md) shows full examples of using Feast in machine learning applications. -* [Running Feast with GCP/AWS](how-to-guides/feast-gcp-aws/) provides a more in-depth guide to using Feast. +* [Running Feast with Snowflake/GCP/AWS](how-to-guides/feast-snowflake-gcp-aws/) provides a more in-depth guide to using Feast. * [Reference](reference/feast-cli-commands.md) contains detailed API and design documents. * [Contributing](project/contributing.md) contains resources for anyone who wants to contribute to Feast. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index ae23cd5d406..e1343ec4855 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -33,10 +33,11 @@ * [Driver ranking](tutorials/driver-ranking-with-feast.md) * [Fraud detection on GCP](tutorials/fraud-detection.md) * [Real-time credit scoring on AWS](tutorials/real-time-credit-scoring-on-aws.md) +* [Driver Stats using Snowflake](tutorials/driver-stats-using-snowflake.md) ## How-to Guides -* [Running Feast with GCP/AWS](how-to-guides/feast-gcp-aws/README.md) +* [Running Feast with Snowflake/GCP/AWS](how-to-guides/feast-snowflake-gcp-aws/README.md) * [Install Feast](how-to-guides/feast-gcp-aws/install-feast.md) * [Create a feature repository](how-to-guides/feast-gcp-aws/create-a-feature-repository.md) * [Deploy a feature store](how-to-guides/feast-gcp-aws/deploy-a-feature-store.md) @@ -54,10 +55,12 @@ * [Data sources](reference/data-sources/README.md) * [File](reference/data-sources/file.md) + * [Snowflake](reference/data-sources/snowflake.md) * [BigQuery](reference/data-sources/bigquery.md) * [Redshift](reference/data-sources/redshift.md) * [Offline stores](reference/offline-stores/README.md) * [File](reference/offline-stores/file.md) + * [Snowflake](reference/offline-stores/snowflake.md) * [BigQuery](reference/offline-stores/bigquery.md) * [Redshift](reference/offline-stores/redshift.md) * [Online stores](reference/online-stores/README.md) diff --git a/docs/getting-started/third-party-integrations.md b/docs/getting-started/third-party-integrations.md index 31b6acdc880..a3a41bb8366 100644 --- a/docs/getting-started/third-party-integrations.md +++ b/docs/getting-started/third-party-integrations.md @@ -13,6 +13,7 @@ Don't see your offline store or online store of choice here? Check out our guide ### **Data Sources** +* [x] [Snowflake source](https://docs.feast.dev/reference/data-sources/snowflake) * [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) @@ -20,11 +21,11 @@ Don't see your offline store or online store of choice here? Check out our guide * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] Kafka source (with [push support into the online store](https://docs.feast.dev/reference/alpha-stream-ingestion)) -* [x] [Snowflake source (community plugin)](https://github.com/sfc-gh-madkins/feast-snowflake) * [ ] HTTP source ### Offline Stores +* [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) @@ -32,7 +33,6 @@ Don't see your offline store or online store of choice here? Check out our guide * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/adding-a-new-offline-store) -* [x] [Snowflake source (community plugin)](https://github.com/sfc-gh-madkins/feast-snowflake) * [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) ### Online Stores diff --git a/docs/how-to-guides/feast-gcp-aws/README.md b/docs/how-to-guides/feast-snowflake-gcp-aws/README.md similarity index 100% rename from docs/how-to-guides/feast-gcp-aws/README.md rename to docs/how-to-guides/feast-snowflake-gcp-aws/README.md diff --git a/docs/how-to-guides/feast-gcp-aws/build-a-training-dataset.md b/docs/how-to-guides/feast-snowflake-gcp-aws/build-a-training-dataset.md similarity index 100% rename from docs/how-to-guides/feast-gcp-aws/build-a-training-dataset.md rename to docs/how-to-guides/feast-snowflake-gcp-aws/build-a-training-dataset.md diff --git a/docs/how-to-guides/feast-gcp-aws/create-a-feature-repository.md b/docs/how-to-guides/feast-snowflake-gcp-aws/create-a-feature-repository.md similarity index 84% rename from docs/how-to-guides/feast-gcp-aws/create-a-feature-repository.md rename to docs/how-to-guides/feast-snowflake-gcp-aws/create-a-feature-repository.md index 1add0a92e86..8754bc051a1 100644 --- a/docs/how-to-guides/feast-gcp-aws/create-a-feature-repository.md +++ b/docs/how-to-guides/feast-snowflake-gcp-aws/create-a-feature-repository.md @@ -13,6 +13,21 @@ Creating a new Feast repository in /<...>/tiny_pika. ``` {% endtab %} +{% tabs %} +{% tab title="Snowflake template" %} +```bash +feast init -t snowflake +Snowflake Deployment URL: ... +Snowflake User Name: ... +Snowflake Password: ... +Snowflake Role Name: ... +Snowflake Warehouse Name: ... +Snowflake Database Name: ... + +Creating a new Feast repository in /<...>/tiny_pika. +``` +{% endtab %} + {% tab title="GCP template" %} ```text feast init -t gcp @@ -30,7 +45,7 @@ Redshift Database Name: ... Redshift User Name: ... Redshift S3 Staging Location (s3://*): ... Redshift IAM Role for S3 (arn:aws:iam::*:role/*): ... -Should I upload example data to Redshift (overwriting 'feast_driver_hourly_stats' table)? (Y/n): +Should I upload example data to Redshift (overwriting 'feast_driver_hourly_stats' table)? (Y/n): Creating a new Feast repository in /<...>/tiny_pika. ``` @@ -63,4 +78,3 @@ You can now use this feature repository for development. You can try the followi * Run `feast apply` to apply these definitions to Feast. * Edit the example feature definitions in `example.py` and run `feast apply` again to change feature definitions. * Initialize a git repository in the same directory and checking the feature repository into version control. - diff --git a/docs/how-to-guides/feast-gcp-aws/deploy-a-feature-store.md b/docs/how-to-guides/feast-snowflake-gcp-aws/deploy-a-feature-store.md similarity index 100% rename from docs/how-to-guides/feast-gcp-aws/deploy-a-feature-store.md rename to docs/how-to-guides/feast-snowflake-gcp-aws/deploy-a-feature-store.md diff --git a/docs/how-to-guides/feast-gcp-aws/install-feast.md b/docs/how-to-guides/feast-snowflake-gcp-aws/install-feast.md similarity index 80% rename from docs/how-to-guides/feast-gcp-aws/install-feast.md rename to docs/how-to-guides/feast-snowflake-gcp-aws/install-feast.md index 019231be095..26d95c6117a 100644 --- a/docs/how-to-guides/feast-gcp-aws/install-feast.md +++ b/docs/how-to-guides/feast-snowflake-gcp-aws/install-feast.md @@ -6,6 +6,12 @@ Install Feast using [pip](https://pip.pypa.io): pip install feast ``` +Install Feast with Snowflake dependencies (required when using Snowflake): + +``` +pip install 'feast[snowflake]' +``` + Install Feast with GCP dependencies (required when using BigQuery or Firestore): ``` diff --git a/docs/how-to-guides/feast-gcp-aws/load-data-into-the-online-store.md b/docs/how-to-guides/feast-snowflake-gcp-aws/load-data-into-the-online-store.md similarity index 100% rename from docs/how-to-guides/feast-gcp-aws/load-data-into-the-online-store.md rename to docs/how-to-guides/feast-snowflake-gcp-aws/load-data-into-the-online-store.md diff --git a/docs/how-to-guides/feast-gcp-aws/read-features-from-the-online-store.md b/docs/how-to-guides/feast-snowflake-gcp-aws/read-features-from-the-online-store.md similarity index 100% rename from docs/how-to-guides/feast-gcp-aws/read-features-from-the-online-store.md rename to docs/how-to-guides/feast-snowflake-gcp-aws/read-features-from-the-online-store.md diff --git a/docs/reference/data-sources/README.md b/docs/reference/data-sources/README.md index 6732fc16a08..fc6e136a9c2 100644 --- a/docs/reference/data-sources/README.md +++ b/docs/reference/data-sources/README.md @@ -4,7 +4,8 @@ Please see [Data Source](../../getting-started/concepts/feature-view.md#data-sou {% page-ref page="file.md" %} +{% page-ref page="snowflake.md" %} + {% page-ref page="bigquery.md" %} {% page-ref page="redshift.md" %} - diff --git a/docs/reference/data-sources/snowflake.md b/docs/reference/data-sources/snowflake.md new file mode 100644 index 00000000000..0f5304b6cdc --- /dev/null +++ b/docs/reference/data-sources/snowflake.md @@ -0,0 +1,44 @@ +# Snowflake + +## Description + +Snowflake data sources allow for the retrieval of historical feature values from Snowflake for building training datasets as well as materializing features into an online store. + +* Either a table reference or a SQL query can be provided. + +## Examples + +Using a table reference + +```python +from feast import SnowflakeSource + +my_snowflake_source = SnowflakeSource( + database="FEAST", + schema="PUBLIC", + table="FEATURE_TABLE", +) +``` + +Using a query + +```python +from feast import SnowflakeSource + +my_snowflake_source = SnowflakeSource( + query=""" + SELECT + timestamp_column AS "ts", + "created", + "f1", + "f2" + FROM + `FEAST.PUBLIC.FEATURE_TABLE` + """, +) +``` + +One thing to remember is how Snowflake handles table and column name conventions. +You can read more about quote identifiers [here](https://docs.snowflake.com/en/sql-reference/identifiers-syntax.html) + +Configuration options are available [here](https://rtd.feast.dev/en/latest/index.html#feast.data_source.SnowflakeSource). diff --git a/docs/reference/offline-stores/README.md b/docs/reference/offline-stores/README.md index 1260fe8b29f..141a34d03b6 100644 --- a/docs/reference/offline-stores/README.md +++ b/docs/reference/offline-stores/README.md @@ -4,7 +4,8 @@ Please see [Offline Store](../../getting-started/architecture-and-components/off {% page-ref page="file.md" %} +{% page-ref page="snowflake.md" %} + {% page-ref page="bigquery.md" %} {% page-ref page="redshift.md" %} - diff --git a/docs/reference/offline-stores/snowflake.md b/docs/reference/offline-stores/snowflake.md new file mode 100644 index 00000000000..fcf9a7a6fd3 --- /dev/null +++ b/docs/reference/offline-stores/snowflake.md @@ -0,0 +1,30 @@ +# Snowflake + +## Description + +The Snowflake offline store provides support for reading [SnowflakeSources](../data-sources/snowflake.md). + +* Snowflake tables and views are allowed as sources. +* All joins happen within Snowflake. +* Entity dataframes can be provided as a SQL query or can be provided as a Pandas dataframe. Pandas dataframes will be uploaded to Snowflake in order to complete join operations. +* A [SnowflakeRetrievalJob](https://github.com/feast-dev/feast/blob/bf557bcb72c7878a16dccb48443bbbe9dc3efa49/sdk/python/feast/infra/offline_stores/snowflake.py#L185) is returned when calling `get_historical_features()`. + +## Example + +{% code title="feature_store.yaml" %} +```yaml +project: my_feature_repo +registry: data/registry.db +provider: local +offline_store: + type: snowflake.offline + account: snowflake_deployment.us-east-1 + user: user_login + password: user_password + role: sysadmin + warehouse: demo_wh + database: FEAST +``` +{% endcode %} + +Configuration options are available [here](https://github.com/feast-dev/feast/blob/bf557bcb72c7878a16dccb48443bbbe9dc3efa49/sdk/python/feast/infra/offline_stores/snowflake.py#L39). diff --git a/docs/reference/offline-stores/untitled.md b/docs/reference/offline-stores/untitled.md deleted file mode 100644 index 8ffa566a70f..00000000000 --- a/docs/reference/offline-stores/untitled.md +++ /dev/null @@ -1,26 +0,0 @@ -# BigQuery - -### Description - -The BigQuery offline store provides support for reading [BigQuerySources](../data-sources/bigquery.md). - -* BigQuery tables and views are allowed as sources. -* All joins happen within BigQuery. -* Entity dataframes can be provided as a SQL query or can be provided as a Pandas dataframe. Pandas dataframes will be uploaded to BigQuery in order to complete join operations. -* A [BigQueryRetrievalJob](https://github.com/feast-dev/feast/blob/c50a36ec1ad5b8d81c6f773c23204db7c7a7d218/sdk/python/feast/infra/offline_stores/bigquery.py#L210) is returned when calling `get_historical_features()`. - -### Example - -{% code title="feature\_store.yaml" %} -```yaml -project: my_feature_repo -registry: gs://my-bucket/data/registry.db -provider: gcp -offline_store: - type: bigquery - dataset: feast_bq_dataset -``` -{% endcode %} - -Configuration options are available [here](https://rtd.feast.dev/en/latest/#feast.repo_config.BigQueryOfflineStoreConfig). - diff --git a/docs/reference/online-stores/README.md b/docs/reference/online-stores/README.md index aadcc0eb655..2c2902bc579 100644 --- a/docs/reference/online-stores/README.md +++ b/docs/reference/online-stores/README.md @@ -9,4 +9,3 @@ Please see [Online Store](../../getting-started/architecture-and-components/onli {% page-ref page="datastore.md" %} {% page-ref page="dynamodb.md" %} - diff --git a/docs/reference/providers/README.md b/docs/reference/providers/README.md index 7eb992d5acd..dc52d927264 100644 --- a/docs/reference/providers/README.md +++ b/docs/reference/providers/README.md @@ -7,4 +7,3 @@ Please see [Provider](../../getting-started/architecture-and-components/provider {% page-ref page="google-cloud-platform.md" %} {% page-ref page="amazon-web-services.md" %} - diff --git a/docs/roadmap.md b/docs/roadmap.md index 723bfba82a6..42da01fcba8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,6 +8,7 @@ The list below contains the functionality that contributors are planning to deve * Want to speak to a Feast contributor? We are more than happy to jump on a call. Please schedule a time using [Calendly](https://calendly.com/d/x2ry-g5bb/meet-with-feast-team). * **Data Sources** + * [x] [Snowflake source](https://docs.feast.dev/reference/data-sources/snowflake) * [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) @@ -18,6 +19,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Snowflake source (community plugin)](https://github.com/sfc-gh-madkins/feast-snowflake) * [ ] HTTP source * **Offline Stores** + * [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) diff --git a/docs/specs/offline_store_format.md b/docs/specs/offline_store_format.md index 6826c501900..ac829dd52f1 100644 --- a/docs/specs/offline_store_format.md +++ b/docs/specs/offline_store_format.md @@ -7,8 +7,8 @@ One of the design goals of Feast is being able to plug seamlessly into existing Feast provides first class support for the following data warehouses (DWH) to store feature data offline out of the box: * [BigQuery](https://cloud.google.com/bigquery) -* [Snowflake](https://www.snowflake.com/) (Coming Soon) -* [Redshift](https://aws.amazon.com/redshift/) (Coming Soon) +* [Snowflake](https://www.snowflake.com/) +* [Redshift](https://aws.amazon.com/redshift/) The integration between Feast and the DWH is highly configurable, but at the same time there are some non-configurable implications and assumptions that Feast imposes on table schemas and mapping between database-native types and Feast type system. This is what this document is about. @@ -28,14 +28,14 @@ Feature data is stored in tables in the DWH. There is one DWH table per Feast Fe ## Type mappings #### Pandas types -Here's how Feast types map to Pandas types for Feast APIs that take in or return a Pandas dataframe: +Here's how Feast types map to Pandas types for Feast APIs that take in or return a Pandas dataframe: | Feast Type | Pandas Type | |-------------|--| | Event Timestamp | `datetime64[ns]` | | BYTES | `bytes` | | STRING | `str` , `category`| -| INT32 | `int32`, `uint32` | +| INT32 | `int16`, `uint16`, `int32`, `uint32` | | INT64 | `int64`, `uint64` | | UNIX_TIMESTAMP | `datetime64[ns]`, `datetime64[ns, tz]` | | DOUBLE | `float64` | @@ -80,3 +80,17 @@ Here's how Feast types map to BigQuery types when using BigQuery for offline sto | BOOL\_LIST | `ARRAY`| Values that are not specified by the table above will cause an error on conversion. + +#### Snowflake Types +Here's how Feast types map to Snowflake types when using Snowflake for offline storage +See source here: +https://docs.snowflake.com/en/user-guide/python-connector-pandas.html#snowflake-to-pandas-data-mapping + +| Feast Type | Snowflake Python Type | +|-------------|--| +| Event Timestamp | `DATETIME64[NS]` | +| UNIX_TIMESTAMP | `DATETIME64[NS]` | +| STRING | `STR` | +| INT32 | `INT8 / UINT8 / INT16 / UINT16 / INT32 / UINT32` | +| INT64 | `INT64 / UINT64` | +| DOUBLE | `FLOAT64` | diff --git a/docs/tutorials/driver-stats-using-snowflake.md b/docs/tutorials/driver-stats-using-snowflake.md new file mode 100644 index 00000000000..c51fc9b1ce7 --- /dev/null +++ b/docs/tutorials/driver-stats-using-snowflake.md @@ -0,0 +1,140 @@ +--- +description: >- + Initial demonstration of using Snowflake with Feast as both and Offline & Online store + using the snowflake demo template. +--- + +# Drivers Stats using Snowflake + +In the following steps below, we will setup a sample feast project that leverages Snowflake +as an Offline Store. + +Starting with data in a Snowflake table, we will register that table to the feature store and +define features associated with the columns in that table. From there, we will generate historical +training data based on those feature definitions. We then will materialize the latest feature values +given our feature definitions into our online feature store. Lastly, we will then call +for those latest feature values. + +Our template that you will leverage will generate new data related to driver statistics. +From there, we will show you code snippets that will call to the offline store for generating +training datasets, and then the code for calling the online store to serve you the +latest feature values to serve models in production. + +## Snowflake Offline/Online Store Example + +#### Install feast-snowflake + +```shell +pip install feast[snowflake] +``` + +#### Get a Snowflake Trial Account (Optional) + +[Snowflake Trial Account](trial.snowflake.com) + +#### Create a feature repository + +```shell +feast init -t snowflake {feature_repo_name} +Snowflake Deployment URL (exclude .snowflakecomputing.com): +Snowflake User Name:: +Snowflake Password:: +Snowflake Role Name (Case Sensitive):: +Snowflake Warehouse Name (Case Sensitive):: +Snowflake Database Name (Case Sensitive):: +Should I upload example data to Snowflake (overwrite table)? [Y/n]: Y +cd {feature_repo_name} +``` + +The following files will automatically be created in your project folder: + +* feature_store.yaml -- This is your main configuration file +* driver_repo.py -- This is your main feature definition file +* test.py -- This is a file to test your feature store configuration + +#### Inspect `feature_store.yaml` + +Here you will see the information that you entered. This template will look to use +Snowflake as both an Offline & Online store. The main thing to remember is by default, +Snowflake Objects have ALL CAPS names unless lower case was specified. + +{% code title="feature_store.yaml" %} +```yaml +project: ... +registry: ... +provider: local +offline_store: + type: snowflake.offline + account: SNOWFLAKE_DEPLOYMENT_URL #drop .snowflakecomputing.com + user: USERNAME + password: PASSWORD + role: ROLE_NAME #case sensitive + warehouse: WAREHOUSE_NAME #case sensitive + database: DATABASE_NAME #case cap sensitive +``` +{% endcode %} + +#### Run our test python script `test.py` + +```shell +python test.py +``` + +## What we did in `test.py` + +#### Initialize our Feature Store +{% code title="test.py" %} +```python +from datetime import datetime, timedelta + +import pandas as pd +from driver_repo import driver, driver_stats_fv + +from feast import FeatureStore + +fs = FeatureStore(repo_path=".") + +fs.apply([driver, driver_stats_fv]) +``` +{% endcode %} + +#### Create a dummy training dataframe, then call our Offline store to add additional columns +{% code title="test.py" %} +```python +entity_df = pd.DataFrame( + { + "event_timestamp": [ + pd.Timestamp(dt, unit="ms", tz="UTC").round("ms") + for dt in pd.date_range( + start=datetime.now() - timedelta(days=3), + end=datetime.now(), + periods=3, + ) + ], + "driver_id": [1001, 1002, 1003], + } +) + +features = ["driver_hourly_stats:conv_rate", "driver_hourly_stats:acc_rate"] + +training_df = fs.get_historical_features( + features=features, entity_df=entity_df +).to_df() +``` +{% endcode %} + +#### Materialize the latest feature values into our Online store +{% code title="test.py" %} +```python +fs.materialize_incremental(end_date=datetime.now()) +``` +{% endcode %} + +#### Retrieve the latest values from our Online store based on our Entity Key +{% code title="test.py" %} +```python +online_features = fs.get_online_features( + features=features, entity_rows=[{"driver_id": 1001}, {"driver_id": 1002}], +).to_dict() +``` +{% endcode %} diff --git a/docs/tutorials/tutorials-overview.md b/docs/tutorials/tutorials-overview.md index a523f9b38e9..86a8c25371c 100644 --- a/docs/tutorials/tutorials-overview.md +++ b/docs/tutorials/tutorials-overview.md @@ -8,3 +8,4 @@ These Feast tutorials showcase how to use Feast to simplify end to end model tra {% page-ref page="real-time-credit-scoring-on-aws.md" %} +{% page-ref page="driver-stats-using-snowflake.md" %} diff --git a/protos/feast/core/DataSource.proto b/protos/feast/core/DataSource.proto index ee5c6939d79..41bba6443fd 100644 --- a/protos/feast/core/DataSource.proto +++ b/protos/feast/core/DataSource.proto @@ -32,19 +32,22 @@ message DataSource { reserved 6 to 10; // Type of Data Source. + // Next available id: 9 enum SourceType { INVALID = 0; BATCH_FILE = 1; + BATCH_SNOWFLAKE = 8; BATCH_BIGQUERY = 2; + BATCH_REDSHIFT = 5; STREAM_KAFKA = 3; STREAM_KINESIS = 4; - BATCH_REDSHIFT = 5; CUSTOM_SOURCE = 6; REQUEST_SOURCE = 7; + } SourceType type = 1; - // Defines mapping between fields in the sourced data + // Defines mapping between fields in the sourced data // and fields in parent FeatureTable. map field_mapping = 2; @@ -128,6 +131,22 @@ message DataSource { string schema = 3; } + // Defines options for DataSource that sources features from a Snowflake Query + message SnowflakeOptions { + // Snowflake table name + string table = 1; + + // SQL query that returns a table containing feature data. Must contain an event_timestamp column, and respective + // entity columns + string query = 2; + + // Snowflake schema name + string schema = 3; + + // Snowflake schema name + string database = 4; + } + // Defines configuration for custom third-party data sources. message CustomSourceOptions { // Serialized configuration information for the data source. The implementer of the custom data source is @@ -153,5 +172,6 @@ message DataSource { RedshiftOptions redshift_options = 15; RequestDataOptions request_data_options = 18; CustomSourceOptions custom_options = 16; + SnowflakeOptions snowflake_options = 19; } } diff --git a/protos/feast/core/SavedDataset.proto b/protos/feast/core/SavedDataset.proto index 6ec9df08356..ebd2e56d350 100644 --- a/protos/feast/core/SavedDataset.proto +++ b/protos/feast/core/SavedDataset.proto @@ -53,6 +53,7 @@ message SavedDatasetStorage { DataSource.FileOptions file_storage = 4; DataSource.BigQueryOptions bigquery_storage = 5; DataSource.RedshiftOptions redshift_storage = 6; + DataSource.SnowflakeOptions snowflake_storage = 7; } } diff --git a/sdk/python/feast/__init__.py b/sdk/python/feast/__init__.py index eada13f9952..9f78f9d98bf 100644 --- a/sdk/python/feast/__init__.py +++ b/sdk/python/feast/__init__.py @@ -5,6 +5,7 @@ from feast.infra.offline_stores.bigquery_source import BigQuerySource 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 from .data_source import KafkaSource, KinesisSource, SourceType from .entity import Entity @@ -43,4 +44,5 @@ "BigQuerySource", "FileSource", "RedshiftSource", + "SnowflakeSource", ] diff --git a/sdk/python/feast/cli.py b/sdk/python/feast/cli.py index 4950977e2a3..f6d326410a3 100644 --- a/sdk/python/feast/cli.py +++ b/sdk/python/feast/cli.py @@ -477,7 +477,7 @@ def materialize_incremental_command(ctx: click.Context, end_ts: str, views: List @click.option( "--template", "-t", - type=click.Choice(["local", "gcp", "aws"], case_sensitive=False), + type=click.Choice(["local", "gcp", "aws", "snowflake"], case_sensitive=False), help="Specify a template for the created project", default="local", ) diff --git a/sdk/python/feast/data_source.py b/sdk/python/feast/data_source.py index b30340f0d2e..94910c6c083 100644 --- a/sdk/python/feast/data_source.py +++ b/sdk/python/feast/data_source.py @@ -360,6 +360,12 @@ def from_proto(data_source: DataSourceProto) -> Any: from feast.infra.offline_stores.redshift_source import RedshiftSource data_source_obj = RedshiftSource.from_proto(data_source) + + elif data_source.snowflake_options.table or data_source.snowflake_options.query: + from feast.infra.offline_stores.snowflake_source import SnowflakeSource + + data_source_obj = SnowflakeSource.from_proto(data_source) + elif ( data_source.kafka_options.bootstrap_servers and data_source.kafka_options.topic diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py index 3fc8c7571ea..17147f8a603 100644 --- a/sdk/python/feast/errors.py +++ b/sdk/python/feast/errors.py @@ -250,6 +250,16 @@ def __init__(self, table_name: str): ) +class SnowflakeCredentialsError(Exception): + def __init__(self): + super().__init__("Snowflake Connector failed due to incorrect credentials") + + +class SnowflakeQueryError(Exception): + def __init__(self, details): + super().__init__(f"Snowflake SQL Query failed to finish. Details: {details}") + + class EntityTimestampInferenceException(Exception): def __init__(self, expected_column_name: str): super().__init__( @@ -310,3 +320,13 @@ def __init__(self, actual_class: str, expected_class: str): class FeastInvalidInfraObjectType(Exception): def __init__(self): super().__init__("Could not identify the type of the InfraObject.") + + +class SnowflakeIncompleteConfig(Exception): + def __init__(self, e: KeyError): + super().__init__(f"{e} not defined in a config file or feature_store.yaml file") + + +class SnowflakeQueryUnknownError(Exception): + def __init__(self, query: str): + super().__init__(f"Snowflake query failed: {query}") diff --git a/sdk/python/feast/inference.py b/sdk/python/feast/inference.py index 642a3c6442d..ce8fa919f13 100644 --- a/sdk/python/feast/inference.py +++ b/sdk/python/feast/inference.py @@ -1,7 +1,14 @@ import re from typing import List -from feast import BigQuerySource, Entity, Feature, FileSource, RedshiftSource +from feast import ( + BigQuerySource, + Entity, + Feature, + FileSource, + RedshiftSource, + SnowflakeSource, +) from feast.data_source import DataSource from feast.errors import RegistryInferenceFailure from feast.feature_view import FeatureView @@ -83,6 +90,8 @@ def update_data_sources_with_inferred_event_timestamp_col( ts_column_type_regex_pattern = "TIMESTAMP|DATETIME" elif isinstance(data_source, RedshiftSource): ts_column_type_regex_pattern = "TIMESTAMP[A-Z]*" + elif isinstance(data_source, SnowflakeSource): + ts_column_type_regex_pattern = "TIMESTAMP_[A-Z]*" else: raise RegistryInferenceFailure( "DataSource", @@ -92,8 +101,10 @@ def update_data_sources_with_inferred_event_timestamp_col( """, ) # for informing the type checker - assert isinstance(data_source, FileSource) or isinstance( - data_source, BigQuerySource + assert ( + isinstance(data_source, FileSource) + or isinstance(data_source, BigQuerySource) + or isinstance(data_source, SnowflakeSource) ) # loop through table columns to find singular match diff --git a/sdk/python/feast/infra/offline_stores/snowflake.py b/sdk/python/feast/infra/offline_stores/snowflake.py new file mode 100644 index 00000000000..ee8cd71ce05 --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/snowflake.py @@ -0,0 +1,632 @@ +import contextlib +import os +from datetime import datetime +from pathlib import Path +from typing import ( + Callable, + ContextManager, + Dict, + Iterator, + List, + Optional, + Tuple, + Union, + cast, +) + +import numpy as np +import pandas as pd +import pyarrow as pa +from pydantic import Field +from pydantic.typing import Literal +from pytz import utc + +from feast import OnDemandFeatureView +from feast.data_source import DataSource +from feast.errors import InvalidEntityType +from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_VAL, FeatureView +from feast.infra.offline_stores import offline_utils +from feast.infra.offline_stores.offline_store import ( + OfflineStore, + RetrievalJob, + RetrievalMetadata, +) +from feast.infra.offline_stores.snowflake_source import ( + SavedDatasetSnowflakeStorage, + SnowflakeSource, +) +from feast.infra.utils.snowflake_utils import ( + execute_snowflake_statement, + get_snowflake_conn, + write_pandas, +) +from feast.registry import Registry +from feast.repo_config import FeastConfigBaseModel, RepoConfig +from feast.saved_dataset import SavedDatasetStorage +from feast.usage import log_exceptions_and_usage + +try: + from snowflake.connector import SnowflakeConnection +except ImportError as e: + from feast.errors import FeastExtrasDependencyImportError + + raise FeastExtrasDependencyImportError("snowflake", str(e)) + + +class SnowflakeOfflineStoreConfig(FeastConfigBaseModel): + """ Offline store config for Snowflake """ + + type: Literal["snowflake.offline"] = "snowflake.offline" + """ Offline store type selector""" + + config_path: Optional[str] = ( + Path(os.environ["HOME"]) / ".snowsql/config" + ).__str__() + """ Snowflake config path -- absolute path required (Cant use ~)""" + + account: Optional[str] = None + """ Snowflake deployment identifier -- drop .snowflakecomputing.com""" + + user: Optional[str] = None + """ Snowflake user name """ + + password: Optional[str] = None + """ Snowflake password """ + + role: Optional[str] = None + """ Snowflake role name""" + + warehouse: Optional[str] = None + """ Snowflake warehouse name """ + + database: Optional[str] = None + """ Snowflake database name """ + + schema_: Optional[str] = Field("PUBLIC", alias="schema") + """ Snowflake schema name """ + + class Config: + allow_population_by_field_name = True + + +class SnowflakeOfflineStore(OfflineStore): + @staticmethod + @log_exceptions_and_usage(offline_store="snowflake") + def pull_latest_from_table_or_query( + 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 isinstance(data_source, SnowflakeSource) + assert isinstance(config.offline_store, SnowflakeOfflineStoreConfig) + + from_expression = ( + data_source.get_table_query_string() + ) # returns schema.table as a string + + if join_key_columns: + partition_by_join_key_string = '"' + '", "'.join(join_key_columns) + '"' + partition_by_join_key_string = ( + "PARTITION BY " + partition_by_join_key_string + ) + else: + partition_by_join_key_string = "" + + timestamp_columns = [event_timestamp_column] + if created_timestamp_column: + timestamp_columns.append(created_timestamp_column) + + timestamp_desc_string = '"' + '" DESC, "'.join(timestamp_columns) + '" DESC' + field_string = ( + '"' + + '", "'.join(join_key_columns + feature_name_columns + timestamp_columns) + + '"' + ) + + snowflake_conn = get_snowflake_conn(config.offline_store) + + query = f""" + SELECT + {field_string} + {f''', TRIM({repr(DUMMY_ENTITY_VAL)}::VARIANT,'"') AS "{DUMMY_ENTITY_ID}"''' if not join_key_columns else ""} + FROM ( + SELECT {field_string}, + ROW_NUMBER() OVER({partition_by_join_key_string} ORDER BY {timestamp_desc_string}) AS "_feast_row" + FROM {from_expression} + WHERE "{event_timestamp_column}" BETWEEN TO_TIMESTAMP_NTZ({start_date.timestamp()}) AND TO_TIMESTAMP_NTZ({end_date.timestamp()}) + ) + WHERE "_feast_row" = 1 + """ + + return SnowflakeRetrievalJob( + query=query, + snowflake_conn=snowflake_conn, + config=config, + full_feature_names=False, + on_demand_feature_views=None, + ) + + @staticmethod + @log_exceptions_and_usage(offline_store="snowflake") + def pull_all_from_table_or_query( + 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 isinstance(data_source, SnowflakeSource) + from_expression = data_source.get_table_query_string() + + field_string = ( + '"' + + '", "'.join( + join_key_columns + feature_name_columns + [event_timestamp_column] + ) + + '"' + ) + + snowflake_conn = get_snowflake_conn(config.offline_store) + + start_date = start_date.astimezone(tz=utc) + end_date = end_date.astimezone(tz=utc) + + query = f""" + SELECT {field_string} + FROM {from_expression} + WHERE "{event_timestamp_column}" BETWEEN TIMESTAMP '{start_date}' AND TIMESTAMP '{end_date}' + """ + + return SnowflakeRetrievalJob( + query=query, + snowflake_conn=snowflake_conn, + config=config, + full_feature_names=False, + ) + + @staticmethod + @log_exceptions_and_usage(offline_store="snowflake") + def get_historical_features( + config: RepoConfig, + feature_views: List[FeatureView], + feature_refs: List[str], + entity_df: Union[pd.DataFrame, str], + registry: Registry, + project: str, + full_feature_names: bool = False, + ) -> RetrievalJob: + assert isinstance(config.offline_store, SnowflakeOfflineStoreConfig) + + snowflake_conn = get_snowflake_conn(config.offline_store) + + entity_schema = _get_entity_schema(entity_df, snowflake_conn, config) + + entity_df_event_timestamp_col = offline_utils.infer_event_timestamp_from_entity_df( + entity_schema + ) + + entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( + entity_df, entity_df_event_timestamp_col, snowflake_conn, + ) + + @contextlib.contextmanager + def query_generator() -> Iterator[str]: + + table_name = offline_utils.get_temp_entity_table_name() + + _upload_entity_df(entity_df, snowflake_conn, config, table_name) + + expected_join_keys = offline_utils.get_expected_join_keys( + project, feature_views, registry + ) + + offline_utils.assert_expected_columns_in_entity_df( + entity_schema, expected_join_keys, entity_df_event_timestamp_col + ) + + # Build a query context containing all information required to template the Snowflake SQL query + query_context = offline_utils.get_feature_view_query_context( + feature_refs, + feature_views, + registry, + project, + entity_df_event_timestamp_range, + ) + + query_context = _fix_entity_selections_identifiers(query_context) + + # Generate the Snowflake SQL query from the query context + query = offline_utils.build_point_in_time_query( + query_context, + left_table_query_string=table_name, + entity_df_event_timestamp_col=entity_df_event_timestamp_col, + entity_df_columns=entity_schema.keys(), + query_template=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN, + full_feature_names=full_feature_names, + ) + + yield query + + return SnowflakeRetrievalJob( + query=query_generator, + snowflake_conn=snowflake_conn, + config=config, + full_feature_names=full_feature_names, + on_demand_feature_views=OnDemandFeatureView.get_requested_odfvs( + feature_refs, project, registry + ), + metadata=RetrievalMetadata( + features=feature_refs, + keys=list(entity_schema.keys() - {entity_df_event_timestamp_col}), + min_event_timestamp=entity_df_event_timestamp_range[0], + max_event_timestamp=entity_df_event_timestamp_range[1], + ), + ) + + +class SnowflakeRetrievalJob(RetrievalJob): + def __init__( + self, + query: Union[str, Callable[[], ContextManager[str]]], + snowflake_conn: SnowflakeConnection, + config: RepoConfig, + full_feature_names: bool, + on_demand_feature_views: Optional[List[OnDemandFeatureView]] = None, + metadata: Optional[RetrievalMetadata] = None, + ): + + if not isinstance(query, str): + self._query_generator = query + else: + + @contextlib.contextmanager + def query_generator() -> Iterator[str]: + assert isinstance(query, str) + yield query + + self._query_generator = query_generator + + self.snowflake_conn = snowflake_conn + self.config = config + self._full_feature_names = full_feature_names + self._on_demand_feature_views = ( + on_demand_feature_views if on_demand_feature_views else [] + ) + 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) -> pd.DataFrame: + with self._query_generator() as query: + + df = execute_snowflake_statement( + self.snowflake_conn, query + ).fetch_pandas_all() + + return df + + def _to_arrow_internal(self) -> pa.Table: + with self._query_generator() as query: + + pa_table = execute_snowflake_statement( + self.snowflake_conn, query + ).fetch_arrow_all() + + if pa_table: + + return pa_table + else: + empty_result = execute_snowflake_statement(self.snowflake_conn, query) + + return pa.Table.from_pandas( + pd.DataFrame(columns=[md.name for md in empty_result.description]) + ) + + def to_snowflake(self, table_name: str) -> None: + """ Save dataset as a new Snowflake table """ + if self.on_demand_feature_views is not None: + transformed_df = self.to_df() + + write_pandas( + self.snowflake_conn, transformed_df, table_name, auto_create_table=True + ) + + return None + + with self._query_generator() as query: + query = f'CREATE TABLE IF NOT EXISTS "{table_name}" AS ({query});\n' + + execute_snowflake_statement(self.snowflake_conn, query) + + def to_sql(self) -> str: + """ + Returns the SQL query that will be executed in Snowflake to build the historical feature table. + """ + with self._query_generator() as query: + return query + + def to_arrow_chunks(self, arrow_options: Optional[Dict] = None) -> Optional[List]: + with self._query_generator() as query: + + arrow_batches = execute_snowflake_statement( + self.snowflake_conn, query + ).get_result_batches() + + return arrow_batches + + def persist(self, storage: SavedDatasetStorage): + assert isinstance(storage, SavedDatasetSnowflakeStorage) + self.to_snowflake(table_name=storage.snowflake_options.table) + + @property + def metadata(self) -> Optional[RetrievalMetadata]: + return self._metadata + + +def _get_entity_schema( + entity_df: Union[pd.DataFrame, str], + snowflake_conn: SnowflakeConnection, + config: RepoConfig, +) -> Dict[str, np.dtype]: + + if isinstance(entity_df, pd.DataFrame): + + return dict(zip(entity_df.columns, entity_df.dtypes)) + + else: + + query = f"SELECT * FROM ({entity_df}) LIMIT 1" + limited_entity_df = execute_snowflake_statement( + snowflake_conn, query + ).fetch_pandas_all() + + return dict(zip(limited_entity_df.columns, limited_entity_df.dtypes)) + + +def _upload_entity_df( + entity_df: Union[pd.DataFrame, str], + snowflake_conn: SnowflakeConnection, + config: RepoConfig, + table_name: str, +) -> None: + + if isinstance(entity_df, pd.DataFrame): + # Write the data from the DataFrame to the table + write_pandas( + snowflake_conn, + entity_df, + table_name, + auto_create_table=True, + create_temp_table=True, + ) + + return None + elif isinstance(entity_df, str): + # If the entity_df is a string (SQL query), create a Snowflake table out of it, + query = f'CREATE TEMPORARY TABLE "{table_name}" AS ({entity_df})' + execute_snowflake_statement(snowflake_conn, query) + + return None + else: + raise InvalidEntityType(type(entity_df)) + + +def _fix_entity_selections_identifiers(query_context) -> list: + + for i, qc in enumerate(query_context): + for j, es in enumerate(qc.entity_selections): + query_context[i].entity_selections[j] = f'"{es}"'.replace(" AS ", '" AS "') + + return query_context + + +def _get_entity_df_event_timestamp_range( + entity_df: Union[pd.DataFrame, str], + entity_df_event_timestamp_col: str, + snowflake_conn: SnowflakeConnection, +) -> Tuple[datetime, datetime]: + if isinstance(entity_df, pd.DataFrame): + entity_df_event_timestamp = entity_df.loc[ + :, entity_df_event_timestamp_col + ].infer_objects() + if pd.api.types.is_string_dtype(entity_df_event_timestamp): + entity_df_event_timestamp = pd.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(), + ) + elif isinstance(entity_df, str): + # If the entity_df is a string (SQL query), determine range + # from table + query = f'SELECT MIN("{entity_df_event_timestamp_col}") AS "min_value", MAX("{entity_df_event_timestamp_col}") AS "max_value" FROM ({entity_df})' + results = execute_snowflake_statement(snowflake_conn, query).fetchall() + + entity_df_event_timestamp_range = cast(Tuple[datetime, datetime], results[0]) + else: + raise InvalidEntityType(type(entity_df)) + + return entity_df_event_timestamp_range + + +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 %} + {% if featureview.entities %} + ,( + {% for entity in featureview.entities %} + CAST("{{entity}}" AS VARCHAR) || + {% endfor %} + CAST("{{entity_df_event_timestamp_col}}" AS VARCHAR) + ) AS "{{featureview.name}}__entity_row_unique_id" + {% else %} + ,CAST("{{entity_df_event_timestamp_col}}" AS VARCHAR) AS "{{featureview.name}}__entity_row_unique_id" + {% endif %} + {% endfor %} + FROM "{{ left_table_query_string }}" +), + +{% for featureview in featureviews %} + +"{{ featureview.name }}__entity_dataframe" AS ( + SELECT + {{ featureview.entities | map('tojson') | join(', ')}}{% if featureview.entities %},{% else %}{% endif %} + "entity_timestamp", + "{{featureview.name}}__entity_row_unique_id" + FROM "entity_dataframe" + GROUP BY + {{ featureview.entities | map('tojson') | 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` + 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 + "{{ featureview.event_timestamp_column }}" 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 }}__{{featureview.field_mapping.get(feature, feature)}}"{% else %}"{{ featureview.field_mapping.get(feature, feature) }}"{% endif %}{% if loop.last %}{% else %}, {% endif %} + {% endfor %} + FROM {{ featureview.table_subquery }} + WHERE "{{ featureview.event_timestamp_column }}" <= '{{ featureview.max_event_timestamp }}' + {% if featureview.ttl == 0 %}{% else %} + AND "{{ featureview.event_timestamp_column }}" >= '{{ featureview.min_event_timestamp }}' + {% endif %} +), + +"{{ featureview.name }}__base" AS ( + SELECT + "subquery".*, + "entity_dataframe"."entity_timestamp", + "entity_dataframe"."{{featureview.name}}__entity_row_unique_id" + FROM "{{ featureview.name }}__subquery" AS "subquery" + INNER JOIN "{{ featureview.name }}__entity_dataframe" AS "entity_dataframe" + ON TRUE + AND "subquery"."event_timestamp" <= "entity_dataframe"."entity_timestamp" + + {% if featureview.ttl == 0 %}{% else %} + AND "subquery"."event_timestamp" >= TIMESTAMPADD(second,-{{ featureview.ttl }},"entity_dataframe"."entity_timestamp") + {% 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 + "event_timestamp", + {% if featureview.created_timestamp_column %}"created_timestamp",{% endif %} + "{{featureview.name}}__entity_row_unique_id" + FROM + ( + SELECT *, + ROW_NUMBER() OVER( + PARTITION BY "{{featureview.name}}__entity_row_unique_id" + ORDER BY "event_timestamp" DESC{% if featureview.created_timestamp_column %},"created_timestamp" DESC{% endif %} + ) AS "row_number" + FROM "{{ featureview.name }}__base" + {% if featureview.created_timestamp_column %} + INNER JOIN "{{ featureview.name }}__dedup" + USING ("{{featureview.name}}__entity_row_unique_id", "event_timestamp", "created_timestamp") + {% endif %} + ) + WHERE "row_number" = 1 +), + +/* + 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" + USING( + "{{featureview.name}}__entity_row_unique_id", + "event_timestamp" + {% if featureview.created_timestamp_column %} + ,"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 "{{ 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 }}__{{featureview.field_mapping.get(feature, feature)}}"{% else %}"{{ featureview.field_mapping.get(feature, feature) }}"{% endif %} + {% endfor %} + FROM "{{ featureview.name }}__cleaned" +) "{{ featureview.name }}__cleaned" USING ("{{featureview.name}}__entity_row_unique_id") +{% endfor %} +""" diff --git a/sdk/python/feast/infra/offline_stores/snowflake_source.py b/sdk/python/feast/infra/offline_stores/snowflake_source.py new file mode 100644 index 00000000000..b5d50be0f4d --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/snowflake_source.py @@ -0,0 +1,315 @@ +from typing import Callable, Dict, Iterable, Optional, Tuple + +from feast import type_map +from feast.data_source import DataSource +from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto +from feast.protos.feast.core.SavedDataset_pb2 import ( + SavedDatasetStorage as SavedDatasetStorageProto, +) +from feast.repo_config import RepoConfig +from feast.saved_dataset import SavedDatasetStorage +from feast.value_type import ValueType + + +class SnowflakeSource(DataSource): + def __init__( + self, + database: Optional[str] = None, + schema: Optional[str] = None, + table: Optional[str] = None, + query: Optional[str] = None, + event_timestamp_column: Optional[str] = "", + created_timestamp_column: Optional[str] = "", + field_mapping: Optional[Dict[str, str]] = None, + date_partition_column: Optional[str] = "", + ): + """ + Creates a SnowflakeSource object. + + Args: + database (optional): Snowflake database where the features are stored. + schema (optional): Snowflake schema in which the table is located. + table (optional): Snowflake table where the features are stored. + event_timestamp_column (optional): Event timestamp column used for point in + time joins of feature values. + query (optional): The query to be executed to obtain the features. + created_timestamp_column (optional): Timestamp column indicating when the + row was created, used for deduplicating rows. + field_mapping (optional): A dictionary mapping of column names in this data + source to column names in a feature table or view. + date_partition_column (optional): Timestamp column used for partitioning. + + """ + super().__init__( + event_timestamp_column, + created_timestamp_column, + field_mapping, + date_partition_column, + ) + + # The default Snowflake schema is named "PUBLIC". + _schema = "PUBLIC" if (database and table and not schema) else schema + + self._snowflake_options = SnowflakeOptions( + database=database, schema=_schema, table=table, query=query + ) + + @staticmethod + def from_proto(data_source: DataSourceProto): + """ + Creates a SnowflakeSource from a protobuf representation of a SnowflakeSource. + + Args: + data_source: A protobuf representation of a SnowflakeSource + + Returns: + A SnowflakeSource object based on the data_source protobuf. + """ + return SnowflakeSource( + field_mapping=dict(data_source.field_mapping), + database=data_source.snowflake_options.database, + schema=data_source.snowflake_options.schema, + table=data_source.snowflake_options.table, + event_timestamp_column=data_source.event_timestamp_column, + created_timestamp_column=data_source.created_timestamp_column, + date_partition_column=data_source.date_partition_column, + query=data_source.snowflake_options.query, + ) + + def __eq__(self, other): + if not isinstance(other, SnowflakeSource): + raise TypeError( + "Comparisons should only involve SnowflakeSource class objects." + ) + + return ( + self.snowflake_options.database == other.snowflake_options.database + and self.snowflake_options.schema == other.snowflake_options.schema + and self.snowflake_options.table == other.snowflake_options.table + and self.snowflake_options.query == other.snowflake_options.query + and self.event_timestamp_column == other.event_timestamp_column + and self.created_timestamp_column == other.created_timestamp_column + and self.field_mapping == other.field_mapping + ) + + @property + def database(self): + """Returns the database of this snowflake source.""" + return self._snowflake_options.database + + @property + def schema(self): + """Returns the schema of this snowflake source.""" + return self._snowflake_options.schema + + @property + def table(self): + """Returns the table of this snowflake source.""" + return self._snowflake_options.table + + @property + def query(self): + """Returns the snowflake options of this snowflake source.""" + return self._snowflake_options.query + + @property + def snowflake_options(self): + """Returns the snowflake options of this snowflake source.""" + return self._snowflake_options + + @snowflake_options.setter + def snowflake_options(self, _snowflake_options): + """Sets the snowflake options of this snowflake source.""" + self._snowflake_options = _snowflake_options + + def to_proto(self) -> DataSourceProto: + """ + Converts a SnowflakeSource object to its protobuf representation. + + Returns: + A DataSourceProto object. + """ + data_source_proto = DataSourceProto( + type=DataSourceProto.BATCH_SNOWFLAKE, + field_mapping=self.field_mapping, + snowflake_options=self.snowflake_options.to_proto(), + ) + + data_source_proto.event_timestamp_column = self.event_timestamp_column + 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 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(config) + + def get_table_query_string(self) -> str: + """Returns a string that can directly be used to reference this table in SQL.""" + if self.database and self.table: + return f'"{self.database}"."{self.schema}"."{self.table}"' + elif self.table: + return f'"{self.table}"' + else: + return f"({self.query})" + + @staticmethod + def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: + return type_map.snowflake_python_type_to_feast_value_type + + def get_table_column_names_and_types( + self, config: RepoConfig + ) -> Iterable[Tuple[str, str]]: + """ + Returns a mapping of column names to types for this snowflake source. + + Args: + config: A RepoConfig describing the feature repo + """ + + from feast.infra.offline_stores.snowflake import SnowflakeOfflineStoreConfig + from feast.infra.utils.snowflake_utils import ( + execute_snowflake_statement, + get_snowflake_conn, + ) + + assert isinstance(config.offline_store, SnowflakeOfflineStoreConfig) + + snowflake_conn = get_snowflake_conn(config.offline_store) + + if self.database and self.table: + query = f'SELECT * FROM "{self.database}"."{self.schema}"."{self.table}" LIMIT 1' + elif self.table: + query = f'SELECT * FROM "{self.table}" LIMIT 1' + else: + query = f"SELECT * FROM ({self.query}) LIMIT 1" + + result = execute_snowflake_statement(snowflake_conn, query).fetch_pandas_all() + + if not result.empty: + metadata = result.dtypes.apply(str) + return list(zip(metadata.index, metadata)) + else: + raise ValueError("The following source:\n" + query + "\n ... is empty") + + +class SnowflakeOptions: + """ + DataSource snowflake options used to source features from snowflake query. + """ + + def __init__( + self, + database: Optional[str], + schema: Optional[str], + table: Optional[str], + query: Optional[str], + ): + self._database = database + self._schema = schema + self._table = table + self._query = query + + @property + def query(self): + """Returns the snowflake SQL query referenced by this source.""" + return self._query + + @query.setter + def query(self, query): + """Sets the snowflake SQL query referenced by this source.""" + self._query = query + + @property + def database(self): + """Returns the database name of this snowflake table.""" + return self._database + + @database.setter + def database(self, database): + """Sets the database ref of this snowflake table.""" + self._database = database + + @property + def schema(self): + """Returns the schema name of this snowflake table.""" + return self._schema + + @schema.setter + def schema(self, schema): + """Sets the schema of this snowflake table.""" + self._schema = schema + + @property + def table(self): + """Returns the table name of this snowflake table.""" + return self._table + + @table.setter + def table(self, table): + """Sets the table ref of this snowflake table.""" + self._table = table + + @classmethod + def from_proto(cls, snowflake_options_proto: DataSourceProto.SnowflakeOptions): + """ + Creates a SnowflakeOptions from a protobuf representation of a snowflake option. + + Args: + snowflake_options_proto: A protobuf representation of a DataSource + + Returns: + A SnowflakeOptions object based on the snowflake_options protobuf. + """ + snowflake_options = cls( + database=snowflake_options_proto.database, + schema=snowflake_options_proto.schema, + table=snowflake_options_proto.table, + query=snowflake_options_proto.query, + ) + + return snowflake_options + + def to_proto(self) -> DataSourceProto.SnowflakeOptions: + """ + Converts an SnowflakeOptionsProto object to its protobuf representation. + + Returns: + A SnowflakeOptionsProto protobuf. + """ + snowflake_options_proto = DataSourceProto.SnowflakeOptions( + database=self.database, + schema=self.schema, + table=self.table, + query=self.query, + ) + + return snowflake_options_proto + + +class SavedDatasetSnowflakeStorage(SavedDatasetStorage): + _proto_attr_name = "snowflake_storage" + + snowflake_options: SnowflakeOptions + + def __init__(self, table_ref: str): + self.snowflake_options = SnowflakeOptions( + database=None, schema=None, table=table_ref, query=None + ) + + @staticmethod + def from_proto(storage_proto: SavedDatasetStorageProto) -> SavedDatasetStorage: + + return SavedDatasetSnowflakeStorage( + table_ref=SnowflakeOptions.from_proto(storage_proto.snowflake_storage).table + ) + + def to_proto(self) -> SavedDatasetStorageProto: + return SavedDatasetStorageProto( + snowflake_storage=self.snowflake_options.to_proto() + ) + + def to_data_source(self) -> DataSource: + return SnowflakeSource(table=self.snowflake_options.table) diff --git a/sdk/python/feast/infra/utils/snowflake_utils.py b/sdk/python/feast/infra/utils/snowflake_utils.py new file mode 100644 index 00000000000..f280cfa218b --- /dev/null +++ b/sdk/python/feast/infra/utils/snowflake_utils.py @@ -0,0 +1,279 @@ +import configparser +import os +import random +import string +from logging import getLogger +from tempfile import TemporaryDirectory +from typing import Dict, Iterator, List, Optional, Tuple, cast + +import pandas as pd +import snowflake.connector +from snowflake.connector import ProgrammingError, SnowflakeConnection +from snowflake.connector.cursor import SnowflakeCursor +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from feast.errors import SnowflakeIncompleteConfig, SnowflakeQueryUnknownError + +getLogger("snowflake.connector.cursor").disabled = True +getLogger("snowflake.connector.connection").disabled = True +getLogger("snowflake.connector.network").disabled = True +logger = getLogger(__name__) + + +def execute_snowflake_statement(conn: SnowflakeConnection, query) -> SnowflakeCursor: + cursor = conn.cursor().execute(query) + if cursor is None: + raise SnowflakeQueryUnknownError(query) + return cursor + + +def get_snowflake_conn(config, autocommit=True) -> SnowflakeConnection: + if config.type == "snowflake.offline": + config_header = "connections.feast_offline_store" + + config = dict(config) + + # read config file + config_reader = configparser.ConfigParser() + config_reader.read([config["config_path"]]) + if config_reader.has_section(config_header): + kwargs = dict(config_reader[config_header]) + else: + kwargs = {} + + kwargs.update((k, v) for k, v in config.items() if v is not None) + + try: + conn = snowflake.connector.connect( + account=kwargs["account"], + user=kwargs["user"], + password=kwargs["password"], + role=f'''"{kwargs['role']}"''', + warehouse=f'''"{kwargs['warehouse']}"''', + database=f'''"{kwargs['database']}"''', + schema=f'''"{kwargs['schema_']}"''', + application="feast", + autocommit=autocommit, + ) + + return conn + except KeyError as e: + raise SnowflakeIncompleteConfig(e) + + +# TO DO -- sfc-gh-madkins +# Remove dependency on write_pandas function by falling back to native snowflake python connector +# Current issue is datetime[ns] types are read incorrectly in Snowflake, need to coerce to datetime[ns, UTC] +def write_pandas( + conn: SnowflakeConnection, + df: pd.DataFrame, + table_name: str, + database: Optional[str] = None, + schema: Optional[str] = None, + chunk_size: Optional[int] = None, + compression: str = "gzip", + on_error: str = "abort_statement", + parallel: int = 4, + quote_identifiers: bool = True, + auto_create_table: bool = False, + create_temp_table: bool = False, +): + """Allows users to most efficiently write back a pandas DataFrame to Snowflake. + + It works by dumping the DataFrame into Parquet files, uploading them and finally copying their data into the table. + + Returns whether all files were ingested correctly, number of chunks uploaded, and number of rows ingested + with all of the COPY INTO command's output for debugging purposes. + + Example usage: + import pandas + from snowflake.connector.pandas_tools import write_pandas + + df = pandas.DataFrame([('Mark', 10), ('Luke', 20)], columns=['name', 'balance']) + success, nchunks, nrows, _ = write_pandas(cnx, df, 'customers') + + Args: + conn: Connection to be used to communicate with Snowflake. + df: Dataframe we'd like to write back. + table_name: Table name where we want to insert into. + database: Database schema and table is in, if not provided the default one will be used (Default value = None). + schema: Schema table is in, if not provided the default one will be used (Default value = None). + chunk_size: Number of elements to be inserted once, if not provided all elements will be dumped once + (Default value = None). + compression: The compression used on the Parquet files, can only be gzip, or snappy. Gzip gives supposedly a + better compression, while snappy is faster. Use whichever is more appropriate (Default value = 'gzip'). + on_error: Action to take when COPY INTO statements fail, default follows documentation at: + https://docs.snowflake.com/en/sql-reference/sql/copy-into-table.html#copy-options-copyoptions + (Default value = 'abort_statement'). + parallel: Number of threads to be used when uploading chunks, default follows documentation at: + https://docs.snowflake.com/en/sql-reference/sql/put.html#optional-parameters (Default value = 4). + quote_identifiers: By default, identifiers, specifically database, schema, table and column names + (from df.columns) will be quoted. If set to False, identifiers are passed on to Snowflake without quoting. + I.e. identifiers will be coerced to uppercase by Snowflake. (Default value = True) + auto_create_table: When true, will automatically create a table with corresponding columns for each column in + the passed in DataFrame. The table will not be created if it already exists + create_temp_table: Will make the auto-created table as a temporary table + """ + if database is not None and schema is None: + raise ProgrammingError( + "Schema has to be provided to write_pandas when a database is provided" + ) + # This dictionary maps the compression algorithm to Snowflake put copy into command type + # https://docs.snowflake.com/en/sql-reference/sql/copy-into-table.html#type-parquet + compression_map = {"gzip": "auto", "snappy": "snappy"} + if compression not in compression_map.keys(): + raise ProgrammingError( + "Invalid compression '{}', only acceptable values are: {}".format( + compression, compression_map.keys() + ) + ) + if quote_identifiers: + location = ( + (('"' + database + '".') if database else "") + + (('"' + schema + '".') if schema else "") + + ('"' + table_name + '"') + ) + else: + location = ( + (database + "." if database else "") + + (schema + "." if schema else "") + + (table_name) + ) + if chunk_size is None: + chunk_size = len(df) + cursor: SnowflakeCursor = conn.cursor() + stage_name = create_temporary_sfc_stage(cursor) + + with TemporaryDirectory() as tmp_folder: + for i, chunk in chunk_helper(df, chunk_size): + chunk_path = os.path.join(tmp_folder, "file{}.txt".format(i)) + # Dump chunk into parquet file + chunk.to_parquet( + chunk_path, + compression=compression, + use_deprecated_int96_timestamps=True, + ) + # Upload parquet file + upload_sql = ( + "PUT /* Python:snowflake.connector.pandas_tools.write_pandas() */ " + "'file://{path}' @\"{stage_name}\" PARALLEL={parallel}" + ).format( + path=chunk_path.replace("\\", "\\\\").replace("'", "\\'"), + stage_name=stage_name, + parallel=parallel, + ) + logger.debug(f"uploading files with '{upload_sql}'") + cursor.execute(upload_sql, _is_internal=True) + # Remove chunk file + os.remove(chunk_path) + if quote_identifiers: + columns = '"' + '","'.join(list(df.columns)) + '"' + else: + columns = ",".join(list(df.columns)) + + if auto_create_table: + file_format_name = create_file_format(compression, compression_map, cursor) + infer_schema_sql = f"SELECT COLUMN_NAME, TYPE FROM table(infer_schema(location=>'@\"{stage_name}\"', file_format=>'{file_format_name}'))" + logger.debug(f"inferring schema with '{infer_schema_sql}'") + result_cursor = cursor.execute(infer_schema_sql, _is_internal=True) + if result_cursor is None: + raise SnowflakeQueryUnknownError(infer_schema_sql) + result = cast(List[Tuple[str, str]], result_cursor.fetchall()) + column_type_mapping: Dict[str, str] = dict(result) + # Infer schema can return the columns out of order depending on the chunking we do when uploading + # so we have to iterate through the dataframe columns to make sure we create the table with its + # columns in order + quote = '"' if quote_identifiers else "" + create_table_columns = ", ".join( + [f"{quote}{c}{quote} {column_type_mapping[c]}" for c in df.columns] + ) + create_table_sql = ( + f"CREATE {'TEMP ' if create_temp_table else ''}TABLE IF NOT EXISTS {location} " + f"({create_table_columns})" + f" /* Python:snowflake.connector.pandas_tools.write_pandas() */ " + ) + logger.debug(f"auto creating table with '{create_table_sql}'") + cursor.execute(create_table_sql, _is_internal=True) + drop_file_format_sql = f"DROP FILE FORMAT IF EXISTS {file_format_name}" + logger.debug(f"dropping file format with '{drop_file_format_sql}'") + cursor.execute(drop_file_format_sql, _is_internal=True) + + # in Snowflake, all parquet data is stored in a single column, $1, so we must select columns explicitly + # see (https://docs.snowflake.com/en/user-guide/script-data-load-transform-parquet.html) + if quote_identifiers: + parquet_columns = "$1:" + ",$1:".join(f'"{c}"' for c in df.columns) + else: + parquet_columns = "$1:" + ",$1:".join(df.columns) + copy_into_sql = ( + "COPY INTO {location} /* Python:snowflake.connector.pandas_tools.write_pandas() */ " + "({columns}) " + 'FROM (SELECT {parquet_columns} FROM @"{stage_name}") ' + "FILE_FORMAT=(TYPE=PARQUET COMPRESSION={compression}) " + "PURGE=TRUE ON_ERROR={on_error}" + ).format( + location=location, + columns=columns, + parquet_columns=parquet_columns, + stage_name=stage_name, + compression=compression_map[compression], + on_error=on_error, + ) + logger.debug("copying into with '{}'".format(copy_into_sql)) + # Snowflake returns the original cursor if the query execution succeeded. + result_cursor = cursor.execute(copy_into_sql, _is_internal=True) + if result_cursor is None: + raise SnowflakeQueryUnknownError(copy_into_sql) + result_cursor.close() + + +@retry( + wait=wait_exponential(multiplier=1, max=4), + retry=retry_if_exception_type(ProgrammingError), + stop=stop_after_attempt(5), + reraise=True, +) +def create_file_format( + compression: str, compression_map: Dict[str, str], cursor: SnowflakeCursor +) -> str: + file_format_name = ( + '"' + "".join(random.choice(string.ascii_lowercase) for _ in range(5)) + '"' + ) + file_format_sql = ( + f"CREATE FILE FORMAT {file_format_name} " + f"/* Python:snowflake.connector.pandas_tools.write_pandas() */ " + f"TYPE=PARQUET COMPRESSION={compression_map[compression]}" + ) + logger.debug(f"creating file format with '{file_format_sql}'") + cursor.execute(file_format_sql, _is_internal=True) + return file_format_name + + +@retry( + wait=wait_exponential(multiplier=1, max=4), + retry=retry_if_exception_type(ProgrammingError), + stop=stop_after_attempt(5), + reraise=True, +) +def create_temporary_sfc_stage(cursor: SnowflakeCursor) -> str: + stage_name = "".join(random.choice(string.ascii_lowercase) for _ in range(5)) + create_stage_sql = ( + "create temporary stage /* Python:snowflake.connector.pandas_tools.write_pandas() */ " + '"{stage_name}"' + ).format(stage_name=stage_name) + logger.debug(f"creating stage with '{create_stage_sql}'") + result_cursor = cursor.execute(create_stage_sql, _is_internal=True) + if result_cursor is None: + raise SnowflakeQueryUnknownError(create_stage_sql) + result_cursor.fetchall() + return stage_name + + +def chunk_helper(lst: pd.DataFrame, n: int) -> Iterator[Tuple[int, pd.DataFrame]]: + """Helper generator to chunk a sequence efficiently with current index like if enumerate was called on sequence.""" + for i in range(0, len(lst), n): + yield int(i / n), lst[i : i + n] diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index e8ba1805681..3f32d18b80b 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -31,12 +31,14 @@ "datastore": "feast.infra.online_stores.datastore.DatastoreOnlineStore", "redis": "feast.infra.online_stores.redis.RedisOnlineStore", "dynamodb": "feast.infra.online_stores.dynamodb.DynamoDBOnlineStore", + "snowflake.online": "feast.infra.online_stores.snowflake.SnowflakeOnlineStore", } OFFLINE_STORE_CLASS_FOR_TYPE = { "file": "feast.infra.offline_stores.file.FileOfflineStore", "bigquery": "feast.infra.offline_stores.bigquery.BigQueryOfflineStore", "redshift": "feast.infra.offline_stores.redshift.RedshiftOfflineStore", + "snowflake.offline": "feast.infra.offline_stores.snowflake.SnowflakeOfflineStore", } FEATURE_SERVER_CONFIG_CLASS_FOR_TYPE = { diff --git a/sdk/python/feast/templates/snowflake/bootstrap.py b/sdk/python/feast/templates/snowflake/bootstrap.py new file mode 100644 index 00000000000..3712651a5d9 --- /dev/null +++ b/sdk/python/feast/templates/snowflake/bootstrap.py @@ -0,0 +1,91 @@ +import click +import snowflake.connector + +from feast.infra.utils.snowflake_utils import write_pandas + + +def bootstrap(): + # Bootstrap() will automatically be called from the init_repo() during `feast init` + + import pathlib + from datetime import datetime, timedelta + + from feast.driver_test_data import create_driver_hourly_stats_df + + repo_path = pathlib.Path(__file__).parent.absolute() + config_file = repo_path / "feature_store.yaml" + + project_name = str(repo_path)[str(repo_path).rfind("/") + 1 :] + + end_date = datetime.now().replace(microsecond=0, second=0, minute=0) + start_date = end_date - timedelta(days=15) + + driver_entities = [1001, 1002, 1003, 1004, 1005] + driver_df = create_driver_hourly_stats_df(driver_entities, start_date, end_date) + + repo_path = pathlib.Path(__file__).parent.absolute() + data_path = repo_path / "data" + data_path.mkdir(exist_ok=True) + driver_stats_path = data_path / "driver_stats.parquet" + driver_df.to_parquet(path=str(driver_stats_path), allow_truncated_timestamps=True) + + snowflake_deployment_url = click.prompt( + "Snowflake Deployment URL (exclude .snowflakecomputing.com):" + ) + snowflake_user = click.prompt("Snowflake User Name:") + snowflake_password = click.prompt("Snowflake Password:", hide_input=True) + snowflake_role = click.prompt("Snowflake Role Name (Case Sensitive):") + snowflake_warehouse = click.prompt("Snowflake Warehouse Name (Case Sensitive):") + snowflake_database = click.prompt("Snowflake Database Name (Case Sensitive):") + + if click.confirm( + f'Should I upload example data to Snowflake (overwriting "{project_name}_feast_driver_hourly_stats" table)?', + default=True, + ): + + conn = snowflake.connector.connect( + account=snowflake_deployment_url, + user=snowflake_user, + password=snowflake_password, + role=snowflake_role, + warehouse=snowflake_warehouse, + application="feast", + ) + + cur = conn.cursor() + cur.execute(f'CREATE DATABASE IF NOT EXISTS "{snowflake_database}"') + cur.execute(f'USE DATABASE "{snowflake_database}"') + cur.execute('CREATE SCHEMA IF NOT EXISTS "PUBLIC"') + cur.execute('USE SCHEMA "PUBLIC"') + cur.execute(f'DROP TABLE IF EXISTS "{project_name}_feast_driver_hourly_stats"') + write_pandas( + conn, + driver_df, + f"{project_name}_feast_driver_hourly_stats", + auto_create_table=True, + ) + conn.close() + + repo_path = pathlib.Path(__file__).parent.absolute() + config_file = repo_path / "feature_store.yaml" + + replace_str_in_file( + config_file, "SNOWFLAKE_DEPLOYMENT_URL", snowflake_deployment_url + ) + replace_str_in_file(config_file, "SNOWFLAKE_USER", snowflake_user) + replace_str_in_file(config_file, "SNOWFLAKE_PASSWORD", snowflake_password) + replace_str_in_file(config_file, "SNOWFLAKE_ROLE", snowflake_role) + replace_str_in_file(config_file, "SNOWFLAKE_WAREHOUSE", snowflake_warehouse) + replace_str_in_file(config_file, "SNOWFLAKE_DATABASE", snowflake_database) + + +def replace_str_in_file(file_path, match_str, sub_str): + with open(file_path, "r") as f: + contents = f.read() + contents = contents.replace(match_str, sub_str) + with open(file_path, "wt") as f: + f.write(contents) + + +if __name__ == "__main__": + bootstrap() diff --git a/sdk/python/feast/templates/snowflake/driver_repo.py b/sdk/python/feast/templates/snowflake/driver_repo.py new file mode 100644 index 00000000000..a63c6cb5030 --- /dev/null +++ b/sdk/python/feast/templates/snowflake/driver_repo.py @@ -0,0 +1,64 @@ +from datetime import timedelta + +import yaml + +from feast import Entity, Feature, FeatureView, SnowflakeSource, ValueType + +# Define an entity for the driver. Entities can be thought of as primary keys used to +# retrieve features. Entities are also used to join multiple tables/views during the +# construction of feature vectors +driver = Entity( + # Name of the entity. Must be unique within a project + name="driver_id", + # The join key of an entity describes the storage level field/column on which + # features can be looked up. The join key is also used to join feature + # tables/views when building feature vectors + join_key="driver_id", +) + +# Indicates a data source from which feature values can be retrieved. Sources are queried when building training +# datasets or materializing features into an online store. +project_name = yaml.safe_load(open("feature_store.yaml"))["project"] + +driver_stats_source = SnowflakeSource( + # The Snowflake table where features can be found + database=yaml.safe_load(open("feature_store.yaml"))["offline_store"]["database"], + table=f"{project_name}_feast_driver_hourly_stats", + # The event timestamp is used for point-in-time joins and for ensuring only + # features within the TTL are returned + event_timestamp_column="event_timestamp", + # The (optional) created timestamp is used to ensure there are no duplicate + # feature rows in the offline store or when building training datasets + created_timestamp_column="created", +) + +# Feature views are a grouping based on how features are stored in either the +# online or offline store. +driver_stats_fv = FeatureView( + # The unique name of this feature view. Two feature views in a single + # project cannot have the same name + name="driver_hourly_stats", + # The list of entities specifies the keys required for joining or looking + # up features from this feature view. The reference provided in this field + # correspond to the name of a defined entity (or entities) + entities=["driver_id"], + # The timedelta is the maximum age that each feature value may have + # relative to its lookup time. For historical features (used in training), + # TTL is relative to each timestamp provided in the entity dataframe. + # TTL also allows for eviction of keys from online stores and limits the + # amount of historical scanning required for historical feature values + # during retrieval + ttl=timedelta(weeks=52), + # The list of features defined below act as a schema to both define features + # for both materialization of features into a store, and are used as references + # during retrieval for building a training dataset or serving features + features=[ + Feature(name="conv_rate", dtype=ValueType.FLOAT), + Feature(name="acc_rate", dtype=ValueType.FLOAT), + Feature(name="avg_daily_trips", dtype=ValueType.INT64), + ], + # Batch sources are used to find feature values. In the case of this feature + # view we will query a source table on Redshift for driver statistics + # features + batch_source=driver_stats_source, +) diff --git a/sdk/python/feast/templates/snowflake/feature_store.yaml b/sdk/python/feast/templates/snowflake/feature_store.yaml new file mode 100644 index 00000000000..9757ea2ead0 --- /dev/null +++ b/sdk/python/feast/templates/snowflake/feature_store.yaml @@ -0,0 +1,11 @@ +project: my_project +registry: registry.db +provider: local +offline_store: + type: snowflake.offline + account: SNOWFLAKE_DEPLOYMENT_URL + user: SNOWFLAKE_USER + password: SNOWFLAKE_PASSWORD + role: SNOWFLAKE_ROLE + warehouse: SNOWFLAKE_WAREHOUSE + database: SNOWFLAKE_DATABASE diff --git a/sdk/python/feast/templates/snowflake/test.py b/sdk/python/feast/templates/snowflake/test.py new file mode 100644 index 00000000000..32aa6380d51 --- /dev/null +++ b/sdk/python/feast/templates/snowflake/test.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta + +import pandas as pd +from driver_repo import driver, driver_stats_fv + +from feast import FeatureStore + + +def main(): + pd.set_option("display.max_columns", None) + pd.set_option("display.width", 1000) + + # Load the feature store from the current path + fs = FeatureStore(repo_path=".") + + # Deploy the feature store to Snowflake + print("Deploying feature store to Snowflake...") + fs.apply([driver, driver_stats_fv]) + + # Select features + features = ["driver_hourly_stats:conv_rate", "driver_hourly_stats:acc_rate"] + + # Create an entity dataframe. This is the dataframe that will be enriched with historical features + entity_df = pd.DataFrame( + { + "event_timestamp": [ + pd.Timestamp(dt, unit="ms", tz="UTC").round("ms") + for dt in pd.date_range( + start=datetime.now() - timedelta(days=3), + end=datetime.now(), + periods=3, + ) + ], + "driver_id": [1001, 1002, 1003], + } + ) + + print("Retrieving training data...") + + # Retrieve historical features by joining the entity dataframe to the Snowflake table source + training_df = fs.get_historical_features( + features=features, entity_df=entity_df + ).to_df() + + print() + print(training_df) + + print() + print("Loading features into the online store...") + fs.materialize_incremental(end_date=datetime.now()) + + print() + print("Retrieving online features...") + + # Retrieve features from the online store + online_features = fs.get_online_features( + features=features, entity_rows=[{"driver_id": 1001}, {"driver_id": 1002}], + ).to_dict() + + print() + print(pd.DataFrame.from_dict(online_features)) + + +if __name__ == "__main__": + main() diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 599be85fdff..e39a4ecb816 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -126,6 +126,8 @@ def python_type_to_feast_value_type( "uint64": ValueType.INT64, "int32": ValueType.INT32, "uint32": ValueType.INT32, + "int16": ValueType.INT32, + "uint16": ValueType.INT32, "uint8": ValueType.INT32, "int8": ValueType.INT32, "bool": ValueType.BOOL, @@ -480,6 +482,28 @@ def redshift_to_feast_value_type(redshift_type_as_str: str) -> ValueType: return type_map[redshift_type_as_str.lower()] +def snowflake_python_type_to_feast_value_type( + snowflake_python_type_as_str: str, +) -> ValueType: + + type_map = { + "str": ValueType.STRING, + "float64": ValueType.DOUBLE, + "int64": ValueType.INT64, + "uint64": ValueType.INT64, + "int32": ValueType.INT32, + "uint32": ValueType.INT32, + "int16": ValueType.INT32, + "uint16": ValueType.INT32, + "uint8": ValueType.INT32, + "int8": ValueType.INT32, + "datetime64[ns]": ValueType.UNIX_TIMESTAMP, + "object": ValueType.UNKNOWN, + } + + return type_map[snowflake_python_type_as_str.lower()] + + def pa_to_redshift_value_type(pa_type: pyarrow.DataType) -> str: # PyArrow types: https://arrow.apache.org/docs/python/api/datatypes.html # Redshift type: https://docs.aws.amazon.com/redshift/latest/dg/c_Supported_data_types.html diff --git a/sdk/python/requirements/py3.7-ci-requirements.txt b/sdk/python/requirements/py3.7-ci-requirements.txt index 87ab9f9813b..293b44e0531 100644 --- a/sdk/python/requirements/py3.7-ci-requirements.txt +++ b/sdk/python/requirements/py3.7-ci-requirements.txt @@ -26,6 +26,10 @@ appdirs==1.4.4 # via black asgiref==3.4.1 # via uvicorn +asn1crypto==1.4.0 + # via + # oscrypto + # snowflake-connector-python assertpy==1.1 # via feast (setup.py) async-timeout==4.0.2 @@ -73,16 +77,19 @@ certifi==2021.10.8 # minio # msrest # requests + # snowflake-connector-python cffi==1.15.0 # via # azure-datalake-store # cryptography + # snowflake-connector-python cfgv==3.3.1 # via pre-commit charset-normalizer==2.0.10 # via # aiohttp # requests + # snowflake-connector-python click==8.0.3 # via # black @@ -101,6 +108,8 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal + # pyopenssl + # snowflake-connector-python decorator==5.1.1 # via gcsfs deprecated==1.2.13 @@ -229,6 +238,7 @@ idna==3.3 # via # anyio # requests + # snowflake-connector-python # yarl imagesize==1.3.0 # via sphinx @@ -316,6 +326,8 @@ numpy==1.21.5 # pyarrow oauthlib==3.1.1 # via requests-oauthlib +oscrypto==1.2.1 + # via snowflake-connector-python packaging==21.3 # via # deprecation @@ -329,6 +341,7 @@ pandas==1.3.5 # via # feast (setup.py) # pandavro + # snowflake-connector-python pandavro==1.5.2 # via feast (setup.py) pathspec==0.9.0 @@ -373,7 +386,9 @@ py==1.11.0 py-cpuinfo==8.0.0 # via pytest-benchmark pyarrow==6.0.1 - # via feast (setup.py) + # via + # feast (setup.py) + # snowflake-connector-python pyasn1==0.4.8 # via # pyasn1-modules @@ -384,6 +399,8 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi +pycryptodomex==3.13.0 + # via snowflake-connector-python pydantic==1.9.0 # via # fastapi @@ -396,6 +413,9 @@ pyjwt[crypto]==2.3.0 # via # adal # msal + # snowflake-connector-python +pyopenssl==21.0.0 + # via snowflake-connector-python pyparsing==3.0.7 # via # httplib2 @@ -444,6 +464,7 @@ pytz==2021.3 # google-api-core # moto # pandas + # snowflake-connector-python pyyaml==6.0 # via # feast (setup.py) @@ -471,6 +492,7 @@ requests==2.27.1 # msrest # requests-oauthlib # responses + # snowflake-connector-python # sphinx requests-oauthlib==1.3.0 # via @@ -497,6 +519,7 @@ six==1.16.0 # mock # msrestazure # pandavro + # pyopenssl # python-dateutil # responses # virtualenv @@ -504,6 +527,8 @@ sniffio==1.2.0 # via anyio snowballstemmer==2.2.0 # via sphinx +snowflake-connector-python[pandas]==2.7.3 + # via feast (setup.py) sphinx==4.3.2 # via # feast (setup.py) diff --git a/sdk/python/requirements/py3.8-ci-requirements.txt b/sdk/python/requirements/py3.8-ci-requirements.txt index 851a0b70548..3cdc118144f 100644 --- a/sdk/python/requirements/py3.8-ci-requirements.txt +++ b/sdk/python/requirements/py3.8-ci-requirements.txt @@ -26,6 +26,10 @@ appdirs==1.4.4 # via black asgiref==3.4.1 # via uvicorn +asn1crypto==1.4.0 + # via + # oscrypto + # snowflake-connector-python assertpy==1.1 # via feast (setup.py) async-timeout==4.0.2 @@ -71,16 +75,19 @@ certifi==2021.10.8 # minio # msrest # requests + # snowflake-connector-python cffi==1.15.0 # via # azure-datalake-store # cryptography + # snowflake-connector-python cfgv==3.3.1 # via pre-commit charset-normalizer==2.0.10 # via # aiohttp # requests + # snowflake-connector-python click==8.0.3 # via # black @@ -99,6 +106,8 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal + # pyopenssl + # snowflake-connector-python decorator==5.1.1 # via gcsfs deprecated==1.2.13 @@ -227,6 +236,7 @@ idna==3.3 # via # anyio # requests + # snowflake-connector-python # yarl imagesize==1.3.0 # via sphinx @@ -302,6 +312,8 @@ numpy==1.22.1 # pyarrow oauthlib==3.1.1 # via requests-oauthlib +oscrypto==1.2.1 + # via snowflake-connector-python packaging==21.3 # via # deprecation @@ -315,6 +327,7 @@ pandas==1.3.5 # via # feast (setup.py) # pandavro + # snowflake-connector-python pandavro==1.5.2 # via feast (setup.py) pathspec==0.9.0 @@ -359,7 +372,9 @@ py==1.11.0 py-cpuinfo==8.0.0 # via pytest-benchmark pyarrow==6.0.1 - # via feast (setup.py) + # via + # feast (setup.py) + # snowflake-connector-python pyasn1==0.4.8 # via # pyasn1-modules @@ -370,6 +385,8 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi +pycryptodomex==3.13.0 + # via snowflake-connector-python pydantic==1.9.0 # via # fastapi @@ -382,6 +399,9 @@ pyjwt[crypto]==2.3.0 # via # adal # msal + # snowflake-connector-python +pyopenssl==21.0.0 + # via snowflake-connector-python pyparsing==3.0.7 # via # httplib2 @@ -430,6 +450,7 @@ pytz==2021.3 # google-api-core # moto # pandas + # snowflake-connector-python pyyaml==6.0 # via # feast (setup.py) @@ -457,6 +478,7 @@ requests==2.27.1 # msrest # requests-oauthlib # responses + # snowflake-connector-python # sphinx requests-oauthlib==1.3.0 # via @@ -483,6 +505,7 @@ six==1.16.0 # mock # msrestazure # pandavro + # pyopenssl # python-dateutil # responses # virtualenv @@ -490,6 +513,8 @@ sniffio==1.2.0 # via anyio snowballstemmer==2.2.0 # via sphinx +snowflake-connector-python[pandas]==2.7.3 + # via feast (setup.py) sphinx==4.3.2 # via # feast (setup.py) diff --git a/sdk/python/requirements/py3.9-ci-requirements.txt b/sdk/python/requirements/py3.9-ci-requirements.txt index 76ed9f1237c..69247a2c7dd 100644 --- a/sdk/python/requirements/py3.9-ci-requirements.txt +++ b/sdk/python/requirements/py3.9-ci-requirements.txt @@ -26,6 +26,10 @@ appdirs==1.4.4 # via black asgiref==3.4.1 # via uvicorn +asn1crypto==1.4.0 + # via + # oscrypto + # snowflake-connector-python assertpy==1.1 # via feast (setup.py) async-timeout==4.0.2 @@ -71,16 +75,19 @@ certifi==2021.10.8 # minio # msrest # requests + # snowflake-connector-python cffi==1.15.0 # via # azure-datalake-store # cryptography + # snowflake-connector-python cfgv==3.3.1 # via pre-commit charset-normalizer==2.0.10 # via # aiohttp # requests + # snowflake-connector-python click==8.0.3 # via # black @@ -99,6 +106,8 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal + # pyopenssl + # snowflake-connector-python decorator==5.1.1 # via gcsfs deprecated==1.2.13 @@ -227,6 +236,7 @@ idna==3.3 # via # anyio # requests + # snowflake-connector-python # yarl imagesize==1.3.0 # via sphinx @@ -300,6 +310,8 @@ numpy==1.22.1 # pyarrow oauthlib==3.1.1 # via requests-oauthlib +oscrypto==1.2.1 + # via snowflake-connector-python packaging==21.3 # via # deprecation @@ -313,6 +325,7 @@ pandas==1.3.5 # via # feast (setup.py) # pandavro + # snowflake-connector-python pandavro==1.5.2 # via feast (setup.py) pathspec==0.9.0 @@ -357,7 +370,9 @@ py==1.11.0 py-cpuinfo==8.0.0 # via pytest-benchmark pyarrow==6.0.1 - # via feast (setup.py) + # via + # feast (setup.py) + # snowflake-connector-python pyasn1==0.4.8 # via # pyasn1-modules @@ -368,6 +383,8 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi +pycryptodomex==3.13.0 + # via snowflake-connector-python pydantic==1.9.0 # via # fastapi @@ -380,6 +397,9 @@ pyjwt[crypto]==2.3.0 # via # adal # msal + # snowflake-connector-python +pyopenssl==21.0.0 + # via snowflake-connector-python pyparsing==3.0.7 # via # httplib2 @@ -428,6 +448,7 @@ pytz==2021.3 # google-api-core # moto # pandas + # snowflake-connector-python pyyaml==6.0 # via # feast (setup.py) @@ -455,6 +476,7 @@ requests==2.27.1 # msrest # requests-oauthlib # responses + # snowflake-connector-python # sphinx requests-oauthlib==1.3.0 # via @@ -481,6 +503,7 @@ six==1.16.0 # mock # msrestazure # pandavro + # pyopenssl # python-dateutil # responses # virtualenv @@ -488,6 +511,8 @@ sniffio==1.2.0 # via anyio snowballstemmer==2.2.0 # via sphinx +snowflake-connector-python[pandas]==2.7.3 + # via feast (setup.py) sphinx==4.3.2 # via # feast (setup.py) diff --git a/sdk/python/setup.py b/sdk/python/setup.py index bae1695bf1a..cb5381813b5 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -86,6 +86,10 @@ "docker>=5.0.2", ] +SNOWFLAKE_REQUIRED = [ + "snowflake-connector-python[pandas]>=2.7.3", +] + CI_REQUIRED = ( [ "cryptography==3.3.2", @@ -130,6 +134,7 @@ + GCP_REQUIRED + REDIS_REQUIRED + AWS_REQUIRED + + SNOWFLAKE_REQUIRED ) DEV_REQUIRED = ["mypy-protobuf>=3.1.0", "grpcio-testing==1.*"] + CI_REQUIRED @@ -231,6 +236,7 @@ def run(self): "gcp": GCP_REQUIRED, "aws": AWS_REQUIRED, "redis": REDIS_REQUIRED, + "snowflake": SNOWFLAKE_REQUIRED }, include_package_data=True, license="Apache", diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index f0fb0b28fda..a9953d5977e 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -29,6 +29,9 @@ from tests.integration.feature_repos.universal.data_sources.redshift import ( RedshiftDataSourceCreator, ) +from tests.integration.feature_repos.universal.data_sources.snowflake import ( + SnowflakeDataSourceCreator, +) from tests.integration.feature_repos.universal.feature_views import ( conv_rate_plus_100_feature_view, create_conv_rate_request_data_source, @@ -83,6 +86,12 @@ offline_store_creator=RedshiftDataSourceCreator, online_store=REDIS_CONFIG, ), + # Snowflake configurations + IntegrationTestRepoConfig( + provider="aws", # no list features, no feature server + offline_store_creator=SnowflakeDataSourceCreator, + online_store=REDIS_CONFIG, + ), ] ) full_repo_configs_module = os.environ.get(FULL_REPO_CONFIGS_MODULE_ENV_NAME) diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/snowflake.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/snowflake.py new file mode 100644 index 00000000000..1ecae0317bf --- /dev/null +++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/snowflake.py @@ -0,0 +1,81 @@ +import os +import uuid +from typing import Dict, List, Optional + +import pandas as pd + +from feast import SnowflakeSource +from feast.data_source import DataSource +from feast.infra.offline_stores.snowflake import SnowflakeOfflineStoreConfig +from feast.infra.offline_stores.snowflake_source import SavedDatasetSnowflakeStorage +from feast.infra.utils.snowflake_utils import get_snowflake_conn, write_pandas +from feast.repo_config import FeastConfigBaseModel +from tests.integration.feature_repos.universal.data_source_creator import ( + DataSourceCreator, +) + + +class SnowflakeDataSourceCreator(DataSourceCreator): + + tables: List[str] = [] + + def __init__(self, project_name: str): + super().__init__() + self.project_name = project_name + self.offline_store_config = SnowflakeOfflineStoreConfig( + type="snowflake.offline", + account=os.environ["SNOWFLAKE_CI_DEPLOYMENT"], + user=os.environ["SNOWFLAKE_CI_USER"], + password=os.environ["SNOWFLAKE_CI_PASSWORD"], + role=os.environ["SNOWFLAKE_CI_ROLE"], + warehouse=os.environ["SNOWFLAKE_CI_WAREHOUSE"], + database="FEAST", + ) + + def create_data_source( + self, + df: pd.DataFrame, + destination_name: str, + suffix: Optional[str] = None, + event_timestamp_column="ts", + created_timestamp_column="created_ts", + field_mapping: Dict[str, str] = None, + ) -> DataSource: + + snowflake_conn = get_snowflake_conn(self.offline_store_config) + + destination_name = self.get_prefixed_table_name(destination_name) + + write_pandas(snowflake_conn, df, destination_name, auto_create_table=True) + + self.tables.append(destination_name) + + return SnowflakeSource( + table=destination_name, + event_timestamp_column=event_timestamp_column, + created_timestamp_column=created_timestamp_column, + date_partition_column="", + field_mapping=field_mapping or {"ts_1": "ts"}, + ) + + def create_saved_dataset_destination(self) -> SavedDatasetSnowflakeStorage: + table = self.get_prefixed_table_name( + f"persisted_ds_{str(uuid.uuid4()).replace('-', '_')}" + ) + self.tables.append(table) + + return SavedDatasetSnowflakeStorage(table_ref=table) + + def create_offline_store_config(self) -> FeastConfigBaseModel: + return self.offline_store_config + + def get_prefixed_table_name(self, suffix: str) -> str: + return f"{self.project_name}_{suffix}" + + def teardown(self): + snowflake_conn = get_snowflake_conn(self.offline_store_config) + + with snowflake_conn as conn: + cur = conn.cursor() + for table in self.tables: + cur.execute(f'DROP TABLE IF EXISTS "{table}"') 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 147e20aee1f..4a396c7e4d8 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 @@ -26,6 +26,9 @@ construct_universal_feature_views, table_name_from_data_source, ) +from tests.integration.feature_repos.universal.data_sources.snowflake import ( + SnowflakeDataSourceCreator, +) from tests.integration.feature_repos.universal.entities import ( customer, driver, @@ -469,7 +472,13 @@ def test_historical_features_with_entities_from_query( if not orders_table: raise pytest.skip("Offline source is not sql-based") - entity_df_query = f"SELECT customer_id, driver_id, order_id, origin_id, destination_id, event_timestamp FROM {orders_table}" + if ( + environment.test_repo_config.offline_store_creator.__name__ + == SnowflakeDataSourceCreator.__name__ + ): + entity_df_query = f'''SELECT "customer_id", "driver_id", "order_id", "origin_id", "destination_id", "event_timestamp" FROM "{orders_table}"''' + else: + entity_df_query = f"SELECT customer_id, driver_id, order_id, origin_id, destination_id, event_timestamp FROM {orders_table}" store.apply([driver(), customer(), location(), *feature_views.values()]) From f6712f0438ad49d25f0da6695a2c9ca335e6d12d Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Mon, 31 Jan 2022 16:18:10 -0500 Subject: [PATCH 48/85] Fix benchmark tests at HEAD by passing in Snowflake secrets (#2262) Signed-off-by: Danny Chiao --- .github/workflows/master_only.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/master_only.yml b/.github/workflows/master_only.yml index 66b5c620739..3cdddba8479 100644 --- a/.github/workflows/master_only.yml +++ b/.github/workflows/master_only.yml @@ -145,6 +145,11 @@ jobs: FEAST_SERVER_DOCKER_IMAGE_TAG: ${{ needs.build-lambda-docker-image.outputs.DOCKER_IMAGE_TAG }} FEAST_USAGE: "False" IS_TEST: "True" + SNOWFLAKE_CI_DEPLOYMENT: ${{ secrets.SNOWFLAKE_CI_DEPLOYMENT }} + SNOWFLAKE_CI_USER: ${{ secrets.SNOWFLAKE_CI_USER }} + SNOWFLAKE_CI_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }} + SNOWFLAKE_CI_ROLE: ${{ secrets.SNOWFLAKE_CI_ROLE }} + SNOWFLAKE_CI_WAREHOUSE: ${{ secrets.SNOWFLAKE_CI_WAREHOUSE }} run: pytest --verbose --color=yes sdk/python/tests --integration --benchmark --benchmark-autosave --benchmark-save-data --durations=5 - name: Upload Benchmark Artifact to S3 run: aws s3 cp --recursive .benchmarks s3://feast-ci-pytest-benchmarks From 53539cf4419483d08ec732f54010feb27db3c3bf Mon Sep 17 00:00:00 2001 From: Tsotne Tabidze Date: Mon, 31 Jan 2022 19:09:36 -0800 Subject: [PATCH 49/85] Update Go protos --- sdk/go/protos/feast/core/DataFormat.pb.go | 2 +- sdk/go/protos/feast/core/DataSource.pb.go | 360 ++++++++++++------ sdk/go/protos/feast/core/Entity.pb.go | 2 +- sdk/go/protos/feast/core/Feature.pb.go | 2 +- sdk/go/protos/feast/core/FeatureTable.pb.go | 2 +- sdk/go/protos/feast/core/Store.pb.go | 2 +- .../protos/feast/serving/ServingService.pb.go | 2 +- sdk/go/protos/feast/storage/Redis.pb.go | 2 +- sdk/go/protos/feast/types/Field.pb.go | 2 +- sdk/go/protos/feast/types/Value.pb.go | 2 +- 10 files changed, 251 insertions(+), 127 deletions(-) diff --git a/sdk/go/protos/feast/core/DataFormat.pb.go b/sdk/go/protos/feast/core/DataFormat.pb.go index 6745171c903..64c4ec80714 100644 --- a/sdk/go/protos/feast/core/DataFormat.pb.go +++ b/sdk/go/protos/feast/core/DataFormat.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/core/DataFormat.proto package core diff --git a/sdk/go/protos/feast/core/DataSource.pb.go b/sdk/go/protos/feast/core/DataSource.pb.go index 83f0bc6736c..d0d42c66dea 100644 --- a/sdk/go/protos/feast/core/DataSource.pb.go +++ b/sdk/go/protos/feast/core/DataSource.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/core/DataSource.proto package core @@ -38,17 +38,19 @@ const ( ) // Type of Data Source. +// Next available id: 9 type DataSource_SourceType int32 const ( - DataSource_INVALID DataSource_SourceType = 0 - DataSource_BATCH_FILE DataSource_SourceType = 1 - DataSource_BATCH_BIGQUERY DataSource_SourceType = 2 - DataSource_STREAM_KAFKA DataSource_SourceType = 3 - DataSource_STREAM_KINESIS DataSource_SourceType = 4 - DataSource_BATCH_REDSHIFT DataSource_SourceType = 5 - DataSource_CUSTOM_SOURCE DataSource_SourceType = 6 - DataSource_REQUEST_SOURCE DataSource_SourceType = 7 + DataSource_INVALID DataSource_SourceType = 0 + DataSource_BATCH_FILE DataSource_SourceType = 1 + DataSource_BATCH_SNOWFLAKE DataSource_SourceType = 8 + DataSource_BATCH_BIGQUERY DataSource_SourceType = 2 + DataSource_BATCH_REDSHIFT DataSource_SourceType = 5 + DataSource_STREAM_KAFKA DataSource_SourceType = 3 + DataSource_STREAM_KINESIS DataSource_SourceType = 4 + DataSource_CUSTOM_SOURCE DataSource_SourceType = 6 + DataSource_REQUEST_SOURCE DataSource_SourceType = 7 ) // Enum value maps for DataSource_SourceType. @@ -56,22 +58,24 @@ var ( DataSource_SourceType_name = map[int32]string{ 0: "INVALID", 1: "BATCH_FILE", + 8: "BATCH_SNOWFLAKE", 2: "BATCH_BIGQUERY", + 5: "BATCH_REDSHIFT", 3: "STREAM_KAFKA", 4: "STREAM_KINESIS", - 5: "BATCH_REDSHIFT", 6: "CUSTOM_SOURCE", 7: "REQUEST_SOURCE", } DataSource_SourceType_value = map[string]int32{ - "INVALID": 0, - "BATCH_FILE": 1, - "BATCH_BIGQUERY": 2, - "STREAM_KAFKA": 3, - "STREAM_KINESIS": 4, - "BATCH_REDSHIFT": 5, - "CUSTOM_SOURCE": 6, - "REQUEST_SOURCE": 7, + "INVALID": 0, + "BATCH_FILE": 1, + "BATCH_SNOWFLAKE": 8, + "BATCH_BIGQUERY": 2, + "BATCH_REDSHIFT": 5, + "STREAM_KAFKA": 3, + "STREAM_KINESIS": 4, + "CUSTOM_SOURCE": 6, + "REQUEST_SOURCE": 7, } ) @@ -132,6 +136,7 @@ type DataSource struct { // *DataSource_RedshiftOptions_ // *DataSource_RequestDataOptions_ // *DataSource_CustomOptions + // *DataSource_SnowflakeOptions_ Options isDataSource_Options `protobuf_oneof:"options"` } @@ -265,6 +270,13 @@ func (x *DataSource) GetCustomOptions() *DataSource_CustomSourceOptions { return nil } +func (x *DataSource) GetSnowflakeOptions() *DataSource_SnowflakeOptions { + if x, ok := x.GetOptions().(*DataSource_SnowflakeOptions_); ok { + return x.SnowflakeOptions + } + return nil +} + type isDataSource_Options interface { isDataSource_Options() } @@ -297,6 +309,10 @@ type DataSource_CustomOptions struct { CustomOptions *DataSource_CustomSourceOptions `protobuf:"bytes,16,opt,name=custom_options,json=customOptions,proto3,oneof"` } +type DataSource_SnowflakeOptions_ struct { + SnowflakeOptions *DataSource_SnowflakeOptions `protobuf:"bytes,19,opt,name=snowflake_options,json=snowflakeOptions,proto3,oneof"` +} + func (*DataSource_FileOptions_) isDataSource_Options() {} func (*DataSource_BigqueryOptions) isDataSource_Options() {} @@ -311,6 +327,8 @@ func (*DataSource_RequestDataOptions_) isDataSource_Options() {} func (*DataSource_CustomOptions) isDataSource_Options() {} +func (*DataSource_SnowflakeOptions_) isDataSource_Options() {} + // Defines options for DataSource that sources features from a file type DataSource_FileOptions struct { state protoimpl.MessageState @@ -646,6 +664,83 @@ func (x *DataSource_RedshiftOptions) GetSchema() string { return "" } +// Defines options for DataSource that sources features from a Snowflake Query +type DataSource_SnowflakeOptions struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Snowflake table name + Table string `protobuf:"bytes,1,opt,name=table,proto3" json:"table,omitempty"` + // SQL query that returns a table containing feature data. Must contain an event_timestamp column, and respective + // entity columns + Query string `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + // Snowflake schema name + Schema string `protobuf:"bytes,3,opt,name=schema,proto3" json:"schema,omitempty"` + // Snowflake schema name + Database string `protobuf:"bytes,4,opt,name=database,proto3" json:"database,omitempty"` +} + +func (x *DataSource_SnowflakeOptions) Reset() { + *x = DataSource_SnowflakeOptions{} + if protoimpl.UnsafeEnabled { + mi := &file_feast_core_DataSource_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DataSource_SnowflakeOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DataSource_SnowflakeOptions) ProtoMessage() {} + +func (x *DataSource_SnowflakeOptions) ProtoReflect() protoreflect.Message { + mi := &file_feast_core_DataSource_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DataSource_SnowflakeOptions.ProtoReflect.Descriptor instead. +func (*DataSource_SnowflakeOptions) Descriptor() ([]byte, []int) { + return file_feast_core_DataSource_proto_rawDescGZIP(), []int{0, 6} +} + +func (x *DataSource_SnowflakeOptions) GetTable() string { + if x != nil { + return x.Table + } + return "" +} + +func (x *DataSource_SnowflakeOptions) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +func (x *DataSource_SnowflakeOptions) GetSchema() string { + if x != nil { + return x.Schema + } + return "" +} + +func (x *DataSource_SnowflakeOptions) GetDatabase() string { + if x != nil { + return x.Database + } + return "" +} + // Defines configuration for custom third-party data sources. type DataSource_CustomSourceOptions struct { state protoimpl.MessageState @@ -660,7 +755,7 @@ type DataSource_CustomSourceOptions struct { func (x *DataSource_CustomSourceOptions) Reset() { *x = DataSource_CustomSourceOptions{} if protoimpl.UnsafeEnabled { - mi := &file_feast_core_DataSource_proto_msgTypes[7] + mi := &file_feast_core_DataSource_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -673,7 +768,7 @@ func (x *DataSource_CustomSourceOptions) String() string { func (*DataSource_CustomSourceOptions) ProtoMessage() {} func (x *DataSource_CustomSourceOptions) ProtoReflect() protoreflect.Message { - mi := &file_feast_core_DataSource_proto_msgTypes[7] + mi := &file_feast_core_DataSource_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -686,7 +781,7 @@ func (x *DataSource_CustomSourceOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use DataSource_CustomSourceOptions.ProtoReflect.Descriptor instead. func (*DataSource_CustomSourceOptions) Descriptor() ([]byte, []int) { - return file_feast_core_DataSource_proto_rawDescGZIP(), []int{0, 6} + return file_feast_core_DataSource_proto_rawDescGZIP(), []int{0, 7} } func (x *DataSource_CustomSourceOptions) GetConfiguration() []byte { @@ -711,7 +806,7 @@ type DataSource_RequestDataOptions struct { func (x *DataSource_RequestDataOptions) Reset() { *x = DataSource_RequestDataOptions{} if protoimpl.UnsafeEnabled { - mi := &file_feast_core_DataSource_proto_msgTypes[8] + mi := &file_feast_core_DataSource_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -724,7 +819,7 @@ func (x *DataSource_RequestDataOptions) String() string { func (*DataSource_RequestDataOptions) ProtoMessage() {} func (x *DataSource_RequestDataOptions) ProtoReflect() protoreflect.Message { - mi := &file_feast_core_DataSource_proto_msgTypes[8] + mi := &file_feast_core_DataSource_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -737,7 +832,7 @@ func (x *DataSource_RequestDataOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use DataSource_RequestDataOptions.ProtoReflect.Descriptor instead. func (*DataSource_RequestDataOptions) Descriptor() ([]byte, []int) { - return file_feast_core_DataSource_proto_rawDescGZIP(), []int{0, 7} + return file_feast_core_DataSource_proto_rawDescGZIP(), []int{0, 8} } func (x *DataSource_RequestDataOptions) GetName() string { @@ -763,7 +858,7 @@ var file_feast_core_DataSource_proto_rawDesc = []byte{ 0x2f, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x44, 0x61, 0x74, 0x61, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x17, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2f, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, - 0x85, 0x10, 0x0a, 0x0a, 0x44, 0x61, 0x74, 0x61, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x35, + 0xe6, 0x11, 0x0a, 0x0a, 0x44, 0x61, 0x74, 0x61, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x21, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, @@ -822,82 +917,96 @@ var file_feast_core_DataSource_proto_rawDesc = []byte{ 0x72, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x4f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4d, 0x61, 0x70, 0x70, 0x69, - 0x6e, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, - 0x02, 0x38, 0x01, 0x1a, 0x93, 0x01, 0x0a, 0x0b, 0x46, 0x69, 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x0b, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x66, 0x6f, 0x72, 0x6d, - 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, - 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, - 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12, 0x19, 0x0a, 0x08, - 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x66, 0x69, 0x6c, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x30, 0x0a, 0x14, 0x73, 0x33, 0x5f, 0x65, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x73, 0x33, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x4f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x1a, 0x44, 0x0a, 0x0f, 0x42, 0x69, 0x67, - 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1b, 0x0a, 0x09, - 0x74, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x66, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, - 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x1a, - 0x92, 0x01, 0x0a, 0x0c, 0x4b, 0x61, 0x66, 0x6b, 0x61, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x12, 0x2b, 0x0a, 0x11, 0x62, 0x6f, 0x6f, 0x74, 0x73, 0x74, 0x72, 0x61, 0x70, 0x5f, 0x73, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x6f, 0x6f, - 0x74, 0x73, 0x74, 0x72, 0x61, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x14, 0x0a, - 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, - 0x70, 0x69, 0x63, 0x12, 0x3f, 0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x66, - 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x66, 0x65, - 0x61, 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x46, - 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x0d, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x46, 0x6f, - 0x72, 0x6d, 0x61, 0x74, 0x1a, 0x88, 0x01, 0x0a, 0x0e, 0x4b, 0x69, 0x6e, 0x65, 0x73, 0x69, 0x73, - 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, - 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, - 0x12, 0x3d, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x5f, 0x66, 0x6f, 0x72, 0x6d, 0x61, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, - 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x46, 0x6f, 0x72, 0x6d, 0x61, - 0x74, 0x52, 0x0c, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x1a, - 0x55, 0x0a, 0x0f, 0x52, 0x65, 0x64, 0x73, 0x68, 0x69, 0x66, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x12, 0x56, 0x0a, 0x11, 0x73, 0x6e, 0x6f, 0x77, 0x66, 0x6c, 0x61, 0x6b, 0x65, 0x5f, + 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, + 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x53, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x6e, 0x6f, 0x77, 0x66, 0x6c, 0x61, 0x6b, 0x65, 0x4f, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x48, 0x00, 0x52, 0x10, 0x73, 0x6e, 0x6f, 0x77, 0x66, 0x6c, + 0x61, 0x6b, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x46, 0x69, + 0x65, 0x6c, 0x64, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x93, 0x01, 0x0a, 0x0b, + 0x46, 0x69, 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x0b, 0x66, + 0x69, 0x6c, 0x65, 0x5f, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x46, 0x69, + 0x6c, 0x65, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x65, 0x46, 0x6f, + 0x72, 0x6d, 0x61, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x75, 0x72, 0x6c, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x69, 0x6c, 0x65, 0x55, 0x72, 0x6c, 0x12, + 0x30, 0x0a, 0x14, 0x73, 0x33, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6f, + 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x73, + 0x33, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x4f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, + 0x65, 0x1a, 0x44, 0x0a, 0x0f, 0x42, 0x69, 0x67, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, + 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x65, + 0x66, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x1a, 0x92, 0x01, 0x0a, 0x0c, 0x4b, 0x61, 0x66, 0x6b, + 0x61, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x62, 0x6f, 0x6f, 0x74, + 0x73, 0x74, 0x72, 0x61, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x6f, 0x6f, 0x74, 0x73, 0x74, 0x72, 0x61, 0x70, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x3f, 0x0a, 0x0e, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, + 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x0d, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x1a, 0x88, 0x01, 0x0a, + 0x0e, 0x4b, 0x69, 0x6e, 0x65, 0x73, 0x69, 0x73, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, + 0x16, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x3d, 0x0a, 0x0d, 0x72, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x5f, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x18, 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x53, 0x74, 0x72, + 0x65, 0x61, 0x6d, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x0c, 0x72, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x1a, 0x55, 0x0a, 0x0f, 0x52, 0x65, 0x64, 0x73, 0x68, + 0x69, 0x66, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x61, + 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x1a, 0x72, + 0x0a, 0x10, 0x53, 0x6e, 0x6f, 0x77, 0x66, 0x6c, 0x61, 0x6b, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x1a, 0x3b, 0x0a, 0x13, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, - 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x24, 0x0a, - 0x0d, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x1a, 0xcf, 0x01, 0x0a, 0x12, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, - 0x61, 0x74, 0x61, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x4d, - 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x35, - 0x2e, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x61, - 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x61, - 0x74, 0x61, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x1a, 0x56, 0x0a, - 0x0b, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x31, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, - 0x66, 0x65, 0x61, 0x73, 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x56, 0x61, 0x6c, 0x75, - 0x65, 0x54, 0x79, 0x70, 0x65, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x9e, 0x01, 0x0a, 0x0a, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, - 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x42, 0x41, 0x54, 0x43, 0x48, 0x5f, 0x46, 0x49, 0x4c, 0x45, 0x10, - 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x42, 0x41, 0x54, 0x43, 0x48, 0x5f, 0x42, 0x49, 0x47, 0x51, 0x55, - 0x45, 0x52, 0x59, 0x10, 0x02, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, - 0x4b, 0x41, 0x46, 0x4b, 0x41, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x54, 0x52, 0x45, 0x41, - 0x4d, 0x5f, 0x4b, 0x49, 0x4e, 0x45, 0x53, 0x49, 0x53, 0x10, 0x04, 0x12, 0x12, 0x0a, 0x0e, 0x42, - 0x41, 0x54, 0x43, 0x48, 0x5f, 0x52, 0x45, 0x44, 0x53, 0x48, 0x49, 0x46, 0x54, 0x10, 0x05, 0x12, - 0x11, 0x0a, 0x0d, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x5f, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, - 0x10, 0x06, 0x12, 0x12, 0x0a, 0x0e, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x5f, 0x53, 0x4f, - 0x55, 0x52, 0x43, 0x45, 0x10, 0x07, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x4a, 0x04, 0x08, 0x06, 0x10, 0x0b, 0x42, 0x58, 0x0a, 0x10, 0x66, 0x65, 0x61, 0x73, 0x74, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x0f, 0x44, 0x61, 0x74, - 0x61, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x5a, 0x33, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2d, 0x64, - 0x65, 0x76, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x67, 0x6f, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x63, 0x6f, 0x72, - 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x61, 0x74, 0x61, 0x62, 0x61, + 0x73, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x61, 0x74, 0x61, 0x62, 0x61, + 0x73, 0x65, 0x1a, 0x3b, 0x0a, 0x13, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, + 0xcf, 0x01, 0x0a, 0x12, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x61, 0x74, 0x61, 0x4f, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x4d, 0x0a, 0x06, 0x73, 0x63, + 0x68, 0x65, 0x6d, 0x61, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x66, 0x65, 0x61, + 0x73, 0x74, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x61, 0x74, 0x61, 0x4f, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x1a, 0x56, 0x0a, 0x0b, 0x53, 0x63, 0x68, + 0x65, 0x6d, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x31, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x66, 0x65, 0x61, 0x73, + 0x74, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x54, 0x79, 0x70, + 0x65, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x22, 0xb3, 0x01, 0x0a, 0x0a, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x0b, 0x0a, 0x07, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, 0x00, 0x12, 0x0e, 0x0a, + 0x0a, 0x42, 0x41, 0x54, 0x43, 0x48, 0x5f, 0x46, 0x49, 0x4c, 0x45, 0x10, 0x01, 0x12, 0x13, 0x0a, + 0x0f, 0x42, 0x41, 0x54, 0x43, 0x48, 0x5f, 0x53, 0x4e, 0x4f, 0x57, 0x46, 0x4c, 0x41, 0x4b, 0x45, + 0x10, 0x08, 0x12, 0x12, 0x0a, 0x0e, 0x42, 0x41, 0x54, 0x43, 0x48, 0x5f, 0x42, 0x49, 0x47, 0x51, + 0x55, 0x45, 0x52, 0x59, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x42, 0x41, 0x54, 0x43, 0x48, 0x5f, + 0x52, 0x45, 0x44, 0x53, 0x48, 0x49, 0x46, 0x54, 0x10, 0x05, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x54, + 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x4b, 0x41, 0x46, 0x4b, 0x41, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, + 0x53, 0x54, 0x52, 0x45, 0x41, 0x4d, 0x5f, 0x4b, 0x49, 0x4e, 0x45, 0x53, 0x49, 0x53, 0x10, 0x04, + 0x12, 0x11, 0x0a, 0x0d, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x5f, 0x53, 0x4f, 0x55, 0x52, 0x43, + 0x45, 0x10, 0x06, 0x12, 0x12, 0x0a, 0x0e, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x5f, 0x53, + 0x4f, 0x55, 0x52, 0x43, 0x45, 0x10, 0x07, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x4a, 0x04, 0x08, 0x06, 0x10, 0x0b, 0x42, 0x58, 0x0a, 0x10, 0x66, 0x65, 0x61, 0x73, + 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x0f, 0x44, 0x61, + 0x74, 0x61, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x5a, 0x33, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2d, + 0x64, 0x65, 0x76, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x67, 0x6f, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x66, 0x65, 0x61, 0x73, 0x74, 0x2f, 0x63, 0x6f, + 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -913,7 +1022,7 @@ func file_feast_core_DataSource_proto_rawDescGZIP() []byte { } var file_feast_core_DataSource_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_feast_core_DataSource_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_feast_core_DataSource_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_feast_core_DataSource_proto_goTypes = []interface{}{ (DataSource_SourceType)(0), // 0: feast.core.DataSource.SourceType (*DataSource)(nil), // 1: feast.core.DataSource @@ -923,12 +1032,13 @@ var file_feast_core_DataSource_proto_goTypes = []interface{}{ (*DataSource_KafkaOptions)(nil), // 5: feast.core.DataSource.KafkaOptions (*DataSource_KinesisOptions)(nil), // 6: feast.core.DataSource.KinesisOptions (*DataSource_RedshiftOptions)(nil), // 7: feast.core.DataSource.RedshiftOptions - (*DataSource_CustomSourceOptions)(nil), // 8: feast.core.DataSource.CustomSourceOptions - (*DataSource_RequestDataOptions)(nil), // 9: feast.core.DataSource.RequestDataOptions - nil, // 10: feast.core.DataSource.RequestDataOptions.SchemaEntry - (*FileFormat)(nil), // 11: feast.core.FileFormat - (*StreamFormat)(nil), // 12: feast.core.StreamFormat - (types.ValueType_Enum)(0), // 13: feast.types.ValueType.Enum + (*DataSource_SnowflakeOptions)(nil), // 8: feast.core.DataSource.SnowflakeOptions + (*DataSource_CustomSourceOptions)(nil), // 9: feast.core.DataSource.CustomSourceOptions + (*DataSource_RequestDataOptions)(nil), // 10: feast.core.DataSource.RequestDataOptions + nil, // 11: feast.core.DataSource.RequestDataOptions.SchemaEntry + (*FileFormat)(nil), // 12: feast.core.FileFormat + (*StreamFormat)(nil), // 13: feast.core.StreamFormat + (types.ValueType_Enum)(0), // 14: feast.types.ValueType.Enum } var file_feast_core_DataSource_proto_depIdxs = []int32{ 0, // 0: feast.core.DataSource.type:type_name -> feast.core.DataSource.SourceType @@ -938,18 +1048,19 @@ var file_feast_core_DataSource_proto_depIdxs = []int32{ 5, // 4: feast.core.DataSource.kafka_options:type_name -> feast.core.DataSource.KafkaOptions 6, // 5: feast.core.DataSource.kinesis_options:type_name -> feast.core.DataSource.KinesisOptions 7, // 6: feast.core.DataSource.redshift_options:type_name -> feast.core.DataSource.RedshiftOptions - 9, // 7: feast.core.DataSource.request_data_options:type_name -> feast.core.DataSource.RequestDataOptions - 8, // 8: feast.core.DataSource.custom_options:type_name -> feast.core.DataSource.CustomSourceOptions - 11, // 9: feast.core.DataSource.FileOptions.file_format:type_name -> feast.core.FileFormat - 12, // 10: feast.core.DataSource.KafkaOptions.message_format:type_name -> feast.core.StreamFormat - 12, // 11: feast.core.DataSource.KinesisOptions.record_format:type_name -> feast.core.StreamFormat - 10, // 12: feast.core.DataSource.RequestDataOptions.schema:type_name -> feast.core.DataSource.RequestDataOptions.SchemaEntry - 13, // 13: feast.core.DataSource.RequestDataOptions.SchemaEntry.value:type_name -> feast.types.ValueType.Enum - 14, // [14:14] is the sub-list for method output_type - 14, // [14:14] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 10, // 7: feast.core.DataSource.request_data_options:type_name -> feast.core.DataSource.RequestDataOptions + 9, // 8: feast.core.DataSource.custom_options:type_name -> feast.core.DataSource.CustomSourceOptions + 8, // 9: feast.core.DataSource.snowflake_options:type_name -> feast.core.DataSource.SnowflakeOptions + 12, // 10: feast.core.DataSource.FileOptions.file_format:type_name -> feast.core.FileFormat + 13, // 11: feast.core.DataSource.KafkaOptions.message_format:type_name -> feast.core.StreamFormat + 13, // 12: feast.core.DataSource.KinesisOptions.record_format:type_name -> feast.core.StreamFormat + 11, // 13: feast.core.DataSource.RequestDataOptions.schema:type_name -> feast.core.DataSource.RequestDataOptions.SchemaEntry + 14, // 14: feast.core.DataSource.RequestDataOptions.SchemaEntry.value:type_name -> feast.types.ValueType.Enum + 15, // [15:15] is the sub-list for method output_type + 15, // [15:15] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_feast_core_DataSource_proto_init() } @@ -1032,7 +1143,7 @@ func file_feast_core_DataSource_proto_init() { } } file_feast_core_DataSource_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DataSource_CustomSourceOptions); i { + switch v := v.(*DataSource_SnowflakeOptions); i { case 0: return &v.state case 1: @@ -1044,6 +1155,18 @@ func file_feast_core_DataSource_proto_init() { } } file_feast_core_DataSource_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DataSource_CustomSourceOptions); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_feast_core_DataSource_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*DataSource_RequestDataOptions); i { case 0: return &v.state @@ -1064,6 +1187,7 @@ func file_feast_core_DataSource_proto_init() { (*DataSource_RedshiftOptions_)(nil), (*DataSource_RequestDataOptions_)(nil), (*DataSource_CustomOptions)(nil), + (*DataSource_SnowflakeOptions_)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -1071,7 +1195,7 @@ func file_feast_core_DataSource_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_feast_core_DataSource_proto_rawDesc, NumEnums: 1, - NumMessages: 10, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/sdk/go/protos/feast/core/Entity.pb.go b/sdk/go/protos/feast/core/Entity.pb.go index 87f5b45164e..245f724e0a2 100644 --- a/sdk/go/protos/feast/core/Entity.pb.go +++ b/sdk/go/protos/feast/core/Entity.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/core/Entity.proto package core diff --git a/sdk/go/protos/feast/core/Feature.pb.go b/sdk/go/protos/feast/core/Feature.pb.go index 50515a822b7..a30fafb9d37 100644 --- a/sdk/go/protos/feast/core/Feature.pb.go +++ b/sdk/go/protos/feast/core/Feature.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/core/Feature.proto package core diff --git a/sdk/go/protos/feast/core/FeatureTable.pb.go b/sdk/go/protos/feast/core/FeatureTable.pb.go index 0fc3feb0cab..144d46d8e2b 100644 --- a/sdk/go/protos/feast/core/FeatureTable.pb.go +++ b/sdk/go/protos/feast/core/FeatureTable.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/core/FeatureTable.proto package core diff --git a/sdk/go/protos/feast/core/Store.pb.go b/sdk/go/protos/feast/core/Store.pb.go index 26e5a5918f5..c56a4ede6dd 100644 --- a/sdk/go/protos/feast/core/Store.pb.go +++ b/sdk/go/protos/feast/core/Store.pb.go @@ -17,7 +17,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/core/Store.proto package core diff --git a/sdk/go/protos/feast/serving/ServingService.pb.go b/sdk/go/protos/feast/serving/ServingService.pb.go index b367a307f47..3527c6688ea 100644 --- a/sdk/go/protos/feast/serving/ServingService.pb.go +++ b/sdk/go/protos/feast/serving/ServingService.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/serving/ServingService.proto package serving diff --git a/sdk/go/protos/feast/storage/Redis.pb.go b/sdk/go/protos/feast/storage/Redis.pb.go index 8fff34e5171..35f38ba2a7e 100644 --- a/sdk/go/protos/feast/storage/Redis.pb.go +++ b/sdk/go/protos/feast/storage/Redis.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/storage/Redis.proto package storage diff --git a/sdk/go/protos/feast/types/Field.pb.go b/sdk/go/protos/feast/types/Field.pb.go index c529d76153b..af964f2c6ea 100644 --- a/sdk/go/protos/feast/types/Field.pb.go +++ b/sdk/go/protos/feast/types/Field.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/types/Field.proto package types diff --git a/sdk/go/protos/feast/types/Value.pb.go b/sdk/go/protos/feast/types/Value.pb.go index fe53c2ec298..79eaa160096 100644 --- a/sdk/go/protos/feast/types/Value.pb.go +++ b/sdk/go/protos/feast/types/Value.pb.go @@ -16,7 +16,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc v3.19.4 // source: feast/types/Value.proto package types From 2080fa32cf5901eb47833cfd9a1a400efc40f94a Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Tue, 1 Feb 2022 18:37:10 +0000 Subject: [PATCH 50/85] Refactor `pa_to_feast_value_type` (#2246) * Refactor `pa_to_feast_value_type` This refactoring is intented to make it more difficult to forget to add conversion for LIST versions of non-LIST types. Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Tidy up `assert_expected_arrow_types` Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/type_map.py | 44 ++++++++++--------- .../registration/test_universal_types.py | 30 ++++++------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index e39a4ecb816..cfb4a69d5a1 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import re from datetime import datetime, timezone from typing import ( Any, @@ -416,27 +415,30 @@ def _proto_value_to_value_type(proto_value: ProtoValue) -> ValueType: def pa_to_feast_value_type(pa_type_as_str: str) -> ValueType: - if re.match(r"^timestamp", pa_type_as_str): - return ValueType.INT64 + is_list = False + if pa_type_as_str.startswith("list", "") - type_map = { - "int32": ValueType.INT32, - "int64": ValueType.INT64, - "double": ValueType.DOUBLE, - "float": ValueType.FLOAT, - "string": ValueType.STRING, - "binary": ValueType.BYTES, - "bool": ValueType.BOOL, - "list": ValueType.INT32_LIST, - "list": ValueType.INT64_LIST, - "list": ValueType.DOUBLE_LIST, - "list": ValueType.FLOAT_LIST, - "list": ValueType.STRING_LIST, - "list": ValueType.BYTES_LIST, - "list": ValueType.BOOL_LIST, - "null": ValueType.NULL, - } - return type_map[pa_type_as_str] + if pa_type_as_str.startswith("timestamp"): + value_type = ValueType.UNIX_TIMESTAMP + else: + type_map = { + "int32": ValueType.INT32, + "int64": ValueType.INT64, + "double": ValueType.DOUBLE, + "float": ValueType.FLOAT, + "string": ValueType.STRING, + "binary": ValueType.BYTES, + "bool": ValueType.BOOL, + "null": ValueType.NULL, + } + value_type = type_map[pa_type_as_str] + + if is_list: + value_type = ValueType[value_type.name + "_LIST"] + + return value_type def bq_to_feast_value_type(bq_type_as_str: str) -> ValueType: diff --git a/sdk/python/tests/integration/registration/test_universal_types.py b/sdk/python/tests/integration/registration/test_universal_types.py index 5c782306e64..663ba55ccb3 100644 --- a/sdk/python/tests/integration/registration/test_universal_types.py +++ b/sdk/python/tests/integration/registration/test_universal_types.py @@ -1,11 +1,11 @@ import logging -import re from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Dict, List, Tuple, Union import numpy as np import pandas as pd +import pyarrow as pa import pytest from feast.infra.offline_stores.offline_store import RetrievalJob @@ -339,23 +339,21 @@ def assert_expected_arrow_types( historical_features_arrow = historical_features.to_arrow() print(historical_features_arrow) feature_list_dtype_to_expected_historical_feature_arrow_type = { - "int32": r"int64", - "int64": r"int64", - "float": r"double", - "string": r"string", - "bool": r"bool", - "datetime": r"timestamp\[.+\]", + "int32": pa.types.is_int64, + "int64": pa.types.is_int64, + "float": pa.types.is_float64, + "string": pa.types.is_string, + "bool": pa.types.is_boolean, + "date": pa.types.is_date, + "datetime": pa.types.is_timestamp, } - arrow_type = feature_list_dtype_to_expected_historical_feature_arrow_type[ + arrow_type_checker = feature_list_dtype_to_expected_historical_feature_arrow_type[ feature_dtype ] + pa_type = historical_features_arrow.schema.field("value").type + if feature_is_list: - assert re.match( - f"list", - str(historical_features_arrow.schema.field_by_name("value").type), - ) + assert pa.types.is_list(pa_type) + assert arrow_type_checker(pa_type.value_type) else: - assert re.match( - arrow_type, - str(historical_features_arrow.schema.field_by_name("value").type), - ) + assert arrow_type_checker(pa_type) From 7c531774d0991da27fb3d3a692bd9186051ee90d Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Tue, 1 Feb 2022 20:11:11 +0000 Subject: [PATCH 51/85] Fix inference of BigQuery ARRAY types. (#2245) * Support more BigQuery ARRAY types Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Correctly infer BigQuery ARRAY types Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- .../infra/offline_stores/bigquery_source.py | 20 ++++++++++--------- sdk/python/feast/type_map.py | 16 +++++++++------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/bigquery_source.py b/sdk/python/feast/infra/offline_stores/bigquery_source.py index 30385e5b290..f97f687b0f6 100644 --- a/sdk/python/feast/infra/offline_stores/bigquery_source.py +++ b/sdk/python/feast/infra/offline_stores/bigquery_source.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Iterable, Optional, Tuple +from typing import Callable, Dict, Iterable, List, Optional, Tuple from feast import type_map from feast.data_source import DataSource @@ -123,18 +123,20 @@ def get_table_column_names_and_types( client = bigquery.Client() if self.table_ref is not None: - table_schema = client.get_table(self.table_ref).schema - if not isinstance(table_schema[0], bigquery.schema.SchemaField): + schema = client.get_table(self.table_ref).schema + if not isinstance(schema[0], bigquery.schema.SchemaField): raise TypeError("Could not parse BigQuery table schema.") - - name_type_pairs = [(field.name, field.field_type) for field in table_schema] else: bq_columns_query = f"SELECT * FROM ({self.query}) LIMIT 1" queryRes = client.query(bq_columns_query).result() - name_type_pairs = [ - (schema_field.name, schema_field.field_type) - for schema_field in queryRes.schema - ] + schema = queryRes.schema + + name_type_pairs: List[Tuple[str, str]] = [] + for field in schema: + bq_type_as_str = field.field_type + if field.mode == "REPEATED": + bq_type_as_str = "ARRAY<" + bq_type_as_str + ">" + name_type_pairs.append((field.name, bq_type_as_str)) return name_type_pairs diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index cfb4a69d5a1..15ddd147391 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -442,6 +442,11 @@ def pa_to_feast_value_type(pa_type_as_str: str) -> ValueType: def bq_to_feast_value_type(bq_type_as_str: str) -> ValueType: + is_list = False + if bq_type_as_str.startswith("ARRAY<"): + is_list = True + bq_type_as_str = bq_type_as_str[6:-1] + type_map: Dict[str, ValueType] = { "DATETIME": ValueType.UNIX_TIMESTAMP, "TIMESTAMP": ValueType.UNIX_TIMESTAMP, @@ -453,15 +458,14 @@ def bq_to_feast_value_type(bq_type_as_str: str) -> ValueType: "BYTES": ValueType.BYTES, "BOOL": ValueType.BOOL, "BOOLEAN": ValueType.BOOL, # legacy sql data type - "ARRAY": ValueType.INT64_LIST, - "ARRAY": ValueType.DOUBLE_LIST, - "ARRAY": ValueType.STRING_LIST, - "ARRAY": ValueType.BYTES_LIST, - "ARRAY": ValueType.BOOL_LIST, "NULL": ValueType.NULL, } - return type_map[bq_type_as_str] + value_type = type_map[bq_type_as_str] + if is_list: + value_type = ValueType[value_type.name + "_LIST"] + + return value_type def redshift_to_feast_value_type(redshift_type_as_str: str) -> ValueType: From e2bbbfde999ae7a6f5ba1fb9f544229e9a3d9d75 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Wed, 2 Feb 2022 11:03:43 +0200 Subject: [PATCH 52/85] fix redis key serialization (#2264) Signed-off-by: pyalex --- .../feast/serving/it/ServingBaseTests.java | 23 +++++ .../feast/serving/it/ServingBenchmarkIT.java | 3 +- .../docker-compose/feast10/definitions.py | 2 +- .../docker-compose/feast10/materialize.py | 12 ++- .../retriever/EntityKeySerializerV2.java | 89 +++++++++---------- 5 files changed, 77 insertions(+), 52 deletions(-) diff --git a/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java b/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java index cd732ce1dd9..c610d7df6b1 100644 --- a/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java +++ b/java/serving/src/test/java/feast/serving/it/ServingBaseTests.java @@ -157,5 +157,28 @@ public void shouldRefreshRegistryAndServeNewFeatures() throws InterruptedExcepti equalTo(3)); } + /** https://github.com/feast-dev/feast/issues/2253 */ + @Test + public void shouldGetOnlineFeaturesWithStringEntity() { + Map entityRows = + ImmutableMap.of( + "entity", + ValueProto.RepeatedValue.newBuilder() + .addVal(DataGenerator.createStrValue("key-1")) + .build()); + + ImmutableList featureReferences = + ImmutableList.of("feature_view_0:feature_0", "feature_view_0:feature_1"); + + ServingAPIProto.GetOnlineFeaturesRequest req = + TestUtils.createOnlineFeatureRequest(featureReferences, entityRows); + + ServingAPIProto.GetOnlineFeaturesResponse resp = servingStub.getOnlineFeatures(req); + + for (final int featureIdx : List.of(0, 1)) { + assertEquals(FieldStatus.PRESENT, resp.getResults(featureIdx).getStatuses(0)); + } + } + abstract void updateRegistryFile(RegistryProto.Registry registry); } diff --git a/java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java b/java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java index 43eed2fa33d..1d77c2e4f7c 100644 --- a/java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java +++ b/java/serving/src/test/java/feast/serving/it/ServingBenchmarkIT.java @@ -51,7 +51,8 @@ protected ServingAPIProto.GetOnlineFeaturesRequest buildOnlineRequest( int rowsCount, int featuresCount) { List entities = IntStream.range(0, rowsCount) - .mapToObj(i -> DataGenerator.createInt64Value(rand.nextInt(1000))) + .mapToObj( + i -> DataGenerator.createStrValue(String.format("key-%s", rand.nextInt(1000)))) .collect(Collectors.toList()); List featureReferences = diff --git a/java/serving/src/test/resources/docker-compose/feast10/definitions.py b/java/serving/src/test/resources/docker-compose/feast10/definitions.py index a598fe93919..c7ed6c96193 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/definitions.py +++ b/java/serving/src/test/resources/docker-compose/feast10/definitions.py @@ -73,7 +73,7 @@ def transformed_conv_rate(features_df: pd.DataFrame) -> pd.DataFrame: entity = Entity( name="entity", - value_type=ValueType.INT64, + value_type=ValueType.STRING, ) benchmark_feature_views = [ diff --git a/java/serving/src/test/resources/docker-compose/feast10/materialize.py b/java/serving/src/test/resources/docker-compose/feast10/materialize.py index 9aba494169f..8389d8527bf 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/materialize.py +++ b/java/serving/src/test/resources/docker-compose/feast10/materialize.py @@ -28,22 +28,26 @@ # for more info. df.to_parquet("driver_stats.parquet") + # For Benchmarks # Please read more in Feast RFC-031 # (link https://docs.google.com/document/d/12UuvTQnTTCJhdRgy6h10zSbInNGSyEJkIxpOcgOen1I/edit) # about this benchmark setup -def generate_data(num_rows: int, num_features: int, key_space: int, destination: str) -> pd.DataFrame: +def generate_data(num_rows: int, num_features: int, destination: str) -> pd.DataFrame: features = [f"feature_{i}" for i in range(num_features)] columns = ["entity", "event_timestamp"] + features df = pd.DataFrame(0, index=np.arange(num_rows), columns=columns) df["event_timestamp"] = datetime.utcnow() - for column in ["entity"] + features: - df[column] = np.random.randint(1, key_space, num_rows) + for column in features: + df[column] = np.random.randint(1, num_rows, num_rows) + + df["entity"] = "key-" + \ + pd.Series(np.arange(1, num_rows + 1)).astype(pd.StringDtype()) df.to_parquet(destination) -generate_data(10**3, 250, 10**3, "benchmark_data.parquet") +generate_data(10**3, 250, "benchmark_data.parquet") fs = FeatureStore(".") diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializerV2.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializerV2.java index 922a09d3f55..3e9ab7e8ab5 100644 --- a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializerV2.java +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializerV2.java @@ -16,16 +16,14 @@ */ package feast.storage.connectors.redis.retriever; -import com.google.common.primitives.UnsignedBytes; import com.google.protobuf.ProtocolStringList; import feast.proto.storage.RedisProto; import feast.proto.types.ValueProto; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; +import java.util.*; +import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.tuple.Pair; // This is derived from @@ -48,70 +46,52 @@ public byte[] serialize(RedisProto.RedisKeyV2 entityKey) { } tuples.sort(Comparator.comparing(Pair::getLeft)); - ByteBuffer stringBytes = ByteBuffer.allocate(Integer.BYTES); - stringBytes.order(ByteOrder.LITTLE_ENDIAN); - stringBytes.putInt(ValueProto.ValueType.Enum.STRING.getNumber()); - for (Pair pair : tuples) { - for (final byte b : stringBytes.array()) { - buffer.add(b); - } - for (final byte b : pair.getLeft().getBytes(StandardCharsets.UTF_8)) { - buffer.add(b); - } + buffer.addAll(encodeInteger(ValueProto.ValueType.Enum.STRING.getNumber())); + buffer.addAll(encodeString(pair.getLeft())); } for (Pair pair : tuples) { final ValueProto.Value val = pair.getRight(); switch (val.getValCase()) { case STRING_VAL: - buffer.add(UnsignedBytes.checkedCast(ValueProto.ValueType.Enum.STRING.getNumber())); - buffer.add( - UnsignedBytes.checkedCast( - val.getStringVal().getBytes(StandardCharsets.UTF_8).length)); - for (final byte b : val.getStringVal().getBytes(StandardCharsets.UTF_8)) { - buffer.add(b); - } + String stringVal = val.getStringVal(); + + buffer.addAll(encodeInteger(ValueProto.ValueType.Enum.STRING.getNumber())); + buffer.addAll(encodeInteger(stringVal.length())); + buffer.addAll(encodeString(stringVal)); + break; case BYTES_VAL: - buffer.add(UnsignedBytes.checkedCast(ValueProto.ValueType.Enum.BYTES.getNumber())); - for (final byte b : val.getBytesVal().toByteArray()) { - buffer.add(b); - } + byte[] bytes = val.getBytesVal().toByteArray(); + + buffer.addAll(encodeInteger(ValueProto.ValueType.Enum.BYTES.getNumber())); + buffer.addAll(encodeInteger(bytes.length)); + buffer.addAll(encodeBytes(bytes)); + break; case INT32_VAL: - ByteBuffer int32ByteBuffer = - ByteBuffer.allocate(Integer.BYTES + Integer.BYTES + Integer.BYTES); - int32ByteBuffer.order(ByteOrder.LITTLE_ENDIAN); - int32ByteBuffer.putInt(ValueProto.ValueType.Enum.INT32.getNumber()); - int32ByteBuffer.putInt(Integer.BYTES); - int32ByteBuffer.putInt(val.getInt32Val()); - for (final byte b : int32ByteBuffer.array()) { - buffer.add(b); - } + buffer.addAll(encodeInteger(ValueProto.ValueType.Enum.INT32.getNumber())); + buffer.addAll(encodeInteger(Integer.BYTES)); + buffer.addAll(encodeInteger(val.getInt32Val())); + break; case INT64_VAL: - ByteBuffer int64ByteBuffer = - ByteBuffer.allocate(Integer.BYTES + Integer.BYTES + Integer.BYTES); - int64ByteBuffer.order(ByteOrder.LITTLE_ENDIAN); - int64ByteBuffer.putInt(ValueProto.ValueType.Enum.INT64.getNumber()); - int64ByteBuffer.putInt(Integer.BYTES); + buffer.addAll(encodeInteger(ValueProto.ValueType.Enum.INT64.getNumber())); + buffer.addAll(encodeInteger(Integer.BYTES)); /* This is super dumb - but in https://github.com/feast-dev/feast/blob/dcae1606f53028ce5413567fb8b66f92cfef0f8e/sdk/python/feast/infra/key_encoding_utils.py#L9 we use `struct.pack(" encodeBytes(byte[] toByteArray) { + return Arrays.asList(ArrayUtils.toObject(toByteArray)); + } + + private List encodeInteger(Integer value) { + ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(value); + + return Arrays.asList(ArrayUtils.toObject(buffer.array())); + } + + private List encodeString(String value) { + byte[] stringBytes = value.getBytes(StandardCharsets.UTF_8); + return encodeBytes(stringBytes); + } } From 6d7f6e5a4af07912c046490fd63f91ca69d5527f Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Wed, 2 Feb 2022 17:01:43 +0200 Subject: [PATCH 53/85] Validating historical features against reference dataset with "great expectations" profiler (#2243) * Validating historical features against reference dataset Signed-off-by: pyalex * fixes after rebase Signed-off-by: pyalex * fixes after rebase Signed-off-by: pyalex * update ci requirements Signed-off-by: pyalex * some reverts Signed-off-by: pyalex * format Signed-off-by: pyalex * move ValidationReference proto message Signed-off-by: pyalex * grammar in docs Signed-off-by: pyalex * Update docs/reference/dqm.md Co-authored-by: Danny Chiao Signed-off-by: pyalex * rebase & resolve conflicts Signed-off-by: pyalex * remove RetrievalJobWithValidation Signed-off-by: pyalex * Update docs/reference/dqm.md Co-authored-by: Danny Chiao Signed-off-by: pyalex * fix type hint Signed-off-by: pyalex * fix function flow Signed-off-by: pyalex * more docstrings Signed-off-by: pyalex * update docs Signed-off-by: pyalex * improve docs Signed-off-by: pyalex Co-authored-by: Danny Chiao --- docs/reference/dqm.md | 77 +++++ protos/feast/core/ValidationProfile.proto | 48 +++ sdk/python/feast/dqm/__init__.py | 0 sdk/python/feast/dqm/errors.py | 13 + sdk/python/feast/dqm/profilers/__init__.py | 0 sdk/python/feast/dqm/profilers/ge_profiler.py | 162 ++++++++++ sdk/python/feast/dqm/profilers/profiler.py | 88 ++++++ sdk/python/feast/infra/offline_stores/file.py | 4 +- .../infra/offline_stores/offline_store.py | 78 ++++- sdk/python/feast/saved_dataset.py | 24 ++ .../requirements/py3.7-ci-requirements.txt | 252 +++++++++++++-- .../requirements/py3.8-ci-requirements.txt | 287 +++++++++++++++-- .../requirements/py3.8-requirements.txt | 2 +- .../requirements/py3.9-ci-requirements.txt | 289 ++++++++++++++++-- .../requirements/py3.9-requirements.txt | 2 +- sdk/python/setup.py | 18 +- sdk/python/tests/__init__.py | 0 sdk/python/tests/data/__init__.py | 0 .../tests/integration/e2e/test_validation.py | 134 ++++++++ 19 files changed, 1358 insertions(+), 120 deletions(-) create mode 100644 docs/reference/dqm.md create mode 100644 protos/feast/core/ValidationProfile.proto create mode 100644 sdk/python/feast/dqm/__init__.py create mode 100644 sdk/python/feast/dqm/errors.py create mode 100644 sdk/python/feast/dqm/profilers/__init__.py create mode 100644 sdk/python/feast/dqm/profilers/ge_profiler.py create mode 100644 sdk/python/feast/dqm/profilers/profiler.py create mode 100644 sdk/python/tests/__init__.py create mode 100644 sdk/python/tests/data/__init__.py create mode 100644 sdk/python/tests/integration/e2e/test_validation.py diff --git a/docs/reference/dqm.md b/docs/reference/dqm.md new file mode 100644 index 00000000000..5a02413e534 --- /dev/null +++ b/docs/reference/dqm.md @@ -0,0 +1,77 @@ +# Data Quality Monitoring + +Data Quality Monitoring (DQM) is a Feast module aimed to help users to validate their data with the user-curated set of rules. +Validation could be applied during: +* Historical retrieval (training dataset generation) +* [planned] Writing features into an online store +* [planned] Reading features from an online store + +Its goal is to address several complex data problems, namely: +* Data consistency - new training datasets can be significantly different from previous datasets. This might require a change in model architecture. +* Issues/bugs in the upstream pipeline - bugs in upstream pipelines can cause invalid values to overwrite existing valid values in an online store. +* Training/serving skew - distribution shift could significantly decrease the performance of the model. + +> To monitor data quality, we check that the characteristics of the tested dataset (aka the tested dataset's profile) are "equivalent" to the characteristics of the reference dataset. +> How exactly profile equivalency should be measured is up to the user. + +### Overview + +The validation process consists of the following steps: +1. User prepares reference dataset (currently only [saved datasets](../getting-started/concepts/dataset.md) from historical retrieval are supported). +2. User defines profiler function, which should produce profile by given dataset (currently only profilers based on [Great Expectations](https://docs.greatexpectations.io) are allowed). +3. Validation of tested dataset is performed with reference dataset and profiler provided as parameters. + +### Preparations +Feast with Great Expectations support can be installed via +```shell +pip install 'feast[ge]' +``` + +### Dataset profile +Currently, Feast supports only [Great Expectation's](https://greatexpectations.io/) [ExpectationSuite](https://legacy.docs.greatexpectations.io/en/latest/autoapi/great_expectations/core/expectation_suite/index.html#great_expectations.core.expectation_suite.ExpectationSuite) +as dataset's profile. Hence, the user needs to define a function (profiler) that would receive a dataset and return an [ExpectationSuite](https://legacy.docs.greatexpectations.io/en/latest/autoapi/great_expectations/core/expectation_suite/index.html#great_expectations.core.expectation_suite.ExpectationSuite). + +Great Expectations supports automatic profiling as well as manually specifying expectations: +```python +from great_expectations.dataset import Dataset +from great_expectations.core.expectation_suite import ExpectationSuite + +from feast.dqm.profilers.ge_profiler import ge_profiler + +@ge_profiler +def automatic_profiler(dataset: Dataset) -> ExpectationSuite: + from great_expectations.profile.user_configurable_profiler import UserConfigurableProfiler + + return UserConfigurableProfiler( + profile_dataset=dataset, + ignored_columns=['conv_rate'], + value_set_threshold='few' + ).build_suite() +``` +However, from our experience capabilities of automatic profiler are quite limited. So we would recommend crafting your own expectations: +```python +@ge_profiler +def manual_profiler(dataset: Dataset) -> ExpectationSuite: + dataset.expect_column_max_to_be_between("column", 1, 2) + return dataset.get_expectation_suite() +``` + + + +### Validating Training Dataset +During retrieval of historical features, `validation_reference` can be passed as a parameter to methods `.to_df(validation_reference=...)` or `.to_arrow(validation_reference=...)` of RetrievalJob. +If parameter is provided Feast will run validation once dataset is materialized. In case if validation successful materialized dataset is returned. +Otherwise, `feast.dqm.errors.ValidationFailed` exception would be raised. It will consist of all details for expectations that didn't pass. + +```python +from feast import FeatureStore + +fs = FeatureStore(".") + +job = fs.get_historical_features(...) +job.to_df( + validation_reference=fs + .get_saved_dataset("my_reference_dataset") + .as_reference(profiler=manual_profiler) +) +``` diff --git a/protos/feast/core/ValidationProfile.proto b/protos/feast/core/ValidationProfile.proto new file mode 100644 index 00000000000..31c4e150a07 --- /dev/null +++ b/protos/feast/core/ValidationProfile.proto @@ -0,0 +1,48 @@ +// +// Copyright 2021 The Feast Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +syntax = "proto3"; + +package feast.core; +option java_package = "feast.proto.core"; +option java_outer_classname = "ValidationProfile"; +option go_package = "github.com/feast-dev/feast/sdk/go/protos/feast/core"; + +import "google/protobuf/timestamp.proto"; +import "feast/core/SavedDataset.proto"; + +message GEValidationProfiler { + message UserDefinedProfiler { + // The python-syntax function body (serialized by dill) + bytes body = 1; + } + + UserDefinedProfiler profiler = 1; +} + +message GEValidationProfile { + // JSON-serialized ExpectationSuite object + bytes expectation_suite = 1; +} + +message ValidationReference { + SavedDataset dataset = 1; + + oneof profiler { + GEValidationProfiler ge_profiler = 2; + } +} diff --git a/sdk/python/feast/dqm/__init__.py b/sdk/python/feast/dqm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/dqm/errors.py b/sdk/python/feast/dqm/errors.py new file mode 100644 index 00000000000..c4179f72b3c --- /dev/null +++ b/sdk/python/feast/dqm/errors.py @@ -0,0 +1,13 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .profilers.profiler import ValidationReport + + +class ValidationFailed(Exception): + def __init__(self, validation_report: "ValidationReport"): + self.validation_report = validation_report + + @property + def report(self) -> "ValidationReport": + return self.validation_report diff --git a/sdk/python/feast/dqm/profilers/__init__.py b/sdk/python/feast/dqm/profilers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/dqm/profilers/ge_profiler.py b/sdk/python/feast/dqm/profilers/ge_profiler.py new file mode 100644 index 00000000000..f1780754de3 --- /dev/null +++ b/sdk/python/feast/dqm/profilers/ge_profiler.py @@ -0,0 +1,162 @@ +import json +from typing import Any, Callable, Dict, List + +import dill +import great_expectations as ge +import numpy as np +import pandas as pd +from great_expectations.core import ExpectationSuite +from great_expectations.dataset import PandasDataset +from great_expectations.profile.base import ProfilerTypeMapping + +from feast.dqm.profilers.profiler import ( + Profile, + Profiler, + ValidationError, + ValidationReport, +) +from feast.protos.feast.core.ValidationProfile_pb2 import ( + GEValidationProfile as GEValidationProfileProto, +) +from feast.protos.feast.core.ValidationProfile_pb2 import ( + GEValidationProfiler as GEValidationProfilerProto, +) + + +def _prepare_dataset(dataset: PandasDataset) -> PandasDataset: + dataset_copy = dataset.copy(deep=True) + + for column in dataset.columns: + if dataset.expect_column_values_to_be_in_type_list( + column, type_list=sorted(list(ProfilerTypeMapping.DATETIME_TYPE_NAMES)) + ).success: + # GE cannot parse Timestamp or other pandas datetime time + dataset_copy[column] = dataset[column].dt.strftime("%Y-%m-%dT%H:%M:%S") + + if dataset[column].dtype == np.float32: + # GE converts expectation arguments into native Python float + # This could cause error on comparison => so better to convert to double prematurely + dataset_copy[column] = dataset[column].astype(np.float64) + + return dataset_copy + + +class GEProfile(Profile): + """ + GEProfile is an implementation of abstract Profile for integration with Great Expectations. + It executes validation by applying expectations from ExpectationSuite instance to a given dataset. + """ + + expectation_suite: ExpectationSuite + + def __init__(self, expectation_suite: ExpectationSuite): + self.expectation_suite = expectation_suite + + def validate(self, df: pd.DataFrame) -> "GEValidationReport": + """ + Validate provided dataframe against GE expectation suite. + 1. Pandas dataframe is converted into PandasDataset (GE type) + 2. Some fixes applied to the data to avoid crashes inside GE (see _prepare_dataset) + 3. Each expectation from ExpectationSuite instance tested against resulting dataset + + Return GEValidationReport, which parses great expectation's schema into list of generic ValidationErrors. + """ + dataset = PandasDataset(df) + + dataset = _prepare_dataset(dataset) + + results = ge.validate( + dataset, expectation_suite=self.expectation_suite, result_format="COMPLETE" + ) + return GEValidationReport(results) + + def to_proto(self): + return GEValidationProfileProto( + expectation_suite=json.dumps(self.expectation_suite.to_json_dict()).encode() + ) + + @classmethod + def from_proto(cls, proto: GEValidationProfileProto) -> "GEProfile": + return GEProfile( + expectation_suite=ExpectationSuite(**json.loads(proto.expectation_suite)) + ) + + def __repr__(self): + expectations = json.dumps( + [e.to_json_dict() for e in self.expectation_suite.expectations], indent=2 + ) + return f"" + + +class GEProfiler(Profiler): + """ + GEProfiler is an implementation of abstract Profiler for integration with Great Expectations. + It wraps around user defined profiler that should accept dataset (in a form of pandas dataframe) + and return ExpectationSuite. + """ + + def __init__( + self, user_defined_profiler: Callable[[pd.DataFrame], ExpectationSuite] + ): + self.user_defined_profiler = user_defined_profiler + + def analyze_dataset(self, df: pd.DataFrame) -> Profile: + """ + Generate GEProfile with ExpectationSuite (set of expectations) + from a given pandas dataframe by applying user defined profiler. + + Some fixes are also applied to the dataset (see _prepare_dataset function) to make it compatible with GE. + + Return GEProfile + """ + dataset = PandasDataset(df) + + dataset = _prepare_dataset(dataset) + + return GEProfile(expectation_suite=self.user_defined_profiler(dataset)) + + def to_proto(self): + return GEValidationProfilerProto( + profiler=GEValidationProfilerProto.UserDefinedProfiler( + body=dill.dumps(self.user_defined_profiler, recurse=True) + ) + ) + + @classmethod + def from_proto(cls, proto: GEValidationProfilerProto) -> "GEProfiler": + return GEProfiler(user_defined_profiler=dill.loads(proto.profiler.body)) + + +class GEValidationReport(ValidationReport): + def __init__(self, validation_result: Dict[Any, Any]): + self._validation_result = validation_result + + @property + def is_success(self) -> bool: + return self._validation_result["success"] + + @property + def errors(self) -> List["ValidationError"]: + return [ + ValidationError( + check_name=res.expectation_config.expectation_type, + column_name=res.expectation_config.kwargs["column"], + check_config=res.expectation_config.kwargs, + missing_count=res["result"].get("missing_count"), + missing_percent=res["result"].get("missing_percent"), + ) + for res in self._validation_result["results"] + if not res["success"] + ] + + def __repr__(self): + failed_expectations = [ + res.to_json_dict() + for res in self._validation_result["results"] + if not res["success"] + ] + return json.dumps(failed_expectations, indent=2) + + +def ge_profiler(func): + return GEProfiler(user_defined_profiler=func) diff --git a/sdk/python/feast/dqm/profilers/profiler.py b/sdk/python/feast/dqm/profilers/profiler.py new file mode 100644 index 00000000000..5d2e9d36bc1 --- /dev/null +++ b/sdk/python/feast/dqm/profilers/profiler.py @@ -0,0 +1,88 @@ +import abc +from typing import Any, List, Optional + +import pandas as pd + + +class Profile: + @abc.abstractmethod + def validate(self, dataset: pd.DataFrame) -> "ValidationReport": + """ + Run set of rules / expectations from current profile against given dataset. + + Return ValidationReport + """ + ... + + @abc.abstractmethod + def to_proto(self): + ... + + @classmethod + @abc.abstractmethod + def from_proto(cls, proto) -> "Profile": + ... + + +class Profiler: + @abc.abstractmethod + def analyze_dataset(self, dataset: pd.DataFrame) -> Profile: + """ + Generate Profile object with dataset's characteristics (with rules / expectations) + from given dataset (as pandas dataframe). + """ + ... + + @abc.abstractmethod + def to_proto(self): + ... + + @classmethod + @abc.abstractmethod + def from_proto(cls, proto) -> "Profiler": + ... + + +class ValidationReport: + @property + @abc.abstractmethod + def is_success(self) -> bool: + """ + Return whether validation was successful + """ + ... + + @property + @abc.abstractmethod + def errors(self) -> List["ValidationError"]: + """ + Return list of ValidationErrors if validation failed (is_success = false) + """ + ... + + +class ValidationError: + check_name: str + column_name: str + + check_config: Optional[Any] + + missing_count: Optional[int] + missing_percent: Optional[float] + + def __init__( + self, + check_name: str, + column_name: str, + check_config: Optional[Any] = None, + missing_count: Optional[int] = None, + missing_percent: Optional[float] = None, + ): + self.check_name = check_name + self.column_name = column_name + self.check_config = check_config + self.missing_count = missing_count + self.missing_percent = missing_percent + + def __repr__(self): + return f"" diff --git a/sdk/python/feast/infra/offline_stores/file.py b/sdk/python/feast/infra/offline_stores/file.py index 6bc8b825389..a49ce643d0b 100644 --- a/sdk/python/feast/infra/offline_stores/file.py +++ b/sdk/python/feast/infra/offline_stores/file.py @@ -83,12 +83,12 @@ def persist(self, storage: SavedDatasetStorage): if path.endswith(".parquet"): pyarrow.parquet.write_table( - self._to_arrow_internal(), where=path, filesystem=filesystem + self.to_arrow(), where=path, filesystem=filesystem ) else: # otherwise assume destination is directory pyarrow.parquet.write_to_dataset( - self._to_arrow_internal(), root_path=path, filesystem=filesystem + self.to_arrow(), root_path=path, filesystem=filesystem ) @property diff --git a/sdk/python/feast/infra/offline_stores/offline_store.py b/sdk/python/feast/infra/offline_stores/offline_store.py index 6a1372b39b3..1e5fe573774 100644 --- a/sdk/python/feast/infra/offline_stores/offline_store.py +++ b/sdk/python/feast/infra/offline_stores/offline_store.py @@ -11,20 +11,25 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import warnings from abc import ABC, abstractmethod from datetime import datetime -from typing import List, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union import pandas as pd import pyarrow from feast.data_source import DataSource +from feast.dqm.errors import ValidationFailed from feast.feature_view import FeatureView from feast.on_demand_feature_view import OnDemandFeatureView from feast.registry import Registry from feast.repo_config import RepoConfig from feast.saved_dataset import SavedDatasetStorage +if TYPE_CHECKING: + from feast.saved_dataset import ValidationReference + class RetrievalMetadata: min_event_timestamp: Optional[datetime] @@ -61,17 +66,37 @@ def full_feature_names(self) -> bool: def on_demand_feature_views(self) -> Optional[List[OnDemandFeatureView]]: pass - def to_df(self) -> pd.DataFrame: - """Return dataset as Pandas DataFrame synchronously including on demand transforms""" + def to_df( + self, validation_reference: Optional["ValidationReference"] = None + ) -> pd.DataFrame: + """ + Return dataset as Pandas DataFrame synchronously including on demand transforms + Args: + validation_reference: If provided resulting dataset will be validated against this reference profile. + """ features_df = self._to_df_internal() - if not self.on_demand_feature_views: - return features_df - # TODO(adchia): Fix requirement to specify dependent feature views in feature_refs - for odfv in self.on_demand_feature_views: - features_df = features_df.join( - odfv.get_transformed_features_df(features_df, self.full_feature_names,) + if self.on_demand_feature_views: + # TODO(adchia): Fix requirement to specify dependent feature views in feature_refs + for odfv in self.on_demand_feature_views: + features_df = features_df.join( + odfv.get_transformed_features_df( + features_df, self.full_feature_names, + ) + ) + + if validation_reference: + warnings.warn( + "Dataset validation is an experimental feature. " + "This API is unstable and it could and most probably will be changed in the future. " + "We do not guarantee that future changes will maintain backward compatibility.", + RuntimeWarning, ) + + validation_result = validation_reference.profile.validate(features_df) + if not validation_result.is_success: + raise ValidationFailed(validation_result) + return features_df @abstractmethod @@ -84,16 +109,39 @@ def _to_arrow_internal(self) -> pyarrow.Table: """Return dataset as pyarrow Table synchronously""" pass - def to_arrow(self) -> pyarrow.Table: - """Return dataset as pyarrow Table synchronously""" - if not self.on_demand_feature_views: + def to_arrow( + self, validation_reference: Optional["ValidationReference"] = None + ) -> pyarrow.Table: + """ + Return dataset as pyarrow Table synchronously + Args: + validation_reference: If provided resulting dataset will be validated against this reference profile. + + """ + if not self.on_demand_feature_views and not validation_reference: return self._to_arrow_internal() features_df = self._to_df_internal() - for odfv in self.on_demand_feature_views: - features_df = features_df.join( - odfv.get_transformed_features_df(features_df, self.full_feature_names,) + if self.on_demand_feature_views: + for odfv in self.on_demand_feature_views: + features_df = features_df.join( + odfv.get_transformed_features_df( + features_df, self.full_feature_names, + ) + ) + + if validation_reference: + warnings.warn( + "Dataset validation is an experimental feature. " + "This API is unstable and it could and most probably will be changed in the future. " + "We do not guarantee that future changes will maintain backward compatibility.", + RuntimeWarning, ) + + validation_result = validation_reference.profile.validate(features_df) + if not validation_result.is_success: + raise ValidationFailed(validation_result) + return pyarrow.Table.from_pandas(features_df) @abstractmethod diff --git a/sdk/python/feast/saved_dataset.py b/sdk/python/feast/saved_dataset.py index 39708685795..75b6d2c199f 100644 --- a/sdk/python/feast/saved_dataset.py +++ b/sdk/python/feast/saved_dataset.py @@ -7,6 +7,7 @@ from google.protobuf.json_format import MessageToJson from feast.data_source import DataSource +from feast.dqm.profilers.profiler import Profile, Profiler from feast.protos.feast.core.SavedDataset_pb2 import SavedDataset as SavedDatasetProto from feast.protos.feast.core.SavedDataset_pb2 import SavedDatasetMeta, SavedDatasetSpec from feast.protos.feast.core.SavedDataset_pb2 import ( @@ -60,6 +61,8 @@ class SavedDataset: min_event_timestamp: Optional[datetime] = None max_event_timestamp: Optional[datetime] = None + _retrieval_job: Optional["RetrievalJob"] = None + def __init__( self, name: str, @@ -76,6 +79,8 @@ def __init__( self.full_feature_names = full_feature_names self.tags = tags or {} + self._retrieval_job = None + def __repr__(self): items = (f"{k} = {v}" for k, v in self.__dict__.items()) return f"<{self.__class__.__name__}({', '.join(items)})>" @@ -183,3 +188,22 @@ def to_arrow(self) -> pyarrow.Table: ) return self._retrieval_job.to_arrow() + + def as_reference(self, profiler: "Profiler") -> "ValidationReference": + return ValidationReference(profiler=profiler, dataset=self) + + def get_profile(self, profiler: Profiler) -> Profile: + return profiler.analyze_dataset(self.to_df()) + + +class ValidationReference: + dataset: SavedDataset + profiler: Profiler + + def __init__(self, dataset: SavedDataset, profiler: Profiler): + self.dataset = dataset + self.profiler = profiler + + @property + def profile(self) -> Profile: + return self.profiler.analyze_dataset(self.dataset.to_df()) diff --git a/sdk/python/requirements/py3.7-ci-requirements.txt b/sdk/python/requirements/py3.7-ci-requirements.txt index 293b44e0531..d5f654e515c 100644 --- a/sdk/python/requirements/py3.7-ci-requirements.txt +++ b/sdk/python/requirements/py3.7-ci-requirements.txt @@ -20,11 +20,21 @@ aiosignal==1.2.0 # via aiohttp alabaster==0.7.12 # via sphinx +altair==4.2.0 + # via great-expectations anyio==3.5.0 # via starlette appdirs==1.4.4 # via black -asgiref==3.4.1 +appnope==0.1.2 + # via + # ipykernel + # ipython +argon2-cffi==21.3.0 + # via notebook +argon2-cffi-bindings==21.2.0 + # via argon2-cffi +asgiref==3.5.0 # via uvicorn asn1crypto==1.4.0 # via @@ -57,13 +67,21 @@ azure-storage-blob==12.9.0 # via adlfs babel==2.9.1 # via sphinx +backcall==0.2.0 + # via ipython +backports.zoneinfo==0.2.1 + # via + # pytz-deprecation-shim + # tzlocal black==19.10b0 # via feast (setup.py) -boto3==1.20.40 +bleach==4.1.0 + # via nbconvert +boto3==1.20.46 # via # feast (setup.py) # moto -botocore==1.23.40 +botocore==1.23.46 # via # boto3 # moto @@ -80,12 +98,13 @@ certifi==2021.10.8 # snowflake-connector-python cffi==1.15.0 # via + # argon2-cffi-bindings # azure-datalake-store # cryptography # snowflake-connector-python cfgv==3.3.1 # via pre-commit -charset-normalizer==2.0.10 +charset-normalizer==2.0.11 # via # aiohttp # requests @@ -94,11 +113,12 @@ click==8.0.3 # via # black # feast (setup.py) + # great-expectations # pip-tools # uvicorn colorama==0.4.4 # via feast (setup.py) -coverage[toml]==6.2 +coverage[toml]==6.3 # via pytest-cov cryptography==3.3.2 # via @@ -108,10 +128,17 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal + # pyjwt # pyopenssl # snowflake-connector-python +debugpy==1.5.1 + # via ipykernel decorator==5.1.1 - # via gcsfs + # via + # gcsfs + # ipython +defusedxml==0.7.1 + # via nbconvert deprecated==1.2.13 # via redis deprecation==2.1.0 @@ -128,9 +155,14 @@ docutils==0.17.1 # via # sphinx # sphinx-rtd-theme +entrypoints==0.3 + # via + # altair + # jupyter-client + # nbconvert execnet==1.9.0 # via pytest-xdist -fastapi==0.72.0 +fastapi==0.73.0 # via feast (setup.py) fastavro==1.4.9 # via @@ -208,6 +240,8 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata +great-expectations==0.14.4 + # via feast (setup.py) grpcio==1.43.0 # via # feast (setup.py) @@ -232,7 +266,7 @@ httplib2==0.20.2 # google-auth-httplib2 httptools==0.3.0 # via uvicorn -identify==2.4.4 +identify==2.4.7 # via pre-commit idna==3.3 # via @@ -246,6 +280,7 @@ importlib-metadata==4.2.0 # via # click # flake8 + # great-expectations # jsonschema # moto # pep517 @@ -258,22 +293,66 @@ importlib-resources==5.4.0 # via jsonschema iniconfig==1.1.1 # via pytest +ipykernel==6.7.0 + # via + # ipywidgets + # notebook +ipython==7.31.1 + # via + # ipykernel + # ipywidgets +ipython-genutils==0.2.0 + # via + # ipywidgets + # nbformat + # notebook +ipywidgets==7.6.5 + # via great-expectations isodate==0.6.1 # via msrest isort==5.10.1 # via feast (setup.py) +jedi==0.18.1 + # via ipython jinja2==3.0.3 # via + # altair # feast (setup.py) + # great-expectations # moto + # nbconvert + # notebook # sphinx jmespath==0.10.0 # via # boto3 # botocore +jsonpatch==1.32 + # via great-expectations +jsonpointer==2.2 + # via jsonpatch jsonschema==4.4.0 - # via feast (setup.py) -libcst==0.4.0 + # via + # altair + # feast (setup.py) + # great-expectations + # nbformat +jupyter-client==7.1.2 + # via + # ipykernel + # nbclient + # notebook +jupyter-core==4.9.1 + # via + # jupyter-client + # nbconvert + # nbformat + # notebook +jupyterlab-pygments==0.1.2 + # via nbconvert +jupyterlab-widgets==1.0.2 + # via ipywidgets +libcst==0.4.1 # via # google-cloud-bigquery-storage # google-cloud-datastore @@ -281,15 +360,23 @@ markupsafe==2.0.1 # via # jinja2 # moto +matplotlib-inline==0.1.3 + # via + # ipykernel + # ipython mccabe==0.6.1 # via flake8 minio==7.1.0 # via feast (setup.py) +mistune==0.8.4 + # via + # great-expectations + # nbconvert mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -moto==3.0.0 +moto==3.0.2 # via feast (setup.py) msal==1.16.0 # via @@ -305,7 +392,7 @@ msrest==0.6.21 # msrestazure msrestazure==0.6.4 # via adlfs -multidict==5.2.0 +multidict==6.0.2 # via # aiohttp # yarl @@ -317,19 +404,41 @@ mypy-extensions==0.4.3 # typing-inspect mypy-protobuf==3.1.0 # via feast (setup.py) +nbclient==0.5.10 + # via nbconvert +nbconvert==6.4.1 + # via notebook +nbformat==5.1.3 + # via + # ipywidgets + # nbclient + # nbconvert + # notebook +nest-asyncio==1.5.4 + # via + # ipykernel + # jupyter-client + # nbclient + # notebook nodeenv==1.6.0 # via pre-commit +notebook==6.4.8 + # via widgetsnbextension numpy==1.21.5 # via + # altair + # great-expectations # pandas # pandavro # pyarrow -oauthlib==3.1.1 + # scipy +oauthlib==3.2.0 # via requests-oauthlib oscrypto==1.2.1 # via snowflake-connector-python packaging==21.3 # via + # bleach # deprecation # google-api-core # google-cloud-bigquery @@ -339,17 +448,27 @@ packaging==21.3 # sphinx pandas==1.3.5 # via + # altair # feast (setup.py) + # great-expectations # pandavro # snowflake-connector-python pandavro==1.5.2 # via feast (setup.py) +pandocfilters==1.5.0 + # via nbconvert +parso==0.8.3 + # via jedi pathspec==0.9.0 # via black pbr==5.8.0 # via mock pep517==0.12.0 # via pip-tools +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython pip-tools==6.4.0 # via feast (setup.py) platformdirs==2.4.1 @@ -360,6 +479,10 @@ portalocker==2.3.2 # via msal-extensions pre-commit==2.17.0 # via feast (setup.py) +prometheus-client==0.13.1 + # via notebook +prompt-toolkit==3.0.26 + # via ipython proto-plus==1.19.6 # via # feast (setup.py) @@ -367,7 +490,7 @@ proto-plus==1.19.6 # google-cloud-bigquery-storage # google-cloud-datastore # google-cloud-firestore -protobuf==3.19.3 +protobuf==3.19.4 # via # feast (setup.py) # google-api-core @@ -379,6 +502,10 @@ protobuf==3.19.3 # mypy-protobuf # proto-plus # tensorflow-metadata +ptyprocess==0.7.0 + # via + # pexpect + # terminado py==1.11.0 # via # pytest @@ -399,7 +526,7 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi -pycryptodomex==3.13.0 +pycryptodomex==3.14.0 # via snowflake-connector-python pydantic==1.9.0 # via @@ -408,7 +535,11 @@ pydantic==1.9.0 pyflakes==2.4.0 # via flake8 pygments==2.11.2 - # via sphinx + # via + # ipython + # jupyterlab-pygments + # nbconvert + # sphinx pyjwt[crypto]==2.3.0 # via # adal @@ -416,8 +547,9 @@ pyjwt[crypto]==2.3.0 # snowflake-connector-python pyopenssl==21.0.0 # via snowflake-connector-python -pyparsing==3.0.7 +pyparsing==2.4.7 # via + # great-expectations # httplib2 # packaging pyrsistent==0.18.1 @@ -454,6 +586,8 @@ python-dateutil==2.8.2 # adal # botocore # google-cloud-bigquery + # great-expectations + # jupyter-client # moto # pandas python-dotenv==0.19.2 @@ -462,16 +596,23 @@ pytz==2021.3 # via # babel # google-api-core + # great-expectations # moto # pandas # snowflake-connector-python +pytz-deprecation-shim==0.1.0.post0 + # via tzlocal pyyaml==6.0 # via # feast (setup.py) # libcst # pre-commit # uvicorn -redis==4.1.1 +pyzmq==22.3.0 + # via + # jupyter-client + # notebook +redis==4.1.2 # via feast (setup.py) regex==2022.1.18 # via black @@ -487,6 +628,7 @@ requests==2.27.1 # google-api-core # google-cloud-bigquery # google-cloud-storage + # great-expectations # moto # msal # msrest @@ -494,7 +636,7 @@ requests==2.27.1 # responses # snowflake-connector-python # sphinx -requests-oauthlib==1.3.0 +requests-oauthlib==1.3.1 # via # google-auth-oauthlib # msrest @@ -502,13 +644,22 @@ responses==0.17.0 # via moto rsa==4.8 # via google-auth +ruamel.yaml==0.17.17 + # via great-expectations +ruamel.yaml.clib==0.2.6 + # via ruamel.yaml s3transfer==0.5.0 # via boto3 +scipy==1.7.3 + # via great-expectations +send2trash==1.8.0 + # via notebook six==1.16.0 # via # absl-py # azure-core # azure-identity + # bleach # cryptography # google-api-core # google-auth @@ -516,6 +667,7 @@ six==1.16.0 # google-cloud-core # google-resumable-media # grpcio + # isodate # mock # msrestazure # pandavro @@ -555,8 +707,14 @@ tenacity==8.0.1 # via feast (setup.py) tensorflow-metadata==1.6.0 # via feast (setup.py) +termcolor==1.1.0 + # via great-expectations +terminado==0.13.1 + # via notebook testcontainers==3.4.2 # via feast (setup.py) +testpath==0.5.0 + # via nbconvert toml==0.10.2 # via # black @@ -568,40 +726,64 @@ tomli==2.0.0 # coverage # mypy # pep517 +toolz==0.11.2 + # via altair +tornado==6.1 + # via + # ipykernel + # jupyter-client + # notebook + # terminado tqdm==4.62.3 - # via feast (setup.py) -typed-ast==1.5.1 + # via + # feast (setup.py) + # great-expectations +traitlets==5.1.1 + # via + # ipykernel + # ipython + # ipywidgets + # jupyter-client + # jupyter-core + # matplotlib-inline + # nbclient + # nbconvert + # nbformat + # notebook +typed-ast==1.5.2 # via # black # mypy -types-futures==3.3.7 +types-futures==3.3.8 # via types-protobuf -types-protobuf==3.19.5 +types-protobuf==3.19.7 # via # feast (setup.py) # mypy-protobuf -types-python-dateutil==2.8.8 +types-python-dateutil==2.8.9 # via feast (setup.py) types-pytz==2021.3.4 # via feast (setup.py) -types-pyyaml==6.0.3 +types-pyyaml==6.0.4 # via feast (setup.py) -types-redis==4.1.10 +types-redis==4.1.13 # via feast (setup.py) -types-requests==2.27.7 +types-requests==2.27.8 # via feast (setup.py) -types-setuptools==57.4.7 +types-setuptools==57.4.8 # via feast (setup.py) types-tabulate==0.8.5 # via feast (setup.py) -types-urllib3==1.26.7 +types-urllib3==1.26.8 # via types-requests typing-extensions==4.0.1 # via # aiohttp # anyio + # argon2-cffi # asgiref # async-timeout + # great-expectations # h11 # importlib-metadata # jsonschema @@ -614,6 +796,10 @@ typing-extensions==4.0.1 # yarl typing-inspect==0.7.1 # via libcst +tzdata==2021.5 + # via pytz-deprecation-shim +tzlocal==4.1 + # via great-expectations uritemplate==4.1.1 # via google-api-python-client urllib3==1.26.8 @@ -623,7 +809,7 @@ urllib3==1.26.8 # minio # requests # responses -uvicorn[standard]==0.17.0 +uvicorn[standard]==0.17.1 # via feast (setup.py) uvloop==0.16.0 # via uvicorn @@ -631,6 +817,10 @@ virtualenv==20.13.0 # via pre-commit watchgod==0.7 # via uvicorn +wcwidth==0.2.5 + # via prompt-toolkit +webencodings==0.5.1 + # via bleach websocket-client==1.2.3 # via docker websockets==10.1 @@ -639,6 +829,8 @@ werkzeug==2.0.2 # via moto wheel==0.37.1 # via pip-tools +widgetsnbextension==3.5.2 + # via ipywidgets wrapt==1.13.3 # via # deprecated diff --git a/sdk/python/requirements/py3.8-ci-requirements.txt b/sdk/python/requirements/py3.8-ci-requirements.txt index 3cdc118144f..7a94294c956 100644 --- a/sdk/python/requirements/py3.8-ci-requirements.txt +++ b/sdk/python/requirements/py3.8-ci-requirements.txt @@ -20,11 +20,21 @@ aiosignal==1.2.0 # via aiohttp alabaster==0.7.12 # via sphinx +altair==4.2.0 + # via great-expectations anyio==3.5.0 # via starlette appdirs==1.4.4 # via black -asgiref==3.4.1 +appnope==0.1.2 + # via + # ipykernel + # ipython +argon2-cffi==21.3.0 + # via notebook +argon2-cffi-bindings==21.2.0 + # via argon2-cffi +asgiref==3.5.0 # via uvicorn asn1crypto==1.4.0 # via @@ -34,6 +44,8 @@ assertpy==1.1 # via feast (setup.py) async-timeout==4.0.2 # via aiohttp +asynctest==0.13.0 + # via aiohttp attrs==21.4.0 # via # aiohttp @@ -55,13 +67,21 @@ azure-storage-blob==12.9.0 # via adlfs babel==2.9.1 # via sphinx +backcall==0.2.0 + # via ipython +backports.zoneinfo==0.2.1 + # via + # pytz-deprecation-shim + # tzlocal black==19.10b0 # via feast (setup.py) -boto3==1.20.40 +bleach==4.1.0 + # via nbconvert +boto3==1.20.46 # via # feast (setup.py) # moto -botocore==1.23.40 +botocore==1.23.46 # via # boto3 # moto @@ -78,12 +98,13 @@ certifi==2021.10.8 # snowflake-connector-python cffi==1.15.0 # via + # argon2-cffi-bindings # azure-datalake-store # cryptography # snowflake-connector-python cfgv==3.3.1 # via pre-commit -charset-normalizer==2.0.10 +charset-normalizer==2.0.11 # via # aiohttp # requests @@ -92,11 +113,12 @@ click==8.0.3 # via # black # feast (setup.py) + # great-expectations # pip-tools # uvicorn colorama==0.4.4 # via feast (setup.py) -coverage[toml]==6.2 +coverage[toml]==6.3 # via pytest-cov cryptography==3.3.2 # via @@ -106,10 +128,17 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal + # pyjwt # pyopenssl # snowflake-connector-python +debugpy==1.5.1 + # via ipykernel decorator==5.1.1 - # via gcsfs + # via + # gcsfs + # ipython +defusedxml==0.7.1 + # via nbconvert deprecated==1.2.13 # via redis deprecation==2.1.0 @@ -126,9 +155,14 @@ docutils==0.17.1 # via # sphinx # sphinx-rtd-theme +entrypoints==0.3 + # via + # altair + # jupyter-client + # nbconvert execnet==1.9.0 # via pytest-xdist -fastapi==0.72.0 +fastapi==0.73.0 # via feast (setup.py) fastavro==1.4.9 # via @@ -206,6 +240,8 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata +great-expectations==0.14.4 + # via feast (setup.py) grpcio==1.43.0 # via # feast (setup.py) @@ -230,7 +266,7 @@ httplib2==0.20.2 # google-auth-httplib2 httptools==0.3.0 # via uvicorn -identify==2.4.4 +identify==2.4.7 # via pre-commit idna==3.3 # via @@ -240,26 +276,83 @@ idna==3.3 # yarl imagesize==1.3.0 # via sphinx +importlib-metadata==4.2.0 + # via + # click + # flake8 + # great-expectations + # jsonschema + # moto + # pep517 + # pluggy + # pre-commit + # pytest + # redis + # virtualenv importlib-resources==5.4.0 # via jsonschema iniconfig==1.1.1 # via pytest +ipykernel==6.7.0 + # via + # ipywidgets + # notebook +ipython==7.31.1 + # via + # ipykernel + # ipywidgets +ipython-genutils==0.2.0 + # via + # ipywidgets + # nbformat + # notebook +ipywidgets==7.6.5 + # via great-expectations isodate==0.6.1 # via msrest isort==5.10.1 # via feast (setup.py) +jedi==0.18.1 + # via ipython jinja2==3.0.3 # via + # altair # feast (setup.py) + # great-expectations # moto + # nbconvert + # notebook # sphinx jmespath==0.10.0 # via # boto3 # botocore +jsonpatch==1.32 + # via great-expectations +jsonpointer==2.2 + # via jsonpatch jsonschema==4.4.0 - # via feast (setup.py) -libcst==0.4.0 + # via + # altair + # feast (setup.py) + # great-expectations + # nbformat +jupyter-client==7.1.2 + # via + # ipykernel + # nbclient + # notebook +jupyter-core==4.9.1 + # via + # jupyter-client + # nbconvert + # nbformat + # notebook +jupyterlab-pygments==0.1.2 + # via nbconvert +jupyterlab-widgets==1.0.2 + # via ipywidgets +libcst==0.4.1 # via # google-cloud-bigquery-storage # google-cloud-datastore @@ -267,15 +360,23 @@ markupsafe==2.0.1 # via # jinja2 # moto +matplotlib-inline==0.1.3 + # via + # ipykernel + # ipython mccabe==0.6.1 # via flake8 minio==7.1.0 # via feast (setup.py) +mistune==0.8.4 + # via + # great-expectations + # nbconvert mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -moto==3.0.0 +moto==3.0.2 # via feast (setup.py) msal==1.16.0 # via @@ -291,7 +392,7 @@ msrest==0.6.21 # msrestazure msrestazure==0.6.4 # via adlfs -multidict==5.2.0 +multidict==6.0.2 # via # aiohttp # yarl @@ -303,19 +404,41 @@ mypy-extensions==0.4.3 # typing-inspect mypy-protobuf==3.1.0 # via feast (setup.py) +nbclient==0.5.10 + # via nbconvert +nbconvert==6.4.1 + # via notebook +nbformat==5.1.3 + # via + # ipywidgets + # nbclient + # nbconvert + # notebook +nest-asyncio==1.5.4 + # via + # ipykernel + # jupyter-client + # nbclient + # notebook nodeenv==1.6.0 # via pre-commit -numpy==1.22.1 +notebook==6.4.8 + # via widgetsnbextension +numpy==1.21.5 # via + # altair + # great-expectations # pandas # pandavro # pyarrow -oauthlib==3.1.1 + # scipy +oauthlib==3.2.0 # via requests-oauthlib oscrypto==1.2.1 # via snowflake-connector-python packaging==21.3 # via + # bleach # deprecation # google-api-core # google-cloud-bigquery @@ -325,17 +448,27 @@ packaging==21.3 # sphinx pandas==1.3.5 # via + # altair # feast (setup.py) + # great-expectations # pandavro # snowflake-connector-python pandavro==1.5.2 # via feast (setup.py) +pandocfilters==1.5.0 + # via nbconvert +parso==0.8.3 + # via jedi pathspec==0.9.0 # via black pbr==5.8.0 # via mock pep517==0.12.0 # via pip-tools +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython pip-tools==6.4.0 # via feast (setup.py) platformdirs==2.4.1 @@ -346,6 +479,10 @@ portalocker==2.3.2 # via msal-extensions pre-commit==2.17.0 # via feast (setup.py) +prometheus-client==0.13.1 + # via notebook +prompt-toolkit==3.0.26 + # via ipython proto-plus==1.19.6 # via # feast (setup.py) @@ -353,7 +490,7 @@ proto-plus==1.19.6 # google-cloud-bigquery-storage # google-cloud-datastore # google-cloud-firestore -protobuf==3.19.3 +protobuf==3.19.4 # via # feast (setup.py) # google-api-core @@ -365,6 +502,10 @@ protobuf==3.19.3 # mypy-protobuf # proto-plus # tensorflow-metadata +ptyprocess==0.7.0 + # via + # pexpect + # terminado py==1.11.0 # via # pytest @@ -385,7 +526,7 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi -pycryptodomex==3.13.0 +pycryptodomex==3.14.0 # via snowflake-connector-python pydantic==1.9.0 # via @@ -394,7 +535,11 @@ pydantic==1.9.0 pyflakes==2.4.0 # via flake8 pygments==2.11.2 - # via sphinx + # via + # ipython + # jupyterlab-pygments + # nbconvert + # sphinx pyjwt[crypto]==2.3.0 # via # adal @@ -402,8 +547,9 @@ pyjwt[crypto]==2.3.0 # snowflake-connector-python pyopenssl==21.0.0 # via snowflake-connector-python -pyparsing==3.0.7 +pyparsing==2.4.7 # via + # great-expectations # httplib2 # packaging pyrsistent==0.18.1 @@ -440,6 +586,8 @@ python-dateutil==2.8.2 # adal # botocore # google-cloud-bigquery + # great-expectations + # jupyter-client # moto # pandas python-dotenv==0.19.2 @@ -448,16 +596,23 @@ pytz==2021.3 # via # babel # google-api-core + # great-expectations # moto # pandas # snowflake-connector-python +pytz-deprecation-shim==0.1.0.post0 + # via tzlocal pyyaml==6.0 # via # feast (setup.py) # libcst # pre-commit # uvicorn -redis==4.1.1 +pyzmq==22.3.0 + # via + # jupyter-client + # notebook +redis==4.1.2 # via feast (setup.py) regex==2022.1.18 # via black @@ -473,6 +628,7 @@ requests==2.27.1 # google-api-core # google-cloud-bigquery # google-cloud-storage + # great-expectations # moto # msal # msrest @@ -480,7 +636,7 @@ requests==2.27.1 # responses # snowflake-connector-python # sphinx -requests-oauthlib==1.3.0 +requests-oauthlib==1.3.1 # via # google-auth-oauthlib # msrest @@ -488,13 +644,22 @@ responses==0.17.0 # via moto rsa==4.8 # via google-auth +ruamel.yaml==0.17.17 + # via great-expectations +ruamel.yaml.clib==0.2.6 + # via ruamel.yaml s3transfer==0.5.0 # via boto3 +scipy==1.7.3 + # via great-expectations +send2trash==1.8.0 + # via notebook six==1.16.0 # via # absl-py # azure-core # azure-identity + # bleach # cryptography # google-api-core # google-auth @@ -502,6 +667,7 @@ six==1.16.0 # google-cloud-core # google-resumable-media # grpcio + # isodate # mock # msrestazure # pandavro @@ -541,8 +707,14 @@ tenacity==8.0.1 # via feast (setup.py) tensorflow-metadata==1.6.0 # via feast (setup.py) +termcolor==1.1.0 + # via great-expectations +terminado==0.13.1 + # via notebook testcontainers==3.4.2 # via feast (setup.py) +testpath==0.5.0 + # via nbconvert toml==0.10.2 # via # black @@ -554,40 +726,80 @@ tomli==2.0.0 # coverage # mypy # pep517 +toolz==0.11.2 + # via altair +tornado==6.1 + # via + # ipykernel + # jupyter-client + # notebook + # terminado tqdm==4.62.3 - # via feast (setup.py) -typed-ast==1.5.1 - # via black -types-futures==3.3.7 + # via + # feast (setup.py) + # great-expectations +traitlets==5.1.1 + # via + # ipykernel + # ipython + # ipywidgets + # jupyter-client + # jupyter-core + # matplotlib-inline + # nbclient + # nbconvert + # nbformat + # notebook +typed-ast==1.5.2 + # via + # black + # mypy +types-futures==3.3.8 # via types-protobuf -types-protobuf==3.19.5 +types-protobuf==3.19.7 # via # feast (setup.py) # mypy-protobuf -types-python-dateutil==2.8.8 +types-python-dateutil==2.8.9 # via feast (setup.py) types-pytz==2021.3.4 # via feast (setup.py) -types-pyyaml==6.0.3 +types-pyyaml==6.0.4 # via feast (setup.py) -types-redis==4.1.10 +types-redis==4.1.13 # via feast (setup.py) -types-requests==2.27.7 +types-requests==2.27.8 # via feast (setup.py) -types-setuptools==57.4.7 +types-setuptools==57.4.8 # via feast (setup.py) types-tabulate==0.8.5 # via feast (setup.py) -types-urllib3==1.26.7 +types-urllib3==1.26.8 # via types-requests typing-extensions==4.0.1 # via + # aiohttp + # anyio + # argon2-cffi + # asgiref + # async-timeout + # great-expectations + # h11 + # importlib-metadata + # jsonschema # libcst # mypy # pydantic + # starlette # typing-inspect + # uvicorn + # yarl typing-inspect==0.7.1 # via libcst +tzdata==2021.5 + # via pytz-deprecation-shim +tzlocal==4.1 + # via great-expectations uritemplate==4.1.1 # via google-api-python-client urllib3==1.26.8 @@ -597,7 +809,7 @@ urllib3==1.26.8 # minio # requests # responses -uvicorn[standard]==0.17.0 +uvicorn[standard]==0.17.1 # via feast (setup.py) uvloop==0.16.0 # via uvicorn @@ -605,6 +817,10 @@ virtualenv==20.13.0 # via pre-commit watchgod==0.7 # via uvicorn +wcwidth==0.2.5 + # via prompt-toolkit +webencodings==0.5.1 + # via bleach websocket-client==1.2.3 # via docker websockets==10.1 @@ -613,6 +829,8 @@ werkzeug==2.0.2 # via moto wheel==0.37.1 # via pip-tools +widgetsnbextension==3.5.2 + # via ipywidgets wrapt==1.13.3 # via # deprecated @@ -622,7 +840,10 @@ xmltodict==0.12.0 yarl==1.7.2 # via aiohttp zipp==3.7.0 - # via importlib-resources + # via + # importlib-metadata + # importlib-resources + # pep517 # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/sdk/python/requirements/py3.8-requirements.txt b/sdk/python/requirements/py3.8-requirements.txt index e94fe117b4a..90b42760132 100644 --- a/sdk/python/requirements/py3.8-requirements.txt +++ b/sdk/python/requirements/py3.8-requirements.txt @@ -65,7 +65,7 @@ markupsafe==2.0.1 # via jinja2 mmh3==3.0.0 # via feast (setup.py) -numpy==1.22.1 +numpy==1.21.5 # via # pandas # pandavro diff --git a/sdk/python/requirements/py3.9-ci-requirements.txt b/sdk/python/requirements/py3.9-ci-requirements.txt index 69247a2c7dd..1421d7e3c31 100644 --- a/sdk/python/requirements/py3.9-ci-requirements.txt +++ b/sdk/python/requirements/py3.9-ci-requirements.txt @@ -20,11 +20,21 @@ aiosignal==1.2.0 # via aiohttp alabaster==0.7.12 # via sphinx +altair==4.2.0 + # via great-expectations anyio==3.5.0 # via starlette appdirs==1.4.4 # via black -asgiref==3.4.1 +appnope==0.1.2 + # via + # ipykernel + # ipython +argon2-cffi==21.3.0 + # via notebook +argon2-cffi-bindings==21.2.0 + # via argon2-cffi +asgiref==3.5.0 # via uvicorn asn1crypto==1.4.0 # via @@ -34,6 +44,8 @@ assertpy==1.1 # via feast (setup.py) async-timeout==4.0.2 # via aiohttp +asynctest==0.13.0 + # via aiohttp attrs==21.4.0 # via # aiohttp @@ -55,13 +67,21 @@ azure-storage-blob==12.9.0 # via adlfs babel==2.9.1 # via sphinx +backcall==0.2.0 + # via ipython +backports.zoneinfo==0.2.1 + # via + # pytz-deprecation-shim + # tzlocal black==19.10b0 # via feast (setup.py) -boto3==1.20.40 +bleach==4.1.0 + # via nbconvert +boto3==1.20.46 # via # feast (setup.py) # moto -botocore==1.23.40 +botocore==1.23.46 # via # boto3 # moto @@ -78,12 +98,13 @@ certifi==2021.10.8 # snowflake-connector-python cffi==1.15.0 # via + # argon2-cffi-bindings # azure-datalake-store # cryptography # snowflake-connector-python cfgv==3.3.1 # via pre-commit -charset-normalizer==2.0.10 +charset-normalizer==2.0.11 # via # aiohttp # requests @@ -92,11 +113,12 @@ click==8.0.3 # via # black # feast (setup.py) + # great-expectations # pip-tools # uvicorn colorama==0.4.4 # via feast (setup.py) -coverage[toml]==6.2 +coverage[toml]==6.3 # via pytest-cov cryptography==3.3.2 # via @@ -106,10 +128,17 @@ cryptography==3.3.2 # feast (setup.py) # moto # msal + # pyjwt # pyopenssl # snowflake-connector-python +debugpy==1.5.1 + # via ipykernel decorator==5.1.1 - # via gcsfs + # via + # gcsfs + # ipython +defusedxml==0.7.1 + # via nbconvert deprecated==1.2.13 # via redis deprecation==2.1.0 @@ -126,9 +155,14 @@ docutils==0.17.1 # via # sphinx # sphinx-rtd-theme +entrypoints==0.3 + # via + # altair + # jupyter-client + # nbconvert execnet==1.9.0 # via pytest-xdist -fastapi==0.72.0 +fastapi==0.73.0 # via feast (setup.py) fastavro==1.4.9 # via @@ -206,6 +240,8 @@ googleapis-common-protos==1.52.0 # feast (setup.py) # google-api-core # tensorflow-metadata +great-expectations==0.14.4 + # via feast (setup.py) grpcio==1.43.0 # via # feast (setup.py) @@ -230,7 +266,7 @@ httplib2==0.20.2 # google-auth-httplib2 httptools==0.3.0 # via uvicorn -identify==2.4.4 +identify==2.4.7 # via pre-commit idna==3.3 # via @@ -240,24 +276,83 @@ idna==3.3 # yarl imagesize==1.3.0 # via sphinx +importlib-metadata==4.2.0 + # via + # click + # flake8 + # great-expectations + # jsonschema + # moto + # pep517 + # pluggy + # pre-commit + # pytest + # redis + # virtualenv +importlib-resources==5.4.0 + # via jsonschema iniconfig==1.1.1 # via pytest +ipykernel==6.7.0 + # via + # ipywidgets + # notebook +ipython==7.31.1 + # via + # ipykernel + # ipywidgets +ipython-genutils==0.2.0 + # via + # ipywidgets + # nbformat + # notebook +ipywidgets==7.6.5 + # via great-expectations isodate==0.6.1 # via msrest isort==5.10.1 # via feast (setup.py) +jedi==0.18.1 + # via ipython jinja2==3.0.3 # via + # altair # feast (setup.py) + # great-expectations # moto + # nbconvert + # notebook # sphinx jmespath==0.10.0 # via # boto3 # botocore +jsonpatch==1.32 + # via great-expectations +jsonpointer==2.2 + # via jsonpatch jsonschema==4.4.0 - # via feast (setup.py) -libcst==0.4.0 + # via + # altair + # feast (setup.py) + # great-expectations + # nbformat +jupyter-client==7.1.2 + # via + # ipykernel + # nbclient + # notebook +jupyter-core==4.9.1 + # via + # jupyter-client + # nbconvert + # nbformat + # notebook +jupyterlab-pygments==0.1.2 + # via nbconvert +jupyterlab-widgets==1.0.2 + # via ipywidgets +libcst==0.4.1 # via # google-cloud-bigquery-storage # google-cloud-datastore @@ -265,15 +360,23 @@ markupsafe==2.0.1 # via # jinja2 # moto +matplotlib-inline==0.1.3 + # via + # ipykernel + # ipython mccabe==0.6.1 # via flake8 minio==7.1.0 # via feast (setup.py) +mistune==0.8.4 + # via + # great-expectations + # nbconvert mmh3==3.0.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) -moto==3.0.0 +moto==3.0.2 # via feast (setup.py) msal==1.16.0 # via @@ -289,7 +392,7 @@ msrest==0.6.21 # msrestazure msrestazure==0.6.4 # via adlfs -multidict==5.2.0 +multidict==6.0.2 # via # aiohttp # yarl @@ -301,19 +404,41 @@ mypy-extensions==0.4.3 # typing-inspect mypy-protobuf==3.1.0 # via feast (setup.py) +nbclient==0.5.10 + # via nbconvert +nbconvert==6.4.1 + # via notebook +nbformat==5.1.3 + # via + # ipywidgets + # nbclient + # nbconvert + # notebook +nest-asyncio==1.5.4 + # via + # ipykernel + # jupyter-client + # nbclient + # notebook nodeenv==1.6.0 # via pre-commit -numpy==1.22.1 +notebook==6.4.8 + # via widgetsnbextension +numpy==1.21.5 # via + # altair + # great-expectations # pandas # pandavro # pyarrow -oauthlib==3.1.1 + # scipy +oauthlib==3.2.0 # via requests-oauthlib oscrypto==1.2.1 # via snowflake-connector-python packaging==21.3 # via + # bleach # deprecation # google-api-core # google-cloud-bigquery @@ -323,17 +448,27 @@ packaging==21.3 # sphinx pandas==1.3.5 # via + # altair # feast (setup.py) + # great-expectations # pandavro # snowflake-connector-python pandavro==1.5.2 # via feast (setup.py) +pandocfilters==1.5.0 + # via nbconvert +parso==0.8.3 + # via jedi pathspec==0.9.0 # via black pbr==5.8.0 # via mock pep517==0.12.0 # via pip-tools +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython pip-tools==6.4.0 # via feast (setup.py) platformdirs==2.4.1 @@ -344,6 +479,10 @@ portalocker==2.3.2 # via msal-extensions pre-commit==2.17.0 # via feast (setup.py) +prometheus-client==0.13.1 + # via notebook +prompt-toolkit==3.0.26 + # via ipython proto-plus==1.19.6 # via # feast (setup.py) @@ -351,7 +490,7 @@ proto-plus==1.19.6 # google-cloud-bigquery-storage # google-cloud-datastore # google-cloud-firestore -protobuf==3.19.3 +protobuf==3.19.4 # via # feast (setup.py) # google-api-core @@ -363,6 +502,10 @@ protobuf==3.19.3 # mypy-protobuf # proto-plus # tensorflow-metadata +ptyprocess==0.7.0 + # via + # pexpect + # terminado py==1.11.0 # via # pytest @@ -383,7 +526,7 @@ pycodestyle==2.8.0 # via flake8 pycparser==2.21 # via cffi -pycryptodomex==3.13.0 +pycryptodomex==3.14.0 # via snowflake-connector-python pydantic==1.9.0 # via @@ -392,7 +535,11 @@ pydantic==1.9.0 pyflakes==2.4.0 # via flake8 pygments==2.11.2 - # via sphinx + # via + # ipython + # jupyterlab-pygments + # nbconvert + # sphinx pyjwt[crypto]==2.3.0 # via # adal @@ -400,8 +547,9 @@ pyjwt[crypto]==2.3.0 # snowflake-connector-python pyopenssl==21.0.0 # via snowflake-connector-python -pyparsing==3.0.7 +pyparsing==2.4.7 # via + # great-expectations # httplib2 # packaging pyrsistent==0.18.1 @@ -438,6 +586,8 @@ python-dateutil==2.8.2 # adal # botocore # google-cloud-bigquery + # great-expectations + # jupyter-client # moto # pandas python-dotenv==0.19.2 @@ -446,16 +596,23 @@ pytz==2021.3 # via # babel # google-api-core + # great-expectations # moto # pandas # snowflake-connector-python +pytz-deprecation-shim==0.1.0.post0 + # via tzlocal pyyaml==6.0 # via # feast (setup.py) # libcst # pre-commit # uvicorn -redis==4.1.1 +pyzmq==22.3.0 + # via + # jupyter-client + # notebook +redis==4.1.2 # via feast (setup.py) regex==2022.1.18 # via black @@ -471,6 +628,7 @@ requests==2.27.1 # google-api-core # google-cloud-bigquery # google-cloud-storage + # great-expectations # moto # msal # msrest @@ -478,7 +636,7 @@ requests==2.27.1 # responses # snowflake-connector-python # sphinx -requests-oauthlib==1.3.0 +requests-oauthlib==1.3.1 # via # google-auth-oauthlib # msrest @@ -486,13 +644,22 @@ responses==0.17.0 # via moto rsa==4.8 # via google-auth +ruamel.yaml==0.17.17 + # via great-expectations +ruamel.yaml.clib==0.2.6 + # via ruamel.yaml s3transfer==0.5.0 # via boto3 +scipy==1.7.3 + # via great-expectations +send2trash==1.8.0 + # via notebook six==1.16.0 # via # absl-py # azure-core # azure-identity + # bleach # cryptography # google-api-core # google-auth @@ -500,6 +667,7 @@ six==1.16.0 # google-cloud-core # google-resumable-media # grpcio + # isodate # mock # msrestazure # pandavro @@ -539,8 +707,14 @@ tenacity==8.0.1 # via feast (setup.py) tensorflow-metadata==1.6.0 # via feast (setup.py) +termcolor==1.1.0 + # via great-expectations +terminado==0.13.1 + # via notebook testcontainers==3.4.2 # via feast (setup.py) +testpath==0.5.0 + # via nbconvert toml==0.10.2 # via # black @@ -552,40 +726,80 @@ tomli==2.0.0 # coverage # mypy # pep517 +toolz==0.11.2 + # via altair +tornado==6.1 + # via + # ipykernel + # jupyter-client + # notebook + # terminado tqdm==4.62.3 - # via feast (setup.py) -typed-ast==1.5.1 - # via black -types-futures==3.3.7 + # via + # feast (setup.py) + # great-expectations +traitlets==5.1.1 + # via + # ipykernel + # ipython + # ipywidgets + # jupyter-client + # jupyter-core + # matplotlib-inline + # nbclient + # nbconvert + # nbformat + # notebook +typed-ast==1.5.2 + # via + # black + # mypy +types-futures==3.3.8 # via types-protobuf -types-protobuf==3.19.5 +types-protobuf==3.19.7 # via # feast (setup.py) # mypy-protobuf -types-python-dateutil==2.8.8 +types-python-dateutil==2.8.9 # via feast (setup.py) types-pytz==2021.3.4 # via feast (setup.py) -types-pyyaml==6.0.3 +types-pyyaml==6.0.4 # via feast (setup.py) -types-redis==4.1.10 +types-redis==4.1.13 # via feast (setup.py) -types-requests==2.27.7 +types-requests==2.27.8 # via feast (setup.py) -types-setuptools==57.4.7 +types-setuptools==57.4.8 # via feast (setup.py) types-tabulate==0.8.5 # via feast (setup.py) -types-urllib3==1.26.7 +types-urllib3==1.26.8 # via types-requests typing-extensions==4.0.1 # via + # aiohttp + # anyio + # argon2-cffi + # asgiref + # async-timeout + # great-expectations + # h11 + # importlib-metadata + # jsonschema # libcst # mypy # pydantic + # starlette # typing-inspect + # uvicorn + # yarl typing-inspect==0.7.1 # via libcst +tzdata==2021.5 + # via pytz-deprecation-shim +tzlocal==4.1 + # via great-expectations uritemplate==4.1.1 # via google-api-python-client urllib3==1.26.8 @@ -595,7 +809,7 @@ urllib3==1.26.8 # minio # requests # responses -uvicorn[standard]==0.17.0 +uvicorn[standard]==0.17.1 # via feast (setup.py) uvloop==0.16.0 # via uvicorn @@ -603,6 +817,10 @@ virtualenv==20.13.0 # via pre-commit watchgod==0.7 # via uvicorn +wcwidth==0.2.5 + # via prompt-toolkit +webencodings==0.5.1 + # via bleach websocket-client==1.2.3 # via docker websockets==10.1 @@ -611,6 +829,8 @@ werkzeug==2.0.2 # via moto wheel==0.37.1 # via pip-tools +widgetsnbextension==3.5.2 + # via ipywidgets wrapt==1.13.3 # via # deprecated @@ -619,6 +839,11 @@ xmltodict==0.12.0 # via moto yarl==1.7.2 # via aiohttp +zipp==3.7.0 + # via + # importlib-metadata + # importlib-resources + # pep517 # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/sdk/python/requirements/py3.9-requirements.txt b/sdk/python/requirements/py3.9-requirements.txt index 187cb02154b..8db9fd4b14f 100644 --- a/sdk/python/requirements/py3.9-requirements.txt +++ b/sdk/python/requirements/py3.9-requirements.txt @@ -63,7 +63,7 @@ markupsafe==2.0.1 # via jinja2 mmh3==3.0.0 # via feast (setup.py) -numpy==1.22.1 +numpy==1.21.5 # via # pandas # pandavro diff --git a/sdk/python/setup.py b/sdk/python/setup.py index cb5381813b5..7535987f833 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -90,8 +90,12 @@ "snowflake-connector-python[pandas]>=2.7.3", ] +GE_REQUIRED = [ + "great_expectations>=0.14.0,<0.15.0" +] + CI_REQUIRED = ( - [ + [ "cryptography==3.3.2", "flake8", "black==19.10b0", @@ -131,10 +135,11 @@ "types-setuptools", "types-tabulate", ] - + GCP_REQUIRED - + REDIS_REQUIRED - + AWS_REQUIRED - + SNOWFLAKE_REQUIRED + + GCP_REQUIRED + + REDIS_REQUIRED + + AWS_REQUIRED + + SNOWFLAKE_REQUIRED + + GE_REQUIRED ) DEV_REQUIRED = ["mypy-protobuf>=3.1.0", "grpcio-testing==1.*"] + CI_REQUIRED @@ -236,7 +241,8 @@ def run(self): "gcp": GCP_REQUIRED, "aws": AWS_REQUIRED, "redis": REDIS_REQUIRED, - "snowflake": SNOWFLAKE_REQUIRED + "snowflake": SNOWFLAKE_REQUIRED, + "ge": GE_REQUIRED, }, include_package_data=True, license="Apache", diff --git a/sdk/python/tests/__init__.py b/sdk/python/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/tests/data/__init__.py b/sdk/python/tests/data/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/tests/integration/e2e/test_validation.py b/sdk/python/tests/integration/e2e/test_validation.py new file mode 100644 index 00000000000..2bd1e3cbbc9 --- /dev/null +++ b/sdk/python/tests/integration/e2e/test_validation.py @@ -0,0 +1,134 @@ +import pandas as pd +import pytest +from great_expectations.core import ExpectationSuite +from great_expectations.dataset import PandasDataset + +from feast.dqm.errors import ValidationFailed +from feast.dqm.profilers.ge_profiler import ge_profiler +from tests.integration.feature_repos.repo_configuration import ( + construct_universal_feature_views, +) +from tests.integration.feature_repos.universal.entities import ( + customer, + driver, + location, +) + +_features = [ + "customer_profile:current_balance", + "customer_profile:avg_passenger_count", + "customer_profile:lifetime_trip_count", + "order:order_is_success", + "global_stats:num_rides", + "global_stats:avg_ride_length", +] + + +@ge_profiler +def configurable_profiler(dataset: PandasDataset) -> ExpectationSuite: + from great_expectations.profile.user_configurable_profiler import ( + UserConfigurableProfiler, + ) + + return UserConfigurableProfiler( + profile_dataset=dataset, + excluded_expectations=[ + "expect_table_columns_to_match_ordered_list", + "expect_table_row_count_to_be_between", + ], + value_set_threshold="few", + ).build_suite() + + +@ge_profiler +def profiler_with_unrealistic_expectations(dataset: PandasDataset) -> ExpectationSuite: + # need to create dataframe with corrupted data first + df = pd.DataFrame() + df["current_balance"] = [-100] + df["avg_passenger_count"] = [0] + + other_ds = PandasDataset(df) + other_ds.expect_column_max_to_be_between("current_balance", -1000, -100) + other_ds.expect_column_values_to_be_in_set("avg_passenger_count", value_set={0}) + + # this should pass + other_ds.expect_column_min_to_be_between("avg_passenger_count", 0, 1000) + + return other_ds.get_expectation_suite() + + +@pytest.mark.integration +@pytest.mark.universal +def test_historical_retrieval_with_validation(environment, universal_data_sources): + store = environment.feature_store + + (entities, datasets, data_sources) = universal_data_sources + feature_views = construct_universal_feature_views(data_sources) + + store.apply([driver(), customer(), location(), *feature_views.values()]) + + entity_df = datasets["entity"].drop( + columns=["order_id", "origin_id", "destination_id"] + ) + + reference_job = store.get_historical_features( + entity_df=entity_df, features=_features, + ) + + store.create_saved_dataset( + from_=reference_job, + name="my_training_dataset", + storage=environment.data_source_creator.create_saved_dataset_destination(), + ) + + job = store.get_historical_features(entity_df=entity_df, features=_features,) + + # if validation pass there will be no exceptions on this point + job.to_df( + validation_reference=store.get_saved_dataset( + "my_training_dataset" + ).as_reference(profiler=configurable_profiler) + ) + + +@pytest.mark.integration +@pytest.mark.universal +def test_historical_retrieval_fails_on_validation(environment, universal_data_sources): + store = environment.feature_store + + (entities, datasets, data_sources) = universal_data_sources + feature_views = construct_universal_feature_views(data_sources) + + store.apply([driver(), customer(), location(), *feature_views.values()]) + + entity_df = datasets["entity"].drop( + columns=["order_id", "origin_id", "destination_id"] + ) + + reference_job = store.get_historical_features( + entity_df=entity_df, features=_features, + ) + + store.create_saved_dataset( + from_=reference_job, + name="my_other_dataset", + storage=environment.data_source_creator.create_saved_dataset_destination(), + ) + + job = store.get_historical_features(entity_df=entity_df, features=_features,) + + with pytest.raises(ValidationFailed) as exc_info: + job.to_df( + validation_reference=store.get_saved_dataset( + "my_other_dataset" + ).as_reference(profiler=profiler_with_unrealistic_expectations) + ) + + failed_expectations = exc_info.value.report.errors + assert len(failed_expectations) == 2 + + assert failed_expectations[0].check_name == "expect_column_max_to_be_between" + assert failed_expectations[0].column_name == "current_balance" + + assert failed_expectations[1].check_name == "expect_column_values_to_be_in_set" + assert failed_expectations[1].column_name == "avg_passenger_count" From 97a614e8d8c37830a134491ff7916b4f89b4180c Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Wed, 2 Feb 2022 07:24:12 -0800 Subject: [PATCH 54/85] Graduate Python feature server (#2263) * Graduate Python feature server from alpha Signed-off-by: Felix Wang * Delete useless doc files Signed-off-by: Felix Wang * Restructure docs to point to correct feast-snowflake-gcp-aws folder Signed-off-by: Felix Wang * Modify Python feature server docs Signed-off-by: Felix Wang * Delete more useless doc files Signed-off-by: Felix Wang * Remove python_feature_server flag from helm charts and Java tests Signed-off-by: Felix Wang --- docs/SUMMARY.md | 15 ++++++++------- docs/architecture.md | 2 -- docs/build-a-training-dataset.md | 2 -- docs/create-a-feature-repository.md | 2 -- docs/deploy-a-feature-store.md | 2 -- docs/entities.md | 2 -- docs/feature-views.md | 2 -- docs/load-data-into-the-online-store.md | 2 -- docs/read-features-from-the-online-store.md | 2 -- docs/reference/feature-servers/README.md | 5 +++++ .../local-feature-server.md} | 9 +-------- docs/reference/repository-config.md | 2 -- docs/reference/telemetry.md | 12 ------------ docs/repository-config.md | 2 -- docs/sources.md | 2 -- infra/charts/feast-python-server/README.md | 12 ------------ .../config/feature_store.yaml | 1 - .../docker-compose/feast10/feature_store.yaml | 1 - sdk/python/feast/feature_store.py | 5 ----- sdk/python/feast/flags.py | 2 -- sdk/python/feast/flags_helper.py | 4 ---- 21 files changed, 14 insertions(+), 74 deletions(-) delete mode 100644 docs/architecture.md delete mode 100644 docs/build-a-training-dataset.md delete mode 100644 docs/create-a-feature-repository.md delete mode 100644 docs/deploy-a-feature-store.md delete mode 100644 docs/entities.md delete mode 100644 docs/feature-views.md delete mode 100644 docs/load-data-into-the-online-store.md delete mode 100644 docs/read-features-from-the-online-store.md create mode 100644 docs/reference/feature-servers/README.md rename docs/reference/{feature-server.md => feature-servers/local-feature-server.md} (92%) delete mode 100644 docs/reference/repository-config.md delete mode 100644 docs/reference/telemetry.md delete mode 100644 docs/repository-config.md delete mode 100644 docs/sources.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e1343ec4855..5bc40196e57 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -38,12 +38,12 @@ ## How-to Guides * [Running Feast with Snowflake/GCP/AWS](how-to-guides/feast-snowflake-gcp-aws/README.md) - * [Install Feast](how-to-guides/feast-gcp-aws/install-feast.md) - * [Create a feature repository](how-to-guides/feast-gcp-aws/create-a-feature-repository.md) - * [Deploy a feature store](how-to-guides/feast-gcp-aws/deploy-a-feature-store.md) - * [Build a training dataset](how-to-guides/feast-gcp-aws/build-a-training-dataset.md) - * [Load data into the online store](how-to-guides/feast-gcp-aws/load-data-into-the-online-store.md) - * [Read features from the online store](how-to-guides/feast-gcp-aws/read-features-from-the-online-store.md) + * [Install Feast](how-to-guides/feast-snowflake-gcp-aws/install-feast.md) + * [Create a feature repository](how-to-guides/feast-snowflake-gcp-aws/create-a-feature-repository.md) + * [Deploy a feature store](how-to-guides/feast-snowflake-gcp-aws/deploy-a-feature-store.md) + * [Build a training dataset](how-to-guides/feast-snowflake-gcp-aws/build-a-training-dataset.md) + * [Load data into the online store](how-to-guides/feast-snowflake-gcp-aws/load-data-into-the-online-store.md) + * [Read features from the online store](how-to-guides/feast-snowflake-gcp-aws/read-features-from-the-online-store.md) * [Running Feast in production](how-to-guides/running-feast-in-production.md) * [Upgrading from Feast 0.9](https://docs.google.com/document/u/1/d/1AOsr\_baczuARjCpmZgVd8mCqTF4AZ49OEyU4Cn-uTT0/edit) * [Adding a custom provider](how-to-guides/creating-a-custom-provider.md) @@ -75,9 +75,10 @@ * [Feature repository](reference/feature-repository/README.md) * [feature\_store.yaml](reference/feature-repository/feature-store-yaml.md) * [.feastignore](reference/feature-repository/feast-ignore.md) +* [Feature servers](reference/feature-servers/README.md) + * [Local feature server](reference/feature-servers/local-feature-server.md) * [\[Alpha\] On demand feature view](reference/alpha-on-demand-feature-view.md) * [\[Alpha\] Stream ingestion](reference/alpha-stream-ingestion.md) -* [\[Alpha\] Local feature server](reference/feature-server.md) * [\[Alpha\] AWS Lambda feature server](reference/alpha-aws-lambda-feature-server.md) * [Feast CLI reference](reference/feast-cli-commands.md) * [Python API reference](http://rtd.feast.dev) diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index a2dc5cd6a8a..00000000000 --- a/docs/architecture.md +++ /dev/null @@ -1,2 +0,0 @@ -# Architecture - diff --git a/docs/build-a-training-dataset.md b/docs/build-a-training-dataset.md deleted file mode 100644 index eff44fdf9c3..00000000000 --- a/docs/build-a-training-dataset.md +++ /dev/null @@ -1,2 +0,0 @@ -# Build a training dataset - diff --git a/docs/create-a-feature-repository.md b/docs/create-a-feature-repository.md deleted file mode 100644 index 5f781f0651d..00000000000 --- a/docs/create-a-feature-repository.md +++ /dev/null @@ -1,2 +0,0 @@ -# Create a feature repository - diff --git a/docs/deploy-a-feature-store.md b/docs/deploy-a-feature-store.md deleted file mode 100644 index 0447b0ffbfe..00000000000 --- a/docs/deploy-a-feature-store.md +++ /dev/null @@ -1,2 +0,0 @@ -# Deploy a feature store - diff --git a/docs/entities.md b/docs/entities.md deleted file mode 100644 index dadeac1cac3..00000000000 --- a/docs/entities.md +++ /dev/null @@ -1,2 +0,0 @@ -# Entities - diff --git a/docs/feature-views.md b/docs/feature-views.md deleted file mode 100644 index 235b8288353..00000000000 --- a/docs/feature-views.md +++ /dev/null @@ -1,2 +0,0 @@ -# Feature Views - diff --git a/docs/load-data-into-the-online-store.md b/docs/load-data-into-the-online-store.md deleted file mode 100644 index 48bfb27fc44..00000000000 --- a/docs/load-data-into-the-online-store.md +++ /dev/null @@ -1,2 +0,0 @@ -# Load data into the online store - diff --git a/docs/read-features-from-the-online-store.md b/docs/read-features-from-the-online-store.md deleted file mode 100644 index db082897a25..00000000000 --- a/docs/read-features-from-the-online-store.md +++ /dev/null @@ -1,2 +0,0 @@ -# Read features from the online store - diff --git a/docs/reference/feature-servers/README.md b/docs/reference/feature-servers/README.md new file mode 100644 index 00000000000..e9e3afa4c09 --- /dev/null +++ b/docs/reference/feature-servers/README.md @@ -0,0 +1,5 @@ +# Feature servers + +Feast users can choose to retrieve features from a feature server, as opposed to through the Python SDK. + +{% page-ref page="local-feature-server.md" %} diff --git a/docs/reference/feature-server.md b/docs/reference/feature-servers/local-feature-server.md similarity index 92% rename from docs/reference/feature-server.md rename to docs/reference/feature-servers/local-feature-server.md index 06837f42140..97a4da9b394 100644 --- a/docs/reference/feature-server.md +++ b/docs/reference/feature-servers/local-feature-server.md @@ -1,10 +1,4 @@ -# \[Alpha\] Local feature server - -**Warning**: This is an _experimental_ feature. It's intended for early testing and feedback, and could change without warnings in future releases. - -{% hint style="info" %} -To enable this feature, run **`feast alpha enable python_feature_server`** -{% endhint %} +# Local feature server ## Overview @@ -122,4 +116,3 @@ curl -X POST \ } }' | jq ``` - diff --git a/docs/reference/repository-config.md b/docs/reference/repository-config.md deleted file mode 100644 index 128d7730717..00000000000 --- a/docs/reference/repository-config.md +++ /dev/null @@ -1,2 +0,0 @@ -# Repository Config - diff --git a/docs/reference/telemetry.md b/docs/reference/telemetry.md deleted file mode 100644 index f8f76787645..00000000000 --- a/docs/reference/telemetry.md +++ /dev/null @@ -1,12 +0,0 @@ -# Telemetry - -### How telemetry is used - -The Feast project logs anonymous usage statistics and errors in order to inform our planning. Several client methods are tracked, beginning in Feast 0.9. Users are assigned a UUID which is sent along with the name of the method, the Feast version, the OS \(using `sys.platform`\), and the current time. - -The [source code](https://github.com/feast-dev/feast/blob/master/sdk/python/feast/telemetry.py) is available here. - -### How to disable telemetry - -Set the environment variable `FEAST_TELEMETRY` to `False`. - diff --git a/docs/repository-config.md b/docs/repository-config.md deleted file mode 100644 index 128d7730717..00000000000 --- a/docs/repository-config.md +++ /dev/null @@ -1,2 +0,0 @@ -# Repository Config - diff --git a/docs/sources.md b/docs/sources.md deleted file mode 100644 index a76d395d098..00000000000 --- a/docs/sources.md +++ /dev/null @@ -1,2 +0,0 @@ -# Sources - diff --git a/infra/charts/feast-python-server/README.md b/infra/charts/feast-python-server/README.md index b8516bc6dcb..ff7246848f4 100644 --- a/infra/charts/feast-python-server/README.md +++ b/infra/charts/feast-python-server/README.md @@ -56,16 +56,4 @@ RUN pip install pip --upgrade RUN pip install feast COPY feature_store.yaml /feature_store.yaml -``` - -Make sure that you have enabled the flags for the python server. Example `feature_store.yaml`: -``` -project: feature_repo -registry: data/registry.db -provider: local -online_store: - path: data/online_store.db -flags: - alpha_features: true - python_feature_server: true ``` \ No newline at end of file diff --git a/infra/charts/feast/charts/transformation-service/config/feature_store.yaml b/infra/charts/feast/charts/transformation-service/config/feature_store.yaml index 234471fb968..555e93a306a 100644 --- a/infra/charts/feast/charts/transformation-service/config/feature_store.yaml +++ b/infra/charts/feast/charts/transformation-service/config/feature_store.yaml @@ -5,5 +5,4 @@ provider: local project: {{ .Values.global.project }} flags: on_demand_transforms: true - python_feature_server: true alpha_features: true \ No newline at end of file diff --git a/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml b/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml index 102dfdd5f4c..7554725004f 100644 --- a/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml +++ b/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml @@ -8,4 +8,3 @@ offline_store: {} flags: alpha_features: true on_demand_transforms: true - python_feature_server: true diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 6b1dadde5c9..fcd94f9bea8 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -1769,9 +1769,6 @@ def _get_feature_views_to_use( @log_exceptions_and_usage def serve(self, host: str, port: int, no_access_log: bool) -> None: """Start the feature consumption server locally on a given port.""" - if not flags_helper.enable_python_feature_server(self.config): - raise ExperimentalFeatureNotEnabled(flags.FLAG_PYTHON_FEATURE_SERVER_NAME) - feature_server.start_server(self, host, port, no_access_log) @log_exceptions_and_usage @@ -1782,8 +1779,6 @@ def get_feature_server_endpoint(self) -> Optional[str]: @log_exceptions_and_usage def serve_transformations(self, port: int) -> None: """Start the feature transformation server locally on a given port.""" - if not flags_helper.enable_python_feature_server(self.config): - raise ExperimentalFeatureNotEnabled(flags.FLAG_PYTHON_FEATURE_SERVER_NAME) if not flags_helper.enable_on_demand_feature_views(self.config): raise ExperimentalFeatureNotEnabled(flags.FLAG_ON_DEMAND_TRANSFORM_NAME) diff --git a/sdk/python/feast/flags.py b/sdk/python/feast/flags.py index 5c6357ec26f..a1ca0c3b736 100644 --- a/sdk/python/feast/flags.py +++ b/sdk/python/feast/flags.py @@ -1,6 +1,5 @@ FLAG_ALPHA_FEATURES_NAME = "alpha_features" FLAG_ON_DEMAND_TRANSFORM_NAME = "on_demand_transforms" -FLAG_PYTHON_FEATURE_SERVER_NAME = "python_feature_server" FLAG_AWS_LAMBDA_FEATURE_SERVER_NAME = "aws_lambda_feature_server" FLAG_DIRECT_INGEST_TO_ONLINE_STORE = "direct_ingest_to_online_store" ENV_FLAG_IS_TEST = "IS_TEST" @@ -8,7 +7,6 @@ FLAG_NAMES = { FLAG_ALPHA_FEATURES_NAME, FLAG_ON_DEMAND_TRANSFORM_NAME, - FLAG_PYTHON_FEATURE_SERVER_NAME, FLAG_AWS_LAMBDA_FEATURE_SERVER_NAME, FLAG_DIRECT_INGEST_TO_ONLINE_STORE, } diff --git a/sdk/python/feast/flags_helper.py b/sdk/python/feast/flags_helper.py index 89784d6ecca..89905e7d36a 100644 --- a/sdk/python/feast/flags_helper.py +++ b/sdk/python/feast/flags_helper.py @@ -35,10 +35,6 @@ def enable_on_demand_feature_views(repo_config: RepoConfig) -> bool: return feature_flag_enabled(repo_config, flags.FLAG_ON_DEMAND_TRANSFORM_NAME) -def enable_python_feature_server(repo_config: RepoConfig) -> bool: - return feature_flag_enabled(repo_config, flags.FLAG_PYTHON_FEATURE_SERVER_NAME) - - def enable_aws_lambda_feature_server(repo_config: RepoConfig) -> bool: return feature_flag_enabled(repo_config, flags.FLAG_AWS_LAMBDA_FEATURE_SERVER_NAME) From 6e304579f6c01574aa56f3c08f1420bcf311935e Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 2 Feb 2022 15:25:43 +0000 Subject: [PATCH 55/85] Use `datetime.utcnow()` to avoid timezone issues (#2265) Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- sdk/python/feast/registry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index 3352c54ffd7..a3ab613fbd8 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -374,7 +374,7 @@ def apply_feature_view( """ feature_view.ensure_valid() if not feature_view.created_timestamp: - feature_view.created_timestamp = datetime.now() + feature_view.created_timestamp = datetime.utcnow() feature_view_proto = feature_view.to_proto() feature_view_proto.spec.project = project self._prepare_registry_for_changes() @@ -819,7 +819,7 @@ def _prepare_registry_for_changes(self): registry_proto = RegistryProto() registry_proto.registry_schema_version = REGISTRY_SCHEMA_VERSION self.cached_registry_proto = registry_proto - self.cached_registry_proto_created = datetime.now() + self.cached_registry_proto_created = datetime.utcnow() return self.cached_registry_proto def _get_registry_proto(self, allow_cache: bool = False) -> RegistryProto: @@ -838,7 +838,7 @@ def _get_registry_proto(self, allow_cache: bool = False) -> RegistryProto: self.cached_registry_proto_ttl.total_seconds() > 0 # 0 ttl means infinity and ( - datetime.now() + datetime.utcnow() > ( self.cached_registry_proto_created + self.cached_registry_proto_ttl @@ -852,7 +852,7 @@ def _get_registry_proto(self, allow_cache: bool = False) -> RegistryProto: registry_proto = self._registry_store.get_registry_proto() self.cached_registry_proto = registry_proto - self.cached_registry_proto_created = datetime.now() + self.cached_registry_proto_created = datetime.utcnow() return registry_proto From a3073ec1b90e9ef4fb24f0224c3f4e210c1a927c Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 2 Feb 2022 19:38:45 +0000 Subject: [PATCH 56/85] Set `created_timestamp` and `last_updated_timestamp` fields (#2266) * Add `last_updated_timestamp` field to ODFV Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Add missing fields to FeatureView classes Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Set `last_updated_timestamp` when applying FeatureView Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Correctly set created and updated fields Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> * Remove logic duplicated by parent class Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com> --- protos/feast/core/OnDemandFeatureView.proto | 3 +++ sdk/python/feast/base_feature_view.py | 4 ++++ sdk/python/feast/feature_view.py | 6 +----- sdk/python/feast/on_demand_feature_view.py | 6 ++++++ sdk/python/feast/registry.py | 23 ++++++++++++++++++++- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/protos/feast/core/OnDemandFeatureView.proto b/protos/feast/core/OnDemandFeatureView.proto index 31fe90a9ba2..58feff5bfdb 100644 --- a/protos/feast/core/OnDemandFeatureView.proto +++ b/protos/feast/core/OnDemandFeatureView.proto @@ -55,6 +55,9 @@ message OnDemandFeatureViewSpec { message OnDemandFeatureViewMeta { // Time where this Feature View is created google.protobuf.Timestamp created_timestamp = 1; + + // Time where this Feature View is last updated + google.protobuf.Timestamp last_updated_timestamp = 2; } message OnDemandInput { diff --git a/sdk/python/feast/base_feature_view.py b/sdk/python/feast/base_feature_view.py index 97180266d7e..b2178ec6312 100644 --- a/sdk/python/feast/base_feature_view.py +++ b/sdk/python/feast/base_feature_view.py @@ -28,12 +28,16 @@ class BaseFeatureView(ABC): """A FeatureView defines a logical grouping of features to be served.""" + created_timestamp: Optional[datetime] + last_updated_timestamp: Optional[datetime] + @abstractmethod def __init__(self, name: str, features: List[Feature]): self._name = name self._features = features self._projection = FeatureViewProjection.from_definition(self) self.created_timestamp: Optional[datetime] = None + self.last_updated_timestamp: Optional[datetime] = None @property def name(self) -> str: diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 57b60c0503b..2c1d0675d4a 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -74,8 +74,7 @@ class FeatureView(BaseFeatureView): online: bool input: DataSource batch_source: DataSource - stream_source: Optional[DataSource] = None - last_updated_timestamp: Optional[datetime] = None + stream_source: Optional[DataSource] materialization_intervals: List[Tuple[datetime, datetime]] @log_exceptions @@ -136,9 +135,6 @@ def __init__( self.materialization_intervals = [] - self.created_timestamp: Optional[datetime] = None - self.last_updated_timestamp: Optional[datetime] = None - # Note: Python requires redefining hash in child classes that override __eq__ def __hash__(self): return super().__hash__() diff --git a/sdk/python/feast/on_demand_feature_view.py b/sdk/python/feast/on_demand_feature_view.py index 789422add4b..04b7f33cc66 100644 --- a/sdk/python/feast/on_demand_feature_view.py +++ b/sdk/python/feast/on_demand_feature_view.py @@ -119,6 +119,8 @@ def to_proto(self) -> OnDemandFeatureViewProto: meta = OnDemandFeatureViewMeta() if self.created_timestamp: meta.created_timestamp.FromDatetime(self.created_timestamp) + if self.last_updated_timestamp: + meta.last_updated_timestamp.FromDatetime(self.last_updated_timestamp) inputs = {} for input_ref, fv_projection in self.input_feature_view_projections.items(): inputs[input_ref] = OnDemandInput( @@ -194,6 +196,10 @@ def from_proto(cls, on_demand_feature_view_proto: OnDemandFeatureViewProto): on_demand_feature_view_obj.created_timestamp = ( on_demand_feature_view_proto.meta.created_timestamp.ToDatetime() ) + if on_demand_feature_view_proto.meta.HasField("last_updated_timestamp"): + on_demand_feature_view_obj.last_updated_timestamp = ( + on_demand_feature_view_proto.meta.last_updated_timestamp.ToDatetime() + ) return on_demand_feature_view_obj diff --git a/sdk/python/feast/registry.py b/sdk/python/feast/registry.py index a3ab613fbd8..07c4c59b012 100644 --- a/sdk/python/feast/registry.py +++ b/sdk/python/feast/registry.py @@ -231,6 +231,12 @@ def apply_entity(self, entity: Entity, project: str, commit: bool = True): commit: Whether the change should be persisted immediately """ entity.is_valid() + + now = datetime.utcnow() + if not entity.created_timestamp: + entity._created_timestamp = now + entity._last_updated_timestamp = now + entity_proto = entity.to_proto() entity_proto.spec.project = project self._prepare_registry_for_changes() @@ -278,6 +284,11 @@ def apply_feature_service( feature_service: A feature service that will be registered project: Feast project that this entity belongs to """ + now = datetime.utcnow() + if not feature_service.created_timestamp: + feature_service.created_timestamp = now + feature_service.last_updated_timestamp = now + feature_service_proto = feature_service.to_proto() feature_service_proto.spec.project = project @@ -373,8 +384,12 @@ def apply_feature_view( commit: Whether the change should be persisted immediately """ feature_view.ensure_valid() + + now = datetime.utcnow() if not feature_view.created_timestamp: - feature_view.created_timestamp = datetime.utcnow() + feature_view.created_timestamp = now + feature_view.last_updated_timestamp = now + feature_view_proto = feature_view.to_proto() feature_view_proto.spec.project = project self._prepare_registry_for_changes() @@ -498,6 +513,7 @@ def apply_materialization( existing_feature_view.materialization_intervals.append( (start_date, end_date) ) + existing_feature_view.last_updated_timestamp = datetime.utcnow() feature_view_proto = existing_feature_view.to_proto() feature_view_proto.spec.project = project del self.cached_registry_proto.feature_views[idx] @@ -686,6 +702,11 @@ def apply_saved_dataset( project: Feast project that this dataset belongs to commit: Whether the change should be persisted immediately """ + now = datetime.utcnow() + if not saved_dataset.created_timestamp: + saved_dataset.created_timestamp = now + saved_dataset.last_updated_timestamp = now + saved_dataset_proto = saved_dataset.to_proto() saved_dataset_proto.spec.project = project self._prepare_registry_for_changes() From ffc6e76614a3b2eea3eacbc7a61c9c309535d9d9 Mon Sep 17 00:00:00 2001 From: Tsotne Tabidze Date: Wed, 2 Feb 2022 11:39:43 -0800 Subject: [PATCH 57/85] Update local-feature-server.md (#2269) Signed-off-by: Tsotne Tabidze --- .../feature-servers/local-feature-server.md | 100 +++++++++++------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/docs/reference/feature-servers/local-feature-server.md b/docs/reference/feature-servers/local-feature-server.md index 97a4da9b394..f49212df421 100644 --- a/docs/reference/feature-servers/local-feature-server.md +++ b/docs/reference/feature-servers/local-feature-server.md @@ -57,48 +57,74 @@ $ curl -X POST \ } }' | jq { - "field_values": [ + "metadata": { + "feature_names": [ + "driver_id", + "conv_rate", + "avg_daily_trips", + "acc_rate" + ] + }, + "results": [ { - "fields": { - "driver_id": 1001, - "conv_rate": 0.07427442818880081, - "avg_daily_trips": 140, - "acc_rate": 0.8625795245170593 - }, - "statuses": { - "conv_rate": "PRESENT", - "acc_rate": "PRESENT", - "driver_id": "PRESENT", - "avg_daily_trips": "PRESENT" - } + "values": [ + 1001, + 0.7037263512611389, + 308, + 0.8724706768989563 + ], + "statuses": [ + "PRESENT", + "PRESENT", + "PRESENT", + "PRESENT" + ], + "event_timestamps": [ + "1970-01-01T00:00:00Z", + "2021-12-31T23:00:00Z", + "2021-12-31T23:00:00Z", + "2021-12-31T23:00:00Z" + ] }, { - "fields": { - "avg_daily_trips": 646, - "acc_rate": 0.8026317954063416, - "conv_rate": 0.41487279534339905, - "driver_id": 1002 - }, - "statuses": { - "driver_id": "PRESENT", - "avg_daily_trips": "PRESENT", - "conv_rate": "PRESENT", - "acc_rate": "PRESENT" - } + "values": [ + 1002, + 0.038169607520103455, + 332, + 0.48534533381462097 + ], + "statuses": [ + "PRESENT", + "PRESENT", + "PRESENT", + "PRESENT" + ], + "event_timestamps": [ + "1970-01-01T00:00:00Z", + "2021-12-31T23:00:00Z", + "2021-12-31T23:00:00Z", + "2021-12-31T23:00:00Z" + ] }, { - "fields": { - "avg_daily_trips": 671, - "conv_rate": 0.4033895432949066, - "driver_id": 1003, - "acc_rate": 0.06059994176030159 - }, - "statuses": { - "driver_id": "PRESENT", - "conv_rate": "PRESENT", - "avg_daily_trips": "PRESENT", - "acc_rate": "PRESENT" - } + "values": [ + 1003, + 0.9665873050689697, + 779, + 0.7793770432472229 + ], + "statuses": [ + "PRESENT", + "PRESENT", + "PRESENT", + "PRESENT" + ], + "event_timestamps": [ + "1970-01-01T00:00:00Z", + "2021-12-31T23:00:00Z", + "2021-12-31T23:00:00Z", + "2021-12-31T23:00:00Z" + ] } ] } From efd83fdfd5964c8de931c887ae5f65de1af7f7eb Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Wed, 2 Feb 2022 16:25:43 -0500 Subject: [PATCH 58/85] Update docs to include Snowflake/DQM and removing unused docs from old versions of Feast (#2268) * Update docs to include Snowflake, update roadmap to include DQM, fix quickstart outdated issues, and remove unnecessary docs from old Feast versions Signed-off-by: Danny Chiao * fix template Signed-off-by: Danny Chiao * fix template Signed-off-by: Danny Chiao * Reference dqm guide Signed-off-by: Danny Chiao --- README.md | 8 +- docs/README.md | 4 +- docs/SUMMARY.md | 3 +- docs/advanced/audit-logging.md | 132 ----- docs/advanced/metrics.md | 59 --- docs/advanced/security.md | 480 ------------------ docs/advanced/troubleshooting.md | 136 ----- docs/advanced/upgrading.md | 113 ----- docs/architecture.png | Bin 106242 -> 0 bytes docs/assets/arch.png | Bin 24680 -> 0 bytes docs/assets/feast-components-overview.png | Bin 82842 -> 0 bytes docs/assets/feast-marchitecture.png | Bin 0 -> 79558 bytes docs/assets/statistics-sources (1).png | Bin 165507 -> 0 bytes docs/assets/statistics-sources (2).png | Bin 165507 -> 0 bytes docs/assets/statistics-sources (3).png | Bin 165507 -> 0 bytes docs/assets/statistics-sources (4).png | Bin 165507 -> 0 bytes docs/assets/statistics-sources.png | Bin 165507 -> 0 bytes .../assets/basic-architecture-diagram.svg | 1 - .../assets/feast-docs-overview-diagram-2.svg | 1 - docs/feast-on-kubernetes/advanced-1/README.md | 2 - .../advanced-1/audit-logging.md | 132 ----- .../feast-on-kubernetes/advanced-1/metrics.md | 59 --- .../advanced-1/security.md | 480 ------------------ .../advanced-1/troubleshooting.md | 136 ----- .../advanced-1/upgrading.md | 113 ----- docs/feast-on-kubernetes/concepts/README.md | 2 - .../concepts/architecture.md | 51 -- docs/feast-on-kubernetes/concepts/entities.md | 64 --- .../concepts/feature-tables.md | 122 ----- docs/feast-on-kubernetes/concepts/overview.md | 21 - docs/feast-on-kubernetes/concepts/sources.md | 90 ---- docs/feast-on-kubernetes/concepts/stores.md | 20 - .../getting-started/README.md | 24 - .../connect-to-feast/README.md | 31 -- .../connect-to-feast/feast-cli.md | 37 -- .../connect-to-feast/python-sdk.md | 20 - .../getting-started/install-feast/README.md | 40 -- .../google-cloud-gke-with-terraform.md | 52 -- .../ibm-cloud-iks-with-kustomize.md | 193 ------- .../kubernetes-amazon-eks-with-terraform.md | 68 --- .../kubernetes-azure-aks-with-helm.md | 139 ----- .../kubernetes-azure-aks-with-terraform.md | 63 --- .../install-feast/kubernetes-with-helm.md | 69 --- .../install-feast/quickstart.md | 91 ---- .../getting-started/learn-feast.md | 15 - .../feast-on-kubernetes/reference-1/README.md | 2 - .../reference-1/api/README.md | 17 - .../reference-1/configuration-reference.md | 132 ----- .../reference-1/feast-and-spark.md | 83 --- .../reference-1/limitations.md | 52 -- .../reference-1/metrics-reference.md | 178 ------- .../feast-on-kubernetes/tutorials-1/README.md | 2 - docs/feast-on-kubernetes/user-guide/README.md | 2 - .../user-guide/define-and-ingest-features.md | 52 -- .../user-guide/getting-online-features.md | 54 -- .../user-guide/getting-training-features.md | 72 --- .../user-guide/overview.md | 32 -- .../{untitled.md => registry.md} | 0 .../connect-to-feast/README.md | 31 -- .../connect-to-feast/feast-cli.md | 37 -- .../connect-to-feast/python-sdk.md | 20 - docs/getting-started/install-feast/README.md | 33 -- .../google-cloud-gke-with-terraform.md | 52 -- .../ibm-cloud-iks-with-kustomize.md | 185 ------- .../kubernetes-amazon-eks-with-terraform.md | 68 --- .../kubernetes-azure-aks-with-helm.md | 139 ----- .../kubernetes-azure-aks-with-terraform.md | 63 --- .../install-feast/kubernetes-with-helm.md | 69 --- docs/getting-started/learn-feast.md | 15 - docs/getting-started/quickstart.md | 13 +- docs/reference/api.md | 17 - docs/reference/api/README.md | 17 - docs/reference/configuration-reference.md | 132 ----- docs/reference/feast-and-spark.md | 83 --- docs/reference/limitations.md | 52 -- docs/reference/metrics-reference.md | 178 ------- docs/roadmap.md | 6 +- docs/user-guide/define-and-ingest-features.md | 56 -- docs/user-guide/getting-online-features.md | 54 -- docs/user-guide/getting-training-features.md | 72 --- docs/user-guide/overview.md | 32 -- examples/quickstart/quickstart.ipynb | 14 +- infra/templates/README.md.jinja2 | 4 +- 83 files changed, 28 insertions(+), 5163 deletions(-) delete mode 100644 docs/advanced/audit-logging.md delete mode 100644 docs/advanced/metrics.md delete mode 100644 docs/advanced/security.md delete mode 100644 docs/advanced/troubleshooting.md delete mode 100644 docs/advanced/upgrading.md delete mode 100644 docs/architecture.png delete mode 100644 docs/assets/arch.png delete mode 100644 docs/assets/feast-components-overview.png create mode 100644 docs/assets/feast-marchitecture.png delete mode 100644 docs/assets/statistics-sources (1).png delete mode 100644 docs/assets/statistics-sources (2).png delete mode 100644 docs/assets/statistics-sources (3).png delete mode 100644 docs/assets/statistics-sources (4).png delete mode 100644 docs/assets/statistics-sources.png delete mode 100644 docs/docs/.gitbook/assets/basic-architecture-diagram.svg delete mode 100644 docs/docs/.gitbook/assets/feast-docs-overview-diagram-2.svg delete mode 100644 docs/feast-on-kubernetes/advanced-1/README.md delete mode 100644 docs/feast-on-kubernetes/advanced-1/audit-logging.md delete mode 100644 docs/feast-on-kubernetes/advanced-1/metrics.md delete mode 100644 docs/feast-on-kubernetes/advanced-1/security.md delete mode 100644 docs/feast-on-kubernetes/advanced-1/troubleshooting.md delete mode 100644 docs/feast-on-kubernetes/advanced-1/upgrading.md delete mode 100644 docs/feast-on-kubernetes/concepts/README.md delete mode 100644 docs/feast-on-kubernetes/concepts/architecture.md delete mode 100644 docs/feast-on-kubernetes/concepts/entities.md delete mode 100644 docs/feast-on-kubernetes/concepts/feature-tables.md delete mode 100644 docs/feast-on-kubernetes/concepts/overview.md delete mode 100644 docs/feast-on-kubernetes/concepts/sources.md delete mode 100644 docs/feast-on-kubernetes/concepts/stores.md delete mode 100644 docs/feast-on-kubernetes/getting-started/README.md delete mode 100644 docs/feast-on-kubernetes/getting-started/connect-to-feast/README.md delete mode 100644 docs/feast-on-kubernetes/getting-started/connect-to-feast/feast-cli.md delete mode 100644 docs/feast-on-kubernetes/getting-started/connect-to-feast/python-sdk.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/README.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/google-cloud-gke-with-terraform.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-helm.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-with-helm.md delete mode 100644 docs/feast-on-kubernetes/getting-started/install-feast/quickstart.md delete mode 100644 docs/feast-on-kubernetes/getting-started/learn-feast.md delete mode 100644 docs/feast-on-kubernetes/reference-1/README.md delete mode 100644 docs/feast-on-kubernetes/reference-1/api/README.md delete mode 100644 docs/feast-on-kubernetes/reference-1/configuration-reference.md delete mode 100644 docs/feast-on-kubernetes/reference-1/feast-and-spark.md delete mode 100644 docs/feast-on-kubernetes/reference-1/limitations.md delete mode 100644 docs/feast-on-kubernetes/reference-1/metrics-reference.md delete mode 100644 docs/feast-on-kubernetes/tutorials-1/README.md delete mode 100644 docs/feast-on-kubernetes/user-guide/README.md delete mode 100644 docs/feast-on-kubernetes/user-guide/define-and-ingest-features.md delete mode 100644 docs/feast-on-kubernetes/user-guide/getting-online-features.md delete mode 100644 docs/feast-on-kubernetes/user-guide/getting-training-features.md delete mode 100644 docs/feast-on-kubernetes/user-guide/overview.md rename docs/getting-started/architecture-and-components/{untitled.md => registry.md} (100%) delete mode 100644 docs/getting-started/connect-to-feast/README.md delete mode 100644 docs/getting-started/connect-to-feast/feast-cli.md delete mode 100644 docs/getting-started/connect-to-feast/python-sdk.md delete mode 100644 docs/getting-started/install-feast/README.md delete mode 100644 docs/getting-started/install-feast/google-cloud-gke-with-terraform.md delete mode 100644 docs/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md delete mode 100644 docs/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md delete mode 100644 docs/getting-started/install-feast/kubernetes-azure-aks-with-helm.md delete mode 100644 docs/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md delete mode 100644 docs/getting-started/install-feast/kubernetes-with-helm.md delete mode 100644 docs/getting-started/learn-feast.md delete mode 100644 docs/reference/api.md delete mode 100644 docs/reference/api/README.md delete mode 100644 docs/reference/configuration-reference.md delete mode 100644 docs/reference/feast-and-spark.md delete mode 100644 docs/reference/limitations.md delete mode 100644 docs/reference/metrics-reference.md delete mode 100644 docs/user-guide/define-and-ingest-features.md delete mode 100644 docs/user-guide/getting-online-features.md delete mode 100644 docs/user-guide/getting-training-features.md delete mode 100644 docs/user-guide/overview.md diff --git a/README.md b/README.md index 7ede0c612a1..4125972046e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Feast is an open source feature store for machine learning. Feast is the fastest Please see our [documentation](https://docs.feast.dev/) for more information about the project. ## πŸ“ Architecture - +![](docs/assets/feast-marchitecture.png) The above architecture is the minimal Feast deployment. Want to run the full Feast on GCP/AWS? Click [here](https://docs.feast.dev/how-to-guides/feast-gcp-aws). @@ -189,7 +189,7 @@ The list below contains the functionality that contributors are planning to deve * [ ] Delete API * [ ] Feature Logging (for training) * **Data Quality Management (See [RFC](https://docs.google.com/document/d/110F72d4NTv80p35wDSONxhhPBqWRwbZXG4f9mNEMd98/edit))** - * [ ] Data profiling and validation (Great Expectations) (Planned for Q1 2022) + * [x] Data profiling and validation (Great Expectations) * [ ] Metric production * [ ] Training-serving skew detection * [ ] Drift detection @@ -197,10 +197,10 @@ The list below contains the functionality that contributors are planning to deve * [x] Python SDK for browsing feature registry * [x] CLI for browsing feature registry * [x] Model-centric feature tracking (feature services) + * [x] Amundsen integration (see [Feast extractor](https://github.com/amundsen-io/amundsen/blob/main/databuilder/databuilder/extractor/feast_extractor.py)) * [ ] REST API for browsing feature registry * [ ] Feast Web UI * [ ] Feature versioning - * [ ] Amundsen integration ## πŸŽ“ Important Resources @@ -224,4 +224,4 @@ Thanks goes to these incredible people: - + \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index d5c5177a18f..f8b9af3c32f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ Feast (**Fea**ture **St**ore) is an operational data system for managing and serving machine learning features to models in production. Feast is able to serve feature data to models from a low-latency online store (for real-time prediction) or from an offline store (for scale-out batch scoring or model training). -![](.gitbook/assets/feast-marchitecture-211014.png) +![](assets/feast-marchitecture.png) ## Problems Feast Solves @@ -30,7 +30,7 @@ Feast addresses this problem by introducing feature reuse through a centralized **Feature discovery:** We also aim for Feast to include a first-class user interface for exploring and discovering entities and features. -**β€ŒFeature validation:** We additionally aim for Feast to improve support for statistics generation of feature data and subsequent validation of these statistics. Current support is limited. +**Feature validation:** We additionally aim for Feast to improve support for statistics generation of feature data and subsequent validation of these statistics. Current support is limited. ## What Feast is not diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5bc40196e57..93a89cbb671 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -20,7 +20,7 @@ * [Architecture](getting-started/architecture-and-components/README.md) * [Overview](getting-started/architecture-and-components/overview.md) * [Feature repository](getting-started/architecture-and-components/feature-repository.md) - * [Registry](getting-started/architecture-and-components/untitled.md) + * [Registry](getting-started/architecture-and-components/registry.md) * [Offline store](getting-started/architecture-and-components/offline-store.md) * [Online store](getting-started/architecture-and-components/online-store.md) * [Provider](getting-started/architecture-and-components/provider.md) @@ -77,6 +77,7 @@ * [.feastignore](reference/feature-repository/feast-ignore.md) * [Feature servers](reference/feature-servers/README.md) * [Local feature server](reference/feature-servers/local-feature-server.md) +* [\[Alpha\] Data quality monitoring](reference/dqm.md) * [\[Alpha\] On demand feature view](reference/alpha-on-demand-feature-view.md) * [\[Alpha\] Stream ingestion](reference/alpha-stream-ingestion.md) * [\[Alpha\] AWS Lambda feature server](reference/alpha-aws-lambda-feature-server.md) diff --git a/docs/advanced/audit-logging.md b/docs/advanced/audit-logging.md deleted file mode 100644 index 1870a687bd4..00000000000 --- a/docs/advanced/audit-logging.md +++ /dev/null @@ -1,132 +0,0 @@ -# Audit Logging - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -## Introduction - -Feast provides audit logging functionality in order to debug problems and to trace the lineage of events. - -## Audit Log Types - -Audit Logs produced by Feast come in three favors: - -| Audit Log Type | Description | -| :--- | :--- | -| Message Audit Log | Logs service calls that can be used to track Feast request handling. Currently only gRPC request/response is supported. Enabling Message Audit Logs can be resource intensive and significantly increase latency, as such is not recommended on Online Serving. | -| Transition Audit Log | Logs transitions in status in resources managed by Feast \(ie an Ingestion Job becoming RUNNING\). | -| Action Audit Log | Logs actions performed on a specific resource managed by Feast \(ie an Ingestion Job is aborted\). | - -## Configuration - -| Audit Log Type | Description | -| :--- | :--- | -| Message Audit Log | Enabled when both `feast.logging.audit.enabled` and `feast.logging.audit.messageLogging.enabled` is set to `true` | -| Transition Audit Log | Enabled when `feast.logging.audit.enabled` is set to `true` | -| Action Audit Log | Enabled when `feast.logging.audit.enabled` is set to `true` | - -## JSON Format - -Audit Logs produced by Feast are written to the console similar to normal logs but in a structured, machine parsable JSON. Example of a Message Audit Log JSON entry produced: - -```text -{ - "message": { - "logType": "FeastAuditLogEntry", - "kind": "MESSAGE", - "statusCode": "OK", - "request": { - "filter": { - "project": "dummy", - } - }, - "application": "Feast", - "response": {}, - "method": "ListFeatureTables", - "identity": "105960238928959148073", - "service": "CoreService", - "component": "feast-core", - "id": "45329ea9-0d48-46c5-b659-4604f6193711", - "version": "0.10.0-SNAPSHOT" - }, - "hostname": "feast.core" - "timestamp": "2020-10-20T04:45:24Z", - "severity": "INFO", -} -``` - -## Log Entry Schema - -Fields common to all Audit Log Types: - -| Field | Description | -| :--- | :--- | -| `logType` | Log Type. Always set to `FeastAuditLogEntry`. Useful for filtering out Feast audit logs. | -| `application` | Application. Always set to `Feast`. | -| `component` | Feast Component producing the Audit Log. Set to `feast-core` for Feast Core and `feast-serving` for Feast Serving. Use to filtering out Audit Logs by component. | -| `version` | Version of Feast producing this Audit Log. Use to filtering out Audit Logs by version. | - -Fields in Message Audit Log Type - -| Field | Description | -| :--- | :--- | -| `id` | Generated UUID that uniquely identifies the service call. | -| `service` | Name of the Service that handled the service call. | -| `method` | Name of the Method that handled the service call. Useful for filtering Audit Logs by method \(ie `ApplyFeatureTable` calls\) | -| `request` | Full request submitted by client in the service call as JSON. | -| `response` | Full response returned to client by the service after handling the service call as JSON. | -| `identity` | Identity of the client making the service call as an user Id. Only set when Authentication is enabled. | -| `statusCode` | The status code returned by the service handling the service call \(ie `OK` if service call handled without error\). | - -Fields in Action Audit Log Type - -| Field | Description | -| :--- | :--- | -| `action` | Name of the action taken on the resource. | -| `resource.type` | Type of resource of which the action was taken on \(i.e `FeatureTable`\) | -| resource.id | Identifier specifying the specific resource of which the action was taken on. | - -Fields in Transition Audit Log Type - -| Field | Description | -| :--- | :--- | -| `status` | The new status that the resource transitioned to | -| `resource.type` | Type of resource of which the transition occurred \(i.e `FeatureTable`\) | -| `resource.id` | Identifier specifying the specific resource of which the transition occurred. | - -## Log Forwarder - -Feast currently only supports forwarding Request/Response \(Message Audit Log Type\) logs to an external fluentD service with `feast.**` Fluentd tag. - -### Request/Response Log Example - -```text -{ - "id": "45329ea9-0d48-46c5-b659-4604f6193711", - "service": "CoreService" - "status_code": "OK", - "identity": "105960238928959148073", - "method": "ListProjects", - "request": {}, - "response": { - "projects": [ - "default", "project1", "project2" - ] - } - "release_name": 506.457.14.512 -} -``` - -### Configuration - -The Fluentd Log Forwarder configured with the with the following configuration options in `application.yml`: - -| Settings | Description | -| :--- | :--- | -| `feast.logging.audit.messageLogging.destination` | `fluentd` | -| `feast.logging.audit.messageLogging.fluentdHost` | `localhost` | -| `feast.logging.audit.messageLogging.fluentdPort` | `24224` | - -When using Fluentd as the Log forwarder, a Feast `release_name` can be logged instead of the IP address \(eg. IP of Kubernetes pod deployment\), by setting an environment variable `RELEASE_NAME` when deploying Feast. - diff --git a/docs/advanced/metrics.md b/docs/advanced/metrics.md deleted file mode 100644 index 5ea69f883f7..00000000000 --- a/docs/advanced/metrics.md +++ /dev/null @@ -1,59 +0,0 @@ -# Metrics - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -## Overview - -Feast Components export metrics that can provide insight into Feast behavior: - -* [Feast Ingestion Jobs can be configured to push metrics into StatsD](metrics.md#pushing-ingestion-metrics-to-statsd) -* [Prometheus can be configured to scrape metrics from Feast Core and Serving.](metrics.md#exporting-feast-metrics-to-prometheus) - -See the [Metrics Reference ](../reference/metrics-reference.md)for documentation on metrics are exported by Feast. - -{% hint style="info" %} -Feast Job Controller currently does not export any metrics on its own. However its `application.yml` is used to configure metrics export for ingestion jobs. -{% endhint %} - -## Pushing Ingestion Metrics to StatsD - -### **Feast Ingestion Job** - -Feast Ingestion Job can be configured to push Ingestion metrics to a StatsD instance. Metrics export to StatsD for Ingestion Job is configured in Job Controller's `application.yml` under `feast.jobs.metrics` - -```yaml - feast: - jobs: - metrics: - # Enables Statd metrics export if true. - enabled: true - type: statsd - # Host and port of the StatsD instance to export to. - host: localhost - port: 9125 -``` - -{% hint style="info" %} -If you need Ingestion Metrics in Prometheus or some other metrics backend, use a metrics forwarder to forward Ingestion Metrics from StatsD to the metrics backend of choice. \(ie Use [`prometheus-statsd-exporter`](https://github.com/prometheus/statsd_exporter) to forward metrics to Prometheus\). -{% endhint %} - -## Exporting Feast Metrics to Prometheus - -### **Feast Core and Serving** - -Feast Core and Serving exports metrics to a Prometheus instance via Prometheus scraping its `/metrics` endpoint. Metrics export to Prometheus for Core and Serving can be configured via their corresponding `application.yml` - -```yaml -server: - # Configures the port where metrics are exposed via /metrics for Prometheus to scrape. - port: 8081 -``` - -[Direct Prometheus](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config) to scrape directly from Core and Serving's `/metrics` endpoint. - -## Further Reading - -See the [Metrics Reference ](../reference/metrics-reference.md)for documentation on metrics are exported by Feast. - diff --git a/docs/advanced/security.md b/docs/advanced/security.md deleted file mode 100644 index 769260074f5..00000000000 --- a/docs/advanced/security.md +++ /dev/null @@ -1,480 +0,0 @@ ---- -description: 'Secure Feast with SSL/TLS, Authentication and Authorization.' ---- - -# Security - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -### Overview - -![Overview of Feast's Security Methods.](../.gitbook/assets/untitled-25-1-%20%282%29%20%282%29%20%282%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%281%29%20%281%29.jpg) - -Feast supports the following security methods: - -* [SSL/TLS on messaging between Feast Core, Feast Online Serving and Feast SDKs.](security.md#2-ssl-tls) -* [Authentication to Feast Core and Serving based on Open ID Connect ID tokens.](security.md#3-authentication) -* [Authorization based on project membership and delegating authorization grants to external Authorization Server.](security.md#4-authorization) - -[Important considerations when integrating Authentication/Authorization](security.md#5-authentication-and-authorization). - -### **SSL/TLS** - -Feast supports SSL/TLS encrypted inter-service communication among Feast Core, Feast Online Serving, and Feast SDKs. - -#### Configuring SSL/TLS on Feast Core and Feast Serving - -The following properties configure SSL/TLS. These properties are located in their corresponding `application.yml`files: - -| Configuration Property | Description | -| :--- | :--- | -| `grpc.server.security.enabled` | Enables SSL/TLS functionality if `true` | -| `grpc.server.security.certificateChain` | Provide the path to certificate chain. | -| `grpc.server.security.privateKey` | Provide the to private key. | - -> Read more on enabling SSL/TLS in the[ gRPC starter docs.](https://yidongnan.github.io/grpc-spring-boot-starter/en/server/security.html#enable-transport-layer-security) - -#### Configuring SSL/TLS on Python SDK/CLI - -To enable SSL/TLS in the [Feast Python SDK](https://api.docs.feast.dev/python/#feast.client.Client) or [Feast CLI](../getting-started/connect-to-feast/feast-cli.md), set the config options via `feast config`: - -| Configuration Option | Description | -| :--- | :--- | -| `core_enable_ssl` | Enables SSL/TLS functionality on connections to Feast core if `true` | -| `serving_enable_ssl` | Enables SSL/TLS functionality on connections to Feast Online Serving if `true` | -| `core_server_ssl_cert` | Optional. Specifies the path of the root certificate used to verify Core Service's identity. If omitted, uses system certificates. | -| `serving_server_ssl_cert` | Optional. Specifies the path of the root certificate used to verify Serving Service's identity. If omitted, uses system certificates. | - -{% hint style="info" %} -The Python SDK automatically uses SSL/TLS when connecting to Feast Core and Feast Online Serving via port 443. -{% endhint %} - -#### Configuring SSL/TLS on Go SDK - -Configure SSL/TLS on the [Go SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go) by passing configuration via `SecurityConfig`: - -```go -cli, err := feast.NewSecureGrpcClient("localhost", 6566, feast.SecurityConfig{ - EnableTLS: true, - TLSCertPath: "/path/to/cert.pem", -})Option -``` - -| Config Option | Description | -| :--- | :--- | -| `EnableTLS` | Enables SSL/TLS functionality when connecting to Feast if `true` | -| `TLSCertPath` | Optional. Provides the path of the root certificate used to verify Feast Service's identity. If omitted, uses system certificates. | - -#### Configuring SSL/TLS on **Java** SDK - -Configure SSL/TLS on the [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk) by passing configuration via `SecurityConfig`: - -```java -FeastClient client = FeastClient.createSecure("localhost", 6566, - SecurityConfig.newBuilder() - .setTLSEnabled(true) - .setCertificatePath(Optional.of("/path/to/cert.pem")) - .build()); -``` - -| Config Option | Description | -| :--- | :--- | -| `setTLSEnabled()` | Enables SSL/TLS functionality when connecting to Feast if `true` | -| `setCertificatesPath()` | Optional. Set the path of the root certificate used to verify Feast Service's identity. If omitted, uses system certificates. | - -### **Authentication** - -{% hint style="warning" %} -To prevent man in the middle attacks, we recommend that SSL/TLS be implemented prior to authentication. -{% endhint %} - -Authentication can be implemented to identify and validate client requests to Feast Core and Feast Online Serving. Currently, Feast uses[ ](https://auth0.com/docs/protocols/openid-connect-protocol)[Open ID Connect \(OIDC\)](https://auth0.com/docs/protocols/openid-connect-protocol) ID tokens \(i.e. [Google Open ID Connect](https://developers.google.com/identity/protocols/oauth2/openid-connect)\) to authenticate client requests. - -#### Configuring Authentication in Feast Core and Feast Online Serving - -Authentication can be configured for Feast Core and Feast Online Serving via properties in their corresponding `application.yml` files: - -| Configuration Property | Description | -| :--- | :--- | -| `feast.security.authentication.enabled` | Enables Authentication functionality if `true` | -| `feast.security.authentication.provider` | Authentication Provider type. Currently only supports `jwt` | -| `feast.security.authentication.option.jwkEndpointURI` | HTTPS URL used by Feast to retrieved the [JWK](https://tools.ietf.org/html/rfc7517) used to verify OIDC ID tokens. | - -{% hint style="info" %} -`jwkEndpointURI`is set to retrieve Google's OIDC JWK by default, allowing OIDC ID tokens issued by Google to be used for authentication. -{% endhint %} - -Behind the scenes, Feast Core and Feast Online Serving authenticate by: - -* Extracting the OIDC ID token `TOKEN`from gRPC metadata submitted with request: - -```text -('authorization', 'Bearer: TOKEN') -``` - -* Validates token's authenticity using the JWK retrieved from the `jwkEndpointURI` - -#### **Authenticating Serving with Feast Core** - -Feast Online Serving communicates with Feast Core during normal operation. When both authentication and authorization are enabled on Feast Core, Feast Online Serving is forced to authenticate its requests to Feast Core. Otherwise, Feast Online Serving produces an Authentication failure error when connecting to Feast Core. - - Properties used to configure Serving authentication via `application.yml`: - -| Configuration Property | Description | -| :--- | :--- | -| `feast.core-authentication.enabled` | Requires Feast Online Serving to authenticate when communicating with Feast Core. | -| `feast.core-authentication.provider` | Selects provider Feast Online Serving uses to retrieve credentials then used to authenticate requests to Feast Core. Valid providers are `google` and `oauth`. | - -{% tabs %} -{% tab title="Google Provider" %} -Google Provider automatically extracts the credential from the credential JSON file. - -* Set [`GOOGLE_APPLICATION_CREDENTIALS` environment variable](https://cloud.google.com/docs/authentication/getting-started#setting_the_environment_variable) to the path of the credential in the JSON file. -{% endtab %} - -{% tab title="OAuth Provider" %} -OAuth Provider makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential. OAuth requires the following options to be set at `feast.security.core-authentication.options.`: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Configuration PropertyDescription
oauth_url - Target URL receiving the client-credentials request.
grant_type - OAuth grant type. Set as client_credentials -
client_id - Client Id used in the client-credentials request.
client_secret - Client secret used in the client-credentials request.
audience - -

Target audience of the credential. Set to host URL of Feast Core.

-

(i.e. https://localhost if Feast Core listens on localhost).

-
jwkEndpointURI - HTTPS URL used to retrieve a JWK that can be used to decode the credential.
-{% endtab %} -{% endtabs %} - -#### **Enabling Authentication in Python SDK/CLI** - -Configure the [Feast Python SDK](https://api.docs.feast.dev/python/) and [Feast CLI](../getting-started/connect-to-feast/feast-cli.md) to use authentication via `feast config`: - -```python -$ feast config set enable_auth true -``` - -| Configuration Option | Description | -| :--- | :--- | -| `enable_auth` | Enables authentication functionality if set to `true`. | -| `auth_provider` | Use an authentication provider to obtain a credential for authentication. Currently supports `google` and `oauth`. | -| `auth_token` | Manually specify a static token for use in authentication. Overrules `auth_provider` if both are set. | - -{% tabs %} -{% tab title="Google Provider" %} -Google Provider automatically finds and uses Google Credentials to authenticate requests: - -* Google Provider automatically uses established credentials for authenticating requests if you are already authenticated with the `gcloud` CLI via: - -```text -$ gcloud auth application-default login -``` - -* Alternatively Google Provider can be configured to use the credentials in the JSON file via`GOOGLE_APPLICATION_CREDENTIALS` environmental variable \([Google Cloud Authentication documentation](https://cloud.google.com/docs/authentication/getting-started)\): - -```bash -$ export GOOGLE_APPLICATION_CREDENTIALS="path/to/key.json" -``` -{% endtab %} - -{% tab title="OAuth Provider" %} -OAuth Provider makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential/token used to authenticate Feast requests. The OAuth provider requires the following config options to be set via `feast config`: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Configuration PropertyDescription
oauth_token_request_url - Target URL receiving the client-credentials request.
oauth_grant_type - OAuth grant type. Set as client_credentials -
oauth_client_id - Client Id used in the client-credentials request.
oauth_client_secret - Client secret used in the client-credentials request.
oauth_audience - -

Target audience of the credential. Set to host URL of target Service.

-

(https://localhost if Service listens on localhost).

-
-{% endtab %} -{% endtabs %} - -#### **Enabling Authentication in Go SDK** - -Configure the [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) to use authentication by specifying the credential via `SecurityConfig`: - -```go -// error handling omitted. -// Use Google Credential as provider. -cred, _ := feast.NewGoogleCredential("localhost:6566") -cli, _ := feast.NewSecureGrpcClient("localhost", 6566, feast.SecurityConfig{ - // Specify the credential to provide tokens for Feast Authentication. - Credential: cred, -}) -``` - -{% tabs %} -{% tab title="Google Credential" %} -Google Credential uses Service Account credentials JSON file set via`GOOGLE_APPLICATION_CREDENTIALS` environmental variable \([Google Cloud Authentication documentation](https://cloud.google.com/docs/authentication/getting-started)\) to obtain tokens for Authenticating Feast requests: - -* Exporting `GOOGLE_APPLICATION_CREDENTIALS` - -```bash -$ export GOOGLE_APPLICATION_CREDENTIALS="path/to/key.json" -``` - -* Create a Google Credential with target audience. - -```go -cred, _ := feast.NewGoogleCredential("localhost:6566") -``` - -> Target audience of the credential should be set to host URL of target Service. \(ie `https://localhost` if Service listens on `localhost`\): -{% endtab %} - -{% tab title="OAuth Credential" %} -OAuth Credential makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential/token used to authenticate Feast requests: - -* Create OAuth Credential with parameters: - -```go -cred := feast.NewOAuthCredential("localhost:6566", "client_id", "secret", "https://oauth.endpoint/auth") -``` - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDescription
audience - -

Target audience of the credential. Set to host URL of target Service.

-

( https://localhost if Service listens on localhost).

-
clientId - Client Id used in the client-credentials request.
clientSecret - Client secret used in the client-credentials request.
endpointURL - Target URL to make the client-credentials request to.
-{% endtab %} -{% endtabs %} - -#### **Enabling Authentication in Java SDK** - -Configure the [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) to use authentication by setting credentials via `SecurityConfig`: - -```java -// Use GoogleAuthCredential as provider. -CallCredentials credentials = new GoogleAuthCredentials( - Map.of("audience", "localhost:6566")); - -FeastClient client = FeastClient.createSecure("localhost", 6566, - SecurityConfig.newBuilder() - // Specify the credentials to provide tokens for Feast Authentication. - .setCredentials(Optional.of(creds)) - .build()); -``` - -{% tabs %} -{% tab title="GoogleAuthCredentials" %} -GoogleAuthCredentials uses Service Account credentials JSON file set via`GOOGLE_APPLICATION_CREDENTIALS` environmental variable \([Google Cloud authentication documentation](https://cloud.google.com/docs/authentication/getting-started)\) to obtain tokens for Authenticating Feast requests: - -* Exporting `GOOGLE_APPLICATION_CREDENTIALS` - -```bash -$ export GOOGLE_APPLICATION_CREDENTIALS="path/to/key.json" -``` - -* Create a Google Credential with target audience. - -```java -CallCredentials credentials = new GoogleAuthCredentials( - Map.of("audience", "localhost:6566")); -``` - -> Target audience of the credentials should be set to host URL of target Service. \(ie `https://localhost` if Service listens on `localhost`\): -{% endtab %} - -{% tab title="OAuthCredentials" %} -OAuthCredentials makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential/token used to authenticate Feast requests: - -* Create OAuthCredentials with parameters: - -```java -CallCredentials credentials = new OAuthCredentials(Map.of( - "audience": "localhost:6566", - "grant_type", "client_credentials", - "client_id", "some_id", - "client_id", "secret", - "oauth_url", "https://oauth.endpoint/auth", - "jwkEndpointURI", "https://jwk.endpoint/jwk")); -``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDescription
audience - -

Target audience of the credential. Set to host URL of target Service.

-

( https://localhost if Service listens on localhost).

-
grant_type - OAuth grant type. Set as client_credentials -
client_id - Client Id used in the client-credentials request.
client_secret - Client secret used in the client-credentials request.
oauth_url - Target URL to make the client-credentials request to obtain credential.
jwkEndpointURI - HTTPS URL used to retrieve a JWK that can be used to decode the credential.
-{% endtab %} -{% endtabs %} - -### Authorization - -{% hint style="info" %} -Authorization requires that authentication be configured to obtain a user identity for use in authorizing requests. -{% endhint %} - -Authorization provides access control to FeatureTables and/or Features based on project membership. Users who are members of a project are authorized to: - -* Create and/or Update a Feature Table in the Project. -* Retrieve Feature Values for Features in that Project. - -#### **Authorization API/Server** - -![Feast Authorization Flow](../.gitbook/assets/rsz_untitled23%20%282%29%20%282%29%20%282%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29.jpg) - -Feast delegates Authorization grants to an external Authorization Server that implements the [Authorization Open API specification](https://github.com/feast-dev/feast/blob/master/common/src/main/resources/api.yaml). - -* Feast checks whether a user is authorized to make a request by making a `checkAccessRequest` to the Authorization Server. -* The Authorization Server should return a `AuthorizationResult` with whether the user is allowed to make the request. - -Authorization can be configured for Feast Core and Feast Online Serving via properties in their corresponding `application.yml` - -| Configuration Property | Description | -| :--- | :--- | -| `feast.security.authorization.enabled` | Enables authorization functionality if `true`. | -| `feast.security.authorization.provider` | Authentication Provider type. Currently only supports `http` | -| `feast.security.authorization.option.authorizationUrl` | URL endpoint of Authorization Server to make check access requests to. | -| `feast.security.authorization.option.subjectClaim` | Optional. Name of the claim of the to extract from the ID Token to include in the check access request as Subject. | - -{% hint style="info" %} -This example of the [Authorization Server with Keto](https://github.com/feast-dev/feast-keto-auth-server) can be used as a reference implementation for implementing an Authorization Server that Feast supports. -{% endhint %} - -### **Authentication & Authorization** - -When using Authentication & Authorization, consider: - -* Enabling Authentication without Authorization makes authentication **optional**. You can still send unauthenticated requests. -* Enabling Authorization forces all requests to be authenticated. Requests that are not authenticated are **dropped.** - - - diff --git a/docs/advanced/troubleshooting.md b/docs/advanced/troubleshooting.md deleted file mode 100644 index 1060466d300..00000000000 --- a/docs/advanced/troubleshooting.md +++ /dev/null @@ -1,136 +0,0 @@ -# Troubleshooting - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -If at any point in time you cannot resolve a problem, please see the [Community](../community.md) section for reaching out to the Feast community. - -### How can I verify that all services are operational? - -#### Docker Compose - -The containers should be in an `up` state: - -```text -docker ps -``` - -#### Google Kubernetes Engine - -All services should either be in a `RUNNING` state or `COMPLETED`state: - -```text -kubectl get pods -``` - -### How can I verify that I can connect to all services? - -First locate the the host and port of the Feast Services. - -#### **Docker Compose \(from inside the docker network\)** - -You will probably need to connect using the hostnames of services and standard Feast ports: - -```bash -export FEAST_CORE_URL=core:6565 -export FEAST_ONLINE_SERVING_URL=online_serving:6566 -export FEAST_HISTORICAL_SERVING_URL=historical_serving:6567 -export FEAST_JOBCONTROLLER_URL=jobcontroller:6570 -``` - -#### **Docker Compose \(from outside the docker network\)** - -You will probably need to connect using `localhost` and standard ports: - -```bash -export FEAST_CORE_URL=localhost:6565 -export FEAST_ONLINE_SERVING_URL=localhost:6566 -export FEAST_HISTORICAL_SERVING_URL=localhost:6567 -export FEAST_JOBCONTROLLER_URL=localhost:6570 -``` - -#### **Google Kubernetes Engine \(GKE\)** - -You will need to find the external IP of one of the nodes as well as the NodePorts. Please make sure that your firewall is open for these ports: - -```bash -export FEAST_IP=$(kubectl describe nodes | grep ExternalIP | awk '{print $2}' | head -n 1) -export FEAST_CORE_URL=${FEAST_IP}:32090 -export FEAST_ONLINE_SERVING_URL=${FEAST_IP}:32091 -export FEAST_HISTORICAL_SERVING_URL=${FEAST_IP}:32092 -``` - -`netcat`, `telnet`, or even `curl` can be used to test whether all services are available and ports are open, but `grpc_cli` is the most powerful. It can be installed from [here](https://github.com/grpc/grpc/blob/master/doc/command_line_tool.md). - -#### Testing Connectivity From Feast Services: - -Use `grpc_cli` to test connetivity by listing the gRPC methods exposed by Feast services: - -```bash -grpc_cli ls ${FEAST_CORE_URL} feast.core.CoreService -``` - -```bash -grpc_cli ls ${FEAST_JOBCONTROLLER_URL} feast.core.JobControllerService -``` - -```bash -grpc_cli ls ${FEAST_HISTORICAL_SERVING_URL} feast.serving.ServingService -``` - -```bash -grpc_cli ls ${FEAST_ONLINE_SERVING_URL} feast.serving.ServingService -``` - -### How can I print logs from the Feast Services? - -Feast will typically have three services that you need to monitor if something goes wrong. - -* Feast Core -* Feast Job Controller -* Feast Serving \(Online\) -* Feast Serving \(Batch\) - -In order to print the logs from these services, please run the commands below. - -#### Docker Compose - -Use `docker-compose logs` to obtain Feast component logs: - -```text - docker logs -f feast_core_1 -``` - -```text - docker logs -f feast_jobcontroller_1 -``` - -```text -docker logs -f feast_historical_serving_1 -``` - -```text -docker logs -f feast_online_serving_1 -``` - -#### Google Kubernetes Engine - -Use `kubectl logs` to obtain Feast component logs: - -```text -kubectl logs $(kubectl get pods | grep feast-core | awk '{print $1}') -``` - -```text -kubectl logs $(kubectl get pods | grep feast-jobcontroller | awk '{print $1}') -``` - -```text -kubectl logs $(kubectl get pods | grep feast-serving-batch | awk '{print $1}') -``` - -```text -kubectl logs $(kubectl get pods | grep feast-serving-online | awk '{print $1}') -``` - diff --git a/docs/advanced/upgrading.md b/docs/advanced/upgrading.md deleted file mode 100644 index 3c7b95d5441..00000000000 --- a/docs/advanced/upgrading.md +++ /dev/null @@ -1,113 +0,0 @@ -# Upgrading Feast - -### Migration from v0.6 to v0.7 - -#### Feast Core Validation changes - -In v0.7, Feast Core no longer accepts starting with number \(0-9\) and using dash in names for: - -* Project -* Feature Set -* Entities -* Features - -Migrate all project, feature sets, entities, feature names: - -* with β€˜-’ by recreating them with '-' replace with '\_' -* recreate any names with a number \(0-9\) as the first letter to one without. - -Feast now prevents feature sets from being applied if no store is subscribed to that Feature Set. - -* Ensure that a store is configured to subscribe to the Feature Set before applying the Feature Set. - -#### Feast Core's Job Coordinator is now Feast Job Controller - -In v0.7, Feast Core's Job Coordinator has been decoupled from Feast Core and runs as a separate Feast Job Controller application. See its [Configuration reference](../reference/configuration-reference.md#2-feast-core-serving-and-job-controller) for how to configure Feast Job Controller. - -**Ingestion Job API** - -In v0.7, the following changes are made to the Ingestion Job API: - -* Changed List Ingestion Job API to return list of `FeatureSetReference` instead of list of FeatureSet in response. -* Moved `ListIngestionJobs`, `StopIngestionJob`, `RestartIngestionJob` calls from `CoreService` to `JobControllerService`. -* Python SDK/CLI: Added new [Job Controller client ](https://github.com/feast-dev/feast/blob/master/sdk/python/feast/contrib/job_controller/client.py)and `jobcontroller_url` config option. - -Users of the Ingestion Job API via gRPC should migrate by: - -* Add new client to connect to Job Controller endpoint to call `JobControllerService` and call `ListIngestionJobs`, `StopIngestionJob`, `RestartIngestionJob` from new client. -* Migrate code to accept feature references instead of feature sets returned in `ListIngestionJobs` response. - -Users of Ingestion Job via Python SDK \(ie `feast ingest-jobs list` or `client.stop_ingest_job()` etc.\) should migrate by: - -* `ingest_job()`methods only: Create a new separate [Job Controller client](https://github.com/feast-dev/feast/blob/master/sdk/python/feast/contrib/job_controller/client.py) to connect to the job controller and call `ingest_job()` methods using the new client. -* Configure the Feast Job Controller endpoint url via `jobcontroller_url` config option. - -#### Configuration Properties Changes - -* Rename `feast.jobs.consolidate-jobs-per-source property` to `feast.jobs.controller.consolidate-jobs-per-sources` -* Rename`feast.security.authorization.options.subjectClaim` to `feast.security.authentication.options.subjectClaim` -* Rename `feast.logging.audit.messageLoggingEnabled` to `feast.audit.messageLogging.enabled` - -### Migration from v0.5 to v0.6 - -#### Database schema - -In Release 0.6 we introduced [Flyway](https://flywaydb.org/) to handle schema migrations in PostgreSQL. Flyway is integrated into `core` and for now on all migrations will be run automatically on `core` start. It uses table `flyway_schema_history` in the same database \(also created automatically\) to keep track of already applied migrations. So no specific maintenance should be needed. - -If you already have existing deployment of feast 0.5 - Flyway will detect existing tables and omit first baseline migration. - -After `core` started you should have `flyway_schema_history` look like this - -```text ->> select version, description, script, checksum from flyway_schema_history - -version | description | script | checksum ---------+-----------------------------------------+-----------------------------------------+------------ - 1 | << Flyway Baseline >> | << Flyway Baseline >> | - 2 | RELEASE 0.6 Generalizing Source AND ... | V2__RELEASE_0.6_Generalizing_Source_... | 1537500232 -``` - -In this release next major schema changes were done: - -* Source is not shared between FeatureSets anymore. It's changed to 1:1 relation - - and source's primary key is now auto-incremented number. - -* Due to generalization of Source `sources.topics` & `sources.bootstrap_servers` columns were deprecated. - - They will be replaced with `sources.config`. Data migration handled by code when respected Source is used. - - `topics` and `bootstrap_servers` will be deleted in the next release. - -* Job \(table `jobs`\) is no longer connected to `Source` \(table `sources`\) since it uses consolidated source for optimization purposes. - - All data required by Job would be embedded in its table. - -New Models \(tables\): - -* feature\_statistics - -Minor changes: - -* FeatureSet has new column version \(see [proto](https://github.com/feast-dev/feast/blob/master/protos/feast/core/FeatureSet.proto) for details\) -* Connecting table `jobs_feature_sets` in many-to-many relation between jobs & feature sets - - has now `version` and `delivery_status`. - -### Migration from v0.4 to v0.6 - -#### Database - -For all versions earlier than 0.5 seamless migration is not feasible due to earlier breaking changes and creation of new database will be required. - -Since database will be empty - first \(baseline\) migration would be applied: - -```text ->> select version, description, script, checksum from flyway_schema_history - -version | description | script | checksum ---------+-----------------------------------------+-----------------------------------------+------------ - 1 | Baseline | V1__Baseline.sql | 1091472110 - 2 | RELEASE 0.6 Generalizing Source AND ... | V2__RELEASE_0.6_Generalizing_Source_... | 1537500232 -``` - diff --git a/docs/architecture.png b/docs/architecture.png deleted file mode 100644 index 6d56a6236053a87a9340e838d473095687fcda21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106242 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 diff --git a/docs/assets/arch.png b/docs/assets/arch.png deleted file mode 100644 index bc655b60f327b7d7d508885666064e0c713522b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24680 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 U{S>BsDu7N?9j;cUYWeKH07gwlNdN!< diff --git a/docs/assets/feast-components-overview.png b/docs/assets/feast-components-overview.png deleted file mode 100644 index 1f69bb7ed8e25e1861521edfd121e0893841fed1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82842 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�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

;E9&-X?69s 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

hOcNPrQ4j9 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 Fe*ksxxm^GN literal 0 HcmV?d00001 diff --git a/docs/assets/statistics-sources (1).png b/docs/assets/statistics-sources (1).png deleted file mode 100644 index 02be233968d3c97e932f8073b66d41b49247782c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165507 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 \ No newline at end of file diff --git a/docs/docs/.gitbook/assets/feast-docs-overview-diagram-2.svg b/docs/docs/.gitbook/assets/feast-docs-overview-diagram-2.svg deleted file mode 100644 index 7f30963ec78..00000000000 --- a/docs/docs/.gitbook/assets/feast-docs-overview-diagram-2.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/feast-on-kubernetes/advanced-1/README.md b/docs/feast-on-kubernetes/advanced-1/README.md deleted file mode 100644 index 0fb91367c25..00000000000 --- a/docs/feast-on-kubernetes/advanced-1/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# Advanced - diff --git a/docs/feast-on-kubernetes/advanced-1/audit-logging.md b/docs/feast-on-kubernetes/advanced-1/audit-logging.md deleted file mode 100644 index 1870a687bd4..00000000000 --- a/docs/feast-on-kubernetes/advanced-1/audit-logging.md +++ /dev/null @@ -1,132 +0,0 @@ -# Audit Logging - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -## Introduction - -Feast provides audit logging functionality in order to debug problems and to trace the lineage of events. - -## Audit Log Types - -Audit Logs produced by Feast come in three favors: - -| Audit Log Type | Description | -| :--- | :--- | -| Message Audit Log | Logs service calls that can be used to track Feast request handling. Currently only gRPC request/response is supported. Enabling Message Audit Logs can be resource intensive and significantly increase latency, as such is not recommended on Online Serving. | -| Transition Audit Log | Logs transitions in status in resources managed by Feast \(ie an Ingestion Job becoming RUNNING\). | -| Action Audit Log | Logs actions performed on a specific resource managed by Feast \(ie an Ingestion Job is aborted\). | - -## Configuration - -| Audit Log Type | Description | -| :--- | :--- | -| Message Audit Log | Enabled when both `feast.logging.audit.enabled` and `feast.logging.audit.messageLogging.enabled` is set to `true` | -| Transition Audit Log | Enabled when `feast.logging.audit.enabled` is set to `true` | -| Action Audit Log | Enabled when `feast.logging.audit.enabled` is set to `true` | - -## JSON Format - -Audit Logs produced by Feast are written to the console similar to normal logs but in a structured, machine parsable JSON. Example of a Message Audit Log JSON entry produced: - -```text -{ - "message": { - "logType": "FeastAuditLogEntry", - "kind": "MESSAGE", - "statusCode": "OK", - "request": { - "filter": { - "project": "dummy", - } - }, - "application": "Feast", - "response": {}, - "method": "ListFeatureTables", - "identity": "105960238928959148073", - "service": "CoreService", - "component": "feast-core", - "id": "45329ea9-0d48-46c5-b659-4604f6193711", - "version": "0.10.0-SNAPSHOT" - }, - "hostname": "feast.core" - "timestamp": "2020-10-20T04:45:24Z", - "severity": "INFO", -} -``` - -## Log Entry Schema - -Fields common to all Audit Log Types: - -| Field | Description | -| :--- | :--- | -| `logType` | Log Type. Always set to `FeastAuditLogEntry`. Useful for filtering out Feast audit logs. | -| `application` | Application. Always set to `Feast`. | -| `component` | Feast Component producing the Audit Log. Set to `feast-core` for Feast Core and `feast-serving` for Feast Serving. Use to filtering out Audit Logs by component. | -| `version` | Version of Feast producing this Audit Log. Use to filtering out Audit Logs by version. | - -Fields in Message Audit Log Type - -| Field | Description | -| :--- | :--- | -| `id` | Generated UUID that uniquely identifies the service call. | -| `service` | Name of the Service that handled the service call. | -| `method` | Name of the Method that handled the service call. Useful for filtering Audit Logs by method \(ie `ApplyFeatureTable` calls\) | -| `request` | Full request submitted by client in the service call as JSON. | -| `response` | Full response returned to client by the service after handling the service call as JSON. | -| `identity` | Identity of the client making the service call as an user Id. Only set when Authentication is enabled. | -| `statusCode` | The status code returned by the service handling the service call \(ie `OK` if service call handled without error\). | - -Fields in Action Audit Log Type - -| Field | Description | -| :--- | :--- | -| `action` | Name of the action taken on the resource. | -| `resource.type` | Type of resource of which the action was taken on \(i.e `FeatureTable`\) | -| resource.id | Identifier specifying the specific resource of which the action was taken on. | - -Fields in Transition Audit Log Type - -| Field | Description | -| :--- | :--- | -| `status` | The new status that the resource transitioned to | -| `resource.type` | Type of resource of which the transition occurred \(i.e `FeatureTable`\) | -| `resource.id` | Identifier specifying the specific resource of which the transition occurred. | - -## Log Forwarder - -Feast currently only supports forwarding Request/Response \(Message Audit Log Type\) logs to an external fluentD service with `feast.**` Fluentd tag. - -### Request/Response Log Example - -```text -{ - "id": "45329ea9-0d48-46c5-b659-4604f6193711", - "service": "CoreService" - "status_code": "OK", - "identity": "105960238928959148073", - "method": "ListProjects", - "request": {}, - "response": { - "projects": [ - "default", "project1", "project2" - ] - } - "release_name": 506.457.14.512 -} -``` - -### Configuration - -The Fluentd Log Forwarder configured with the with the following configuration options in `application.yml`: - -| Settings | Description | -| :--- | :--- | -| `feast.logging.audit.messageLogging.destination` | `fluentd` | -| `feast.logging.audit.messageLogging.fluentdHost` | `localhost` | -| `feast.logging.audit.messageLogging.fluentdPort` | `24224` | - -When using Fluentd as the Log forwarder, a Feast `release_name` can be logged instead of the IP address \(eg. IP of Kubernetes pod deployment\), by setting an environment variable `RELEASE_NAME` when deploying Feast. - diff --git a/docs/feast-on-kubernetes/advanced-1/metrics.md b/docs/feast-on-kubernetes/advanced-1/metrics.md deleted file mode 100644 index 43f7b973b67..00000000000 --- a/docs/feast-on-kubernetes/advanced-1/metrics.md +++ /dev/null @@ -1,59 +0,0 @@ -# Metrics - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -## Overview - -Feast Components export metrics that can provide insight into Feast behavior: - -* [Feast Ingestion Jobs can be configured to push metrics into StatsD](metrics.md#pushing-ingestion-metrics-to-statsd) -* [Prometheus can be configured to scrape metrics from Feast Core and Serving.](metrics.md#exporting-feast-metrics-to-prometheus) - -See the [Metrics Reference ](../reference-1/metrics-reference.md)for documentation on metrics are exported by Feast. - -{% hint style="info" %} -Feast Job Controller currently does not export any metrics on its own. However its `application.yml` is used to configure metrics export for ingestion jobs. -{% endhint %} - -## Pushing Ingestion Metrics to StatsD - -### **Feast Ingestion Job** - -Feast Ingestion Job can be configured to push Ingestion metrics to a StatsD instance. Metrics export to StatsD for Ingestion Job is configured in Job Controller's `application.yml` under `feast.jobs.metrics` - -```yaml - feast: - jobs: - metrics: - # Enables Statd metrics export if true. - enabled: true - type: statsd - # Host and port of the StatsD instance to export to. - host: localhost - port: 9125 -``` - -{% hint style="info" %} -If you need Ingestion Metrics in Prometheus or some other metrics backend, use a metrics forwarder to forward Ingestion Metrics from StatsD to the metrics backend of choice. \(ie Use [`prometheus-statsd-exporter`](https://github.com/prometheus/statsd_exporter) to forward metrics to Prometheus\). -{% endhint %} - -## Exporting Feast Metrics to Prometheus - -### **Feast Core and Serving** - -Feast Core and Serving exports metrics to a Prometheus instance via Prometheus scraping its `/metrics` endpoint. Metrics export to Prometheus for Core and Serving can be configured via their corresponding `application.yml` - -```yaml -server: - # Configures the port where metrics are exposed via /metrics for Prometheus to scrape. - port: 8081 -``` - -[Direct Prometheus](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config) to scrape directly from Core and Serving's `/metrics` endpoint. - -## Further Reading - -See the [Metrics Reference ](../reference-1/metrics-reference.md)for documentation on metrics are exported by Feast. - diff --git a/docs/feast-on-kubernetes/advanced-1/security.md b/docs/feast-on-kubernetes/advanced-1/security.md deleted file mode 100644 index b6e42afd73e..00000000000 --- a/docs/feast-on-kubernetes/advanced-1/security.md +++ /dev/null @@ -1,480 +0,0 @@ ---- -description: 'Secure Feast with SSL/TLS, Authentication and Authorization.' ---- - -# Security - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -### Overview - -![Overview of Feast's Security Methods.](../../.gitbook/assets/untitled-25-1-%20%282%29%20%282%29%20%282%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%281%29%20%284%29.jpg) - -Feast supports the following security methods: - -* [SSL/TLS on messaging between Feast Core, Feast Online Serving and Feast SDKs.](security.md#2-ssl-tls) -* [Authentication to Feast Core and Serving based on Open ID Connect ID tokens.](security.md#3-authentication) -* [Authorization based on project membership and delegating authorization grants to external Authorization Server.](security.md#4-authorization) - -[Important considerations when integrating Authentication/Authorization](security.md#5-authentication-and-authorization). - -### **SSL/TLS** - -Feast supports SSL/TLS encrypted inter-service communication among Feast Core, Feast Online Serving, and Feast SDKs. - -#### Configuring SSL/TLS on Feast Core and Feast Serving - -The following properties configure SSL/TLS. These properties are located in their corresponding `application.yml`files: - -| Configuration Property | Description | -| :--- | :--- | -| `grpc.server.security.enabled` | Enables SSL/TLS functionality if `true` | -| `grpc.server.security.certificateChain` | Provide the path to certificate chain. | -| `grpc.server.security.privateKey` | Provide the to private key. | - -> Read more on enabling SSL/TLS in the[ gRPC starter docs.](https://yidongnan.github.io/grpc-spring-boot-starter/en/server/security.html#enable-transport-layer-security) - -#### Configuring SSL/TLS on Python SDK/CLI - -To enable SSL/TLS in the [Feast Python SDK](https://api.docs.feast.dev/python/#feast.client.Client) or [Feast CLI](../getting-started/connect-to-feast/feast-cli.md), set the config options via `feast config`: - -| Configuration Option | Description | -| :--- | :--- | -| `core_enable_ssl` | Enables SSL/TLS functionality on connections to Feast core if `true` | -| `serving_enable_ssl` | Enables SSL/TLS functionality on connections to Feast Online Serving if `true` | -| `core_server_ssl_cert` | Optional. Specifies the path of the root certificate used to verify Core Service's identity. If omitted, uses system certificates. | -| `serving_server_ssl_cert` | Optional. Specifies the path of the root certificate used to verify Serving Service's identity. If omitted, uses system certificates. | - -{% hint style="info" %} -The Python SDK automatically uses SSL/TLS when connecting to Feast Core and Feast Online Serving via port 443. -{% endhint %} - -#### Configuring SSL/TLS on Go SDK - -Configure SSL/TLS on the [Go SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go) by passing configuration via `SecurityConfig`: - -```go -cli, err := feast.NewSecureGrpcClient("localhost", 6566, feast.SecurityConfig{ - EnableTLS: true, - TLSCertPath: "/path/to/cert.pem", -})Option -``` - -| Config Option | Description | -| :--- | :--- | -| `EnableTLS` | Enables SSL/TLS functionality when connecting to Feast if `true` | -| `TLSCertPath` | Optional. Provides the path of the root certificate used to verify Feast Service's identity. If omitted, uses system certificates. | - -#### Configuring SSL/TLS on **Java** SDK - -Configure SSL/TLS on the [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk) by passing configuration via `SecurityConfig`: - -```java -FeastClient client = FeastClient.createSecure("localhost", 6566, - SecurityConfig.newBuilder() - .setTLSEnabled(true) - .setCertificatePath(Optional.of("/path/to/cert.pem")) - .build()); -``` - -| Config Option | Description | -| :--- | :--- | -| `setTLSEnabled()` | Enables SSL/TLS functionality when connecting to Feast if `true` | -| `setCertificatesPath()` | Optional. Set the path of the root certificate used to verify Feast Service's identity. If omitted, uses system certificates. | - -### **Authentication** - -{% hint style="warning" %} -To prevent man in the middle attacks, we recommend that SSL/TLS be implemented prior to authentication. -{% endhint %} - -Authentication can be implemented to identify and validate client requests to Feast Core and Feast Online Serving. Currently, Feast uses[ ](https://auth0.com/docs/protocols/openid-connect-protocol)[Open ID Connect \(OIDC\)](https://auth0.com/docs/protocols/openid-connect-protocol) ID tokens \(i.e. [Google Open ID Connect](https://developers.google.com/identity/protocols/oauth2/openid-connect)\) to authenticate client requests. - -#### Configuring Authentication in Feast Core and Feast Online Serving - -Authentication can be configured for Feast Core and Feast Online Serving via properties in their corresponding `application.yml` files: - -| Configuration Property | Description | -| :--- | :--- | -| `feast.security.authentication.enabled` | Enables Authentication functionality if `true` | -| `feast.security.authentication.provider` | Authentication Provider type. Currently only supports `jwt` | -| `feast.security.authentication.option.jwkEndpointURI` | HTTPS URL used by Feast to retrieved the [JWK](https://tools.ietf.org/html/rfc7517) used to verify OIDC ID tokens. | - -{% hint style="info" %} -`jwkEndpointURI`is set to retrieve Google's OIDC JWK by default, allowing OIDC ID tokens issued by Google to be used for authentication. -{% endhint %} - -Behind the scenes, Feast Core and Feast Online Serving authenticate by: - -* Extracting the OIDC ID token `TOKEN`from gRPC metadata submitted with request: - -```text -('authorization', 'Bearer: TOKEN') -``` - -* Validates token's authenticity using the JWK retrieved from the `jwkEndpointURI` - -#### **Authenticating Serving with Feast Core** - -Feast Online Serving communicates with Feast Core during normal operation. When both authentication and authorization are enabled on Feast Core, Feast Online Serving is forced to authenticate its requests to Feast Core. Otherwise, Feast Online Serving produces an Authentication failure error when connecting to Feast Core. - - Properties used to configure Serving authentication via `application.yml`: - -| Configuration Property | Description | -| :--- | :--- | -| `feast.core-authentication.enabled` | Requires Feast Online Serving to authenticate when communicating with Feast Core. | -| `feast.core-authentication.provider` | Selects provider Feast Online Serving uses to retrieve credentials then used to authenticate requests to Feast Core. Valid providers are `google` and `oauth`. | - -{% tabs %} -{% tab title="Google Provider" %} -Google Provider automatically extracts the credential from the credential JSON file. - -* Set [`GOOGLE_APPLICATION_CREDENTIALS` environment variable](https://cloud.google.com/docs/authentication/getting-started#setting_the_environment_variable) to the path of the credential in the JSON file. -{% endtab %} - -{% tab title="OAuth Provider" %} -OAuth Provider makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential. OAuth requires the following options to be set at `feast.security.core-authentication.options.`: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Configuration PropertyDescription
oauth_url - Target URL receiving the client-credentials request.
grant_type - OAuth grant type. Set as client_credentials -
client_id - Client Id used in the client-credentials request.
client_secret - Client secret used in the client-credentials request.
audience - -

Target audience of the credential. Set to host URL of Feast Core.

-

(i.e. https://localhost if Feast Core listens on localhost).

-
jwkEndpointURI - HTTPS URL used to retrieve a JWK that can be used to decode the credential.
-{% endtab %} -{% endtabs %} - -#### **Enabling Authentication in Python SDK/CLI** - -Configure the [Feast Python SDK](https://api.docs.feast.dev/python/) and [Feast CLI](../getting-started/connect-to-feast/feast-cli.md) to use authentication via `feast config`: - -```python -$ feast config set enable_auth true -``` - -| Configuration Option | Description | -| :--- | :--- | -| `enable_auth` | Enables authentication functionality if set to `true`. | -| `auth_provider` | Use an authentication provider to obtain a credential for authentication. Currently supports `google` and `oauth`. | -| `auth_token` | Manually specify a static token for use in authentication. Overrules `auth_provider` if both are set. | - -{% tabs %} -{% tab title="Google Provider" %} -Google Provider automatically finds and uses Google Credentials to authenticate requests: - -* Google Provider automatically uses established credentials for authenticating requests if you are already authenticated with the `gcloud` CLI via: - -```text -$ gcloud auth application-default login -``` - -* Alternatively Google Provider can be configured to use the credentials in the JSON file via`GOOGLE_APPLICATION_CREDENTIALS` environmental variable \([Google Cloud Authentication documentation](https://cloud.google.com/docs/authentication/getting-started)\): - -```bash -$ export GOOGLE_APPLICATION_CREDENTIALS="path/to/key.json" -``` -{% endtab %} - -{% tab title="OAuth Provider" %} -OAuth Provider makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential/token used to authenticate Feast requests. The OAuth provider requires the following config options to be set via `feast config`: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Configuration PropertyDescription
oauth_token_request_url - Target URL receiving the client-credentials request.
oauth_grant_type - OAuth grant type. Set as client_credentials -
oauth_client_id - Client Id used in the client-credentials request.
oauth_client_secret - Client secret used in the client-credentials request.
oauth_audience - -

Target audience of the credential. Set to host URL of target Service.

-

(https://localhost if Service listens on localhost).

-
-{% endtab %} -{% endtabs %} - -#### **Enabling Authentication in Go SDK** - -Configure the [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) to use authentication by specifying the credential via `SecurityConfig`: - -```go -// error handling omitted. -// Use Google Credential as provider. -cred, _ := feast.NewGoogleCredential("localhost:6566") -cli, _ := feast.NewSecureGrpcClient("localhost", 6566, feast.SecurityConfig{ - // Specify the credential to provide tokens for Feast Authentication. - Credential: cred, -}) -``` - -{% tabs %} -{% tab title="Google Credential" %} -Google Credential uses Service Account credentials JSON file set via`GOOGLE_APPLICATION_CREDENTIALS` environmental variable \([Google Cloud Authentication documentation](https://cloud.google.com/docs/authentication/getting-started)\) to obtain tokens for Authenticating Feast requests: - -* Exporting `GOOGLE_APPLICATION_CREDENTIALS` - -```bash -$ export GOOGLE_APPLICATION_CREDENTIALS="path/to/key.json" -``` - -* Create a Google Credential with target audience. - -```go -cred, _ := feast.NewGoogleCredential("localhost:6566") -``` - -> Target audience of the credential should be set to host URL of target Service. \(ie `https://localhost` if Service listens on `localhost`\): -{% endtab %} - -{% tab title="OAuth Credential" %} -OAuth Credential makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential/token used to authenticate Feast requests: - -* Create OAuth Credential with parameters: - -```go -cred := feast.NewOAuthCredential("localhost:6566", "client_id", "secret", "https://oauth.endpoint/auth") -``` - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDescription
audience - -

Target audience of the credential. Set to host URL of target Service.

-

( https://localhost if Service listens on localhost).

-
clientId - Client Id used in the client-credentials request.
clientSecret - Client secret used in the client-credentials request.
endpointURL - Target URL to make the client-credentials request to.
-{% endtab %} -{% endtabs %} - -#### **Enabling Authentication in Java SDK** - -Configure the [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) to use authentication by setting credentials via `SecurityConfig`: - -```java -// Use GoogleAuthCredential as provider. -CallCredentials credentials = new GoogleAuthCredentials( - Map.of("audience", "localhost:6566")); - -FeastClient client = FeastClient.createSecure("localhost", 6566, - SecurityConfig.newBuilder() - // Specify the credentials to provide tokens for Feast Authentication. - .setCredentials(Optional.of(creds)) - .build()); -``` - -{% tabs %} -{% tab title="GoogleAuthCredentials" %} -GoogleAuthCredentials uses Service Account credentials JSON file set via`GOOGLE_APPLICATION_CREDENTIALS` environmental variable \([Google Cloud authentication documentation](https://cloud.google.com/docs/authentication/getting-started)\) to obtain tokens for Authenticating Feast requests: - -* Exporting `GOOGLE_APPLICATION_CREDENTIALS` - -```bash -$ export GOOGLE_APPLICATION_CREDENTIALS="path/to/key.json" -``` - -* Create a Google Credential with target audience. - -```java -CallCredentials credentials = new GoogleAuthCredentials( - Map.of("audience", "localhost:6566")); -``` - -> Target audience of the credentials should be set to host URL of target Service. \(ie `https://localhost` if Service listens on `localhost`\): -{% endtab %} - -{% tab title="OAuthCredentials" %} -OAuthCredentials makes an OAuth [client credentials](https://auth0.com/docs/flows/call-your-api-using-the-client-credentials-flow) request to obtain the credential/token used to authenticate Feast requests: - -* Create OAuthCredentials with parameters: - -```java -CallCredentials credentials = new OAuthCredentials(Map.of( - "audience": "localhost:6566", - "grant_type", "client_credentials", - "client_id", "some_id", - "client_id", "secret", - "oauth_url", "https://oauth.endpoint/auth", - "jwkEndpointURI", "https://jwk.endpoint/jwk")); -``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDescription
audience - -

Target audience of the credential. Set to host URL of target Service.

-

( https://localhost if Service listens on localhost).

-
grant_type - OAuth grant type. Set as client_credentials -
client_id - Client Id used in the client-credentials request.
client_secret - Client secret used in the client-credentials request.
oauth_url - Target URL to make the client-credentials request to obtain credential.
jwkEndpointURI - HTTPS URL used to retrieve a JWK that can be used to decode the credential.
-{% endtab %} -{% endtabs %} - -### Authorization - -{% hint style="info" %} -Authorization requires that authentication be configured to obtain a user identity for use in authorizing requests. -{% endhint %} - -Authorization provides access control to FeatureTables and/or Features based on project membership. Users who are members of a project are authorized to: - -* Create and/or Update a Feature Table in the Project. -* Retrieve Feature Values for Features in that Project. - -#### **Authorization API/Server** - -![Feast Authorization Flow](../../.gitbook/assets/rsz_untitled23%20%282%29%20%282%29%20%282%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29.jpg) - -Feast delegates Authorization grants to an external Authorization Server that implements the [Authorization Open API specification](https://github.com/feast-dev/feast/blob/master/common/src/main/resources/api.yaml). - -* Feast checks whether a user is authorized to make a request by making a `checkAccessRequest` to the Authorization Server. -* The Authorization Server should return a `AuthorizationResult` with whether the user is allowed to make the request. - -Authorization can be configured for Feast Core and Feast Online Serving via properties in their corresponding `application.yml` - -| Configuration Property | Description | -| :--- | :--- | -| `feast.security.authorization.enabled` | Enables authorization functionality if `true`. | -| `feast.security.authorization.provider` | Authentication Provider type. Currently only supports `http` | -| `feast.security.authorization.option.authorizationUrl` | URL endpoint of Authorization Server to make check access requests to. | -| `feast.security.authorization.option.subjectClaim` | Optional. Name of the claim of the to extract from the ID Token to include in the check access request as Subject. | - -{% hint style="info" %} -This example of the [Authorization Server with Keto](https://github.com/feast-dev/feast-keto-auth-server) can be used as a reference implementation for implementing an Authorization Server that Feast supports. -{% endhint %} - -### **Authentication & Authorization** - -When using Authentication & Authorization, consider: - -* Enabling Authentication without Authorization makes authentication **optional**. You can still send unauthenticated requests. -* Enabling Authorization forces all requests to be authenticated. Requests that are not authenticated are **dropped.** - - - diff --git a/docs/feast-on-kubernetes/advanced-1/troubleshooting.md b/docs/feast-on-kubernetes/advanced-1/troubleshooting.md deleted file mode 100644 index 7b0224abe31..00000000000 --- a/docs/feast-on-kubernetes/advanced-1/troubleshooting.md +++ /dev/null @@ -1,136 +0,0 @@ -# Troubleshooting - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -If at any point in time you cannot resolve a problem, please see the [Community](../../community.md) section for reaching out to the Feast community. - -### How can I verify that all services are operational? - -#### Docker Compose - -The containers should be in an `up` state: - -```text -docker ps -``` - -#### Google Kubernetes Engine - -All services should either be in a `RUNNING` state or `COMPLETED`state: - -```text -kubectl get pods -``` - -### How can I verify that I can connect to all services? - -First locate the the host and port of the Feast Services. - -#### **Docker Compose \(from inside the docker network\)** - -You will probably need to connect using the hostnames of services and standard Feast ports: - -```bash -export FEAST_CORE_URL=core:6565 -export FEAST_ONLINE_SERVING_URL=online_serving:6566 -export FEAST_HISTORICAL_SERVING_URL=historical_serving:6567 -export FEAST_JOBCONTROLLER_URL=jobcontroller:6570 -``` - -#### **Docker Compose \(from outside the docker network\)** - -You will probably need to connect using `localhost` and standard ports: - -```bash -export FEAST_CORE_URL=localhost:6565 -export FEAST_ONLINE_SERVING_URL=localhost:6566 -export FEAST_HISTORICAL_SERVING_URL=localhost:6567 -export FEAST_JOBCONTROLLER_URL=localhost:6570 -``` - -#### **Google Kubernetes Engine \(GKE\)** - -You will need to find the external IP of one of the nodes as well as the NodePorts. Please make sure that your firewall is open for these ports: - -```bash -export FEAST_IP=$(kubectl describe nodes | grep ExternalIP | awk '{print $2}' | head -n 1) -export FEAST_CORE_URL=${FEAST_IP}:32090 -export FEAST_ONLINE_SERVING_URL=${FEAST_IP}:32091 -export FEAST_HISTORICAL_SERVING_URL=${FEAST_IP}:32092 -``` - -`netcat`, `telnet`, or even `curl` can be used to test whether all services are available and ports are open, but `grpc_cli` is the most powerful. It can be installed from [here](https://github.com/grpc/grpc/blob/master/doc/command_line_tool.md). - -#### Testing Connectivity From Feast Services: - -Use `grpc_cli` to test connetivity by listing the gRPC methods exposed by Feast services: - -```bash -grpc_cli ls ${FEAST_CORE_URL} feast.core.CoreService -``` - -```bash -grpc_cli ls ${FEAST_JOBCONTROLLER_URL} feast.core.JobControllerService -``` - -```bash -grpc_cli ls ${FEAST_HISTORICAL_SERVING_URL} feast.serving.ServingService -``` - -```bash -grpc_cli ls ${FEAST_ONLINE_SERVING_URL} feast.serving.ServingService -``` - -### How can I print logs from the Feast Services? - -Feast will typically have three services that you need to monitor if something goes wrong. - -* Feast Core -* Feast Job Controller -* Feast Serving \(Online\) -* Feast Serving \(Batch\) - -In order to print the logs from these services, please run the commands below. - -#### Docker Compose - -Use `docker-compose logs` to obtain Feast component logs: - -```text - docker logs -f feast_core_1 -``` - -```text - docker logs -f feast_jobcontroller_1 -``` - -```text -docker logs -f feast_historical_serving_1 -``` - -```text -docker logs -f feast_online_serving_1 -``` - -#### Google Kubernetes Engine - -Use `kubectl logs` to obtain Feast component logs: - -```text -kubectl logs $(kubectl get pods | grep feast-core | awk '{print $1}') -``` - -```text -kubectl logs $(kubectl get pods | grep feast-jobcontroller | awk '{print $1}') -``` - -```text -kubectl logs $(kubectl get pods | grep feast-serving-batch | awk '{print $1}') -``` - -```text -kubectl logs $(kubectl get pods | grep feast-serving-online | awk '{print $1}') -``` - diff --git a/docs/feast-on-kubernetes/advanced-1/upgrading.md b/docs/feast-on-kubernetes/advanced-1/upgrading.md deleted file mode 100644 index 7e61d3518b1..00000000000 --- a/docs/feast-on-kubernetes/advanced-1/upgrading.md +++ /dev/null @@ -1,113 +0,0 @@ -# Upgrading Feast - -### Migration from v0.6 to v0.7 - -#### Feast Core Validation changes - -In v0.7, Feast Core no longer accepts starting with number \(0-9\) and using dash in names for: - -* Project -* Feature Set -* Entities -* Features - -Migrate all project, feature sets, entities, feature names: - -* with β€˜-’ by recreating them with '-' replace with '\_' -* recreate any names with a number \(0-9\) as the first letter to one without. - -Feast now prevents feature sets from being applied if no store is subscribed to that Feature Set. - -* Ensure that a store is configured to subscribe to the Feature Set before applying the Feature Set. - -#### Feast Core's Job Coordinator is now Feast Job Controller - -In v0.7, Feast Core's Job Coordinator has been decoupled from Feast Core and runs as a separate Feast Job Controller application. See its [Configuration reference](../reference-1/configuration-reference.md#2-feast-core-serving-and-job-controller) for how to configure Feast Job Controller. - -**Ingestion Job API** - -In v0.7, the following changes are made to the Ingestion Job API: - -* Changed List Ingestion Job API to return list of `FeatureSetReference` instead of list of FeatureSet in response. -* Moved `ListIngestionJobs`, `StopIngestionJob`, `RestartIngestionJob` calls from `CoreService` to `JobControllerService`. -* Python SDK/CLI: Added new [Job Controller client ](https://github.com/feast-dev/feast/blob/master/sdk/python/feast/contrib/job_controller/client.py)and `jobcontroller_url` config option. - -Users of the Ingestion Job API via gRPC should migrate by: - -* Add new client to connect to Job Controller endpoint to call `JobControllerService` and call `ListIngestionJobs`, `StopIngestionJob`, `RestartIngestionJob` from new client. -* Migrate code to accept feature references instead of feature sets returned in `ListIngestionJobs` response. - -Users of Ingestion Job via Python SDK \(ie `feast ingest-jobs list` or `client.stop_ingest_job()` etc.\) should migrate by: - -* `ingest_job()`methods only: Create a new separate [Job Controller client](https://github.com/feast-dev/feast/blob/master/sdk/python/feast/contrib/job_controller/client.py) to connect to the job controller and call `ingest_job()` methods using the new client. -* Configure the Feast Job Controller endpoint url via `jobcontroller_url` config option. - -#### Configuration Properties Changes - -* Rename `feast.jobs.consolidate-jobs-per-source property` to `feast.jobs.controller.consolidate-jobs-per-sources` -* Rename`feast.security.authorization.options.subjectClaim` to `feast.security.authentication.options.subjectClaim` -* Rename `feast.logging.audit.messageLoggingEnabled` to `feast.audit.messageLogging.enabled` - -### Migration from v0.5 to v0.6 - -#### Database schema - -In Release 0.6 we introduced [Flyway](https://flywaydb.org/) to handle schema migrations in PostgreSQL. Flyway is integrated into `core` and for now on all migrations will be run automatically on `core` start. It uses table `flyway_schema_history` in the same database \(also created automatically\) to keep track of already applied migrations. So no specific maintenance should be needed. - -If you already have existing deployment of feast 0.5 - Flyway will detect existing tables and omit first baseline migration. - -After `core` started you should have `flyway_schema_history` look like this - -```text ->> select version, description, script, checksum from flyway_schema_history - -version | description | script | checksum ---------+-----------------------------------------+-----------------------------------------+------------ - 1 | << Flyway Baseline >> | << Flyway Baseline >> | - 2 | RELEASE 0.6 Generalizing Source AND ... | V2__RELEASE_0.6_Generalizing_Source_... | 1537500232 -``` - -In this release next major schema changes were done: - -* Source is not shared between FeatureSets anymore. It's changed to 1:1 relation - - and source's primary key is now auto-incremented number. - -* Due to generalization of Source `sources.topics` & `sources.bootstrap_servers` columns were deprecated. - - They will be replaced with `sources.config`. Data migration handled by code when respected Source is used. - - `topics` and `bootstrap_servers` will be deleted in the next release. - -* Job \(table `jobs`\) is no longer connected to `Source` \(table `sources`\) since it uses consolidated source for optimization purposes. - - All data required by Job would be embedded in its table. - -New Models \(tables\): - -* feature\_statistics - -Minor changes: - -* FeatureSet has new column version \(see [proto](https://github.com/feast-dev/feast/blob/master/protos/feast/core/FeatureSet.proto) for details\) -* Connecting table `jobs_feature_sets` in many-to-many relation between jobs & feature sets - - has now `version` and `delivery_status`. - -### Migration from v0.4 to v0.6 - -#### Database - -For all versions earlier than 0.5 seamless migration is not feasible due to earlier breaking changes and creation of new database will be required. - -Since database will be empty - first \(baseline\) migration would be applied: - -```text ->> select version, description, script, checksum from flyway_schema_history - -version | description | script | checksum ---------+-----------------------------------------+-----------------------------------------+------------ - 1 | Baseline | V1__Baseline.sql | 1091472110 - 2 | RELEASE 0.6 Generalizing Source AND ... | V2__RELEASE_0.6_Generalizing_Source_... | 1537500232 -``` - diff --git a/docs/feast-on-kubernetes/concepts/README.md b/docs/feast-on-kubernetes/concepts/README.md deleted file mode 100644 index e834417d3fa..00000000000 --- a/docs/feast-on-kubernetes/concepts/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# Concepts - diff --git a/docs/feast-on-kubernetes/concepts/architecture.md b/docs/feast-on-kubernetes/concepts/architecture.md deleted file mode 100644 index f4cf23eb956..00000000000 --- a/docs/feast-on-kubernetes/concepts/architecture.md +++ /dev/null @@ -1,51 +0,0 @@ -# Architecture - -![](../../.gitbook/assets/image%20%286%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%283%29%20%282%29%20%281%29%20%282%29.png) - -## Sequence description - -1. **Log Raw Events:** Production backend applications are configured to emit internal state changes as events to a stream. -2. **Create Stream Features:** Stream processing systems like Flink, Spark, and Beam are used to transform and refine events and to produce features that are logged back to the stream. -3. **Log Streaming Features:** Both raw and refined events are logged into a data lake or batch storage location. -4. **Create Batch Features:** ELT/ETL systems like Spark and SQL are used to transform data in the batch store. -5. **Define and Ingest Features:** The Feast user defines [feature tables](feature-tables.md) based on the features available in batch and streaming sources and publish these definitions to Feast Core. -6. **Poll Feature Definitions:** The Feast Job Service polls for new or changed feature definitions. -7. **Start Ingestion Jobs:** Every new feature table definition results in a new ingestion job being provisioned \(see limitations\). -8. **Batch Ingestion:** Batch ingestion jobs are short-lived jobs that load data from batch sources into either an offline or online store \(see limitations\). -9. **Stream Ingestion:** Streaming ingestion jobs are long-lived jobs that load data from stream sources into online stores. A stream source and batch source on a feature table must have the same features/fields. -10. **Model Training:** A model training pipeline is launched. It uses the Feast Python SDK to retrieve a training dataset and trains a model. -11. **Get Historical Features:** Feast exports a point-in-time correct training dataset based on the list of features and entity DataFrame provided by the model training pipeline. -12. **Deploy Model:** The trained model binary \(and list of features\) are deployed into a model serving system. -13. **Get Prediction:** A backend system makes a request for a prediction from the model serving service. -14. **Retrieve Online Features:** The model serving service makes a request to the Feast Online Serving service for online features using a Feast SDK. -15. **Return Prediction:** The model serving service makes a prediction using the returned features and returns the outcome. - -{% hint style="warning" %} -Limitations - -* Only Redis is supported for online storage. -* Batch ingestion jobs must be triggered from your own scheduler like Airflow. Streaming ingestion jobs are automatically launched by the Feast Job Service. -{% endhint %} - -## Components: - -A complete Feast deployment contains the following components: - -* **Feast Core:** Acts as the central registry for feature and entity definitions in Feast. -* **Feast Job Service:** Manages data processing jobs that load data from sources into stores, and jobs that export training datasets. -* **Feast Serving:** Provides low-latency access to feature values in an online store. -* **Feast Python SDK CLI:** The primary user facing SDK. Used to: - * Manage feature definitions with Feast Core. - * Launch jobs through the Feast Job Service. - * Retrieve training datasets. - * Retrieve online features. -* **Online Store:** The online store is a database that stores only the latest feature values for each entity. The online store can be populated by either batch ingestion jobs \(in the case the user has no streaming source\), or can be populated by a streaming ingestion job from a streaming source. Feast Online Serving looks up feature values from the online store. -* **Offline Store:** The offline store persists batch data that has been ingested into Feast. This data is used for producing training datasets. -* **Feast Spark SDK:** A Spark specific Feast SDK. Allows teams to use Spark for loading features into an online store and for building training datasets over offline sources. - -Please see the [configuration reference](../reference-1/configuration-reference.md#overview) for more details on configuring these components. - -{% hint style="info" %} -Java and Go Clients are also available for online feature retrieval. See [API Reference](../reference-1/api/). -{% endhint %} - diff --git a/docs/feast-on-kubernetes/concepts/entities.md b/docs/feast-on-kubernetes/concepts/entities.md deleted file mode 100644 index e8134cf1425..00000000000 --- a/docs/feast-on-kubernetes/concepts/entities.md +++ /dev/null @@ -1,64 +0,0 @@ -# Entities - -## Overview - -An entity is any domain object that can be modeled and about which information can be stored. Entities are usually recognizable concepts, either concrete or abstract, such as persons, places, things, or events. - -Examples of entities in the context of ride-hailing and food delivery: `customer`, `order`, `driver`, `restaurant`, `dish`, `area`. - -Entities are important in the context of feature stores since features are always properties of a specific entity. For example, we could have a feature `total_trips_24h` for driver `D011234` with a feature value of `11`. - -Feast uses entities in the following way: - -* Entities serve as the keys used to look up features for producing training datasets and online feature values. -* Entities serve as a natural grouping of features in a feature table. A feature table must belong to an entity \(which could be a composite entity\) - -## Structure of an Entity - -When creating an entity specification, consider the following fields: - -* **Name**: Name of the entity -* **Description**: Description of the entity -* **Value Type**: Value type of the entity. Feast will attempt to coerce entity columns in your data sources into this type. -* **Labels**: Labels are maps that allow users to attach their own metadata to entities - -A valid entity specification is shown below: - -```python -customer = Entity( - name="customer_id", - description="Customer id for ride customer", - value_type=ValueType.INT64, - labels={} -) -``` - -## Working with an Entity - -### Creating an Entity: - -```python -# Create a customer entity -customer_entity = Entity(name="customer_id", description="ID of car customer") -client.apply(customer_entity) -``` - -### Updating an Entity: - -```python -# Update a customer entity -customer_entity = client.get_entity("customer_id") -customer_entity.description = "ID of bike customer" -client.apply(customer_entity) -``` - -Permitted changes include: - -* The entity's description and labels - -The following changes are not permitted: - -* Project -* Name of an entity -* Type - diff --git a/docs/feast-on-kubernetes/concepts/feature-tables.md b/docs/feast-on-kubernetes/concepts/feature-tables.md deleted file mode 100644 index 5b5c0efc56d..00000000000 --- a/docs/feast-on-kubernetes/concepts/feature-tables.md +++ /dev/null @@ -1,122 +0,0 @@ -# Feature Tables - -## Overview - -Feature tables are both a schema and a logical means of grouping features, data [sources](sources.md), and other related metadata. - -Feature tables serve the following purposes: - -* Feature tables are a means for defining the location and properties of data [sources](sources.md). -* Feature tables are used to create within Feast a database-level structure for the storage of feature values. -* The data sources described within feature tables allow Feast to find and ingest feature data into stores within Feast. -* Feature tables ensure data is efficiently stored during [ingestion](../user-guide/define-and-ingest-features.md) by providing a grouping mechanism of features values that occur on the same event timestamp. - -{% hint style="info" %} -Feast does not yet apply feature transformations. Transformations are currently expected to happen before data is ingested into Feast. The data sources described within feature tables should reference feature values in their already transformed form. -{% endhint %} - -### Features - -A feature is an individual measurable property observed on an entity. For example the amount of transactions \(feature\) a customer \(entity\) has completed. Features are used for both model training and scoring \(batch, online\). - -Features are defined as part of feature tables. Since Feast does not apply transformations, a feature is basically a schema that only contains a name and a type: - -```python -avg_daily_ride = Feature("average_daily_rides", ValueType.FLOAT) -``` - -Visit [FeatureSpec](https://api.docs.feast.dev/grpc/feast.core.pb.html#FeatureSpecV2) for the complete feature specification API. - -## Structure of a Feature Table - -Feature tables contain the following fields: - -* **Name:** Name of feature table. This name must be unique within a project. -* **Entities:** List of [entities](entities.md) to associate with the features defined in this feature table. Entities are used as lookup keys when retrieving features from a feature table. -* **Features:** List of features within a feature table. -* **Labels:** Labels are arbitrary key-value properties that can be defined by users. -* **Max age:** Max age affect the retrieval of features from a feature table. Age is measured as the duration of time between the event timestamp of a feature and the lookup time on an [entity key]() used to retrieve the feature. Feature values outside max age will be returned as unset values. Max age allows for eviction of keys from online stores and limits the amount of historical scanning required for historical feature values during retrieval. -* **Batch Source:** The batch data source from which Feast will ingest feature values into stores. This can either be used to back-fill stores before switching over to a streaming source, or it can be used as the primary source of data for a feature table. Visit [Sources](sources.md) to learn more about batch sources. -* **Stream Source:** The streaming data source from which you can ingest streaming feature values into Feast. Streaming sources must be paired with a batch source containing the same feature values. A streaming source is only used to populate online stores. The batch equivalent source that is paired with a streaming source is used during the generation of historical feature datasets. Visit [Sources](sources.md) to learn more about stream sources. - -Here is a ride-hailing example of a valid feature table specification: - -{% tabs %} -{% tab title="driver\_trips\_feature\_table.py" %} -```python -from feast import BigQuerySource, FeatureTable, Feature, ValueType -from google.protobuf.duration_pb2 import Duration - -driver_ft = FeatureTable( - name="driver_trips", - entities=["driver_id"], - features=[ - Feature("average_daily_rides", ValueType.FLOAT), - Feature("rating", ValueType.FLOAT) - ], - max_age=Duration(seconds=3600), - labels={ - "team": "driver_matching" - }, - batch_source=BigQuerySource( - table_ref="gcp_project:bq_dataset.bq_table", - event_timestamp_column="datetime", - created_timestamp_column="timestamp", - field_mapping={ - "rating": "driver_rating" - } - ) -) -``` -{% endtab %} -{% endtabs %} - -By default, Feast assumes that features specified in the feature-table specification corresponds one-to-one to the fields found in the sources. All features defined in a feature table should be available in the defined sources. - -Field mappings can be used to map features defined in Feast to fields as they occur in data sources. - -In the example feature-specification table above, we use field mappings to ensure the feature named `rating` in the batch source is mapped to the field named `driver_rating`. - -## Working with a Feature Table - -#### Creating a Feature Table - -```python -driver_ft = FeatureTable(...) -client.apply(driver_ft) -``` - -#### Updating a Feature Table - -```python -driver_ft = FeatureTable() - -client.apply(driver_ft) - -driver_ft.labels = {"team": "marketplace"} - -client.apply(driver_ft) -``` - -#### Feast currently supports the following changes to feature tables: - -* Adding new features. -* Removing features. -* Updating source, max age, and labels. - -{% hint style="warning" %} -Deleted features are archived, rather than removed completely. Importantly, new features cannot use the names of these deleted features. -{% endhint %} - -#### Feast currently does not support the following changes to feature tables: - -* Changes to the project or name of a feature table. -* Changes to entities related to a feature table. -* Changes to names and types of existing features. - -#### Deleting a Feature Table - -{% hint style="danger" %} -Feast currently does not support the deletion of feature tables. -{% endhint %} - diff --git a/docs/feast-on-kubernetes/concepts/overview.md b/docs/feast-on-kubernetes/concepts/overview.md deleted file mode 100644 index 461510984b3..00000000000 --- a/docs/feast-on-kubernetes/concepts/overview.md +++ /dev/null @@ -1,21 +0,0 @@ -# Overview - -### Concepts - -[Entities](entities.md) are objects in an organization like customers, transactions, and drivers, products, etc. - -[Sources](sources.md) are external sources of data where feature data can be found. - -[Feature Tables](feature-tables.md) are objects that define logical groupings of features, data sources, and other related metadata. - -### Concept Hierarchy - -![](../../.gitbook/assets/image%20%284%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%283%29%20%283%29%20%283%29%20%283%29%20%281%29.png) - -Feast contains the following core concepts: - -* **Projects:** Serve as a top level namespace for all Feast resources. Each project is a completely independent environment in Feast. Users can only work in a single project at a time. -* **Entities:** Entities are the objects in an organization on which features occur. They map to your business domain \(users, products, transactions, locations\). -* **Feature Tables:** Defines a group of features that occur on a specific entity. -* **Features:** Individual feature within a feature table. - diff --git a/docs/feast-on-kubernetes/concepts/sources.md b/docs/feast-on-kubernetes/concepts/sources.md deleted file mode 100644 index 65595d94a99..00000000000 --- a/docs/feast-on-kubernetes/concepts/sources.md +++ /dev/null @@ -1,90 +0,0 @@ -# Sources - -### Overview - -Sources are descriptions of external feature data and are registered to Feast as part of [feature tables](feature-tables.md). Once registered, Feast can ingest feature data from these sources into stores. - -Currently, Feast supports the following source types: - -#### Batch Source - -* File \(as in Spark\): Parquet \(only\). -* BigQuery - -#### Stream Source - -* Kafka -* Kinesis - -The following encodings are supported on streams - -* Avro -* Protobuf - -### Structure of a Source - -For both batch and stream sources, the following configurations are necessary: - -* **Event timestamp column**: Name of column containing timestamp when event data occurred. Used during point-in-time join of feature values to [entity timestamps](). -* **Created timestamp column**: Name of column containing timestamp when data is created. Used to deduplicate data when multiple copies of the same [entity key]() is ingested. - -Example data source specifications: - -{% tabs %} -{% tab title="batch\_sources.py" %} -```python -from feast import FileSource -from feast.data_format import ParquetFormat - -batch_file_source = FileSource( - file_format=ParquetFormat(), - file_url="file:///feast/customer.parquet", - event_timestamp_column="event_timestamp", - created_timestamp_column="created_timestamp", -) -``` -{% endtab %} - -{% tab title="stream\_sources.py" %} -```python -from feast import KafkaSource -from feast.data_format import ProtoFormat - -stream_kafka_source = KafkaSource( - bootstrap_servers="localhost:9094", - message_format=ProtoFormat(class_path="class.path"), - topic="driver_trips", - event_timestamp_column="event_timestamp", - created_timestamp_column="created_timestamp", -) -``` -{% endtab %} -{% endtabs %} - -The [Feast Python API documentation](https://api.docs.feast.dev/python/) provides more information about options to specify for the above sources. - -### Working with a Source - -#### Creating a Source - -Sources are defined as part of [feature tables](feature-tables.md): - -```python -batch_bigquery_source = BigQuerySource( - table_ref="gcp_project:bq_dataset.bq_table", - event_timestamp_column="event_timestamp", - created_timestamp_column="created_timestamp", -) - -stream_kinesis_source = KinesisSource( - bootstrap_servers="localhost:9094", - record_format=ProtoFormat(class_path="class.path"), - region="us-east-1", - stream_name="driver_trips", - event_timestamp_column="event_timestamp", - created_timestamp_column="created_timestamp", -) -``` - -Feast ensures that the source complies with the schema of the feature table. These specified data sources can then be included inside a feature table specification and registered to Feast Core. - diff --git a/docs/feast-on-kubernetes/concepts/stores.md b/docs/feast-on-kubernetes/concepts/stores.md deleted file mode 100644 index 59deac0a6a6..00000000000 --- a/docs/feast-on-kubernetes/concepts/stores.md +++ /dev/null @@ -1,20 +0,0 @@ -# Stores - -In Feast, a store is a database that is populated with feature data that will ultimately be served to models. - -## Offline \(Historical\) Store - -The offline store maintains historical copies of feature values. These features are grouped and stored in feature tables. During retrieval of historical data, features are queries from these feature tables in order to produce training datasets. - -## Online Store - -The online store maintains only the latest values for a specific feature. - -* Feature values are stored based on their [entity keys]() -* Feast currently supports Redis as an online store. -* Online stores are meant for very high throughput writes from ingestion jobs and very low latency access to features during online serving. - -{% hint style="info" %} -Feast only supports a single online store in production -{% endhint %} - diff --git a/docs/feast-on-kubernetes/getting-started/README.md b/docs/feast-on-kubernetes/getting-started/README.md deleted file mode 100644 index b9423182feb..00000000000 --- a/docs/feast-on-kubernetes/getting-started/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Getting started - -{% hint style="danger" %} -Feast on Kubernetes is only supported using Feast 0.9 \(and below\). We are working to add support for Feast on Kubernetes with the latest release of Feast. Please see our [roadmap](../../roadmap.md) for more details. -{% endhint %} - -### Install Feast - -If you would like to deploy a new installation of Feast, click on [Install Feast](install-feast/) - -{% page-ref page="install-feast/" %} - -### Connect to Feast - -If you would like to connect to an existing Feast deployment, click on [Connect to Feast](connect-to-feast/) - -{% page-ref page="connect-to-feast/" %} - -### Learn Feast - -If you would like to learn more about Feast, click on [Learn Feast](learn-feast.md) - -{% page-ref page="learn-feast.md" %} - diff --git a/docs/feast-on-kubernetes/getting-started/connect-to-feast/README.md b/docs/feast-on-kubernetes/getting-started/connect-to-feast/README.md deleted file mode 100644 index 4333359f902..00000000000 --- a/docs/feast-on-kubernetes/getting-started/connect-to-feast/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Connect to Feast - -### Feast Python SDK - -The Feast Python SDK is used as a library to interact with a Feast deployment. - -* Define, register, and manage entities and features -* Ingest data into Feast -* Build and retrieve training datasets -* Retrieve online features - -{% page-ref page="python-sdk.md" %} - -### Feast CLI - -The Feast CLI is a command line implementation of the Feast Python SDK. - -* Define, register, and manage entities and features from the terminal -* Ingest data into Feast -* Manage ingestion jobs - -{% page-ref page="feast-cli.md" %} - -### Online Serving Clients - -The following clients can be used to retrieve online feature values: - -* [Feast Python SDK](https://api.docs.feast.dev/python/) -* [Feast Go SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go) -* [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk) - diff --git a/docs/feast-on-kubernetes/getting-started/connect-to-feast/feast-cli.md b/docs/feast-on-kubernetes/getting-started/connect-to-feast/feast-cli.md deleted file mode 100644 index 47471b84717..00000000000 --- a/docs/feast-on-kubernetes/getting-started/connect-to-feast/feast-cli.md +++ /dev/null @@ -1,37 +0,0 @@ -# Feast CLI - -Install the Feast CLI using pip: - -```bash -pip install feast==0.9.* -``` - -Configure the CLI to connect to your Feast Core deployment: - -```text -feast config set core_url your.feast.deployment -``` - -{% hint style="info" %} -By default, all configuration is stored in `~/.feast/config` -{% endhint %} - -The CLI is a wrapper around the [Feast Python SDK](python-sdk.md): - -```aspnet -$ feast - -Usage: feast [OPTIONS] COMMAND [ARGS]... - -Options: - --help Show this message and exit. - -Commands: - config View and edit Feast properties - entities Create and manage entities - feature-tables Create and manage feature tables - jobs Create and manage jobs - projects Create and manage projects - version Displays version and connectivity information -``` - diff --git a/docs/feast-on-kubernetes/getting-started/connect-to-feast/python-sdk.md b/docs/feast-on-kubernetes/getting-started/connect-to-feast/python-sdk.md deleted file mode 100644 index 3e7c86880e5..00000000000 --- a/docs/feast-on-kubernetes/getting-started/connect-to-feast/python-sdk.md +++ /dev/null @@ -1,20 +0,0 @@ -# Python SDK - -Install the [Feast Python SDK](https://api.docs.feast.dev/python/) using pip: - -```bash -pip install feast==0.9.* -``` - -Connect to an existing Feast Core deployment: - -```python -from feast import Client - -# Connect to an existing Feast Core deployment -client = Client(core_url='feast.example.com:6565') - -# Ensure that your client is connected by printing out some feature tables -client.list_feature_tables() -``` - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/README.md b/docs/feast-on-kubernetes/getting-started/install-feast/README.md deleted file mode 100644 index 0b77ab431a0..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Install Feast - -A production deployment of Feast is deployed using Kubernetes. - -## Kubernetes \(with Helm\) - -This guide installs Feast into an existing Kubernetes cluster using Helm. The installation is not specific to any cloud platform or environment, but requires Kubernetes and Helm. - -{% page-ref page="kubernetes-with-helm.md" %} - -## Amazon EKS \(with Terraform\) - -This guide installs Feast into an AWS environment using Terraform. The Terraform script is opinionated and intended to allow you to start quickly. - -{% page-ref page="kubernetes-amazon-eks-with-terraform.md" %} - -## Azure AKS \(with Helm\) - -This guide installs Feast into an Azure AKS environment with Helm. - -{% page-ref page="kubernetes-azure-aks-with-helm.md" %} - -## Azure AKS \(with Terraform\) - -This guide installs Feast into an Azure environment using Terraform. The Terraform script is opinionated and intended to allow you to start quickly. - -{% page-ref page="kubernetes-azure-aks-with-terraform.md" %} - -## Google Cloud GKE \(with Terraform\) - -This guide installs Feast into a Google Cloud environment using Terraform. The Terraform script is opinionated and intended to allow you to start quickly. - -{% page-ref page="google-cloud-gke-with-terraform.md" %} - -## IBM Cloud Kubernetes Service \(IKS\) and Red Hat OpenShift \(using Kustomize\) - -This guide installs Feast into an existing [IBM Cloud Kubernetes Service](https://www.ibm.com/cloud/kubernetes-service) or [Red Hat OpenShift on IBM Cloud](https://www.ibm.com/cloud/openshift) using Kustomize. - -{% page-ref page="ibm-cloud-iks-with-kustomize.md" %} - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/google-cloud-gke-with-terraform.md b/docs/feast-on-kubernetes/getting-started/install-feast/google-cloud-gke-with-terraform.md deleted file mode 100644 index a3252cf0bbb..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/google-cloud-gke-with-terraform.md +++ /dev/null @@ -1,52 +0,0 @@ -# Google Cloud GKE \(with Terraform\) - -### Overview - -This guide installs Feast on GKE using our [reference Terraform configuration](https://github.com/feast-dev/feast/tree/master/infra/terraform/gcp). - -{% hint style="info" %} -The Terraform configuration used here is a greenfield installation that neither assumes anything about, nor integrates with, existing resources in your GCP account. The Terraform configuration presents an easy way to get started, but you may want to customize this set up before using Feast in production. -{% endhint %} - -This Terraform configuration creates the following resources: - -* GKE cluster -* Feast services running on GKE -* Google Memorystore \(Redis\) as online store -* Dataproc cluster -* Kafka running on GKE, exposed to the dataproc cluster via internal load balancer - -### 1. Requirements - -* Install [Terraform](https://www.terraform.io/) > = 0.12 \(tested with 0.13.3\) -* Install [Helm](https://helm.sh/docs/intro/install/) \(tested with v3.3.4\) -* GCP [authentication](https://cloud.google.com/docs/authentication) and sufficient [privilege](https://cloud.google.com/iam/docs/understanding-roles) to create the resources listed above. - -### 2. Configure Terraform - -Create a `.tfvars` file under`feast/infra/terraform/gcp`. Name the file. In our example, we use `my_feast.tfvars`. You can see the full list of configuration variables in `variables.tf`. Sample configurations are provided below: - -{% code title="my\_feast.tfvars" %} -```typescript -gcp_project_name = "kf-feast" -name_prefix = "feast-0-8" -region = "asia-east1" -gke_machine_type = "n1-standard-2" -network = "default" -subnetwork = "default" -dataproc_staging_bucket = "feast-dataproc" -``` -{% endcode %} - -### 3. Apply - -After completing the configuration, initialize Terraform and apply: - -```bash -$ cd feast/infra/terraform/gcp -$ terraform init -$ terraform apply -var-file=my_feast.tfvars -``` - - - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md b/docs/feast-on-kubernetes/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md deleted file mode 100644 index 0abca57b6de..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md +++ /dev/null @@ -1,193 +0,0 @@ -# IBM Cloud Kubernetes Service \(IKS\) and Red Hat OpenShift \(with Kustomize\) - -## Overview - -This guide installs Feast on an existing IBM Cloud Kubernetes cluster or Red Hat OpenShift on IBM Cloud , and ensures the following services are running: - -* Feast Core -* Feast Online Serving -* Postgres -* Redis -* Kafka \(Optional\) -* Feast Jupyter \(Optional\) -* Prometheus \(Optional\) - -## 1. Prerequisites - -1. [IBM Cloud Kubernetes Service](https://www.ibm.com/cloud/kubernetes-service) or [Red Hat OpenShift on IBM Cloud](https://www.ibm.com/cloud/openshift) -2. Install [Kubectl](https://cloud.ibm.com/docs/containers?topic=containers-cs_cli_install#kubectl) that matches the major.minor versions of your IKS or Install the [OpenShift CLI](https://cloud.ibm.com/docs/openshift?topic=openshift-openshift-cli#cli_oc) that matches your local operating system and OpenShift cluster version. -3. Install [Helm 3](https://helm.sh/) -4. Install [Kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/) - -## 2. Preparation - -### IBM Cloud Block Storage Setup \(IKS only\) - -:warning: If you have Red Hat OpenShift Cluster on IBM Cloud skip to this [section](ibm-cloud-iks-with-kustomize.md#Security-Context-Constraint-Setup). - -By default, IBM Cloud Kubernetes cluster uses [IBM Cloud File Storage](https://www.ibm.com/cloud/file-storage) based on NFS as the default storage class, and non-root users do not have write permission on the volume mount path for NFS-backed storage. Some common container images in Feast, such as Redis, Postgres, and Kafka specify a non-root user to access the mount path in the images. When containers are deployed using these images, the containers fail to start due to insufficient permissions of the non-root user creating folders on the mount path. - -[IBM Cloud Block Storage](https://www.ibm.com/cloud/block-storage) allows for the creation of raw storage volumes and provides faster performance without the permission restriction of NFS-backed storage - -Therefore, to deploy Feast we need to set up [IBM Cloud Block Storage](https://cloud.ibm.com/docs/containers?topic=containers-block_storage#install_block) as the default storage class so that you can have all the functionalities working and get the best experience from Feast. - -1. [Follow the instructions](https://helm.sh/docs/intro/install/) to install the Helm version 3 client on your local machine. -2. Add the IBM Cloud Helm chart repository to the cluster where you want to use the IBM Cloud Block Storage plug-in. - - ```text - helm repo add iks-charts https://icr.io/helm/iks-charts - helm repo update - ``` - -3. Install the IBM Cloud Block Storage plug-in. When you install the plug-in, pre-defined block storage classes are added to your cluster. - - ```text - helm install v2.0.2 iks-charts/ibmcloud-block-storage-plugin -n kube-system - ``` - - Example output: - - ```text - NAME: v2.0.2 - LAST DEPLOYED: Fri Feb 5 12:29:50 2021 - NAMESPACE: kube-system - STATUS: deployed - REVISION: 1 - NOTES: - Thank you for installing: ibmcloud-block-storage-plugin. Your release is named: v2.0.2 - ... - ``` - -4. Verify that all block storage plugin pods are in a "Running" state. - - ```text - kubectl get pods -n kube-system | grep ibmcloud-block-storage - ``` - -5. Verify that the storage classes for Block Storage were added to your cluster. - - ```text - kubectl get storageclasses | grep ibmc-block - ``` - -6. Set the Block Storage as the default storageclass. - - ```text - kubectl patch storageclass ibmc-block-gold -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' - kubectl patch storageclass ibmc-file-gold -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}' - - # Check the default storageclass is block storage - kubectl get storageclass | grep \(default\) - ``` - - Example output: - - ```text - ibmc-block-gold (default) ibm.io/ibmc-block 65s - ``` - - **Security Context Constraint Setup \(OpenShift only\)** - -By default, in OpenShift, all pods or containers will use the [Restricted SCC](https://docs.openshift.com/container-platform/4.6/authentication/managing-security-context-constraints.html) which limits the UIDs pods can run with, causing the Feast installation to fail. To overcome this, you can allow Feast pods to run with any UID by executing the following: - -```text -oc adm policy add-scc-to-user anyuid -z default,kf-feast-kafka -n feast -``` - -## 3. Installation - -Install Feast using kustomize. The pods may take a few minutes to initialize. - -```bash -git clone https://github.com/kubeflow/manifests -cd manifests/contrib/feast/ -kustomize build feast/base | kubectl apply -n feast -f - -``` - -### Optional: Enable Feast Jupyter and Kafka - -You may optionally enable the Feast Jupyter component which contains code examples to demonstrate Feast. Some examples require Kafka to stream real time features to the Feast online serving. To enable, edit the following properties in the `values.yaml` under the `manifests/contrib/feast` folder: - -```text -kafka.enabled: true -feast-jupyter.enabled: true -``` - -Then regenerate the resource manifests and deploy: - -```text -make feast/base -kustomize build feast/base | kubectl apply -n feast -f - -``` - -## 4. Use Feast Jupyter Notebook Server to connect to Feast - -After all the pods are in a `RUNNING` state, port-forward to the Jupyter Notebook Server in the cluster: - -```bash -kubectl port-forward \ -$(kubectl get pod -l app=feast-jupyter -o custom-columns=:metadata.name) 8888:8888 -n feast -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - -{% embed url="http://localhost:8888/tree?" caption="" %} - -## 5. Uninstall Feast - -```text -kustomize build feast/base | kubectl delete -n feast -f - -``` - -## 6. Troubleshooting - -When running the minimal\_ride\_hailing\_example Jupyter Notebook example the following errors may occur: - -1. When running `job = client.get_historical_features(...)`: - - ```text - KeyError: 'historical_feature_output_location' - ``` - - or - - ```text - KeyError: 'spark_staging_location' - ``` - - Add the following environment variable: - - ```text - os.environ["FEAST_HISTORICAL_FEATURE_OUTPUT_LOCATION"] = "file:///home/jovyan/historical_feature_output" - os.environ["FEAST_SPARK_STAGING_LOCATION"] = "file:///home/jovyan/test_data" - ``` - -2. When running `job.get_status()` - - ```text - - ``` - - Add the following environment variable: - - ```text - os.environ["FEAST_REDIS_HOST"] = "feast-release-redis-master" - ``` - -3. When running `job = client.start_stream_to_online_ingestion(...)` - - ```text - org.apache.kafka.vendor.common.KafkaException: Failed to construct kafka consumer - ``` - - Add the following environment variable: - - ```text - os.environ["DEMO_KAFKA_BROKERS"] = "feast-release-kafka:9092" - ``` - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md b/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md deleted file mode 100644 index d03d7fb863e..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md +++ /dev/null @@ -1,68 +0,0 @@ -# Amazon EKS \(with Terraform\) - -### Overview - -This guide installs Feast on AWS using our [reference Terraform configuration](https://github.com/feast-dev/feast/tree/master/infra/terraform/aws). - -{% hint style="info" %} -The Terraform configuration used here is a greenfield installation that neither assumes anything about, nor integrates with, existing resources in your AWS account. The Terraform configuration presents an easy way to get started, but you may want to customize this set up before using Feast in production. -{% endhint %} - -This Terraform configuration creates the following resources: - -* Kubernetes cluster on Amazon EKS \(3x r3.large nodes\) -* Kafka managed by Amazon MSK \(2x kafka.t3.small nodes\) -* Postgres database for Feast metadata, using serverless Aurora \(min capacity: 2\) -* Redis cluster, using Amazon Elasticache \(1x cache.t2.micro\) -* Amazon EMR cluster to run Spark \(3x spot m4.xlarge\) -* Staging S3 bucket to store temporary data - -![](../../../.gitbook/assets/feast-on-aws-3-%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%283%29.png) - -### 1. Requirements - -* Create an AWS account and [configure credentials locally](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) -* Install [Terraform](https://www.terraform.io/) > = 0.12 \(tested with 0.13.3\) -* Install [Helm](https://helm.sh/docs/intro/install/) \(tested with v3.3.4\) - -### 2. Configure Terraform - -Create a `.tfvars` file under`feast/infra/terraform/aws`. Name the file. In our example, we use `my_feast.tfvars`. You can see the full list of configuration variables in `variables.tf`. At a minimum, you need to set `name_prefix` and an AWS region: - -{% code title="my\_feast.tfvars" %} -```typescript -name_prefix = "my-feast" -region = "us-east-1" -``` -{% endcode %} - -### 3. Apply - -After completing the configuration, initialize Terraform and apply: - -```bash -$ cd feast/infra/terraform/aws -$ terraform init -$ terraform apply -var-file=my_feast.tfvars -``` - -Starting may take a minute. A kubectl configuration file is also created in this directory, and the file's name will start with `kubeconfig_` and end with a random suffix. - -### 4. Connect to Feast using Jupyter - -After all pods are running, connect to the Jupyter Notebook Server running in the cluster. - -To connect to the remote Feast server you just created, forward a port from the remote k8s cluster to your local machine. Replace `kubeconfig_XXXXXXX` below with the kubeconfig file name Terraform generates for you. - -```bash -KUBECONFIG=kubeconfig_XXXXXXX kubectl port-forward \ -$(kubectl get pod -o custom-columns=:metadata.name | grep jupyter) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-helm.md b/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-helm.md deleted file mode 100644 index 39dcdbd7003..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-helm.md +++ /dev/null @@ -1,139 +0,0 @@ -# Azure AKS \(with Helm\) - -## Overview - -This guide installs Feast on Azure Kubernetes cluster \(known as AKS\), and ensures the following services are running: - -* Feast Core -* Feast Online Serving -* Postgres -* Redis -* Spark -* Kafka -* Feast Jupyter \(Optional\) -* Prometheus \(Optional\) - -## 1. Requirements - -1. Install and configure [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) -2. Install and configure [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) -3. Install [Helm 3](https://helm.sh/) - -## 2. Preparation - -Create an AKS cluster with Azure CLI. The detailed steps can be found [here](https://docs.microsoft.com/en-us/azure/aks/kubernetes-walkthrough), and a high-level walk through includes: - -```bash -az group create --name myResourceGroup --location eastus -az acr create --resource-group myResourceGroup --name feast-AKS-ACR --sku Basic -az aks create -g myResourceGroup -n feast-AKS --location eastus --attach-acr feast-AKS-ACR --generate-ssh-keys - -az aks install-cli -az aks get-credentials --resource-group myResourceGroup --name feast-AKS -``` - -Add the Feast Helm repository and download the latest charts: - -```bash -helm version # make sure you have the latest Helm installed -helm repo add feast-charts https://feast-helm-charts.storage.googleapis.com -helm repo update -``` - -Feast includes a Helm chart that installs all necessary components to run Feast Core, Feast Online Serving, and an example Jupyter notebook. - -Feast Core requires Postgres to run, which requires a secret to be set on Kubernetes: - -```bash -kubectl create secret generic feast-postgresql --from-literal=postgresql-password=password -``` - -## 3. Feast installation - -Install Feast using Helm. The pods may take a few minutes to initialize. - -```bash -helm install feast-release feast-charts/feast -``` - -## 4. Spark operator installation - -Follow the documentation [to install Spark operator on Kubernetes ](https://github.com/GoogleCloudPlatform/spark-on-k8s-operator), and Feast documentation to [configure Spark roles](../../reference-1/feast-and-spark.md) - -```bash -helm repo add spark-operator https://googlecloudplatform.github.io/spark-on-k8s-operator -helm install my-release spark-operator/spark-operator --set serviceAccounts.spark.name=spark --set image.tag=v1beta2-1.1.2-2.4.5 -``` - -and ensure the service account used by Feast has permissions to manage Spark Application resources. This depends on your k8s setup, but typically you'd need to configure a Role and a RoleBinding like the one below: - -```text -cat < -rules: -- apiGroups: ["sparkoperator.k8s.io"] - resources: ["sparkapplications"] - verbs: ["create", "delete", "deletecollection", "get", "list", "update", "watch", "patch"] ---- -apiVersion: rbac.authorization.k8s.io/v1beta1 -kind: RoleBinding -metadata: - name: use-spark-operator - namespace: -roleRef: - kind: Role - name: use-spark-operator - apiGroup: rbac.authorization.k8s.io -subjects: - - kind: ServiceAccount - name: default -EOF -``` - -## 5. Use Jupyter to connect to Feast - -After all the pods are in a `RUNNING` state, port-forward to the Jupyter Notebook Server in the cluster: - -```bash -kubectl port-forward \ -$(kubectl get pod -o custom-columns=:metadata.name | grep jupyter) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - -{% embed url="http://localhost:8888/tree?" caption="" %} - -## 6. Environment variables - -If you are running the [Minimal Ride Hailing Example](https://github.com/feast-dev/feast/blob/master/examples/minimal/minimal_ride_hailing.ipynb), you may want to make sure the following environment variables are correctly set: - -```text -demo_data_location = "wasbs://@.blob.core.windows.net/" -os.environ["FEAST_AZURE_BLOB_ACCOUNT_NAME"] = "" -os.environ["FEAST_AZURE_BLOB_ACCOUNT_ACCESS_KEY"] = -os.environ["FEAST_HISTORICAL_FEATURE_OUTPUT_LOCATION"] = "wasbs://@.blob.core.windows.net/out/" -os.environ["FEAST_SPARK_STAGING_LOCATION"] = "wasbs://@.blob.core.windows.net/artifacts/" -os.environ["FEAST_SPARK_LAUNCHER"] = "k8s" -os.environ["FEAST_SPARK_K8S_NAMESPACE"] = "default" -os.environ["FEAST_HISTORICAL_FEATURE_OUTPUT_FORMAT"] = "parquet" -os.environ["FEAST_REDIS_HOST"] = "feast-release-redis-master.default.svc.cluster.local" -os.environ["DEMO_KAFKA_BROKERS"] = "feast-release-kafka.default.svc.cluster.local:9092" -``` - -## 7. Further Reading - -* [Feast Concepts](../../concepts/overview.md) -* [Feast Examples/Tutorials](https://github.com/feast-dev/feast/tree/master/examples) -* [Feast Helm Chart Documentation](https://github.com/feast-dev/feast/blob/master/infra/charts/feast/README.md) -* [Configuring Feast components](../../reference-1/configuration-reference.md) -* [Feast and Spark](../../reference-1/feast-and-spark.md) - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md b/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md deleted file mode 100644 index 71dd15908de..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md +++ /dev/null @@ -1,63 +0,0 @@ -# Azure AKS \(with Terraform\) - -## Overview - -This guide installs Feast on Azure using our [reference Terraform configuration](https://github.com/feast-dev/feast/tree/master/infra/terraform/azure). - -{% hint style="info" %} -The Terraform configuration used here is a greenfield installation that neither assumes anything about, nor integrates with, existing resources in your Azure account. The Terraform configuration presents an easy way to get started, but you may want to customize this set up before using Feast in production. -{% endhint %} - -This Terraform configuration creates the following resources: - -* Kubernetes cluster on Azure AKS -* Kafka managed by HDInsight -* Postgres database for Feast metadata, running as a pod on AKS -* Redis cluster, using Azure Cache for Redis -* [spark-on-k8s-operator](https://github.com/GoogleCloudPlatform/spark-on-k8s-operator) to run Spark -* Staging Azure blob storage container to store temporary data - -## 1. Requirements - -* Create an Azure account and [configure credentials locally](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) -* Install [Terraform](https://www.terraform.io/) \(tested with 0.13.5\) -* Install [Helm](https://helm.sh/docs/intro/install/) \(tested with v3.4.2\) - -## 2. Configure Terraform - -Create a `.tfvars` file under`feast/infra/terraform/azure`. Name the file. In our example, we use `my_feast.tfvars`. You can see the full list of configuration variables in `variables.tf`. At a minimum, you need to set `name_prefix` and `resource_group`: - -{% code title="my\_feast.tfvars" %} -```typescript -name_prefix = "feast" -resource_group = "Feast" # pre-existing resource group -``` -{% endcode %} - -## 3. Apply - -After completing the configuration, initialize Terraform and apply: - -```bash -$ cd feast/infra/terraform/azure -$ terraform init -$ terraform apply -var-file=my_feast.tfvars -``` - -## 4. Connect to Feast using Jupyter - -After all pods are running, connect to the Jupyter Notebook Server running in the cluster. - -To connect to the remote Feast server you just created, forward a port from the remote k8s cluster to your local machine. - -```bash -kubectl port-forward $(kubectl get pod -o custom-columns=:metadata.name | grep jupyter) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-with-helm.md b/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-with-helm.md deleted file mode 100644 index 032554d1208..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/kubernetes-with-helm.md +++ /dev/null @@ -1,69 +0,0 @@ -# Kubernetes \(with Helm\) - -## Overview
- -This guide installs Feast on an existing Kubernetes cluster, and ensures the following services are running: - -* Feast Core -* Feast Online Serving -* Postgres -* Redis -* Feast Jupyter \(Optional\) -* Prometheus \(Optional\) - -## 1. Requirements - -1. Install and configure [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) -2. Install [Helm 3](https://helm.sh/) - -## 2. Preparation - -Add the Feast Helm repository and download the latest charts: - -```text -helm repo add feast-charts https://feast-helm-charts.storage.googleapis.com -helm repo update -``` - -Feast includes a Helm chart that installs all necessary components to run Feast Core, Feast Online Serving, and an example Jupyter notebook. - -Feast Core requires Postgres to run, which requires a secret to be set on Kubernetes: - -```bash -kubectl create secret generic feast-postgresql --from-literal=postgresql-password=password -``` - -## 3. Installation - -Install Feast using Helm. The pods may take a few minutes to initialize. - -```bash -helm install feast-release feast-charts/feast -``` - -## 4. Use Jupyter to connect to Feast - -After all the pods are in a `RUNNING` state, port-forward to the Jupyter Notebook Server in the cluster: - -```bash -kubectl port-forward \ -$(kubectl get pod -l app=feast-jupyter -o custom-columns=:metadata.name) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - -{% embed url="http://localhost:8888/tree?" caption="" %} - -## 5. Further Reading - -* [Feast Concepts](../../concepts/overview.md) -* [Feast Examples/Tutorials](https://github.com/feast-dev/feast/tree/master/examples) -* [Feast Helm Chart Documentation](https://github.com/feast-dev/feast/blob/master/infra/charts/feast/README.md) -* [Configuring Feast components](../../reference-1/configuration-reference.md) -* [Feast and Spark](../../reference-1/feast-and-spark.md) - diff --git a/docs/feast-on-kubernetes/getting-started/install-feast/quickstart.md b/docs/feast-on-kubernetes/getting-started/install-feast/quickstart.md deleted file mode 100644 index b5e50d193c9..00000000000 --- a/docs/feast-on-kubernetes/getting-started/install-feast/quickstart.md +++ /dev/null @@ -1,91 +0,0 @@ -# Docker Compose - -{% hint style="success" %} -This guide is meant for exploratory purposes only. It allows users to run Feast locally using Docker Compose instead of Kubernetes. The goal of this guide is for users to be able to quickly try out the full Feast stack without needing to deploy to Kubernetes. It is not meant for production use. -{% endhint %} - -## Overview - -This guide shows you how to deploy Feast using [Docker Compose](https://docs.docker.com/get-started/). Docker Compose allows you to explore the functionality provided by Feast while requiring only minimal infrastructure. - -This guide includes the following containerized components: - -* [A complete Feast deployment](../../concepts/architecture.md) - * Feast Core with Postgres - * Feast Online Serving with Redis. - * Feast Job Service -* A Jupyter Notebook Server with built in Feast example\(s\). For demo purposes only. -* A Kafka cluster for testing streaming ingestion. For demo purposes only. - -## Get Feast - -Clone the latest stable version of Feast from the [Feast repository](https://github.com/feast-dev/feast/): - -```text -git clone https://github.com/feast-dev/feast.git -cd feast/infra/docker-compose -``` - -Create a new configuration file: - -```text -cp .env.sample .env -``` - -## Start Feast - -Start Feast with Docker Compose: - -```text -docker-compose pull && docker-compose up -d -``` - -Wait until all all containers are in a running state: - -```text -docker-compose ps -``` - -## Try our example\(s\) - -You can now connect to the bundled Jupyter Notebook Server running at `localhost:8888` and follow the example Jupyter notebook. - -{% embed url="http://localhost:8888/tree?" caption="" %} - -## Troubleshooting - -### Open ports - -Please ensure that the following ports are available on your host machine: - -* `6565` -* `6566` -* `8888` -* `9094` -* `5432` - -If a port conflict cannot be resolved, you can modify the port mappings in the provided [docker-compose.yml](https://github.com/feast-dev/feast/tree/master/infra/docker-compose) file to use different ports on the host. - -### Containers are restarting or unavailable - -If some of the containers continue to restart, or you are unable to access a service, inspect the logs using the following command: - -```javascript -docker-compose logs -f -t -``` - -If you are unable to resolve the problem, visit [GitHub](https://github.com/feast-dev/feast/issues) to create an issue. - -## Configuration - -The Feast Docker Compose setup can be configured by modifying properties in your `.env` file. - -### Accessing Google Cloud Storage \(GCP\) - -To access Google Cloud Storage as a data source, the Docker Compose installation requires access to a GCP service account. - -* Create a new [service account](https://cloud.google.com/iam/docs/creating-managing-service-accounts) and save a JSON key. -* Grant the service account access to your bucket\(s\). -* Copy the service account to the path you have configured in `.env` under `GCP_SERVICE_ACCOUNT`. -* Restart your Docker Compose setup of Feast. - diff --git a/docs/feast-on-kubernetes/getting-started/learn-feast.md b/docs/feast-on-kubernetes/getting-started/learn-feast.md deleted file mode 100644 index 983799ca9b9..00000000000 --- a/docs/feast-on-kubernetes/getting-started/learn-feast.md +++ /dev/null @@ -1,15 +0,0 @@ -# Learn Feast - -Explore the following resources to learn more about Feast: - -* [Concepts](../../) describes all important Feast API concepts. -* [User guide](../user-guide/define-and-ingest-features.md) provides guidance on completing Feast workflows. -* [Examples](https://github.com/feast-dev/feast/tree/master/examples) contains Jupyter notebooks that you can run on your Feast deployment. -* [Advanced](../advanced-1/troubleshooting.md) contains information about both advanced and operational aspects of Feast. -* [Reference](../reference-1/api/) contains detailed API and design documents for advanced users. -* [Contributing](../../contributing/contributing.md) contains resources for anyone who wants to contribute to Feast. - -{% hint style="info" %} -The best way to learn Feast is to use it. Jump over to our [Quickstart](install-feast/quickstart.md) guide to have one of our examples running in no time at all! -{% endhint %} - diff --git a/docs/feast-on-kubernetes/reference-1/README.md b/docs/feast-on-kubernetes/reference-1/README.md deleted file mode 100644 index 02577ad8e3a..00000000000 --- a/docs/feast-on-kubernetes/reference-1/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# Reference - diff --git a/docs/feast-on-kubernetes/reference-1/api/README.md b/docs/feast-on-kubernetes/reference-1/api/README.md deleted file mode 100644 index cd75f5bf88f..00000000000 --- a/docs/feast-on-kubernetes/reference-1/api/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# API Reference - -Please see the following API specific reference documentation: - -* [Feast Core gRPC API](https://api.docs.feast.dev/grpc/feast/core/coreservice.pb.html): This is the gRPC API used by Feast Core. This API contains RPCs for creating and managing feature sets, stores, projects, and jobs. -* [Feast Serving gRPC API](https://api.docs.feast.dev/grpc/feast/serving/servingservice.pb.html): This is the gRPC API used by Feast Serving. It contains RPCs used for the retrieval of online feature data or historical feature data. -* [Feast gRPC Types](https://api.docs.feast.dev/grpc/feast/types/value.pb): These are the gRPC types used by both Feast Core, Feast Serving, and the Go, Java, and Python clients. -* [Go Client SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go): The Go library used for the retrieval of online features from Feast. -* [Java Client SDK](https://javadoc.io/doc/dev.feast/feast-sdk): The Java library used for the retrieval of online features from Feast. -* [Python SDK](https://api.docs.feast.dev/python/): This is the complete reference to the Feast Python SDK. The SDK is used to manage feature sets, features, jobs, projects, and entities. It can also be used to retrieve training datasets or online features from Feast Serving. - -## Community Contributions - -The following community provided SDKs are available: - -* [Node.js SDK](https://github.com/MichaelHirn/feast-client/): A Node.js SDK written in TypeScript. The SDK can be used to manage feature sets, features, jobs, projects, and entities. - diff --git a/docs/feast-on-kubernetes/reference-1/configuration-reference.md b/docs/feast-on-kubernetes/reference-1/configuration-reference.md deleted file mode 100644 index 6f9a97dabfd..00000000000 --- a/docs/feast-on-kubernetes/reference-1/configuration-reference.md +++ /dev/null @@ -1,132 +0,0 @@ -# Configuration Reference - -## Overview - -This reference describes how to configure Feast components: - -* [Feast Core and Feast Online Serving](configuration-reference.md#2-feast-core-serving-and-job-controller) -* [Feast CLI and Feast Python SDK](configuration-reference.md#3-feast-cli-and-feast-python-sdk) -* [Feast Go and Feast Java SDK](configuration-reference.md#4-feast-java-and-go-sdk) - -## 1. Feast Core and Feast Online Serving - -Available configuration properties for Feast Core and Feast Online Serving can be referenced from the corresponding `application.yml` of each component: - -| Component | Configuration Reference | -| :--- | :--- | -| Core | [core/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/core/src/main/resources/application.yml) | -| Serving \(Online\) | [serving/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/serving/src/main/resources/application.yml) | - -Configuration properties for Feast Core and Feast Online Serving are defined depending on Feast is deployed: - -* [Docker Compose deployment](configuration-reference.md#docker-compose-deployment) - Feast is deployed with Docker Compose. -* [Kubernetes deployment](configuration-reference.md#kubernetes-deployment) - Feast is deployed with Kubernetes. -* [Direct Configuration](configuration-reference.md#direct-configuration) - Feast is built and run from source code. - -## Docker Compose Deployment - -For each Feast component deployed using Docker Compose, configuration properties from `application.yml` can be set at: - -| Component | Configuration Path | -| :--- | :--- | -| Core | `infra/docker-compose/core/core.yml` | -| Online Serving | `infra/docker-compose/serving/online-serving.yml` | - -## Kubernetes Deployment - -The Kubernetes Feast Deployment is configured using `values.yaml` in the [Helm chart](https://github.com/feast-dev/feast-helm-charts) included with Feast: - -```yaml -# values.yaml -feast-core: - enabled: true # whether to deploy the feast-core subchart to deploy Feast Core. - # feast-core subchart specific config. - gcpServiceAccount: - enabled: true - # .... -``` - -A reference of the sub-chart-specific configuration can found in its `values.yml`: - -* [feast-core](https://github.com/feast-dev/feast-java/tree/master/infra/charts/feast-core) -* [feast-serving](https://github.com/feast-dev/feast-java/tree/master/infra/charts/feast-serving) - -Configuration properties can be set via `application-override.yaml` for each component in `values.yaml`: - -```yaml -# values.yaml -feast-core: - # .... - application-override.yaml: - # application.yml config properties for Feast Core. - # ... -``` - -Visit the [Helm chart](https://github.com/feast-dev/feast-helm-charts) included with Feast to learn more about configuration. - -## Direct Configuration - -If Feast is built and running from source, configuration properties can be set directly in the Feast component's `application.yml`: - -| Component | Configuration Path | -| :--- | :--- | -| Core | [core/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/core/src/main/resources/application.yml) | -| Serving \(Online\) | [serving/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/serving/src/main/resources/application.yml) | - -## 2. Feast CLI and Feast Python SDK - -Configuration options for both the [Feast CLI](../getting-started/connect-to-feast/feast-cli.md) and [Feast Python SDK](https://api.docs.feast.dev/python/) can be defined in the following locations, in order of precedence: - -**1. Command line arguments or initialized arguments:** Passing parameters to the Feast CLI or instantiating the Feast Client object with specific parameters will take precedence above other parameters. - -```bash -# Set option as command line arguments. -feast config set core_url "localhost:6565" -``` - -```python -# Pass options as initialized arguments. -client = Client( - core_url="localhost:6565", - project="default" -) -``` - -**2. Environmental variables:** Environmental variables can be set to provide configuration options. They must be prefixed with `FEAST_`. For example `FEAST_CORE_URL`. - -```bash -FEAST_CORE_URL=my_feast:6565 FEAST_PROJECT=default feast projects list -``` - -**3. Configuration file:** Options with the lowest precedence are configured in the Feast configuration file. Feast looks for or creates this configuration file in `~/.feast/config` if it does not already exist. All options must be defined in the `[general]` section of this file. - -```text -[general] -project = default -core_url = localhost:6565 -``` - -Visit the [available configuration parameters](https://api.docs.feast.dev/python/#module-feast.constants) for Feast Python SDK and Feast CLI to learn more. - -## 3. Feast Java and Go SDK - -The [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) and [Feast Go SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go) are configured via arguments passed when instantiating the respective Clients: - -### Go SDK - -```go -// configure serving host and port. -cli := feast.NewGrpcClient("localhost", 6566) -``` - -Visit the[ Feast Go SDK API reference](https://godoc.org/github.com/feast-dev/feast/sdk/go) to learn more about available configuration parameters. - -### Java SDK - -```java -// configure serving host and port. -client = FeastClient.create(servingHost, servingPort); -``` - -Visit the [Feast Java SDK API reference](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) to learn more about available configuration parameters. - diff --git a/docs/feast-on-kubernetes/reference-1/feast-and-spark.md b/docs/feast-on-kubernetes/reference-1/feast-and-spark.md deleted file mode 100644 index be05f177aeb..00000000000 --- a/docs/feast-on-kubernetes/reference-1/feast-and-spark.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -description: Configuring Feast to use Spark for ingestion. ---- - -# Feast and Spark - -Feast relies on Spark to ingest data from the offline store to the online store, streaming ingestion, and running queries to retrieve historical data from the offline store. Feast supports several Spark deployment options. - -## Option 1. Use Kubernetes Operator for Apache Spark - -To install the Spark on K8s Operator - -```bash -helm repo add spark-operator \ - https://googlecloudplatform.github.io/spark-on-k8s-operator - -helm install my-release spark-operator/spark-operator \ - --set serviceAccounts.spark.name=spark -``` - -Currently Feast is tested using `v1beta2-1.1.2-2.4.5`version of the operator image. To configure Feast to use it, set the following options in Feast config: - -| Feast Setting | Value | -| :--- | :--- | -| `SPARK_LAUNCHER` | `"k8s"` | -| `SPARK_STAGING_LOCATION` | S3/GCS/Azure Blob Storage URL to use as a staging location, must be readable and writable by Feast. For S3, use `s3a://` prefix here. Ex.: `s3a://some-bucket/some-prefix/artifacts/` | -| `HISTORICAL_FEATURE_OUTPUT_LOCATION` | S3/GCS/Azure Blob Storage URL used to store results of historical retrieval queries, must be readable and writable by Feast. For S3, use `s3a://` prefix here. Ex.: `s3a://some-bucket/some-prefix/out/` | -| `SPARK_K8S_NAMESPACE` | Only needs to be set if you are customizing the spark-on-k8s-operator. The name of the Kubernetes namespace to run Spark jobs in. This should match the value of `sparkJobNamespace` set on spark-on-k8s-operator Helm chart. Typically this is also the namespace Feast itself will run in. | -| `SPARK_K8S_JOB_TEMPLATE_PATH` | Only needs to be set if you are customizing the Spark job template. Local file path with the template of the SparkApplication resource. No prefix required. Ex.: `/home/jovyan/work/sparkapp-template.yaml`. An example template is [here](https://github.com/feast-dev/feast/blob/4059a21dc4eba9cd27b2d5b0fabe476c07a8b3bd/sdk/python/feast/pyspark/launchers/k8s/k8s_utils.py#L280-L317) and the spec is defined in the [k8s-operator User Guide](https://github.com/GoogleCloudPlatform/spark-on-k8s-operator/blob/master/docs/user-guide.md). | - -Lastly, make sure that the service account used by Feast has permissions to manage Spark Application resources. This depends on your k8s setup, but typically you'd need to configure a Role and a RoleBinding like the one below: - -```text -cat < - - - Limitation - Motivation - - - - - Features names and entity names cannot overlap in feature table definitions - Features and entities become columns in historical stores which may cause - conflicts - - - -

The following field names are reserved in feature tables

-
    -
  • event_timestamp -
  • -
  • datetime -
  • -
  • created_timestamp -
  • -
  • ingestion_id -
  • -
  • job_id -
  • -
- - These keywords are used for column names when persisting metadata in historical - stores - - - - -### Ingestion - -| Limitation | Motivation | -| :--- | :--- | -| Once data has been ingested into Feast, there is currently no way to delete the data without manually going to the database and deleting it. However, during retrieval only the latest rows will be returned for a specific key \(`event_timestamp`, `entity`\) based on its `created_timestamp`. | This functionality simply doesn't exist yet as a Feast API | - -### Storage - -| Limitation | Motivation | -| :--- | :--- | -| Feast does not support offline storage in Feast 0.8 | As part of our re-architecture of Feast, we moved from GCP to cloud-agnostic deployments. Developing offline storage support that is available in all cloud environments is a pending action. | - diff --git a/docs/feast-on-kubernetes/reference-1/metrics-reference.md b/docs/feast-on-kubernetes/reference-1/metrics-reference.md deleted file mode 100644 index 78f94bc3901..00000000000 --- a/docs/feast-on-kubernetes/reference-1/metrics-reference.md +++ /dev/null @@ -1,178 +0,0 @@ -# Metrics Reference - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -Reference of the metrics that each Feast component exports: - -* [Feast Core](metrics-reference.md#feast-core) -* [Feast Serving](metrics-reference.md#feast-serving) -* [Feast Ingestion Job](metrics-reference.md#feast-ingestion-job) - -For how to configure Feast to export Metrics, see the [Metrics user guide.](../advanced-1/metrics.md) - -## Feast Core - -**Exported Metrics** - -Feast Core exports the following metrics: - -| Metrics | Description | Tags | -| :--- | :--- | :--- | -| `feast_core_request_latency_seconds` | Feast Core's latency in serving Requests in Seconds. | `service`, `method`, `status_code` | -| `feast_core_feature_set_total` | No. of Feature Sets registered with Feast Core. | None | -| `feast_core_store_total` | No. of Stores registered with Feast Core. | None | -| `feast_core_max_memory_bytes` | Max amount of memory the Java virtual machine will attempt to use. | None | -| `feast_core_total_memory_bytes` | Total amount of memory in the Java virtual machine | None | -| `feast_core_free_memory_bytes` | Total amount of free memory in the Java virtual machine. | None | -| `feast_core_gc_collection_seconds` | Time spent in a given JVM garbage collector in seconds. | None | - -**Metric Tags** - -Exported Feast Core metrics may be filtered by the following tags/keys - -| Tag | Description | -| :--- | :--- | -| `service` | Name of the Service that request is made to. Should be set to `CoreService` | -| `method` | Name of the Method that the request is calling. \(ie `ListFeatureSets`\) | -| `status_code` | Status code returned as a result of handling the requests \(ie `OK`\). Can be used to find request failures. | - -## Feast Serving - -**Exported Metrics** - -Feast Serving exports the following metrics: - -| Metric | Description | Tags | -| :--- | :--- | :--- | -| `feast_serving_request_latency_seconds` | Feast Serving's latency in serving Requests in Seconds. | `method` | -| `feast_serving_request_feature_count` | No. of requests retrieving a Feature from Feast Serving. | `project`, `feature_name` | -| `feast_serving_not_found_feature_count` | No. of requests retrieving a Feature has resulted in a [`NOT_FOUND` field status.](../user-guide/getting-training-features.md#online-field-statuses) | `project`, `feature_name` | -| `feast_serving_stale_feature_count` | No. of requests retrieving a Feature resulted in a [`OUTSIDE_MAX_AGE` field status.](../user-guide/getting-training-features.md#online-field-statuses) | `project`, `feature_name` | -| `feast_serving_grpc_request_count` | Total gRPC requests served. | `method` | - -**Metric Tags** - -Exported Feast Serving metrics may be filtered by the following tags/keys - -| Tag | Description | -| :--- | :--- | -| `method` | Name of the Method that the request is calling. \(ie `ListFeatureSets`\) | -| `status_code` | Status code returned as a result of handling the requests \(ie `OK`\). Can be used to find request failures. | -| `project` | Name of the project that the FeatureSet of the Feature retrieved belongs to. | -| `feature_name` | Name of the Feature being retrieved. | - -## Feast Ingestion Job - -Feast Ingestion computes both metrics an statistics on [data ingestion.](../user-guide/define-and-ingest-features.md) Make sure you familar with data ingestion concepts before proceeding. - -**Metrics Namespace** - -Metrics are computed at two stages of the Feature Row's/Feature Value's life cycle when being processed by the Ingestion Job: - -* `Inflight`- Prior to writing data to stores, but after successful validation of data. -* `WriteToStoreSucess`- After a successful store write. - -Metrics processed by each staged will be tagged with `metrics_namespace` to the stage where the metric was computed. - -**Metrics Bucketing** - -Metrics with a `{BUCKET}` are computed on a 60 second window/bucket. Suffix with the following to select the bucket to use: - -* `min` - minimum value. -* `max` - maximum value. -* `mean`- mean value. -* `percentile_90`- 90 percentile. -* `percentile_95`- 95 percentile. -* `percentile_99`- 99 percentile. - -**Exported Metrics** - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MetricDescriptionTags
feast_ingestion_feature_row_lag_ms_{BUCKET} - Lag time in milliseconds between succeeding ingested Feature Rows. -

feast_store, feast_project_name,feast_featureSet_name,ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_feature_value_lag_ms_{BUCKET} - Lag time in milliseconds between succeeding ingested values for each Feature. -

feast_store, feast_project_name,feast_featureSet_name,

-

feast_feature_name,

-

ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_feature_value_{BUCKET} - Last value feature for each Feature.feast_store, feature_project_name, feast_feature_name,feast_featureSet_name, ingest_job_name, metrics_namepace -
feast_ingestion_feature_row_ingested_count - No. of Ingested Feature Rows -

feast_store, feast_project_name,feast_featureSet_name,ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_feature_value_missing_count - No. of times a ingested Feature values did not provide a value for the - Feature. -

feast_store, feast_project_name,feast_featureSet_name,

-

feast_feature_name,

-

ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_deadletter_row_count - No. of Feature Rows that that the Ingestion Job did not successfully write - to store.feast_store, feast_project_name,feast_featureSet_name,ingestion_job_name -
- -**Metric Tags** - -Exported Feast Ingestion Job metrics may be filtered by the following tags/keys - -| Tag | Description | -| :--- | :--- | -| `feast_store` | Name of the target store the Ingestion Job is writing to. | -| `feast_project_name` | Name of the project that the ingested FeatureSet belongs to. | -| `feast_featureSet_name` | Name of the Feature Set being ingested. | -| `feast_feature_name` | Name of the Feature being ingested. | -| `ingestion_job_name` | Name of the Ingestion Job performing data ingestion. Typically this is set to the Id of the Ingestion Job. | -| `metrics_namespace` | Stage where metrics where computed. Either `Inflight` or `WriteToStoreSuccess` | - diff --git a/docs/feast-on-kubernetes/tutorials-1/README.md b/docs/feast-on-kubernetes/tutorials-1/README.md deleted file mode 100644 index 84ce15b7886..00000000000 --- a/docs/feast-on-kubernetes/tutorials-1/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# Tutorials - diff --git a/docs/feast-on-kubernetes/user-guide/README.md b/docs/feast-on-kubernetes/user-guide/README.md deleted file mode 100644 index be02a733729..00000000000 --- a/docs/feast-on-kubernetes/user-guide/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# User guide - diff --git a/docs/feast-on-kubernetes/user-guide/define-and-ingest-features.md b/docs/feast-on-kubernetes/user-guide/define-and-ingest-features.md deleted file mode 100644 index 5a7e7288ec9..00000000000 --- a/docs/feast-on-kubernetes/user-guide/define-and-ingest-features.md +++ /dev/null @@ -1,52 +0,0 @@ -# Define and ingest features - -In order to retrieve features for both training and serving, Feast requires data being ingested into its offline and online stores. - -Users are expected to already have either a batch or stream source with data stored in it, ready to be ingested into Feast. Once a feature table \(with the corresponding sources\) has been registered with Feast, it is possible to load data from this source into stores. - -The following depicts an example ingestion flow from a data source to the online store. - -## Batch Source to Online Store - -```python -from feast import Client -from datetime import datetime, timedelta - -client = Client(core_url="localhost:6565") -driver_ft = client.get_feature_table("driver_trips") - -# Initialize date ranges -today = datetime.now() -yesterday = today - timedelta(1) - -# Launches a short-lived job that ingests data over the provided date range. -client.start_offline_to_online_ingestion( - driver_ft, yesterday, today -) -``` - -## Stream Source to Online Store - -```python -from feast import Client -from datetime import datetime, timedelta - -client = Client(core_url="localhost:6565") -driver_ft = client.get_feature_table("driver_trips") - -# Launches a long running streaming ingestion job -client.start_stream_to_online_ingestion(driver_ft) -``` - -## Batch Source to Offline Store - -{% hint style="danger" %} -Not supported in Feast 0.8 -{% endhint %} - -## Stream Source to Offline Store - -{% hint style="danger" %} -Not supported in Feast 0.8 -{% endhint %} - diff --git a/docs/feast-on-kubernetes/user-guide/getting-online-features.md b/docs/feast-on-kubernetes/user-guide/getting-online-features.md deleted file mode 100644 index c16dc08a013..00000000000 --- a/docs/feast-on-kubernetes/user-guide/getting-online-features.md +++ /dev/null @@ -1,54 +0,0 @@ -# Getting online features - -Feast provides an API through which online feature values can be retrieved. This allows teams to look up feature values at low latency in production during model serving, in order to make online predictions. - -{% hint style="info" %} -Online stores only maintain the current state of features, i.e latest feature values. No historical data is stored or served. -{% endhint %} - -```python -from feast import Client - -online_client = Client( - core_url="localhost:6565", - serving_url="localhost:6566", -) - -entity_rows = [ - {"driver_id": 1001}, - {"driver_id": 1002}, -] - -# Features in format -feature_refs = [ - "driver_trips:average_daily_rides", - "driver_trips:maximum_daily_rides", - "driver_trips:rating", -] - -response = online_client.get_online_features( - feature_refs=feature_refs, # Contains only feature references - entity_rows=entity_rows, # Contains only entities (driver ids) -) - -# Print features in dictionary format -response_dict = response.to_dict() -print(response_dict) -``` - -The online store must be populated through [ingestion jobs](define-and-ingest-features.md#batch-source-to-online-store) prior to being used for online serving. - -Feast Serving provides a [gRPC API](https://api.docs.feast.dev/grpc/feast.serving.pb.html) that is backed by [Redis](https://redis.io/). We have native clients in [Python](https://api.docs.feast.dev/python/), [Go](https://godoc.org/github.com/gojek/feast/sdk/go), and [Java](https://javadoc.io/doc/dev.feast). - -### Online Field Statuses - -Feast also returns status codes when retrieving features from the Feast Serving API. These status code give useful insight into the quality of data being served. - -| Status | Meaning | -| :--- | :--- | -| NOT\_FOUND | The feature value was not found in the online store. This might mean that no feature value was ingested for this feature. | -| NULL\_VALUE | A entity key was successfully found but no feature values had been set. This status code should not occur during normal operation. | -| OUTSIDE\_MAX\_AGE | The age of the feature row in the online store \(in terms of its event timestamp\) has exceeded the maximum age defined within the feature table. | -| PRESENT | The feature values have been found and are within the maximum age. | -| UNKNOWN | Indicates a system failure. | - diff --git a/docs/feast-on-kubernetes/user-guide/getting-training-features.md b/docs/feast-on-kubernetes/user-guide/getting-training-features.md deleted file mode 100644 index e0f52a8cd96..00000000000 --- a/docs/feast-on-kubernetes/user-guide/getting-training-features.md +++ /dev/null @@ -1,72 +0,0 @@ -# Getting training features - -Feast provides a historical retrieval interface for exporting feature data in order to train machine learning models. Essentially, users are able to enrich their data with features from any feature tables. - -### Retrieving historical features - -Below is an example of the process required to produce a training dataset: - -```python -# Feature references with target feature -features = [ - "driver_trips:average_daily_rides", - "driver_trips:maximum_daily_rides", - "driver_trips:rating", - "driver_trips:rating:trip_completed", -] - -# Define entity source -entity_source = FileSource( - "event_timestamp", - ParquetFormat(), - "gs://some-bucket/customer" -) - -# Retrieve historical dataset from Feast. -historical_feature_retrieval_job = client.get_historical_features( - features=features, - entity_rows=entity_source -) - -output_file_uri = historical_feature_retrieval_job.get_output_file_uri() -``` - -#### 1. Define feature references - -[Feature references]() define the specific features that will be retrieved from Feast. These features can come from multiple feature tables. The only requirement is that the feature tables that make up the feature references have the same entity \(or composite entity\). - -**2. Define an entity dataframe** - -Feast needs to join feature values onto specific entities at specific points in time. Thus, it is necessary to provide an [entity dataframe]() as part of the `get_historical_features` method. In the example above we are defining an entity source. This source is an external file that provides Feast with the entity dataframe. - -**3. Launch historical retrieval job** - -Once the feature references and an entity source are defined, it is possible to call `get_historical_features()`. This method launches a job that extracts features from the sources defined in the provided feature tables, joins them onto the provided entity source, and returns a reference to the training dataset that is produced. - -Please see the [Feast SDK](https://api.docs.feast.dev/python) for more details. - -### Point-in-time Joins - -Feast always joins features onto entity data in a point-in-time correct way. The process can be described through an example. - -In the example below there are two tables \(or dataframes\): - -* The dataframe on the left is the [entity dataframe]() that contains timestamps, entities, and the target variable \(trip\_completed\). This dataframe is provided to Feast through an entity source. -* The dataframe on the right contains driver features. This dataframe is represented in Feast through a feature table and its accompanying data source\(s\). - -The user would like to have the driver features joined onto the entity dataframe to produce a training dataset that contains both the target \(trip\_completed\) and features \(average\_daily\_rides, maximum\_daily\_rides, rating\). This dataset will then be used to train their model. - -![](../../.gitbook/assets/point_in_time_join%20%281%29%20%282%29%20%282%29%20%283%29%20%283%29%20%283%29%20%283%29%20%282%29.png) - -Feast is able to intelligently join feature data with different timestamps to a single entity dataframe. It does this through a point-in-time join as follows: - -1. Feast loads the entity dataframe and all feature tables \(driver dataframe\) into the same location. This can either be a database or in memory. -2. For each [entity row]() in the [entity dataframe](getting-online-features.md), Feast tries to find feature values in each feature table to join to it. Feast extracts the timestamp and entity key of each row in the entity dataframe and scans backward through the feature table until it finds a matching entity key. -3. If the event timestamp of the matching entity key within the driver feature table is within the maximum age configured for the feature table, then the features at that entity key are joined onto the entity dataframe. If the event timestamp is outside of the maximum age, then only null values are returned. -4. If multiple entity keys are found with the same event timestamp, then they are deduplicated by the created timestamp, with newer values taking precedence. -5. Feast repeats this joining process for all feature tables and returns the resulting dataset. - -{% hint style="info" %} -Point-in-time correct joins attempts to prevent the occurrence of feature leakage by trying to recreate the state of the world at a single point in time, instead of joining features based on exact timestamps only. -{% endhint %} - diff --git a/docs/feast-on-kubernetes/user-guide/overview.md b/docs/feast-on-kubernetes/user-guide/overview.md deleted file mode 100644 index 5f367924794..00000000000 --- a/docs/feast-on-kubernetes/user-guide/overview.md +++ /dev/null @@ -1,32 +0,0 @@ -# Overview - -### Using Feast - -Feast development happens through three key workflows: - -1. [Define and load feature data into Feast](define-and-ingest-features.md) -2. [Retrieve historical features for training models](getting-training-features.md) -3. [Retrieve online features for serving models](getting-online-features.md) - -### Defining feature tables and ingesting data into Feast - -Feature creators model the data within their organization into Feast through the definition of [feature tables](../concepts/feature-tables.md) that contain [data sources](../concepts/sources.md). Feature tables are both a schema and a means of identifying data sources for features, and allow Feast to know how to interpret your data, and where to find it. - -After registering a feature table with Feast, users can trigger an ingestion from their data source into Feast. This loads feature values from an upstream data source into Feast stores through ingestion jobs. - -Visit [feature tables](../concepts/feature-tables.md#overview) to learn more about them. - -{% page-ref page="define-and-ingest-features.md" %} - -### Retrieving historical features for training - -In order to generate a training dataset it is necessary to provide both an [entity dataframe ]()and feature references through the[ Feast SDK](https://api.docs.feast.dev/python/) to retrieve historical features. For historical serving, Feast requires that you provide the entities and timestamps for the corresponding feature data. Feast produces a point-in-time correct dataset using the requested features. These features can be requested from an unlimited number of feature sets. - -{% page-ref page="getting-training-features.md" %} - -### Retrieving online features for online serving - -Online retrieval uses feature references through the [Feast Online Serving API](https://api.docs.feast.dev/grpc/feast.serving.pb.html) to retrieve online features. Online serving allows for very low latency requests to feature data at very high throughput. - -{% page-ref page="getting-online-features.md" %} - diff --git a/docs/getting-started/architecture-and-components/untitled.md b/docs/getting-started/architecture-and-components/registry.md similarity index 100% rename from docs/getting-started/architecture-and-components/untitled.md rename to docs/getting-started/architecture-and-components/registry.md diff --git a/docs/getting-started/connect-to-feast/README.md b/docs/getting-started/connect-to-feast/README.md deleted file mode 100644 index 4333359f902..00000000000 --- a/docs/getting-started/connect-to-feast/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Connect to Feast - -### Feast Python SDK - -The Feast Python SDK is used as a library to interact with a Feast deployment. - -* Define, register, and manage entities and features -* Ingest data into Feast -* Build and retrieve training datasets -* Retrieve online features - -{% page-ref page="python-sdk.md" %} - -### Feast CLI - -The Feast CLI is a command line implementation of the Feast Python SDK. - -* Define, register, and manage entities and features from the terminal -* Ingest data into Feast -* Manage ingestion jobs - -{% page-ref page="feast-cli.md" %} - -### Online Serving Clients - -The following clients can be used to retrieve online feature values: - -* [Feast Python SDK](https://api.docs.feast.dev/python/) -* [Feast Go SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go) -* [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk) - diff --git a/docs/getting-started/connect-to-feast/feast-cli.md b/docs/getting-started/connect-to-feast/feast-cli.md deleted file mode 100644 index d15414f3604..00000000000 --- a/docs/getting-started/connect-to-feast/feast-cli.md +++ /dev/null @@ -1,37 +0,0 @@ -# Feast CLI - -Install the Feast CLI using pip: - -```bash -pip install feast -``` - -Configure the CLI to connect to your Feast Core deployment: - -```text -feast config set core_url your.feast.deployment -``` - -{% hint style="info" %} -By default, all configuration is stored in `~/.feast/config` -{% endhint %} - -The CLI is a wrapper around the [Feast Python SDK](python-sdk.md): - -```aspnet -$ feast - -Usage: feast [OPTIONS] COMMAND [ARGS]... - -Options: - --help Show this message and exit. - -Commands: - config View and edit Feast properties - entities Create and manage entities - feature-tables Create and manage feature tables - jobs Create and manage jobs - projects Create and manage projects - version Displays version and connectivity information -``` - diff --git a/docs/getting-started/connect-to-feast/python-sdk.md b/docs/getting-started/connect-to-feast/python-sdk.md deleted file mode 100644 index bf31bd38491..00000000000 --- a/docs/getting-started/connect-to-feast/python-sdk.md +++ /dev/null @@ -1,20 +0,0 @@ -# Python SDK - -Install the [Feast Python SDK](https://api.docs.feast.dev/python/) using pip: - -```bash -pip install feast -``` - -Connect to an existing Feast Core deployment: - -```python -from feast import Client - -# Connect to an existing Feast Core deployment -client = Client(core_url='feast.example.com:6565') - -# Ensure that your client is connected by printing out some feature tables -client.list_feature_tables() -``` - diff --git a/docs/getting-started/install-feast/README.md b/docs/getting-started/install-feast/README.md deleted file mode 100644 index 6c1dd80134c..00000000000 --- a/docs/getting-started/install-feast/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Install Feast - -{% hint style="success" %} -_Would you prefer a lighter-weight, pip-install, no-Kubernetes deployment of Feast?_ The Feast maintainers are currently building a new deployment experience for Feast. If you have thoughts on Feast's deployment, [chat with the maintainers](https://calendly.com/d/gc29-y88c/feast-chat-w-willem-and-jay) to learn more and provide feedback. -{% endhint %} - -A production deployment of Feast is deployed using Kubernetes. - -## Kubernetes \(with Helm\) - -This guide installs Feast into an existing Kubernetes cluster using Helm. The installation is not specific to any cloud platform or environment, but requires Kubernetes and Helm. - -## Amazon EKS \(with Terraform\) - -This guide installs Feast into an AWS environment using Terraform. The Terraform script is opinionated and intended to allow you to start quickly. - -## Azure AKS \(with Helm\) - -This guide installs Feast into an Azure AKS environment with Helm. - -## Azure AKS \(with Terraform\) - -This guide installs Feast into an Azure environment using Terraform. The Terraform script is opinionated and intended to allow you to start quickly. - -## Google Cloud GKE \(with Terraform\) - -This guide installs Feast into a Google Cloud environment using Terraform. The Terraform script is opinionated and intended to allow you to start quickly. - -## IBM Cloud Kubernetes Service \(IKS\) and Red Hat OpenShift \(using Kustomize\) - -This guide installs Feast into an existing [IBM Cloud Kubernetes Service](https://www.ibm.com/cloud/kubernetes-service) or [Red Hat OpenShift on IBM Cloud](https://www.ibm.com/cloud/openshift) using Kustomize. - -{% page-ref page="ibm-cloud-iks-with-kustomize.md" %} diff --git a/docs/getting-started/install-feast/google-cloud-gke-with-terraform.md b/docs/getting-started/install-feast/google-cloud-gke-with-terraform.md deleted file mode 100644 index a3252cf0bbb..00000000000 --- a/docs/getting-started/install-feast/google-cloud-gke-with-terraform.md +++ /dev/null @@ -1,52 +0,0 @@ -# Google Cloud GKE \(with Terraform\) - -### Overview - -This guide installs Feast on GKE using our [reference Terraform configuration](https://github.com/feast-dev/feast/tree/master/infra/terraform/gcp). - -{% hint style="info" %} -The Terraform configuration used here is a greenfield installation that neither assumes anything about, nor integrates with, existing resources in your GCP account. The Terraform configuration presents an easy way to get started, but you may want to customize this set up before using Feast in production. -{% endhint %} - -This Terraform configuration creates the following resources: - -* GKE cluster -* Feast services running on GKE -* Google Memorystore \(Redis\) as online store -* Dataproc cluster -* Kafka running on GKE, exposed to the dataproc cluster via internal load balancer - -### 1. Requirements - -* Install [Terraform](https://www.terraform.io/) > = 0.12 \(tested with 0.13.3\) -* Install [Helm](https://helm.sh/docs/intro/install/) \(tested with v3.3.4\) -* GCP [authentication](https://cloud.google.com/docs/authentication) and sufficient [privilege](https://cloud.google.com/iam/docs/understanding-roles) to create the resources listed above. - -### 2. Configure Terraform - -Create a `.tfvars` file under`feast/infra/terraform/gcp`. Name the file. In our example, we use `my_feast.tfvars`. You can see the full list of configuration variables in `variables.tf`. Sample configurations are provided below: - -{% code title="my\_feast.tfvars" %} -```typescript -gcp_project_name = "kf-feast" -name_prefix = "feast-0-8" -region = "asia-east1" -gke_machine_type = "n1-standard-2" -network = "default" -subnetwork = "default" -dataproc_staging_bucket = "feast-dataproc" -``` -{% endcode %} - -### 3. Apply - -After completing the configuration, initialize Terraform and apply: - -```bash -$ cd feast/infra/terraform/gcp -$ terraform init -$ terraform apply -var-file=my_feast.tfvars -``` - - - diff --git a/docs/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md b/docs/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md deleted file mode 100644 index 817d4dbe14d..00000000000 --- a/docs/getting-started/install-feast/ibm-cloud-iks-with-kustomize.md +++ /dev/null @@ -1,185 +0,0 @@ -# IBM Cloud Kubernetes Service and Red Hat OpenShift \(with Kustomize\) - -## Overview - -This guide installs Feast on an existing IBM Cloud Kubernetes cluster or Red Hat OpenShift on IBM Cloud , and ensures the following services are running: - -* Feast Core -* Feast Online Serving -* Postgres -* Redis -* Kafka \(Optional\) -* Feast Jupyter \(Optional\) -* Prometheus \(Optional\) - -## 1. Prerequisites - -1. [IBM Cloud Kubernetes Service](https://www.ibm.com/cloud/kubernetes-service) or [Red Hat OpenShift on IBM Cloud](https://www.ibm.com/cloud/openshift) -2. Install [Kubectl](https://cloud.ibm.com/docs/containers?topic=containers-cs_cli_install#kubectl) that matches the major.minor versions of your IKS or Install the [OpenShift CLI](https://cloud.ibm.com/docs/openshift?topic=openshift-openshift-cli#cli_oc) that matches your local operating system and OpenShift cluster version. -3. Install [Helm 3](https://helm.sh/) -4. Install [Kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/) - -## 2. Preparation -### IBM Cloud Block Storage Setup (IKS only) - -:warning: If you have Red Hat OpenShift Cluster on IBM Cloud skip to this [section](#Security-Context-Constraint-Setup). - -By default, IBM Cloud Kubernetes cluster uses [IBM Cloud File Storage](https://www.ibm.com/cloud/file-storage) based on NFS as the default storage class, and non-root users do not have write permission on the volume mount path for NFS-backed storage. Some common container images in Feast, such as Redis, Postgres, and Kafka specify a non-root user to access the mount path in the images. When containers are deployed using these images, the containers fail to start due to insufficient permissions of the non-root user creating folders on the mount path. - -[IBM Cloud Block Storage](https://www.ibm.com/cloud/block-storage) allows for the creation of raw storage volumes and provides faster performance without the permission restriction of NFS-backed storage - -Therefore, to deploy Feast we need to set up [IBM Cloud Block Storage](https://cloud.ibm.com/docs/containers?topic=containers-block_storage#install_block) as the default storage class so that you can have all the functionalities working and get the best experience from Feast. - -1. [Follow the instructions](https://helm.sh/docs/intro/install/) to install the Helm version 3 client on your local machine. -2. Add the IBM Cloud Helm chart repository to the cluster where you want to use the IBM Cloud Block Storage plug-in. - - ```text - helm repo add iks-charts https://icr.io/helm/iks-charts - helm repo update - ``` - -3. Install the IBM Cloud Block Storage plug-in. When you install the plug-in, pre-defined block storage classes are added to your cluster. - - ```text - helm install v2.0.2 iks-charts/ibmcloud-block-storage-plugin -n kube-system - ``` - - Example output: - - ```text - NAME: v2.0.2 - LAST DEPLOYED: Fri Feb 5 12:29:50 2021 - NAMESPACE: kube-system - STATUS: deployed - REVISION: 1 - NOTES: - Thank you for installing: ibmcloud-block-storage-plugin. Your release is named: v2.0.2 - ... - ``` - -4. Verify that all block storage plugin pods are in a "Running" state. - - ```text - kubectl get pods -n kube-system | grep ibmcloud-block-storage - ``` - -5. Verify that the storage classes for Block Storage were added to your cluster. - - ```text - kubectl get storageclasses | grep ibmc-block - ``` - -6. Set the Block Storage as the default storageclass. - - ```text - kubectl patch storageclass ibmc-block-gold -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' - kubectl patch storageclass ibmc-file-gold -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}' - - # Check the default storageclass is block storage - kubectl get storageclass | grep \(default\) - ``` - - Example output: - - ```text - ibmc-block-gold (default) ibm.io/ibmc-block 65s - ``` -### Security Context Constraint Setup - -By default, in OpenShift, all pods or containers will use the [Restricted SCC](https://docs.openshift.com/container-platform/4.6/authentication/managing-security-context-constraints.html) which limits the UIDs pods can run with, causing the Feast installation to fail. To overcome this, you can allow Feast pods to run with any UID by executing the following: - -```text -oc adm policy add-scc-to-user anyuid -z default,kf-feast-kafka -n feast -``` -## 3. Installation - -Install Feast using kustomize. The pods may take a few minutes to initialize. - -```bash -git clone https://github.com/kubeflow/manifests -cd manifests/contrib/feast/ -kustomize build feast/base | kubectl apply -n feast -f - -``` -### Optional: Enable Feast Jupyter and Kafka - -You may optionally enable the Feast Jupyter component which contains code examples to demonstrate Feast. Some examples require Kafka to stream real time features to the Feast online serving. To enable, edit the following properties in the `values.yaml` under the `manifests/contrib/feast` folder: -``` -kafka.enabled: true -feast-jupyter.enabled: true -``` - -Then regenerate the resource manifests and deploy: -``` -make feast/base -kustomize build feast/base | kubectl apply -n feast -f - -``` - -## 4. Use Feast Jupyter to connect to Feast - -After all the pods are in a `RUNNING` state, port-forward to the Jupyter Notebook Server in the cluster: - -```bash -kubectl port-forward \ -$(kubectl get pod -l app=feast-jupyter -o custom-columns=:metadata.name) 8888:8888 -n feast -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - -{% embed url="http://localhost:8888/tree?" caption="" %} - -## 5. Uninstall Feast -```text -kustomize build feast/base | kubectl delete -n feast -f - -``` -## 6. Troubleshooting - -When running the minimal\_ride\_hailing\_example Jupyter Notebook example the following errors may occur: - -1. When running `job = client.get_historical_features(...)`: - - ```text - KeyError: 'historical_feature_output_location' - ``` - - or - - ```text - KeyError: 'spark_staging_location' - ``` - - Add the following environment variable: - - ```text - os.environ["FEAST_HISTORICAL_FEATURE_OUTPUT_LOCATION"] = "file:///home/jovyan/historical_feature_output" - os.environ["FEAST_SPARK_STAGING_LOCATION"] = "file:///home/jovyan/test_data" - ``` - -2. When running `job.get_status()` - - ```text - - ``` - - Add the following environment variable: - - ```text - os.environ["FEAST_REDIS_HOST"] = "feast-release-redis-master" - ``` - -3. When running `job = client.start_stream_to_online_ingestion(...)` - - ```text - org.apache.kafka.vendor.common.KafkaException: Failed to construct kafka consumer - ``` - - Add the following environment variable: - - ```text - os.environ["DEMO_KAFKA_BROKERS"] = "feast-release-kafka:9092" - ``` - diff --git a/docs/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md b/docs/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md deleted file mode 100644 index 99ff4a8e81b..00000000000 --- a/docs/getting-started/install-feast/kubernetes-amazon-eks-with-terraform.md +++ /dev/null @@ -1,68 +0,0 @@ -# Amazon EKS \(with Terraform\) - -### Overview - -This guide installs Feast on AWS using our [reference Terraform configuration](https://github.com/feast-dev/feast/tree/master/infra/terraform/aws). - -{% hint style="info" %} -The Terraform configuration used here is a greenfield installation that neither assumes anything about, nor integrates with, existing resources in your AWS account. The Terraform configuration presents an easy way to get started, but you may want to customize this set up before using Feast in production. -{% endhint %} - -This Terraform configuration creates the following resources: - -* Kubernetes cluster on Amazon EKS \(3x r3.large nodes\) -* Kafka managed by Amazon MSK \(2x kafka.t3.small nodes\) -* Postgres database for Feast metadata, using serverless Aurora \(min capacity: 2\) -* Redis cluster, using Amazon Elasticache \(1x cache.t2.micro\) -* Amazon EMR cluster to run Spark \(3x spot m4.xlarge\) -* Staging S3 bucket to store temporary data - -![](../../.gitbook/assets/feast-on-aws-3-%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%282%29%20%281%29.png) - -### 1. Requirements - -* Create an AWS account and [configure credentials locally](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) -* Install [Terraform](https://www.terraform.io/) > = 0.12 \(tested with 0.13.3\) -* Install [Helm](https://helm.sh/docs/intro/install/) \(tested with v3.3.4\) - -### 2. Configure Terraform - -Create a `.tfvars` file under`feast/infra/terraform/aws`. Name the file. In our example, we use `my_feast.tfvars`. You can see the full list of configuration variables in `variables.tf`. At a minimum, you need to set `name_prefix` and an AWS region: - -{% code title="my\_feast.tfvars" %} -```typescript -name_prefix = "my-feast" -region = "us-east-1" -``` -{% endcode %} - -### 3. Apply - -After completing the configuration, initialize Terraform and apply: - -```bash -$ cd feast/infra/terraform/aws -$ terraform init -$ terraform apply -var-file=my_feast.tfvars -``` - -Starting may take a minute. A kubectl configuration file is also created in this directory, and the file's name will start with `kubeconfig_` and end with a random suffix. - -### 4. Connect to Feast using Jupyter - -After all pods are running, connect to the Jupyter Notebook Server running in the cluster. - -To connect to the remote Feast server you just created, forward a port from the remote k8s cluster to your local machine. Replace `kubeconfig_XXXXXXX` below with the kubeconfig file name Terraform generates for you. - -```bash -KUBECONFIG=kubeconfig_XXXXXXX kubectl port-forward \ -$(kubectl get pod -o custom-columns=:metadata.name | grep jupyter) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - diff --git a/docs/getting-started/install-feast/kubernetes-azure-aks-with-helm.md b/docs/getting-started/install-feast/kubernetes-azure-aks-with-helm.md deleted file mode 100644 index 66ba73ef23e..00000000000 --- a/docs/getting-started/install-feast/kubernetes-azure-aks-with-helm.md +++ /dev/null @@ -1,139 +0,0 @@ -# Azure AKS \(with Helm\) - -## Overview - -This guide installs Feast on Azure Kubernetes cluster \(known as AKS\), and ensures the following services are running: - -* Feast Core -* Feast Online Serving -* Postgres -* Redis -* Spark -* Kafka -* Feast Jupyter \(Optional\) -* Prometheus \(Optional\) - -## 1. Requirements - -1. Install and configure [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) -2. Install and configure [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) -3. Install [Helm 3](https://helm.sh/) - -## 2. Preparation - -Create an AKS cluster with Azure CLI. The detailed steps can be found [here](https://docs.microsoft.com/en-us/azure/aks/kubernetes-walkthrough), and a high-level walk through includes: - -```bash -az group create --name myResourceGroup --location eastus -az acr create --resource-group myResourceGroup --name feast-AKS-ACR --sku Basic -az aks create -g myResourceGroup -n feast-AKS --location eastus --attach-acr feast-AKS-ACR --generate-ssh-keys - -az aks install-cli -az aks get-credentials --resource-group myResourceGroup --name feast-AKS -``` - -Add the Feast Helm repository and download the latest charts: - -```bash -helm version # make sure you have the latest Helm installed -helm repo add feast-charts https://feast-helm-charts.storage.googleapis.com -helm repo update -``` - -Feast includes a Helm chart that installs all necessary components to run Feast Core, Feast Online Serving, and an example Jupyter notebook. - -Feast Core requires Postgres to run, which requires a secret to be set on Kubernetes: - -```bash -kubectl create secret generic feast-postgresql --from-literal=postgresql-password=password -``` - -## 3. Feast installation - -Install Feast using Helm. The pods may take a few minutes to initialize. - -```bash -helm install feast-release feast-charts/feast -``` - -## 4. Spark operator installation - -Follow the documentation [to install Spark operator on Kubernetes ](https://github.com/GoogleCloudPlatform/spark-on-k8s-operator), and Feast documentation to [configure Spark roles](../../reference/feast-and-spark.md) - -```bash -helm repo add spark-operator https://googlecloudplatform.github.io/spark-on-k8s-operator -helm install my-release spark-operator/spark-operator --set serviceAccounts.spark.name=spark --set image.tag=v1beta2-1.1.2-2.4.5 -``` - -and ensure the service account used by Feast has permissions to manage Spark Application resources. This depends on your k8s setup, but typically you'd need to configure a Role and a RoleBinding like the one below: - -```text -cat < -rules: -- apiGroups: ["sparkoperator.k8s.io"] - resources: ["sparkapplications"] - verbs: ["create", "delete", "deletecollection", "get", "list", "update", "watch", "patch"] ---- -apiVersion: rbac.authorization.k8s.io/v1beta1 -kind: RoleBinding -metadata: - name: use-spark-operator - namespace: -roleRef: - kind: Role - name: use-spark-operator - apiGroup: rbac.authorization.k8s.io -subjects: - - kind: ServiceAccount - name: default -EOF -``` - -## 5. Use Jupyter to connect to Feast - -After all the pods are in a `RUNNING` state, port-forward to the Jupyter Notebook Server in the cluster: - -```bash -kubectl port-forward \ -$(kubectl get pod -o custom-columns=:metadata.name | grep jupyter) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - -{% embed url="http://localhost:8888/tree?" caption="" %} - -## 6. Environment variables - -If you are running the [Minimal Ride Hailing Example](https://github.com/feast-dev/feast/blob/master/examples/minimal/minimal_ride_hailing.ipynb), you may want to make sure the following environment variables are correctly set: - -```text -demo_data_location = "wasbs://@.blob.core.windows.net/" -os.environ["FEAST_AZURE_BLOB_ACCOUNT_NAME"] = "" -os.environ["FEAST_AZURE_BLOB_ACCOUNT_ACCESS_KEY"] = -os.environ["FEAST_HISTORICAL_FEATURE_OUTPUT_LOCATION"] = "wasbs://@.blob.core.windows.net/out/" -os.environ["FEAST_SPARK_STAGING_LOCATION"] = "wasbs://@.blob.core.windows.net/artifacts/" -os.environ["FEAST_SPARK_LAUNCHER"] = "k8s" -os.environ["FEAST_SPARK_K8S_NAMESPACE"] = "default" -os.environ["FEAST_HISTORICAL_FEATURE_OUTPUT_FORMAT"] = "parquet" -os.environ["FEAST_REDIS_HOST"] = "feast-release-redis-master.default.svc.cluster.local" -os.environ["DEMO_KAFKA_BROKERS"] = "feast-release-kafka.default.svc.cluster.local:9092" -``` - -## 7. Further Reading - -* [Feast Concepts](../../concepts/overview.md) -* [Feast Examples/Tutorials](https://github.com/feast-dev/feast/tree/master/examples) -* [Feast Helm Chart Documentation](https://github.com/feast-dev/feast/blob/master/infra/charts/feast/README.md) -* [Configuring Feast components](../../reference/configuration-reference.md) -* [Feast and Spark](../../reference/feast-and-spark.md) - diff --git a/docs/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md b/docs/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md deleted file mode 100644 index 71dd15908de..00000000000 --- a/docs/getting-started/install-feast/kubernetes-azure-aks-with-terraform.md +++ /dev/null @@ -1,63 +0,0 @@ -# Azure AKS \(with Terraform\) - -## Overview - -This guide installs Feast on Azure using our [reference Terraform configuration](https://github.com/feast-dev/feast/tree/master/infra/terraform/azure). - -{% hint style="info" %} -The Terraform configuration used here is a greenfield installation that neither assumes anything about, nor integrates with, existing resources in your Azure account. The Terraform configuration presents an easy way to get started, but you may want to customize this set up before using Feast in production. -{% endhint %} - -This Terraform configuration creates the following resources: - -* Kubernetes cluster on Azure AKS -* Kafka managed by HDInsight -* Postgres database for Feast metadata, running as a pod on AKS -* Redis cluster, using Azure Cache for Redis -* [spark-on-k8s-operator](https://github.com/GoogleCloudPlatform/spark-on-k8s-operator) to run Spark -* Staging Azure blob storage container to store temporary data - -## 1. Requirements - -* Create an Azure account and [configure credentials locally](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) -* Install [Terraform](https://www.terraform.io/) \(tested with 0.13.5\) -* Install [Helm](https://helm.sh/docs/intro/install/) \(tested with v3.4.2\) - -## 2. Configure Terraform - -Create a `.tfvars` file under`feast/infra/terraform/azure`. Name the file. In our example, we use `my_feast.tfvars`. You can see the full list of configuration variables in `variables.tf`. At a minimum, you need to set `name_prefix` and `resource_group`: - -{% code title="my\_feast.tfvars" %} -```typescript -name_prefix = "feast" -resource_group = "Feast" # pre-existing resource group -``` -{% endcode %} - -## 3. Apply - -After completing the configuration, initialize Terraform and apply: - -```bash -$ cd feast/infra/terraform/azure -$ terraform init -$ terraform apply -var-file=my_feast.tfvars -``` - -## 4. Connect to Feast using Jupyter - -After all pods are running, connect to the Jupyter Notebook Server running in the cluster. - -To connect to the remote Feast server you just created, forward a port from the remote k8s cluster to your local machine. - -```bash -kubectl port-forward $(kubectl get pod -o custom-columns=:metadata.name | grep jupyter) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - diff --git a/docs/getting-started/install-feast/kubernetes-with-helm.md b/docs/getting-started/install-feast/kubernetes-with-helm.md deleted file mode 100644 index f31d666ba9d..00000000000 --- a/docs/getting-started/install-feast/kubernetes-with-helm.md +++ /dev/null @@ -1,69 +0,0 @@ -# Kubernetes \(with Helm\) - -## Overview - -This guide installs Feast on an existing Kubernetes cluster, and ensures the following services are running: - -* Feast Core -* Feast Online Serving -* Postgres -* Redis -* Feast Jupyter \(Optional\) -* Prometheus \(Optional\) - -## 1. Requirements - -1. Install and configure [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) -2. Install [Helm 3](https://helm.sh/) - -## 2. Preparation - -Add the Feast Helm repository and download the latest charts: - -```text -helm repo add feast-charts https://feast-helm-charts.storage.googleapis.com -helm repo update -``` - -Feast includes a Helm chart that installs all necessary components to run Feast Core, Feast Online Serving, and an example Jupyter notebook. - -Feast Core requires Postgres to run, which requires a secret to be set on Kubernetes: - -```bash -kubectl create secret generic feast-postgresql --from-literal=postgresql-password=password -``` - -## 3. Installation - -Install Feast using Helm. The pods may take a few minutes to initialize. - -```bash -helm install feast-release feast-charts/feast -``` - -## 4. Use Jupyter to connect to Feast - -After all the pods are in a `RUNNING` state, port-forward to the Jupyter Notebook Server in the cluster: - -```bash -kubectl port-forward \ -$(kubectl get pod -l app=feast-jupyter -o custom-columns=:metadata.name) 8888:8888 -``` - -```text -Forwarding from 127.0.0.1:8888 -> 8888 -Forwarding from [::1]:8888 -> 8888 -``` - -You can now connect to the bundled Jupyter Notebook Server at `localhost:8888` and follow the example Jupyter notebook. - -{% embed url="http://localhost:8888/tree?" caption="" %} - -## 5. Further Reading - -* [Feast Concepts](../../concepts/overview.md) -* [Feast Examples/Tutorials](https://github.com/feast-dev/feast/tree/master/examples) -* [Feast Helm Chart Documentation](https://github.com/feast-dev/feast/blob/master/infra/charts/feast/README.md) -* [Configuring Feast components](../../reference/configuration-reference.md) -* [Feast and Spark](../../reference/feast-and-spark.md) - diff --git a/docs/getting-started/learn-feast.md b/docs/getting-started/learn-feast.md deleted file mode 100644 index 10f2eb6d291..00000000000 --- a/docs/getting-started/learn-feast.md +++ /dev/null @@ -1,15 +0,0 @@ -# Learn Feast - -Explore the following resources to learn more about Feast: - -* [Concepts](../) describes all important Feast API concepts. -* [User guide](../user-guide/define-and-ingest-features.md) provides guidance on completing Feast workflows. -* [Examples](https://github.com/feast-dev/feast/tree/master/examples) contains Jupyter notebooks that you can run on your Feast deployment. -* [Advanced](../advanced/troubleshooting.md) contains information about both advanced and operational aspects of Feast. -* [Reference](../reference/api/) contains detailed API and design documents for advanced users. -* [Contributing](../contributing/contributing.md) contains resources for anyone who wants to contribute to Feast. - -{% hint style="info" %} -The best way to learn Feast is to use it. Jump over to our [Quickstart](../quickstart.md) guide to have one of our examples running in no time at all! -{% endhint %} - diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index f93a3aa714e..7686a7885f3 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -17,7 +17,7 @@ In this tutorial, we use feature stores to generate training data and power onli 1. **Training-serving skew and complex data joins:** Feature values often exist across multiple tables. Joining these datasets can be complicated, slow, and error-prone. * Feast joins these tables with battle-tested logic that ensures _point-in-time_ correctness so future feature values do not leak to models. - * _\*Upcoming_: Feast alerts users to offline / online skew with data quality monitoring. + * Feast alerts users to offline / online skew with data quality monitoring 2. **Online feature availability:** At inference time, models often need access to features that aren't readily available and need to be precomputed from other datasources. * Feast manages deployment to a variety of online stores (e.g. DynamoDB, Redis, Google Cloud Datastore) and ensures necessary features are consistently _available_ and _freshly computed_ at inference time. 3. **Feature reusability and model versioning:** Different teams within an organization are often unable to reuse features across projects, resulting in duplicate feature creation logic. Models have data dependencies that need to be versioned, for example when running A/B tests on model versions. @@ -123,11 +123,14 @@ The key line defining the overall architecture of the feature store is the **pro Valid values for `provider` in `feature_store.yaml` are: -* local: use file source / SQLite -* gcp: use BigQuery / Google Cloud Datastore -* aws: use Redshift / DynamoDB +* local: use file source with SQLite/Redis +* gcp: use BigQuery/Snowflake with Google Cloud Datastore/Redis +* aws: use Redshift/Snowflake with DynamoDB/Redis + +Note that there are many other sources Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/getting-started/third-party-integrations for all supported datasources. + +A custom setup can also be made by following [adding a custom provider](../how-to-guides/creating-a-custom-provider.md). -To use a custom provider, see [adding a custom provider](../how-to-guides/creating-a-custom-provider.md). There are also several plugins maintained by the community: [Azure](https://github.com/Azure/feast-azure), [Postgres](https://github.com/nossrannug/feast-postgres), and [Hive](https://github.com/baineng/feast-hive). Note that the choice of provider gives sensible defaults but does not enforce those choices; for example, if you choose the AWS provider, you can use [Redis](../reference/online-stores/redis.md) as an online store alongside Redshift as an offline store. ## Step 3: Register feature definitions and deploy your feature store diff --git a/docs/reference/api.md b/docs/reference/api.md deleted file mode 100644 index 16467bb2dc7..00000000000 --- a/docs/reference/api.md +++ /dev/null @@ -1,17 +0,0 @@ -# API Reference - -Please see the following API specific reference documentation: - -* [Feast Core gRPC API](https://api.docs.feast.dev/grpc/feast.core.pb.html): This is the gRPC API used by Feast Core. Feast Core has a dual function of schema registry and job manager. This API contains RPCs for creating and managing feature sets, stores, projects, and jobs. -* [Feast Serving gRPC API](https://api.docs.feast.dev/grpc/feast.serving.pb.html): This is the gRPC API used by Feast Serving. It contains RPCs used for the retrieval of online feature data or historical feature data. -* [Feast gRPC Types](https://api.docs.feast.dev/grpc/feast.types.pb.html): These are the gRPC types used by both Feast Core, Feast Serving, and the Go, Java, and Python clients. -* [Go Client SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go): The Go library used for the retrieval of online features from Feast. -* [Java Client SDK](https://javadoc.io/doc/dev.feast/feast-sdk): The Java library used for the retrieval of online features from Feast. -* [Python SDK](https://api.docs.feast.dev/python/): This is the complete reference to the Feast Python SDK. The SDK is used to manage feature sets, features, jobs, projects, and entities. It can also be used to retrieve training datasets or online features from Feast Serving. - -## Community Contributions - -The following community provided SDKs are available: - -* [Node.js SDK](https://github.com/MichaelHirn/feast-client/): A Node.js SDK written in TypeScript. The SDK can be used to manage feature sets, features, jobs, projects, and entities. - diff --git a/docs/reference/api/README.md b/docs/reference/api/README.md deleted file mode 100644 index cd75f5bf88f..00000000000 --- a/docs/reference/api/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# API Reference - -Please see the following API specific reference documentation: - -* [Feast Core gRPC API](https://api.docs.feast.dev/grpc/feast/core/coreservice.pb.html): This is the gRPC API used by Feast Core. This API contains RPCs for creating and managing feature sets, stores, projects, and jobs. -* [Feast Serving gRPC API](https://api.docs.feast.dev/grpc/feast/serving/servingservice.pb.html): This is the gRPC API used by Feast Serving. It contains RPCs used for the retrieval of online feature data or historical feature data. -* [Feast gRPC Types](https://api.docs.feast.dev/grpc/feast/types/value.pb): These are the gRPC types used by both Feast Core, Feast Serving, and the Go, Java, and Python clients. -* [Go Client SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go): The Go library used for the retrieval of online features from Feast. -* [Java Client SDK](https://javadoc.io/doc/dev.feast/feast-sdk): The Java library used for the retrieval of online features from Feast. -* [Python SDK](https://api.docs.feast.dev/python/): This is the complete reference to the Feast Python SDK. The SDK is used to manage feature sets, features, jobs, projects, and entities. It can also be used to retrieve training datasets or online features from Feast Serving. - -## Community Contributions - -The following community provided SDKs are available: - -* [Node.js SDK](https://github.com/MichaelHirn/feast-client/): A Node.js SDK written in TypeScript. The SDK can be used to manage feature sets, features, jobs, projects, and entities. - diff --git a/docs/reference/configuration-reference.md b/docs/reference/configuration-reference.md deleted file mode 100644 index 6f9a97dabfd..00000000000 --- a/docs/reference/configuration-reference.md +++ /dev/null @@ -1,132 +0,0 @@ -# Configuration Reference - -## Overview - -This reference describes how to configure Feast components: - -* [Feast Core and Feast Online Serving](configuration-reference.md#2-feast-core-serving-and-job-controller) -* [Feast CLI and Feast Python SDK](configuration-reference.md#3-feast-cli-and-feast-python-sdk) -* [Feast Go and Feast Java SDK](configuration-reference.md#4-feast-java-and-go-sdk) - -## 1. Feast Core and Feast Online Serving - -Available configuration properties for Feast Core and Feast Online Serving can be referenced from the corresponding `application.yml` of each component: - -| Component | Configuration Reference | -| :--- | :--- | -| Core | [core/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/core/src/main/resources/application.yml) | -| Serving \(Online\) | [serving/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/serving/src/main/resources/application.yml) | - -Configuration properties for Feast Core and Feast Online Serving are defined depending on Feast is deployed: - -* [Docker Compose deployment](configuration-reference.md#docker-compose-deployment) - Feast is deployed with Docker Compose. -* [Kubernetes deployment](configuration-reference.md#kubernetes-deployment) - Feast is deployed with Kubernetes. -* [Direct Configuration](configuration-reference.md#direct-configuration) - Feast is built and run from source code. - -## Docker Compose Deployment - -For each Feast component deployed using Docker Compose, configuration properties from `application.yml` can be set at: - -| Component | Configuration Path | -| :--- | :--- | -| Core | `infra/docker-compose/core/core.yml` | -| Online Serving | `infra/docker-compose/serving/online-serving.yml` | - -## Kubernetes Deployment - -The Kubernetes Feast Deployment is configured using `values.yaml` in the [Helm chart](https://github.com/feast-dev/feast-helm-charts) included with Feast: - -```yaml -# values.yaml -feast-core: - enabled: true # whether to deploy the feast-core subchart to deploy Feast Core. - # feast-core subchart specific config. - gcpServiceAccount: - enabled: true - # .... -``` - -A reference of the sub-chart-specific configuration can found in its `values.yml`: - -* [feast-core](https://github.com/feast-dev/feast-java/tree/master/infra/charts/feast-core) -* [feast-serving](https://github.com/feast-dev/feast-java/tree/master/infra/charts/feast-serving) - -Configuration properties can be set via `application-override.yaml` for each component in `values.yaml`: - -```yaml -# values.yaml -feast-core: - # .... - application-override.yaml: - # application.yml config properties for Feast Core. - # ... -``` - -Visit the [Helm chart](https://github.com/feast-dev/feast-helm-charts) included with Feast to learn more about configuration. - -## Direct Configuration - -If Feast is built and running from source, configuration properties can be set directly in the Feast component's `application.yml`: - -| Component | Configuration Path | -| :--- | :--- | -| Core | [core/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/core/src/main/resources/application.yml) | -| Serving \(Online\) | [serving/src/main/resources/application.yml](https://github.com/feast-dev/feast-java/blob/master/serving/src/main/resources/application.yml) | - -## 2. Feast CLI and Feast Python SDK - -Configuration options for both the [Feast CLI](../getting-started/connect-to-feast/feast-cli.md) and [Feast Python SDK](https://api.docs.feast.dev/python/) can be defined in the following locations, in order of precedence: - -**1. Command line arguments or initialized arguments:** Passing parameters to the Feast CLI or instantiating the Feast Client object with specific parameters will take precedence above other parameters. - -```bash -# Set option as command line arguments. -feast config set core_url "localhost:6565" -``` - -```python -# Pass options as initialized arguments. -client = Client( - core_url="localhost:6565", - project="default" -) -``` - -**2. Environmental variables:** Environmental variables can be set to provide configuration options. They must be prefixed with `FEAST_`. For example `FEAST_CORE_URL`. - -```bash -FEAST_CORE_URL=my_feast:6565 FEAST_PROJECT=default feast projects list -``` - -**3. Configuration file:** Options with the lowest precedence are configured in the Feast configuration file. Feast looks for or creates this configuration file in `~/.feast/config` if it does not already exist. All options must be defined in the `[general]` section of this file. - -```text -[general] -project = default -core_url = localhost:6565 -``` - -Visit the [available configuration parameters](https://api.docs.feast.dev/python/#module-feast.constants) for Feast Python SDK and Feast CLI to learn more. - -## 3. Feast Java and Go SDK - -The [Feast Java SDK](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) and [Feast Go SDK](https://godoc.org/github.com/feast-dev/feast/sdk/go) are configured via arguments passed when instantiating the respective Clients: - -### Go SDK - -```go -// configure serving host and port. -cli := feast.NewGrpcClient("localhost", 6566) -``` - -Visit the[ Feast Go SDK API reference](https://godoc.org/github.com/feast-dev/feast/sdk/go) to learn more about available configuration parameters. - -### Java SDK - -```java -// configure serving host and port. -client = FeastClient.create(servingHost, servingPort); -``` - -Visit the [Feast Java SDK API reference](https://javadoc.io/doc/dev.feast/feast-sdk/latest/com/gojek/feast/package-summary.html) to learn more about available configuration parameters. - diff --git a/docs/reference/feast-and-spark.md b/docs/reference/feast-and-spark.md deleted file mode 100644 index be05f177aeb..00000000000 --- a/docs/reference/feast-and-spark.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -description: Configuring Feast to use Spark for ingestion. ---- - -# Feast and Spark - -Feast relies on Spark to ingest data from the offline store to the online store, streaming ingestion, and running queries to retrieve historical data from the offline store. Feast supports several Spark deployment options. - -## Option 1. Use Kubernetes Operator for Apache Spark - -To install the Spark on K8s Operator - -```bash -helm repo add spark-operator \ - https://googlecloudplatform.github.io/spark-on-k8s-operator - -helm install my-release spark-operator/spark-operator \ - --set serviceAccounts.spark.name=spark -``` - -Currently Feast is tested using `v1beta2-1.1.2-2.4.5`version of the operator image. To configure Feast to use it, set the following options in Feast config: - -| Feast Setting | Value | -| :--- | :--- | -| `SPARK_LAUNCHER` | `"k8s"` | -| `SPARK_STAGING_LOCATION` | S3/GCS/Azure Blob Storage URL to use as a staging location, must be readable and writable by Feast. For S3, use `s3a://` prefix here. Ex.: `s3a://some-bucket/some-prefix/artifacts/` | -| `HISTORICAL_FEATURE_OUTPUT_LOCATION` | S3/GCS/Azure Blob Storage URL used to store results of historical retrieval queries, must be readable and writable by Feast. For S3, use `s3a://` prefix here. Ex.: `s3a://some-bucket/some-prefix/out/` | -| `SPARK_K8S_NAMESPACE` | Only needs to be set if you are customizing the spark-on-k8s-operator. The name of the Kubernetes namespace to run Spark jobs in. This should match the value of `sparkJobNamespace` set on spark-on-k8s-operator Helm chart. Typically this is also the namespace Feast itself will run in. | -| `SPARK_K8S_JOB_TEMPLATE_PATH` | Only needs to be set if you are customizing the Spark job template. Local file path with the template of the SparkApplication resource. No prefix required. Ex.: `/home/jovyan/work/sparkapp-template.yaml`. An example template is [here](https://github.com/feast-dev/feast/blob/4059a21dc4eba9cd27b2d5b0fabe476c07a8b3bd/sdk/python/feast/pyspark/launchers/k8s/k8s_utils.py#L280-L317) and the spec is defined in the [k8s-operator User Guide](https://github.com/GoogleCloudPlatform/spark-on-k8s-operator/blob/master/docs/user-guide.md). | - -Lastly, make sure that the service account used by Feast has permissions to manage Spark Application resources. This depends on your k8s setup, but typically you'd need to configure a Role and a RoleBinding like the one below: - -```text -cat < - - - Limitation - Motivation - - - - - Features names and entity names cannot overlap in feature table definitions - Features and entities become columns in historical stores which may cause - conflicts - - - -

The following field names are reserved in feature tables

-
    -
  • event_timestamp -
  • -
  • datetime -
  • -
  • created_timestamp -
  • -
  • ingestion_id -
  • -
  • job_id -
  • -
- - These keywords are used for column names when persisting metadata in historical - stores - - - - -### Ingestion - -| Limitation | Motivation | -| :--- | :--- | -| Once data has been ingested into Feast, there is currently no way to delete the data without manually going to the database and deleting it. However, during retrieval only the latest rows will be returned for a specific key \(`event_timestamp`, `entity`\) based on its `created_timestamp`. | This functionality simply doesn't exist yet as a Feast API | - -### Storage - -| Limitation | Motivation | -| :--- | :--- | -| Feast does not support offline storage in Feast 0.8 | As part of our re-architecture of Feast, we moved from GCP to cloud-agnostic deployments. Developing offline storage support that is available in all cloud environments is a pending action. | - diff --git a/docs/reference/metrics-reference.md b/docs/reference/metrics-reference.md deleted file mode 100644 index 34c97c7be60..00000000000 --- a/docs/reference/metrics-reference.md +++ /dev/null @@ -1,178 +0,0 @@ -# Metrics Reference - -{% hint style="warning" %} -This page applies to Feast 0.7. The content may be out of date for Feast 0.8+ -{% endhint %} - -Reference of the metrics that each Feast component exports: - -* [Feast Core](metrics-reference.md#feast-core) -* [Feast Serving](metrics-reference.md#feast-serving) -* [Feast Ingestion Job](metrics-reference.md#feast-ingestion-job) - -For how to configure Feast to export Metrics, see the [Metrics user guide.](../advanced/metrics.md) - -## Feast Core - -**Exported Metrics** - -Feast Core exports the following metrics: - -| Metrics | Description | Tags | -| :--- | :--- | :--- | -| `feast_core_request_latency_seconds` | Feast Core's latency in serving Requests in Seconds. | `service`, `method`, `status_code` | -| `feast_core_feature_set_total` | No. of Feature Sets registered with Feast Core. | None | -| `feast_core_store_total` | No. of Stores registered with Feast Core. | None | -| `feast_core_max_memory_bytes` | Max amount of memory the Java virtual machine will attempt to use. | None | -| `feast_core_total_memory_bytes` | Total amount of memory in the Java virtual machine | None | -| `feast_core_free_memory_bytes` | Total amount of free memory in the Java virtual machine. | None | -| `feast_core_gc_collection_seconds` | Time spent in a given JVM garbage collector in seconds. | None | - -**Metric Tags** - -Exported Feast Core metrics may be filtered by the following tags/keys - -| Tag | Description | -| :--- | :--- | -| `service` | Name of the Service that request is made to. Should be set to `CoreService` | -| `method` | Name of the Method that the request is calling. \(ie `ListFeatureSets`\) | -| `status_code` | Status code returned as a result of handling the requests \(ie `OK`\). Can be used to find request failures. | - -## Feast Serving - -**Exported Metrics** - -Feast Serving exports the following metrics: - -| Metric | Description | Tags | -| :--- | :--- | :--- | -| `feast_serving_request_latency_seconds` | Feast Serving's latency in serving Requests in Seconds. | `method` | -| `feast_serving_request_feature_count` | No. of requests retrieving a Feature from Feast Serving. | `project`, `feature_name` | -| `feast_serving_not_found_feature_count` | No. of requests retrieving a Feature has resulted in a [`NOT_FOUND` field status.](../user-guide/getting-training-features.md#online-field-statuses) | `project`, `feature_name` | -| `feast_serving_stale_feature_count` | No. of requests retrieving a Feature resulted in a [`OUTSIDE_MAX_AGE` field status.](../user-guide/getting-training-features.md#online-field-statuses) | `project`, `feature_name` | -| `feast_serving_grpc_request_count` | Total gRPC requests served. | `method` | - -**Metric Tags** - -Exported Feast Serving metrics may be filtered by the following tags/keys - -| Tag | Description | -| :--- | :--- | -| `method` | Name of the Method that the request is calling. \(ie `ListFeatureSets`\) | -| `status_code` | Status code returned as a result of handling the requests \(ie `OK`\). Can be used to find request failures. | -| `project` | Name of the project that the FeatureSet of the Feature retrieved belongs to. | -| `feature_name` | Name of the Feature being retrieved. | - -## Feast Ingestion Job - -Feast Ingestion computes both metrics an statistics on [data ingestion.](../user-guide/define-and-ingest-features.md) Make sure you familar with data ingestion concepts before proceeding. - -**Metrics Namespace** - -Metrics are computed at two stages of the Feature Row's/Feature Value's life cycle when being processed by the Ingestion Job: - -* `Inflight`- Prior to writing data to stores, but after successful validation of data. -* `WriteToStoreSucess`- After a successful store write. - -Metrics processed by each staged will be tagged with `metrics_namespace` to the stage where the metric was computed. - -**Metrics Bucketing** - -Metrics with a `{BUCKET}` are computed on a 60 second window/bucket. Suffix with the following to select the bucket to use: - -* `min` - minimum value. -* `max` - maximum value. -* `mean`- mean value. -* `percentile_90`- 90 percentile. -* `percentile_95`- 95 percentile. -* `percentile_99`- 99 percentile. - -**Exported Metrics** - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MetricDescriptionTags
feast_ingestion_feature_row_lag_ms_{BUCKET} - Lag time in milliseconds between succeeding ingested Feature Rows. -

feast_store, feast_project_name,feast_featureSet_name,ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_feature_value_lag_ms_{BUCKET} - Lag time in milliseconds between succeeding ingested values for each Feature. -

feast_store, feast_project_name,feast_featureSet_name,

-

feast_feature_name,

-

ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_feature_value_{BUCKET} - Last value feature for each Feature.feast_store, feature_project_name, feast_feature_name,feast_featureSet_name, ingest_job_name, metrics_namepace -
feast_ingestion_feature_row_ingested_count - No. of Ingested Feature Rows -

feast_store, feast_project_name,feast_featureSet_name,ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_feature_value_missing_count - No. of times a ingested Feature values did not provide a value for the - Feature. -

feast_store, feast_project_name,feast_featureSet_name,

-

feast_feature_name,

-

ingestion_job_name,

-

metrics_namespace -

-
feast_ingestion_deadletter_row_count - No. of Feature Rows that that the Ingestion Job did not successfully write - to store.feast_store, feast_project_name,feast_featureSet_name,ingestion_job_name -
- -**Metric Tags** - -Exported Feast Ingestion Job metrics may be filtered by the following tags/keys - -| Tag | Description | -| :--- | :--- | -| `feast_store` | Name of the target store the Ingestion Job is writing to. | -| `feast_project_name` | Name of the project that the ingested FeatureSet belongs to. | -| `feast_featureSet_name` | Name of the Feature Set being ingested. | -| `feast_feature_name` | Name of the Feature being ingested. | -| `ingestion_job_name` | Name of the Ingestion Job performing data ingestion. Typically this is set to the Id of the Ingestion Job. | -| `metrics_namespace` | Stage where metrics where computed. Either `Inflight` or `WriteToStoreSuccess` | - diff --git a/docs/roadmap.md b/docs/roadmap.md index 42da01fcba8..addd3dbb9f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,6 @@ The list below contains the functionality that contributors are planning to deve * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] Kafka source (with [push support into the online store](reference/alpha-stream-ingestion.md)) - * [x] [Snowflake source (community plugin)](https://github.com/sfc-gh-madkins/feast-snowflake) * [ ] HTTP source * **Offline Stores** * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) @@ -27,7 +26,6 @@ The list below contains the functionality that contributors are planning to deve * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/adding-a-new-offline-store) - * [x] [Snowflake (community plugin)](https://github.com/sfc-gh-madkins/feast-snowflake) * [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) * **Online Stores** * [x] [DynamoDB](https://docs.feast.dev/reference/online-stores/dynamodb) @@ -63,7 +61,7 @@ The list below contains the functionality that contributors are planning to deve * [ ] Delete API * [ ] Feature Logging (for training) * **Data Quality Management (See [RFC](https://docs.google.com/document/d/110F72d4NTv80p35wDSONxhhPBqWRwbZXG4f9mNEMd98/edit))** - * [ ] Data profiling and validation (Great Expectations) (Planned for Q1 2022) + * [x] Data profiling and validation (Great Expectations) * [ ] Metric production * [ ] Training-serving skew detection * [ ] Drift detection @@ -71,7 +69,7 @@ The list below contains the functionality that contributors are planning to deve * [x] Python SDK for browsing feature registry * [x] CLI for browsing feature registry * [x] Model-centric feature tracking (feature services) + * [x] Amundsen integration (see [Feast extractor](https://github.com/amundsen-io/amundsen/blob/main/databuilder/databuilder/extractor/feast_extractor.py)) * [ ] REST API for browsing feature registry * [ ] Feast Web UI * [ ] Feature versioning - * [ ] Amundsen integration diff --git a/docs/user-guide/define-and-ingest-features.md b/docs/user-guide/define-and-ingest-features.md deleted file mode 100644 index d55fcb1d857..00000000000 --- a/docs/user-guide/define-and-ingest-features.md +++ /dev/null @@ -1,56 +0,0 @@ -# Define and ingest features - -In order to retrieve features for both training and serving, Feast requires data being ingested into its offline and online stores. - -{% hint style="warning" %} -Feast 0.8 does not have an offline store. Only Online storage support exists currently. Feast 0.9 will have offline storage support. In Feast 0.8, historical data is retrieved directly from batch sources. -{% endhint %} - -Users are expected to already have either a batch or stream source with data stored in it, ready to be ingested into Feast. Once a feature table \(with the corresponding sources\) has been registered with Feast, it is possible to load data from this source into stores. - -The following depicts an example ingestion flow from a data source to the online store. - -### Batch Source to Online Store - -```python -from feast import Client -from datetime import datetime, timedelta - -client = Client(core_url="localhost:6565") -driver_ft = client.get_feature_table("driver_trips") - -# Initialize date ranges -today = datetime.now() -yesterday = today - timedelta(1) - -# Launches a short-lived job that ingests data over the provided date range. -client.start_offline_to_online_ingestion( - driver_ft, yesterday, today -) -``` - -### Stream Source to Online Store - -```python -from feast import Client -from datetime import datetime, timedelta - -client = Client(core_url="localhost:6565") -driver_ft = client.get_feature_table("driver_trips") - -# Launches a long running streaming ingestion job -client.start_stream_to_online_ingestion(driver_ft) -``` - -### Batch Source to Offline Store - -{% hint style="danger" %} -Not supported in Feast 0.8 -{% endhint %} - -### Stream Source to Offline Store - -{% hint style="danger" %} -Not supported in Feast 0.8 -{% endhint %} - diff --git a/docs/user-guide/getting-online-features.md b/docs/user-guide/getting-online-features.md deleted file mode 100644 index c16dc08a013..00000000000 --- a/docs/user-guide/getting-online-features.md +++ /dev/null @@ -1,54 +0,0 @@ -# Getting online features - -Feast provides an API through which online feature values can be retrieved. This allows teams to look up feature values at low latency in production during model serving, in order to make online predictions. - -{% hint style="info" %} -Online stores only maintain the current state of features, i.e latest feature values. No historical data is stored or served. -{% endhint %} - -```python -from feast import Client - -online_client = Client( - core_url="localhost:6565", - serving_url="localhost:6566", -) - -entity_rows = [ - {"driver_id": 1001}, - {"driver_id": 1002}, -] - -# Features in format -feature_refs = [ - "driver_trips:average_daily_rides", - "driver_trips:maximum_daily_rides", - "driver_trips:rating", -] - -response = online_client.get_online_features( - feature_refs=feature_refs, # Contains only feature references - entity_rows=entity_rows, # Contains only entities (driver ids) -) - -# Print features in dictionary format -response_dict = response.to_dict() -print(response_dict) -``` - -The online store must be populated through [ingestion jobs](define-and-ingest-features.md#batch-source-to-online-store) prior to being used for online serving. - -Feast Serving provides a [gRPC API](https://api.docs.feast.dev/grpc/feast.serving.pb.html) that is backed by [Redis](https://redis.io/). We have native clients in [Python](https://api.docs.feast.dev/python/), [Go](https://godoc.org/github.com/gojek/feast/sdk/go), and [Java](https://javadoc.io/doc/dev.feast). - -### Online Field Statuses - -Feast also returns status codes when retrieving features from the Feast Serving API. These status code give useful insight into the quality of data being served. - -| Status | Meaning | -| :--- | :--- | -| NOT\_FOUND | The feature value was not found in the online store. This might mean that no feature value was ingested for this feature. | -| NULL\_VALUE | A entity key was successfully found but no feature values had been set. This status code should not occur during normal operation. | -| OUTSIDE\_MAX\_AGE | The age of the feature row in the online store \(in terms of its event timestamp\) has exceeded the maximum age defined within the feature table. | -| PRESENT | The feature values have been found and are within the maximum age. | -| UNKNOWN | Indicates a system failure. | - diff --git a/docs/user-guide/getting-training-features.md b/docs/user-guide/getting-training-features.md deleted file mode 100644 index b9d0b050f29..00000000000 --- a/docs/user-guide/getting-training-features.md +++ /dev/null @@ -1,72 +0,0 @@ -# Getting training features - -Feast provides a historical retrieval interface for exporting feature data in order to train machine learning models. Essentially, users are able to enrich their data with features from any feature tables. - -### Retrieving historical features - -Below is an example of the process required to produce a training dataset: - -```python -# Feature references with target feature -features = [ - "driver_trips:average_daily_rides", - "driver_trips:maximum_daily_rides", - "driver_trips:rating", - "driver_trips:rating:trip_completed", -] - -# Define entity source -entity_source = FileSource( - "event_timestamp", - ParquetFormat(), - "gs://some-bucket/customer" -) - -# Retrieve historical dataset from Feast. -historical_feature_retrieval_job = client.get_historical_features( - features=features, - entity_rows=entity_source -) - -output_file_uri = historical_feature_retrieval_job.get_output_file_uri() -``` - -#### 1. Define feature references - -[Feature references](../concepts/glossary.md#feature-references) define the specific features that will be retrieved from Feast. These features can come from multiple feature tables. The only requirement is that the feature tables that make up the feature references have the same entity \(or composite entity\). - -**2. Define an entity dataframe** - -Feast needs to join feature values onto specific entities at specific points in time. Thus, it is necessary to provide an [entity dataframe](../concepts/glossary.md#entity-dataframe) as part of the `get_historical_features` method. In the example above we are defining an entity source. This source is an external file that provides Feast with the entity dataframe. - -**3. Launch historical retrieval job** - -Once the feature references and an entity source are defined, it is possible to call `get_historical_features()`. This method launches a job that extracts features from the sources defined in the provided feature tables, joins them onto the provided entity source, and returns a reference to the training dataset that is produced. - -Please see the [Feast SDK](https://api.docs.feast.dev/python) for more details. - -### Point-in-time Joins - -Feast always joins features onto entity data in a point-in-time correct way. The process can be described through an example. - -In the example below there are two tables \(or dataframes\): - -* The dataframe on the left is the [entity dataframe](../concepts/glossary.md#entity-dataframe) that contains timestamps, entities, and the target variable \(trip\_completed\). This dataframe is provided to Feast through an entity source. -* The dataframe on the right contains driver features. This dataframe is represented in Feast through a feature table and its accompanying data source\(s\). - -The user would like to have the driver features joined onto the entity dataframe to produce a training dataset that contains both the target \(trip\_completed\) and features \(average\_daily\_rides, maximum\_daily\_rides, rating\). This dataset will then be used to train their model. - -![](../.gitbook/assets/point_in_time_join%20%281%29%20%282%29%20%282%29%20%283%29%20%283%29%20%283%29%20%283%29%20%281%29.png) - -Feast is able to intelligently join feature data with different timestamps to a single entity dataframe. It does this through a point-in-time join as follows: - -1. Feast loads the entity dataframe and all feature tables \(driver dataframe\) into the same location. This can either be a database or in memory. -2. For each [entity row](../concepts/glossary.md#entity-rows) in the [entity dataframe](getting-online-features.md), Feast tries to find feature values in each feature table to join to it. Feast extracts the timestamp and entity key of each row in the entity dataframe and scans backward through the feature table until it finds a matching entity key. -3. If the event timestamp of the matching entity key within the driver feature table is within the maximum age configured for the feature table, then the features at that entity key are joined onto the entity dataframe. If the event timestamp is outside of the maximum age, then only null values are returned. -4. If multiple entity keys are found with the same event timestamp, then they are deduplicated by the created timestamp, with newer values taking precedence. -5. Feast repeats this joining process for all feature tables and returns the resulting dataset. - -{% hint style="info" %} -Point-in-time correct joins attempts to prevent the occurrence of feature leakage by trying to recreate the state of the world at a single point in time, instead of joining features based on exact timestamps only. -{% endhint %} - diff --git a/docs/user-guide/overview.md b/docs/user-guide/overview.md deleted file mode 100644 index 2d6eb9981bb..00000000000 --- a/docs/user-guide/overview.md +++ /dev/null @@ -1,32 +0,0 @@ -# Overview - -### Using Feast - -Feast development happens through three key workflows: - -1. [Define and load feature data into Feast](define-and-ingest-features.md) -2. [Retrieve historical features for training models](getting-training-features.md) -3. [Retrieve online features for serving models](getting-online-features.md) - -### Defining feature tables and ingesting data into Feast - -Feature creators model the data within their organization into Feast through the definition of [feature tables](../concepts/feature-tables.md) that contain [data sources](../concepts/sources.md). Feature tables are both a schema and a means of identifying data sources for features, and allow Feast to know how to interpret your data, and where to find it. - -After registering a feature table with Feast, users can trigger an ingestion from their data source into Feast. This loads feature values from an upstream data source into Feast stores through ingestion jobs. - -Visit [feature tables](../concepts/feature-tables.md#overview) to learn more about them. - -{% page-ref page="define-and-ingest-features.md" %} - -### Retrieving historical features for training - -In order to generate a training dataset it is necessary to provide both an [entity dataframe ](../concepts/glossary.md#entity-dataframe)and feature references through the[ Feast SDK](https://api.docs.feast.dev/python/) to retrieve historical features. For historical serving, Feast requires that you provide the entities and timestamps for the corresponding feature data. Feast produces a point-in-time correct dataset using the requested features. These features can be requested from an unlimited number of feature sets. - -{% page-ref page="getting-training-features.md" %} - -### Retrieving online features for online serving - -Online retrieval uses feature references through the [Feast Online Serving API](https://api.docs.feast.dev/grpc/feast.serving.pb.html) to retrieve online features. Online serving allows for very low latency requests to feature data at very high throughput. - -{% page-ref page="getting-online-features.md" %} - diff --git a/examples/quickstart/quickstart.ipynb b/examples/quickstart/quickstart.ipynb index 3b148137ef9..2ef0759a8dc 100644 --- a/examples/quickstart/quickstart.ipynb +++ b/examples/quickstart/quickstart.ipynb @@ -27,12 +27,12 @@ "In this tutorial, we use feature stores to generate training data and power online model inference for a ride-sharing driver satisfaction prediction model. Feast addresses several common issues in this flow:\n", "1. **Training-serving skew and complex data joins:** Feature values often exist across multiple tables. Joining these datasets can be complicated, slow, and error-prone.\n", " - Feast joins these tables with battle-tested logic that ensures *point-in-time* correctness so future feature values do not leak to models.\n", - " - **Upcoming*: Feast alerts users to offline / online skew with data quality monitoring. \n", + " - Feast alerts users to offline / online skew with data quality monitoring. \n", "2. **Online feature availability:** At inference time, models often need access to features that aren't readily available and need to be precomputed from other datasources. \n", " - Feast manages deployment to a variety of online stores (e.g. DynamoDB, Redis, Google Cloud Datastore) and ensures necessary features are consistently *available* and *freshly computed* at inference time.\n", "3. **Feature reusability and model versioning:** Different teams within an organization are often unable to reuse features across projects, resulting in duplicate feature creation logic. Models have data dependencies that need to be versioned, for example when running A/B tests on model versions.\n", " - Feast enables discovery of and collaboration on previously used features and enables versioning of sets of features (via *feature services*). \n", - " - **Upcoming*: Feast enables feature transformation so users can re-use transformation logic across online / offline usecases and across models.\n", + " - Feast enables feature transformation so users can re-use transformation logic across online / offline usecases and across models.\n", "\n", "We will:\n", "- Deploy a local feature store with a Parquet file offline store and Sqlite online store.\n", @@ -188,11 +188,13 @@ "\n", "Valid values for `provider` in `feature_store.yaml` are:\n", "\n", - "* local: use file source / SQLite\n", - "* gcp: use BigQuery / Google Cloud Datastore\n", - "* aws: use Redshift / DynamoDB\n", + "* local: use file source with SQLite/Redis\n", + "* gcp: use BigQuery/Snowflake with Google Cloud Datastore/Redis\n", + "* aws: use Redshift/Snowflake with DynamoDB/Redis\n", "\n", - "A custom setup (e.g. using the built-in support for Redis) can be made by following https://docs.feast.dev/v/master/how-to-guides/creating-a-custom-provider" + "Note that there are many other sources Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/getting-started/third-party-integrations for all supported datasources." + "\n", + "A custom setup can also be made by following https://docs.feast.dev/v/master/how-to-guides/creating-a-custom-provider" ] }, { diff --git a/infra/templates/README.md.jinja2 b/infra/templates/README.md.jinja2 index a9277bb0700..737f8d8f52b 100644 --- a/infra/templates/README.md.jinja2 +++ b/infra/templates/README.md.jinja2 @@ -21,7 +21,7 @@ Feast is an open source feature store for machine learning. Feast is the fastest Please see our [documentation](https://docs.feast.dev/) for more information about the project. ## πŸ“ Architecture - +![](docs/assets/feast-marchitecture.png) The above architecture is the minimal Feast deployment. Want to run the full Feast on GCP/AWS? Click [here](https://docs.feast.dev/how-to-guides/feast-gcp-aws). @@ -133,7 +133,7 @@ pprint(feature_vector) Please refer to the official documentation at [Documentation](https://docs.feast.dev/) * [Quickstart](https://docs.feast.dev/getting-started/quickstart) * [Tutorials](https://docs.feast.dev/tutorials/tutorials-overview) - * [Running Feast with GCP/AWS](https://docs.feast.dev/how-to-guides/feast-gcp-aws) + * [Running Feast with Snowflake/GCP/AWS](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws) * [Change Log](https://github.com/feast-dev/feast/blob/master/CHANGELOG.md) * [Slack (#Feast)](https://slack.feast.dev/) From d84adbe278d379c7d986e9f184e88a059799e0a2 Mon Sep 17 00:00:00 2001 From: Felix Wang Date: Wed, 2 Feb 2022 15:39:43 -0800 Subject: [PATCH 59/85] Fix Snowflake docs (#2270) * Fix Snowflake docs Signed-off-by: Felix Wang * Small fixes Signed-off-by: Felix Wang --- .../tutorials/driver-stats-using-snowflake.md | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/docs/tutorials/driver-stats-using-snowflake.md b/docs/tutorials/driver-stats-using-snowflake.md index c51fc9b1ce7..1dcb4ca4091 100644 --- a/docs/tutorials/driver-stats-using-snowflake.md +++ b/docs/tutorials/driver-stats-using-snowflake.md @@ -1,31 +1,23 @@ --- description: >- - Initial demonstration of using Snowflake with Feast as both and Offline & Online store - using the snowflake demo template. + Initial demonstration of Snowflake as an offline store with Feast, using the Snowflake demo template. --- # Drivers Stats using Snowflake -In the following steps below, we will setup a sample feast project that leverages Snowflake -as an Offline Store. +In the steps below, we will set up a sample Feast project that leverages Snowflake +as an offline store. -Starting with data in a Snowflake table, we will register that table to the feature store and -define features associated with the columns in that table. From there, we will generate historical -training data based on those feature definitions. We then will materialize the latest feature values -given our feature definitions into our online feature store. Lastly, we will then call -for those latest feature values. +Starting with data in a Snowflake table, we will register that table to the feature store and define features associated with the columns in that table. From there, we will generate historical training data based on those feature definitions and then materialize the latest feature values into the online store. Lastly, we will retrieve the materialized feature values. -Our template that you will leverage will generate new data related to driver statistics. -From there, we will show you code snippets that will call to the offline store for generating -training datasets, and then the code for calling the online store to serve you the -latest feature values to serve models in production. +Our template will generate new data containing driver statistics. From there, we will show you code snippets that will call to the offline store for generating training datasets, and then the code for calling the online store to serve you the latest feature values to serve models in production. ## Snowflake Offline/Online Store Example #### Install feast-snowflake ```shell -pip install feast[snowflake] +pip install 'feast[snowflake]' ``` #### Get a Snowflake Trial Account (Optional) @@ -54,9 +46,7 @@ The following files will automatically be created in your project folder: #### Inspect `feature_store.yaml` -Here you will see the information that you entered. This template will look to use -Snowflake as both an Offline & Online store. The main thing to remember is by default, -Snowflake Objects have ALL CAPS names unless lower case was specified. +Here you will see the information that you entered. This template will use Snowflake as an offline store and SQLite as the online store. The main thing to remember is by default, Snowflake objects have ALL CAPS names unless lower case was specified. {% code title="feature_store.yaml" %} ```yaml @@ -98,7 +88,7 @@ fs.apply([driver, driver_stats_fv]) ``` {% endcode %} -#### Create a dummy training dataframe, then call our Offline store to add additional columns +#### Create a dummy training dataframe, then call our offline store to add additional columns {% code title="test.py" %} ```python entity_df = pd.DataFrame( @@ -123,14 +113,14 @@ training_df = fs.get_historical_features( ``` {% endcode %} -#### Materialize the latest feature values into our Online store +#### Materialize the latest feature values into our online store {% code title="test.py" %} ```python fs.materialize_incremental(end_date=datetime.now()) ``` {% endcode %} -#### Retrieve the latest values from our Online store based on our Entity Key +#### Retrieve the latest values from our online store based on our entity key {% code title="test.py" %} ```python online_features = fs.get_online_features( From 393d6d9879cd202608ccf0e59c9c9342cc6a731f Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Wed, 2 Feb 2022 19:54:57 -0500 Subject: [PATCH 60/85] Fix quickstart with a missing comma Signed-off-by: Danny Chiao --- examples/quickstart/quickstart.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/quickstart/quickstart.ipynb b/examples/quickstart/quickstart.ipynb index 2ef0759a8dc..d6f349faae5 100644 --- a/examples/quickstart/quickstart.ipynb +++ b/examples/quickstart/quickstart.ipynb @@ -192,7 +192,7 @@ "* gcp: use BigQuery/Snowflake with Google Cloud Datastore/Redis\n", "* aws: use Redshift/Snowflake with DynamoDB/Redis\n", "\n", - "Note that there are many other sources Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/getting-started/third-party-integrations for all supported datasources." + "Note that there are many other sources Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/getting-started/third-party-integrations for all supported datasources.", "\n", "A custom setup can also be made by following https://docs.feast.dev/v/master/how-to-guides/creating-a-custom-provider" ] From fc37896f103fb31bba1180f0fb5d7f3872d5ebe5 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Wed, 2 Feb 2022 21:21:43 -0500 Subject: [PATCH 61/85] Update helm chart workflow to push the feast python server as well. Also fixing broken publishing flow and updating versions (#2273) Signed-off-by: Danny Chiao --- infra/charts/feast-python-server/Chart.yaml | 2 +- infra/charts/feast-python-server/README.md | 2 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/requirements.yaml | 4 ++-- infra/scripts/helm/push-helm-charts.sh | 8 ++++---- infra/scripts/helm/validate-helm-chart-versions.sh | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/infra/charts/feast-python-server/Chart.yaml b/infra/charts/feast-python-server/Chart.yaml index fc20d180bc0..c25b36b5d74 100644 --- a/infra/charts/feast-python-server/Chart.yaml +++ b/infra/charts/feast-python-server/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: feast-python-server description: Feast Feature Server in Python type: application -version: 0.1.0 +version: 0.17.0 keywords: - machine learning - big data diff --git a/infra/charts/feast-python-server/README.md b/infra/charts/feast-python-server/README.md index ff7246848f4..b7db8444729 100644 --- a/infra/charts/feast-python-server/README.md +++ b/infra/charts/feast-python-server/README.md @@ -1,6 +1,6 @@ # feast-python-server -![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) +![Version: 0.17.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) Feast Feature Server in Python diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index 4dd16aa906a..88ff3238479 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 description: Feature store for machine learning name: feast -version: 0.101.0 +version: 0.17.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index a1ccdde0f33..795c352d1de 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,12 +1,12 @@ dependencies: - name: feature-server alias: feature-server - version: 0.101.0 + version: 0.17.0 condition: feature-server.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: transformation-service alias: transformation-service - version: 0.101.0 + version: 0.17.0 condition: transformation-service.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: redis diff --git a/infra/scripts/helm/push-helm-charts.sh b/infra/scripts/helm/push-helm-charts.sh index 74961b196a5..255a3f69326 100755 --- a/infra/scripts/helm/push-helm-charts.sh +++ b/infra/scripts/helm/push-helm-charts.sh @@ -14,9 +14,9 @@ helm plugin install https://github.com/hayorov/helm-gcs.git --version 0.2.2 || helm repo add feast-helm-chart-repo $bucket -mkdir -p feast -cp -R * feast/ || true - +cd infra/charts helm package feast +helm package feast-python-server -helm gcs push --public feast-${1}.tgz feast-helm-chart-repo --force \ No newline at end of file +helm gcs push --public feast-${1}.tgz feast-helm-chart-repo --force +helm gcs push --public feast-python-server${1}.tgz feast-helm-chart-repo --force \ No newline at end of file diff --git a/infra/scripts/helm/validate-helm-chart-versions.sh b/infra/scripts/helm/validate-helm-chart-versions.sh index 8d0b2941f5f..6f426a0f558 100755 --- a/infra/scripts/helm/validate-helm-chart-versions.sh +++ b/infra/scripts/helm/validate-helm-chart-versions.sh @@ -3,7 +3,7 @@ set -e # Amount of file locations that need to be bumped in unison when versions increment -UNIQUE_VERSIONS_COUNT=4 +UNIQUE_VERSIONS_COUNT=9 if [ $# -ne 1 ]; then echo "Please provide a single semver version (without a \"v\" prefix) to test the repository against, e.g 0.99.0" From f68dd88dbb393234136897ca6b8569c9a336bcd3 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Thu, 3 Feb 2022 08:36:43 +0200 Subject: [PATCH 62/85] Unify all chart versions (#2274) Signed-off-by: pyalex --- infra/charts/feast/README.md | 6 +++--- infra/charts/feast/charts/feature-server/Chart.yaml | 4 ++-- infra/charts/feast/charts/feature-server/README.md | 2 +- infra/charts/feast/charts/transformation-service/Chart.yaml | 4 ++-- infra/charts/feast/charts/transformation-service/README.md | 2 +- infra/scripts/helm/push-helm-charts.sh | 4 ++-- infra/scripts/helm/validate-helm-chart-versions.sh | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index b8411cc9f78..25b207f22dd 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -10,7 +10,7 @@ This repo contains Helm charts for Feast components that are being installed on ## Chart: Feast -Feature store for machine learning Current chart version is `0.101.0` +Feature store for machine learning Current chart version is `0.17.0` ## Installation @@ -57,8 +57,8 @@ For more details, please see: https://docs.feast.dev/how-to-guides/running-feast | Repository | Name | Version | |------------|------|---------| | https://charts.helm.sh/stable | redis | 10.5.6 | -| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.101.0 | -| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.101.0 | +| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.17.0 | +| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.17.0 | ## Values diff --git a/infra/charts/feast/charts/feature-server/Chart.yaml b/infra/charts/feast/charts/feature-server/Chart.yaml index f0336cee2f0..fa7809d9714 100644 --- a/infra/charts/feast/charts/feature-server/Chart.yaml +++ b/infra/charts/feast/charts/feature-server/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Feast Feature Server: Online feature serving service for Feast" name: feature-server -version: 0.100.4 -appVersion: v0.15.0 +version: 0.17.0 +appVersion: v0.17.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/feature-server/README.md b/infra/charts/feast/charts/feature-server/README.md index 773f03af5e9..5621237cf67 100644 --- a/infra/charts/feast/charts/feature-server/README.md +++ b/infra/charts/feast/charts/feature-server/README.md @@ -1,6 +1,6 @@ # feature-server -![Version: 0.100.4](https://img.shields.io/badge/Version-0.100.4-informational?style=flat-square) ![AppVersion: v0.15.0](https://img.shields.io/badge/AppVersion-v0.15.0-informational?style=flat-square) +![Version: 0.17.0](https://img.shields.io/badge/Version-0.17.0-informational?style=flat-square) ![AppVersion: v0.17.0](https://img.shields.io/badge/AppVersion-v0.17.0-informational?style=flat-square) Feast Feature Server: Online feature serving service for Feast diff --git a/infra/charts/feast/charts/transformation-service/Chart.yaml b/infra/charts/feast/charts/transformation-service/Chart.yaml index 2760aa93fd9..4af54a79c43 100644 --- a/infra/charts/feast/charts/transformation-service/Chart.yaml +++ b/infra/charts/feast/charts/transformation-service/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Transformation service: to compute on-demand features" name: transformation-service -version: 0.100.4 -appVersion: v0.15.0 +version: 0.17.0 +appVersion: v0.17.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/transformation-service/README.md b/infra/charts/feast/charts/transformation-service/README.md index 8089c1572b2..0ea413d8e06 100644 --- a/infra/charts/feast/charts/transformation-service/README.md +++ b/infra/charts/feast/charts/transformation-service/README.md @@ -1,6 +1,6 @@ # transformation-service -![Version: 0.100.4](https://img.shields.io/badge/Version-0.100.4-informational?style=flat-square) ![AppVersion: v0.15.0](https://img.shields.io/badge/AppVersion-v0.15.0-informational?style=flat-square) +![Version: 0.17.0](https://img.shields.io/badge/Version-0.17.0-informational?style=flat-square) ![AppVersion: v0.17.0](https://img.shields.io/badge/AppVersion-v0.17.0-informational?style=flat-square) Transformation service: to compute on-demand features diff --git a/infra/scripts/helm/push-helm-charts.sh b/infra/scripts/helm/push-helm-charts.sh index 255a3f69326..f9750ccecc4 100755 --- a/infra/scripts/helm/push-helm-charts.sh +++ b/infra/scripts/helm/push-helm-charts.sh @@ -10,7 +10,7 @@ fi bucket=gs://feast-helm-charts repo_url=https://feast-helm-charts.storage.googleapis.com/ -helm plugin install https://github.com/hayorov/helm-gcs.git --version 0.2.2 || true +helm plugin install https://github.com/hayorov/helm-gcs.git --version 0.3.18 || true helm repo add feast-helm-chart-repo $bucket @@ -19,4 +19,4 @@ helm package feast helm package feast-python-server helm gcs push --public feast-${1}.tgz feast-helm-chart-repo --force -helm gcs push --public feast-python-server${1}.tgz feast-helm-chart-repo --force \ No newline at end of file +helm gcs push --public feast-python-server-${1}.tgz feast-helm-chart-repo --force \ No newline at end of file diff --git a/infra/scripts/helm/validate-helm-chart-versions.sh b/infra/scripts/helm/validate-helm-chart-versions.sh index 6f426a0f558..8a6b464cbb2 100755 --- a/infra/scripts/helm/validate-helm-chart-versions.sh +++ b/infra/scripts/helm/validate-helm-chart-versions.sh @@ -3,7 +3,7 @@ set -e # Amount of file locations that need to be bumped in unison when versions increment -UNIQUE_VERSIONS_COUNT=9 +UNIQUE_VERSIONS_COUNT=18 if [ $# -ne 1 ]; then echo "Please provide a single semver version (without a \"v\" prefix) to test the repository against, e.g 0.99.0" From ffe82fd39744aaa2b0de8c7c4338dc41623c7932 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Thu, 3 Feb 2022 08:38:43 +0200 Subject: [PATCH 63/85] Publish alternative python sdk with FEAST_USAGE=False by default (#2275) Signed-off-by: pyalex --- .github/workflows/release.yml | 21 +++++++++++++++++++++ sdk/python/feast/constants.py | 3 +++ sdk/python/feast/usage.py | 4 ++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc717beab26..8dd29aeb588 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,6 +144,27 @@ jobs: python3 setup.py sdist bdist_wheel python3 -m twine upload --verbose dist/* + publish-python-sdk-no-telemetry: + runs-on: ubuntu-latest + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + container: python:3.7 + steps: + - uses: actions/checkout@v2 + - name: Install pip-tools + run: pip install pip-tools + - name: Install dependencies + run: make install-python-ci-dependencies PYTHON=3.7 + - name: Publish Python Package + run: | + cd sdk/python + sed -i 's/DEFAULT_FEAST_USAGE_VALUE = "True"/DEFAULT_FEAST_USAGE_VALUE = "False"/g' feast/constants.py + sed -i 's/NAME = "feast"/NAME = "feast-no-telemetry"/g' setup.py + python3 -m pip install --user --upgrade setuptools wheel twine + python3 setup.py sdist bdist_wheel + python3 -m twine upload --verbose dist/* + publish-java-sdk: container: maven:3.6-jdk-11 runs-on: ubuntu-latest diff --git a/sdk/python/feast/constants.py b/sdk/python/feast/constants.py index ff93347130d..a2fe6f15c58 100644 --- a/sdk/python/feast/constants.py +++ b/sdk/python/feast/constants.py @@ -29,6 +29,9 @@ # Environment variable for toggling usage FEAST_USAGE = "FEAST_USAGE" +# Default value for FEAST_USAGE when environment variable is not set +DEFAULT_FEAST_USAGE_VALUE = "True" + # Environment variable for the path for overwriting universal test configs FULL_REPO_CONFIGS_MODULE_ENV_NAME: str = "FULL_REPO_CONFIGS_MODULE" diff --git a/sdk/python/feast/usage.py b/sdk/python/feast/usage.py index 1a2bf2e2907..6a6a7146ce7 100644 --- a/sdk/python/feast/usage.py +++ b/sdk/python/feast/usage.py @@ -29,7 +29,7 @@ import requests -from feast.constants import FEAST_USAGE +from feast.constants import DEFAULT_FEAST_USAGE_VALUE, FEAST_USAGE from feast.version import get_version USAGE_ENDPOINT = "https://usage.feast.dev" @@ -37,7 +37,7 @@ _logger = logging.getLogger(__name__) _executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) -_is_enabled = os.getenv(FEAST_USAGE, default="True") == "True" +_is_enabled = os.getenv(FEAST_USAGE, default=DEFAULT_FEAST_USAGE_VALUE) == "True" _constant_attributes = { "session_id": str(uuid.uuid4()), From bb676f2bc6f22c1fa451f0ba762f56551a525e56 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 01:51:43 -0500 Subject: [PATCH 64/85] Update Feast Serving documentation with ways to run and debug locally (#2272) Signed-off-by: Danny Chiao --- java/serving/README.md | 165 ++++++++++++++++++++++------------------- 1 file changed, 88 insertions(+), 77 deletions(-) diff --git a/java/serving/README.md b/java/serving/README.md index cce8c7d6e29..7a885939bdd 100644 --- a/java/serving/README.md +++ b/java/serving/README.md @@ -1,101 +1,112 @@ -### Getting Started Guide for Feast Serving Developers +## Getting Started Guide for Feast Serving Developers -Pre-requisites: +### Pre-requisites: - [Maven](https://maven.apache.org/install.html) build tool version 3.6.x -- A running Feast Core instance -- A running Store instance e.g. local Redis Store instance +- A Feast feature repo (e.g. https://github.com/feast-dev/feast-demo) +- A running Store instance e.g. local Redis instance with `redis-server` -From the Feast project root directory, run the following Maven command to start Feast Serving gRPC service running on port 6566 locally: +### Building and running Feast Serving locally: +From the Feast GitHub root, run: -```bash -# Assumptions: -# - Local Feast Core is running on localhost:6565 -# Uses configuration from serving/src/main/resources/application.yml -mvn -pl serving spring-boot:run -Dspring-boot.run.arguments=\ ---feast.core-host=localhost,\ ---feast.core-port=6565 -``` +1. `mvn -f java/pom.xml install -Dmaven.test.skip=true` +2. Package an executable jar for serving: `mvn -f java/serving/pom.xml package -Dmaven.test.skip=true` +3. Make a file called `application-override.yaml` that specifies your Feast repo project and registry path: + 1. Note if you have a remote registry, you can specify that too (e.g. `gs://...`) + ```yaml + feast: + project: "feast_demo" + registry: "/Users/[your username]/GitHub/feast-demo/feature_repo/data/registry.db" + ``` +4. Run the jar with dependencies that was built from Maven (note the version might vary): + ``` + java \ + -Xms1g \ + -Xmx4g \ + -jar java/serving/target/feast-serving-0.17.1-SNAPSHOT-jar-with-dependencies.jar \ + classpath:/application.yml,file:./application-override.yaml + ``` +5. Now you have a Feast Serving gRPC service running on port 6566 locally! +### Running test queries If you have [grpc_cli](https://github.com/grpc/grpc/blob/master/doc/command_line_tool.md) installed, you can check that Feast Serving is running ``` grpc_cli ls localhost:6566 -grpc_cli call localhost:6566 GetFeastServingVersion '' -grpc_cli call localhost:6566 GetFeastServingType '' ``` +An example of fetching features ```bash -grpc_cli call localhost:6565 ApplyFeatureSet ' -feature_set { - name: "driver" - entities { - name: "driver_id" - value_type: STRING - } - features { - name: "city" - value_type: STRING - } - features { - name: "booking_completed_count" - value_type: INT64 - } - source { - type: KAFKA - kafka_source_config { - bootstrap_servers: "localhost:9092" +grpc_cli call localhost:6566 GetOnlineFeatures ' +features { + val: "driver_hourly_stats:conv_rate" + val: "driver_hourly_stats:acc_rate" +} +entities { + key: "driver_id" + value { + val { + int64_val: 1001 + } + val { + int64_val: 1002 } } } ' - -grpc_cli call localhost:6565 GetFeatureSets ' -filter { - feature_set_name: "driver" +``` +Example output: +``` +connecting to localhost:6566 +metadata { + feature_names { + val: "driver_hourly_stats:conv_rate" + val: "driver_hourly_stats:acc_rate" + } } -' - -grpc_cli call localhost:6566 GetBatchFeatures ' -feature_sets { - name: "driver" - feature_names: "booking_completed_count" - max_age { - seconds: 86400 +results { + values { + float_val: 0.812357187 + } + values { + float_val: 0.379484832 + } + statuses: PRESENT + statuses: PRESENT + event_timestamps { + seconds: 1631725200 + } + event_timestamps { + seconds: 1631725200 } } -entity_dataset { - entity_names: "driver_id" - entity_dataset_rows { - entity_timestamp { - seconds: 1569873954 - } +results { + values { + float_val: 0.840873241 + } + values { + float_val: 0.151376978 + } + statuses: PRESENT + statuses: PRESENT + event_timestamps { + seconds: 1631725200 + } + event_timestamps { + seconds: 1631725200 } } -' -``` - +Rpc succeeded with OK status ``` -python3 < Date: Thu, 3 Feb 2022 16:03:21 +0200 Subject: [PATCH 65/85] Tutorial on validation of historical features (#2277) * dqm tutorial in docs Signed-off-by: pyalex * gh link Signed-off-by: pyalex * cleanup Signed-off-by: pyalex * typo Signed-off-by: pyalex --- docs/getting-started/concepts/dataset.md | 6 +- docs/tutorials/tutorials-overview.md | 2 + .../validating-historical-features.md | 910 ++++++++++++++++++ 3 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 docs/tutorials/validating-historical-features.md diff --git a/docs/getting-started/concepts/dataset.md b/docs/getting-started/concepts/dataset.md index 9bdbbfffdfe..59f71689050 100644 --- a/docs/getting-started/concepts/dataset.md +++ b/docs/getting-started/concepts/dataset.md @@ -43,4 +43,8 @@ Saved dataset can be later retrieved using `get_saved_dataset` method: ```python dataset = store.get_saved_dataset('my_training_dataset') dataset.to_df() -``` \ No newline at end of file +``` + +--- + +Check out our [tutorial on validating historical features](../../tutorials/validating-historical-features.md) to see how this concept can be applied in real-world use case. \ No newline at end of file diff --git a/docs/tutorials/tutorials-overview.md b/docs/tutorials/tutorials-overview.md index 86a8c25371c..e28e5836f73 100644 --- a/docs/tutorials/tutorials-overview.md +++ b/docs/tutorials/tutorials-overview.md @@ -9,3 +9,5 @@ These Feast tutorials showcase how to use Feast to simplify end to end model tra {% page-ref page="real-time-credit-scoring-on-aws.md" %} {% page-ref page="driver-stats-using-snowflake.md" %} + +{% page-ref page="validating-historical-features.md" %} diff --git a/docs/tutorials/validating-historical-features.md b/docs/tutorials/validating-historical-features.md new file mode 100644 index 00000000000..8dcf82c0115 --- /dev/null +++ b/docs/tutorials/validating-historical-features.md @@ -0,0 +1,910 @@ +# Data Quality Monitoring + +## Validating Historical Features with Great Expectations + +In this tutorial, we will use the public dataset of Chicago taxi trips to present data validation capabilities of Feast. The original dataset is stored in BigQuery and consists of raw data for each taxi trip (one row per trip) since 2013. We will generate several training datasets (aka historical features in Feast) for different periods and evaluate expectations made on one dataset against another. Our features will represent aggregations of raw data with daily intervals (eg, trips per day, average fare or speed for a specific day, etc.). We will craft some features using SQL while pulling data from BigQuery (like total trips time or total miles travelled). Another chunk of features will be implemented using Feast's on-demand transformations - features calculated on the fly when requested. + +Our plan: + +0. Prepare environment +1. Pull data from BigQuery (optional) +2. Declare & apply features and feature views in Feast +3. Generate reference dataset +4. Develop & test profiler function +5. Run validation on different dataset using reference dataset & profiler + + +> The original notebook and datasets for this tutorial can be found on [GitHub](https://github.com/feast-dev/dqm-tutorial). + +### 0. Setup + +Install Feast Python SDK and great expectations: + + +```python +!pip install 'feast[ge]' +``` + + +### 1. Dataset preparation (Optional) + +**You can skip this step if you don't have GCP account. Please use parquet files that are coming with this tutorial instead** + + +```python +!pip install google-cloud-bigquery +``` + + +```python +import pyarrow.parquet + +from google.cloud.bigquery import Client +``` + + +```python +bq_client = Client(project='kf-feast') +``` + +Running some basic aggregations while pulling data from BigQuery. Grouping by taxi_id and day: + + +```python +data_query = """SELECT + taxi_id, + TIMESTAMP_TRUNC(trip_start_timestamp, DAY) as day, + SUM(trip_miles) as total_miles_travelled, + SUM(trip_seconds) as total_trip_seconds, + SUM(fare) as total_earned, + COUNT(*) as trip_count +FROM `bigquery-public-data.chicago_taxi_trips.taxi_trips` +WHERE + trip_miles > 0 AND trip_seconds > 60 AND + trip_start_timestamp BETWEEN '2019-01-01' and '2020-12-31' AND + trip_total < 1000 +GROUP BY taxi_id, TIMESTAMP_TRUNC(trip_start_timestamp, DAY)""" +``` + + +```python +driver_stats_table = bq_client.query(data_query).to_arrow() + +# Storing resulting dataset into parquet file +pyarrow.parquet.write_table(driver_stats_table, "trips_stats.parquet") +``` + + +```python +def entities_query(year): + return f"""SELECT + distinct taxi_id +FROM `bigquery-public-data.chicago_taxi_trips.taxi_trips` +WHERE + trip_miles > 0 AND trip_seconds > 0 AND + trip_start_timestamp BETWEEN '{year}-01-01' and '{year}-12-31' +""" +``` + + +```python +entities_2019_table = bq_client.query(entities_query(2019)).to_arrow() + +# Storing entities (taxi ids) into parquet file +pyarrow.parquet.write_table(entities_2019_table, "entities.parquet") +``` + + +## 2. Declaring features + + +```python +import pyarrow.parquet +import pandas as pd + +from feast import Feature, FeatureView, Entity, FeatureStore +from feast.value_type import ValueType +from feast.data_format import ParquetFormat +from feast.on_demand_feature_view import on_demand_feature_view +from feast.infra.offline_stores.file_source import FileSource +from feast.infra.offline_stores.file import SavedDatasetFileStorage + +from google.protobuf.duration_pb2 import Duration +``` + + +```python +batch_source = FileSource( + event_timestamp_column="day", + path="trips_stats.parquet", # using parquet file that we created on previous step + file_format=ParquetFormat() +) +``` + + +```python +taxi_entity = Entity(name='taxi', join_key='taxi_id') +``` + + +```python +trips_stats_fv = FeatureView( + name='trip_stats', + entities=['taxi'], + features=[ + Feature("total_miles_travelled", ValueType.DOUBLE), + Feature("total_trip_seconds", ValueType.DOUBLE), + Feature("total_earned", ValueType.DOUBLE), + Feature("trip_count", ValueType.INT64), + + ], + ttl=Duration(seconds=86400), + batch_source=batch_source, +) +``` + +*Read more about feature views in [Feast docs](https://docs.feast.dev/getting-started/concepts/feature-view)* + + +```python +@on_demand_feature_view( + features=[ + Feature("avg_fare", ValueType.DOUBLE), + Feature("avg_speed", ValueType.DOUBLE), + Feature("avg_trip_seconds", ValueType.DOUBLE), + Feature("earned_per_hour", ValueType.DOUBLE), + ], + inputs={ + "stats": trips_stats_fv + } +) +def on_demand_stats(inp): + out = pd.DataFrame() + out["avg_fare"] = inp["total_earned"] / inp["trip_count"] + out["avg_speed"] = 3600 * inp["total_miles_travelled"] / inp["total_trip_seconds"] + out["avg_trip_seconds"] = inp["total_trip_seconds"] / inp["trip_count"] + out["earned_per_hour"] = 3600 * inp["total_earned"] / inp["total_trip_seconds"] + return out +``` + +*Read more about on demand feature views [here](https://docs.feast.dev/reference/alpha-on-demand-feature-view)* + + +```python +store = FeatureStore(".") # using feature_store.yaml that stored in the same directory +``` + + +```python +store.apply([taxi_entity, trips_stats_fv, on_demand_stats]) # writing to the registry +``` + + +## 3. Generating training (reference) dataset + + +```python +taxi_ids = pyarrow.parquet.read_table("entities.parquet").to_pandas() +``` + +Generating range of timestamps with daily frequency: + + +```python +timestamps = pd.DataFrame() +timestamps["event_timestamp"] = pd.date_range("2019-06-01", "2019-07-01", freq='D') +``` + +Cross merge (aka relation multiplication) produces entity dataframe with each taxi_id repeated for each timestamp: + + +```python +entity_df = pd.merge(taxi_ids, timestamps, how='cross') +entity_df +``` + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
taxi_idevent_timestamp
091d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2019-06-01
191d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2019-06-02
291d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2019-06-03
391d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2019-06-04
491d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2019-06-05
.........
1569797ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2019-06-27
1569807ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2019-06-28
1569817ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2019-06-29
1569827ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2019-06-30
1569837ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2019-07-01
+

156984 rows Γ— 2 columns

+
+ + + +Retrieving historical features for resulting entity dataframe and persisting output as a saved dataset: + + +```python +job = store.get_historical_features( + entity_df=entity_df, + features=[ + "trip_stats:total_miles_travelled", + "trip_stats:total_trip_seconds", + "trip_stats:total_earned", + "trip_stats:trip_count", + "on_demand_stats:avg_fare", + "on_demand_stats:avg_trip_seconds", + "on_demand_stats:avg_speed", + "on_demand_stats:earned_per_hour", + ] +) + +store.create_saved_dataset( + from_=job, + name='my_training_ds', + storage=SavedDatasetFileStorage(path='my_training_ds.parquet') +) +``` + +```python +, full_feature_names = False, tags = {}, _retrieval_job = , min_event_timestamp = 2019-06-01 00:00:00, max_event_timestamp = 2019-07-01 00:00:00)> +``` + + +## 4. Developing dataset profiler + +Dataset profiler is a function that accepts dataset and generates set of its characteristics. This charasteristics will be then used to evaluate (validate) next datasets. + +**Important: datasets are not compared to each other! +Feast use a reference dataset and a profiler function to generate a reference profile. +This profile will be then used during validation of the tested dataset.** + + +```python +import numpy as np + +from feast.dqm.profilers.ge_profiler import ge_profiler + +from great_expectations.core.expectation_suite import ExpectationSuite +from great_expectations.dataset import PandasDataset +``` + + +Loading saved dataset first and exploring the data: + + +```python +ds = store.get_saved_dataset('my_training_ds') +ds.to_df() +``` + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
total_earnedavg_trip_secondstaxi_idtotal_miles_travelledtrip_countearned_per_hourevent_timestamptotal_trip_secondsavg_fareavg_speed
068.252270.00000091d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...24.702.054.1189432019-06-01 00:00:00+00:004540.034.12500019.585903
1221.00560.5000007a4a6162eaf27805aef407d25d5cb21fe779cd962922cb...54.1824.059.1436222019-06-01 00:00:00+00:0013452.09.20833314.499554
2160.501010.769231f4c9d05b215d7cbd08eca76252dae51cdb7aca9651d4ef...41.3013.043.9726032019-06-01 00:00:00+00:0013140.012.34615411.315068
3183.75697.550000c1f533318f8480a59173a9728ea0248c0d3eb187f4b897...37.3020.047.4159562019-06-01 00:00:00+00:0013951.09.1875009.625116
4217.751054.076923455b6b5cae6ca5a17cddd251485f2266d13d6a2c92f07c...69.6913.057.2064512019-06-01 00:00:00+00:0013703.016.75000018.308692
.................................
15697938.001980.0000000cccf0ec1f46d1e0beefcfdeaf5188d67e170cdff92618...14.901.069.0909092019-07-01 00:00:00+00:001980.038.00000027.090909
156980135.00551.250000beefd3462e3f5a8e854942a2796876f6db73ebbd25b435...28.4016.055.1020412019-07-01 00:00:00+00:008820.08.43750011.591837
156981NaNNaN9a3c52aa112f46cf0d129fafbd42051b0fb9b0ff8dcb0e...NaNNaNNaN2019-07-01 00:00:00+00:00NaNNaNNaN
15698263.00815.00000008308c31cd99f495dea73ca276d19a6258d7b4c9c88e43...19.964.069.5705522019-07-01 00:00:00+00:003260.015.75000022.041718
156983NaNNaN7ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...NaNNaNNaN2019-07-01 00:00:00+00:00NaNNaNNaN
+

156984 rows Γ— 10 columns

+
+ + + +Feast uses [Great Expectations](https://docs.greatexpectations.io/docs/) as a validation engine and [ExpectationSuite](https://legacy.docs.greatexpectations.io/en/latest/autoapi/great_expectations/core/expectation_suite/index.html#great_expectations.core.expectation_suite.ExpectationSuite) as a dataset's profile. Hence, we need to develop a function that will generate ExpectationSuite. This function will receive instance of [PandasDataset](https://legacy.docs.greatexpectations.io/en/latest/autoapi/great_expectations/dataset/index.html?highlight=pandasdataset#great_expectations.dataset.PandasDataset) (wrapper around pandas.DataFrame) so we can utilize both Pandas DataFrame API and some helper functions from PandasDataset during profiling. + + +```python +DELTA = 0.1 # controlling allowed window in fraction of the value on scale [0, 1] + +@ge_profiler +def stats_profiler(ds: PandasDataset) -> ExpectationSuite: + # simple checks on data consistency + ds.expect_column_values_to_be_between( + "avg_speed", + min_value=0, + max_value=60, + mostly=0.99 # allow some outliers + ) + + ds.expect_column_values_to_be_between( + "total_miles_travelled", + min_value=0, + max_value=500, + mostly=0.99 # allow some outliers + ) + + # expectation of means based on observed values + observed_mean = ds.trip_count.mean() + ds.expect_column_mean_to_be_between("trip_count", + min_value=observed_mean * (1 - DELTA), + max_value=observed_mean * (1 + DELTA)) + + observed_mean = ds.earned_per_hour.mean() + ds.expect_column_mean_to_be_between("earned_per_hour", + min_value=observed_mean * (1 - DELTA), + max_value=observed_mean * (1 + DELTA)) + + + # expectation of quantiles + qs = [0.5, 0.75, 0.9, 0.95] + observed_quantiles = ds.avg_fare.quantile(qs) + + ds.expect_column_quantile_values_to_be_between( + "avg_fare", + quantile_ranges={ + "quantiles": qs, + "value_ranges": [[None, max_value] for max_value in observed_quantiles] + }) + + return ds.get_expectation_suite() +``` + +Testing our profiler function: + + +```python +ds.get_profile(profiler=stats_profiler) +``` + 02/02/2022 02:43:47 PM INFO: 5 expectation(s) included in expectation_suite. result_format settings filtered. + + + + +**Verify that all expectations that we coded in our profiler are present here. Otherwise (if you can't find some expectations) it means that it failed to pass on the reference dataset (do it silently is default behavior of Great Expectations).** + +Now we can create validation reference from dataset and profiler function: + + +```python +validation_reference = ds.as_reference(profiler=stats_profiler) +``` + +and test it against our existing retrieval job + + +```python +_ = job.to_df(validation_reference=validation_reference) +``` + + 02/02/2022 02:43:52 PM INFO: 5 expectation(s) included in expectation_suite. result_format settings filtered. + 02/02/2022 02:43:53 PM INFO: Validating data_asset_name None with expectation_suite_name default + + +Validation successfully passed as no exception were raised. + + +### 5. Validating new historical retrieval + +Creating new timestamps for Dec 2020: + + +```python +from feast.dqm.errors import ValidationFailed +``` + + +```python +timestamps = pd.DataFrame() +timestamps["event_timestamp"] = pd.date_range("2020-12-01", "2020-12-07", freq='D') +``` + + +```python +entity_df = pd.merge(taxi_ids, timestamps, how='cross') +entity_df +``` + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
taxi_idevent_timestamp
091d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2020-12-01
191d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2020-12-02
291d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2020-12-03
391d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2020-12-04
491d5288487e87c5917b813ba6f75ab1c3a9749af906a2d...2020-12-05
.........
354437ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2020-12-03
354447ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2020-12-04
354457ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2020-12-05
354467ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2020-12-06
354477ebf27414a0c7b128e7925e1da56d51a8b81484f7630cf...2020-12-07
+

35448 rows Γ— 2 columns

+
+ + +```python +job = store.get_historical_features( + entity_df=entity_df, + features=[ + "trip_stats:total_miles_travelled", + "trip_stats:total_trip_seconds", + "trip_stats:total_earned", + "trip_stats:trip_count", + "on_demand_stats:avg_fare", + "on_demand_stats:avg_trip_seconds", + "on_demand_stats:avg_speed", + "on_demand_stats:earned_per_hour", + ] +) +``` + +Execute retrieval job with validation reference: + + +```python +try: + df = job.to_df(validation_reference=validation_reference) +except ValidationFailed as exc: + print(exc.validation_report) +``` + + 02/02/2022 02:43:58 PM INFO: 5 expectation(s) included in expectation_suite. result_format settings filtered. + 02/02/2022 02:43:59 PM INFO: Validating data_asset_name None with expectation_suite_name default + + [ + { + "expectation_config": { + "expectation_type": "expect_column_mean_to_be_between", + "kwargs": { + "column": "trip_count", + "min_value": 10.387244591346153, + "max_value": 12.695521167200855, + "result_format": "COMPLETE" + }, + "meta": {} + }, + "meta": {}, + "result": { + "observed_value": 6.692920555429092, + "element_count": 35448, + "missing_count": 31055, + "missing_percent": 87.6071992778154 + }, + "exception_info": { + "raised_exception": false, + "exception_message": null, + "exception_traceback": null + }, + "success": false + }, + { + "expectation_config": { + "expectation_type": "expect_column_mean_to_be_between", + "kwargs": { + "column": "earned_per_hour", + "min_value": 52.320624975640214, + "max_value": 63.94743052578249, + "result_format": "COMPLETE" + }, + "meta": {} + }, + "meta": {}, + "result": { + "observed_value": 68.99268345164135, + "element_count": 35448, + "missing_count": 31055, + "missing_percent": 87.6071992778154 + }, + "exception_info": { + "raised_exception": false, + "exception_message": null, + "exception_traceback": null + }, + "success": false + }, + { + "expectation_config": { + "expectation_type": "expect_column_quantile_values_to_be_between", + "kwargs": { + "column": "avg_fare", + "quantile_ranges": { + "quantiles": [ + 0.5, + 0.75, + 0.9, + 0.95 + ], + "value_ranges": [ + [ + null, + 16.4 + ], + [ + null, + 26.229166666666668 + ], + [ + null, + 36.4375 + ], + [ + null, + 42.0 + ] + ] + }, + "result_format": "COMPLETE" + }, + "meta": {} + }, + "meta": {}, + "result": { + "observed_value": { + "quantiles": [ + 0.5, + 0.75, + 0.9, + 0.95 + ], + "values": [ + 19.5, + 28.1, + 38.0, + 44.125 + ] + }, + "element_count": 35448, + "missing_count": 31055, + "missing_percent": 87.6071992778154, + "details": { + "success_details": [ + false, + false, + false, + false + ] + } + }, + "exception_info": { + "raised_exception": false, + "exception_message": null, + "exception_traceback": null + }, + "success": false + } + ] + + +Validation failed since several expectations didn't pass: +* Trip count (mean) decreased more than 10% (which is expected when comparing Dec 2020 vs June 2019) +* Average Fare increased - all quantiles are higher than expected +* Earn per hour (mean) increased more than 10% (most probably due to increased fare) + From 4236903060f91c8aff01a99dc35beabf9d2dd619 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 09:40:13 -0500 Subject: [PATCH 66/85] Force retrigger of link building in Gitbook by removing and adding (in followup commit) Signed-off-by: Danny Chiao --- docs/how-to-guides/feast-snowflake-gcp-aws/README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/how-to-guides/feast-snowflake-gcp-aws/README.md b/docs/how-to-guides/feast-snowflake-gcp-aws/README.md index d120eab3144..8229ac9f178 100644 --- a/docs/how-to-guides/feast-snowflake-gcp-aws/README.md +++ b/docs/how-to-guides/feast-snowflake-gcp-aws/README.md @@ -1,14 +1,4 @@ # Running Feast with GCP/AWS -{% page-ref page="install-feast.md" %} -{% page-ref page="create-a-feature-repository.md" %} - -{% page-ref page="deploy-a-feature-store.md" %} - -{% page-ref page="build-a-training-dataset.md" %} - -{% page-ref page="load-data-into-the-online-store.md" %} - -{% page-ref page="read-features-from-the-online-store.md" %} From 992f81b8ead535f7c89ac06f9a6c77a3760df267 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 09:42:55 -0500 Subject: [PATCH 67/85] Force retrigger of link building in Gitbook by removing and adding (in followup commit) Signed-off-by: Danny Chiao --- docs/how-to-guides/feast-snowflake-gcp-aws/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/how-to-guides/feast-snowflake-gcp-aws/README.md b/docs/how-to-guides/feast-snowflake-gcp-aws/README.md index 8229ac9f178..d120eab3144 100644 --- a/docs/how-to-guides/feast-snowflake-gcp-aws/README.md +++ b/docs/how-to-guides/feast-snowflake-gcp-aws/README.md @@ -1,4 +1,14 @@ # Running Feast with GCP/AWS +{% page-ref page="install-feast.md" %} +{% page-ref page="create-a-feature-repository.md" %} + +{% page-ref page="deploy-a-feature-store.md" %} + +{% page-ref page="build-a-training-dataset.md" %} + +{% page-ref page="load-data-into-the-online-store.md" %} + +{% page-ref page="read-features-from-the-online-store.md" %} From 77efd9e9fb9a3b5d6dfb704d7673dcde3f97b066 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 12:25:21 -0500 Subject: [PATCH 68/85] Fix broken links on documentation (#2278) * Fix broken links on documentation Signed-off-by: Danny Chiao * Fix broken contributor links Signed-off-by: Danny Chiao * Remove broken badge Signed-off-by: Danny Chiao * Remove broken java link Signed-off-by: Danny Chiao --- CONTRIBUTING.md | 17 ++-- docs/getting-started/quickstart.md | 2 +- .../third-party-integrations.md | 2 +- .../feast-snowflake-gcp-aws/README.md | 2 +- docs/project/release-process.md | 1 - .../feature-servers/local-feature-server.md | 2 +- .../tutorials/driver-stats-using-snowflake.md | 4 +- examples/quickstart/quickstart.ipynb | 2 +- java/CONTRIBUTING.md | 89 ++----------------- java/README.md | 3 +- java/serving/README.md | 16 +++- 11 files changed, 35 insertions(+), 105 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbf44d4bef9..bef64577f91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,8 @@ This guide is targeted at developers looking to contribute to Feast components in the main Feast repository: - [Feast Python SDK / CLI](#feast-python-sdk-%2F-cli) +- [Feast Java Serving](#feast-java-serving) - [Feast Go Client](#feast-go-client) -- [Feast Terraform](#feast-terraform) ## Making a pull request @@ -117,6 +117,9 @@ AWS Then run `make test-python-integration`. Note that for GCP / AWS, this will create new temporary tables / datasets. +## Feast Java Serving +See [Java contributing guide](java/CONTRIBUTING.md) + ## Feast Go Client :warning: Feast Go Client will move to its own standalone repository in the future. @@ -152,14 +155,4 @@ go vet Unit tests for the Feast Go Client can be run as follows: ```sh go test -``` - -## Feast on Kubernetes -:warning: Feast Terraform will move to its own standalone repository in the future. - -See the deployment guide of the respective cloud providers for how to work with these deployments: -- [Helm Deployment on Kubernetes](https://docs.feast.dev/feast-on-kubernetes/getting-started/install-feast/kubernetes-with-helm) -- [Terraform Deployment on Amazon EKS](https://docs.feast.dev/feast-on-kubernetes/getting-started/install-feast/kubernetes-amazon-eks-with-terraform) -- [Terraform Deployment on Azure AKS](https://docs.feast.dev/feast-on-kubernetes/getting-started/install-feast/kubernetes-azure-aks-with-terraform) -- [Terraform Deployment on Google Cloud GKE](https://docs.feast.dev/feast-on-kubernetes/getting-started/install-feast/google-cloud-gke-with-terraform) -- [Kustomize Deployment on IBM Cloud IKS or OpenShift](https://docs.feast.dev/feast-on-kubernetes/getting-started/install-feast/ibm-cloud-iks-with-kustomize) +``` \ No newline at end of file diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 7686a7885f3..5af91a48c76 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -348,5 +348,5 @@ pprint(feature_vector) * Read the [Concepts](concepts/) page to understand the Feast data model. * Read the [Architecture](architecture-and-components/) page. * Check out our [Tutorials](../tutorials/tutorials-overview.md) section for more examples on how to use Feast. -* Follow our [Running Feast with GCP/AWS](../how-to-guides/feast-gcp-aws/) guide for a more in-depth tutorial on using Feast. +* Follow our [Running Feast with Snowflake/GCP/AWS](../how-to-guides/feast-snowflake-gcp-aws/) guide for a more in-depth tutorial on using Feast. * Join other Feast users and contributors in [Slack](https://slack.feast.dev) and become part of the community! diff --git a/docs/getting-started/third-party-integrations.md b/docs/getting-started/third-party-integrations.md index a3a41bb8366..c09507f5908 100644 --- a/docs/getting-started/third-party-integrations.md +++ b/docs/getting-started/third-party-integrations.md @@ -59,7 +59,7 @@ Don't see your offline store or online store of choice here? Check out our guide In order for a plugin integration to be highlighted on this page, it must meet the following requirements: -1. The plugin must have tests. Ideally it would use the Feast universal tests (see this [guide](broken-reference) for an example), but custom tests are fine. +1. The plugin must have tests. Ideally it would use the Feast universal tests (see this [guide](../how-to-guides/adding-or-reusing-tests) for an example), but custom tests are fine. 2. The plugin must have some basic documentation on how it should be used. 3. The author must work with a maintainer to pass a basic code review (e.g. to ensure that the implementation roughly matches the core Feast implementations). diff --git a/docs/how-to-guides/feast-snowflake-gcp-aws/README.md b/docs/how-to-guides/feast-snowflake-gcp-aws/README.md index d120eab3144..753650080b0 100644 --- a/docs/how-to-guides/feast-snowflake-gcp-aws/README.md +++ b/docs/how-to-guides/feast-snowflake-gcp-aws/README.md @@ -1,4 +1,4 @@ -# Running Feast with GCP/AWS +# Running Feast with Snowflake/GCP/AWS {% page-ref page="install-feast.md" %} diff --git a/docs/project/release-process.md b/docs/project/release-process.md index 8ecd55a63f4..af573c92c76 100644 --- a/docs/project/release-process.md +++ b/docs/project/release-process.md @@ -22,7 +22,6 @@ For Feast maintainers, these are the concrete steps for making a new release. 2. Add the change log by applying the change log commit created in step 2. 3. Check that versions are updated with `env TARGET_MERGE_BRANCH=master make lint-versions` 7. Create a [GitHub release](https://github.com/feast-dev/feast/releases) which includes a summary of im~~p~~ortant changes as well as any artifacts associated with the release. Make sure to include the same change log as added in [CHANGELOG.md](../../CHANGELOG.md). Use `Feast vX.Y.Z` as the title. -8. Update the[ Upgrade Guide](broken-reference) to include the action required instructions for users to upgrade to this new release. Instructions should include a migration for each breaking change made to this release. When a tag that matches a Semantic Version string is pushed, CI will automatically build and push the relevant artifacts to their repositories or package managers (docker images, Python wheels, etc). JVM artifacts are promoted from Sonatype OSSRH to Maven Central, but it sometimes takes some time for them to be available. The `sdk/go/v tag` is required to version the Go SDK go module so that users can go get a specific tagged release of the Go SDK. diff --git a/docs/reference/feature-servers/local-feature-server.md b/docs/reference/feature-servers/local-feature-server.md index f49212df421..4ea37d4f1eb 100644 --- a/docs/reference/feature-servers/local-feature-server.md +++ b/docs/reference/feature-servers/local-feature-server.md @@ -2,7 +2,7 @@ ## Overview -The local feature server is an HTTP endpoint that serves features with JSON I/O. This enables users to get features from Feast using any programming language that can make HTTP requests. A [remote feature server](alpha-aws-lambda-feature-server.md) on AWS Lambda is also available. A remote feature server on GCP Cloud Run is currently being developed. +The local feature server is an HTTP endpoint that serves features with JSON I/O. This enables users to get features from Feast using any programming language that can make HTTP requests. A [remote feature server](../alpha-aws-lambda-feature-server.md) on AWS Lambda is also available. A remote feature server on GCP Cloud Run is currently being developed. ## CLI diff --git a/docs/tutorials/driver-stats-using-snowflake.md b/docs/tutorials/driver-stats-using-snowflake.md index 1dcb4ca4091..3afd3c10afb 100644 --- a/docs/tutorials/driver-stats-using-snowflake.md +++ b/docs/tutorials/driver-stats-using-snowflake.md @@ -3,7 +3,7 @@ description: >- Initial demonstration of Snowflake as an offline store with Feast, using the Snowflake demo template. --- -# Drivers Stats using Snowflake +# Drivers stats on Snowflake In the steps below, we will set up a sample Feast project that leverages Snowflake as an offline store. @@ -22,7 +22,7 @@ pip install 'feast[snowflake]' #### Get a Snowflake Trial Account (Optional) -[Snowflake Trial Account](trial.snowflake.com) +[Snowflake Trial Account](http://trial.snowflake.com) #### Create a feature repository diff --git a/examples/quickstart/quickstart.ipynb b/examples/quickstart/quickstart.ipynb index d6f349faae5..3679fcc7788 100644 --- a/examples/quickstart/quickstart.ipynb +++ b/examples/quickstart/quickstart.ipynb @@ -796,7 +796,7 @@ "\n", "- Read the [Concepts](https://docs.feast.dev/getting-started/concepts/) page to understand the Feast data model and architecture.\n", "- Check out our [Tutorials](https://docs.feast.dev/tutorials/tutorials-overview) section for more examples on how to use Feast.\n", - "- Follow our [Running Feast with GCP/AWS](https://docs.feast.dev/how-to-guides/feast-gcp-aws) guide for a more in-depth tutorial on using Feast.\n", + "- Follow our [Running Feast with Snowflake/GCP/AWS](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws) guide for a more in-depth tutorial on using Feast.\n", "- Join other Feast users and contributors in [Slack](https://slack.feast.dev/) and become part of the community!" ] } diff --git a/java/CONTRIBUTING.md b/java/CONTRIBUTING.md index 1694b3f33f9..86eacfef419 100644 --- a/java/CONTRIBUTING.md +++ b/java/CONTRIBUTING.md @@ -5,7 +5,6 @@ ### Overview This guide is targeted at developers looking to contribute to Feast components in the feast-java Repository: -- [Feast Core](#feast-core) - [Feast Serving](#feast-serving) - [Feast Java Client](#feast-java-client) @@ -15,11 +14,14 @@ the feast-java Repository: #### Common Setup Common Environment Setup for all feast-java Feast components: -1. . Ensure following development tools are installed: -- Java SE Development Kit 11, Maven 3.6, `make` + +Ensure following development tools are installed: +- Java SE Development Kit 11 +- Maven 3.6 +- `make` #### Code Style -feast-java's codebase conforms to the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html). +Feast's Java codebase conforms to the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html). Automatically format the code to conform the style guide by: @@ -59,82 +61,8 @@ Specifically, proto-generated code is not indexed by IntelliJ. To fix this, navi - target/generated-sources/protobuf/java - target/generated-sources/annotations - -## Feast Core -### Environment Setup -Setting up your development environment for Feast Core: -1. Complete the feast-java [Common Setup](#common-setup) -2. Boot up a PostgreSQL instance (version 11 and above). Example of doing so via Docker: -```sh -# spawn a PostgreSQL instance as a Docker container running in the background -docker run \ - --rm -it -d \ - --name postgres \ - -e POSTGRES_DB=postgres \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=password \ - -p 5432:5432 postgres:12-alpine -``` - -### Configuration -Feast Core is configured using it's [application.yml](https://docs.feast.dev/reference/configuration-reference#1-feast-core-and-feast-online-serving). - -### Building and Running -1. Build / Compile Feast Core with Maven to produce an executable Feast Core JAR -```sh -mvn package -pl core --also-make -Dmaven.test.skip=true -``` - -2. Run Feast Core using the built JAR: -```sh -# where X.X.X is the version of the Feast Core JAR built -java -jar core/target/feast-core-X.X.X-exec.jar -``` - -### Unit / Integration Tests -Unit & Integration Tests can be used to verify functionality: -```sh -# run unit tests -mvn test -pl core --also-make -# run integration tests -mvn verify -pl core --also-make -``` - ## Feast Serving -### Environment Setup -Setting up your development environment for Feast Serving: -1. Complete the feast-java [Common Setup](#common-setup) -2. Boot up a Redis instance (version 5.x). Example of doing so via Docker: -```sh -docker run --name redis --rm -it -d -p 6379:6379 redis:5-alpine -``` - -> Feast Serving requires a running Feast Core instance to retrieve Feature metadata -> in order to serve features. See the [Feast Core section](#feast-core) for -> how to get a Feast Core instance running. - -### Configuration -Feast Serving is configured using it's [application.yml](https://docs.feast.dev/reference/configuration-reference#1-feast-core-and-feast-online-serving). - -### Building and Running -1. Build / Compile Feast Serving with Maven to produce an executable Feast Serving JAR -```sh -mvn package -pl serving --also-make -Dmaven.test.skip=true - -2. Run Feast Serving using the built JAR: -```sh -# where X.X.X is the version of the Feast serving JAR built -java -jar serving/target/feast-serving-X.X.X-exec.jar -``` - -### Unit / Integration Tests -Unit & Integration Tests can be used to verify functionality: -```sh -# run unit tests -mvn test -pl serving --also-make -# run integration tests -mvn verify -pl serving --also-make -``` +See instructions [here](serving/README.md) for developing. ## Feast Java Client ### Environment Setup @@ -144,9 +72,6 @@ Setting up your development environment for Feast Java SDK: > Feast Java Client is a Java Client for retrieving Features from a running Feast Serving instance. > See the [Feast Serving Section](#feast-serving) section for how to get a Feast Serving instance running. -### Configuration -Feast Java Client is [configured as code](https://docs.feast.dev/v/master/reference/configuration-reference#4-feast-java-and-go-sdk) - ### Building 1. Build / Compile Feast Java Client with Maven: diff --git a/java/README.md b/java/README.md index 8d6141faa84..ff5a1b85539 100644 --- a/java/README.md +++ b/java/README.md @@ -1,5 +1,4 @@ # Feast Java components -[![complete](https://github.com/feast-dev/feast-java/actions/workflows/complete.yml/badge.svg)](https://github.com/feast-dev/feast-java/actions/workflows/complete.yml) ### Overview @@ -19,4 +18,4 @@ Guides on Contributing: - [Development Guide for feast-java (this repository)](CONTRIBUTING.md) ### Installing using Helm -Please see the Helm charts in [charts](https://github.com/feast-dev/feast-helm-charts). +Please see the Helm charts in [infra/charts/feast](../infra/charts/feast). diff --git a/java/serving/README.md b/java/serving/README.md index 7a885939bdd..0ce2edc091d 100644 --- a/java/serving/README.md +++ b/java/serving/README.md @@ -1,5 +1,10 @@ ## Getting Started Guide for Feast Serving Developers +### Overview +This guide is targeted at developers looking to contribute to Feast Serving: +- [Building and running Feast Serving locally](#building-and-running-feast-serving-locally) +- [Feast Java Client](#feast-java-client) + ### Pre-requisites: - [Maven](https://maven.apache.org/install.html) build tool version 3.6.x @@ -109,4 +114,13 @@ You can debug this like any other Java executable. Swap the java command above w -jar java/serving/target/feast-serving-0.17.1-SNAPSHOT-jar-with-dependencies.jar \ classpath:/application.yml,file:./application-override.yaml ``` -Now you can attach e.g. a Remote debugger in IntelliJ to port 5005 to debug / make breakpoints. \ No newline at end of file +Now you can attach e.g. a Remote debugger in IntelliJ to port 5005 to debug / make breakpoints. + +### Unit / Integration Tests +Unit & Integration Tests can be used to verify functionality: +```sh +# run unit tests +mvn test -pl serving --also-make +# run integration tests +mvn verify -pl serving --also-make +``` \ No newline at end of file From a7006149cd8a5c751521cfc70334f184f52520bd Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 12:41:30 -0500 Subject: [PATCH 69/85] Fixing broken universal test link Signed-off-by: Danny Chiao --- docs/getting-started/third-party-integrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/third-party-integrations.md b/docs/getting-started/third-party-integrations.md index c09507f5908..9791b664904 100644 --- a/docs/getting-started/third-party-integrations.md +++ b/docs/getting-started/third-party-integrations.md @@ -59,7 +59,7 @@ Don't see your offline store or online store of choice here? Check out our guide In order for a plugin integration to be highlighted on this page, it must meet the following requirements: -1. The plugin must have tests. Ideally it would use the Feast universal tests (see this [guide](../how-to-guides/adding-or-reusing-tests) for an example), but custom tests are fine. +1. The plugin must have tests. Ideally it would use the Feast universal tests (see this [guide](../how-to-guides/adding-or-reusing-tests.md) for an example), but custom tests are fine. 2. The plugin must have some basic documentation on how it should be used. 3. The author must work with a maintainer to pass a basic code review (e.g. to ensure that the implementation roughly matches the core Feast implementations). From 241996f590c08a28b120a116a2170f368f5da404 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 12:44:22 -0500 Subject: [PATCH 70/85] Fixing broken quickstart link Signed-off-by: Danny Chiao --- docs/getting-started/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 5af91a48c76..87b11e05d26 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -28,7 +28,7 @@ In this tutorial, we use feature stores to generate training data and power onli Install the Feast SDK and CLI using pip: -* In this tutorial, we focus on a local deployment. For a more in-depth guide on how to use Feast with GCP or AWS deployments, see [Running Feast with GCP/AWS](../how-to-guides/feast-gcp-aws/) +* In this tutorial, we focus on a local deployment. For a more in-depth guide on how to use Feast with Snowflake / GCP / AWS deployments, see [Running Feast with Snowflake/GCP/AWS](../how-to-guides/feast-snowflake-gcp-aws/) {% tabs %} {% tab title="Bash" %} From e6c4fc79455b859ee274703e0c6d82f0b13cbc36 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 12:46:04 -0500 Subject: [PATCH 71/85] Fixing broken integrations link in quickstart Signed-off-by: Danny Chiao --- docs/getting-started/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 87b11e05d26..c067513d313 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -127,7 +127,7 @@ Valid values for `provider` in `feature_store.yaml` are: * gcp: use BigQuery/Snowflake with Google Cloud Datastore/Redis * aws: use Redshift/Snowflake with DynamoDB/Redis -Note that there are many other sources Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/getting-started/third-party-integrations for all supported datasources. +Note that there are many other sources Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See [Third party integrations](../getting-started/third-party-integrations.md) for all supported datasources. A custom setup can also be made by following [adding a custom provider](../how-to-guides/creating-a-custom-provider.md). From 52e4c0313c2ce02a06bbcf0d4cbf238d542b93b3 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 12:55:30 -0500 Subject: [PATCH 72/85] Fixing broken link in snowflake reference Signed-off-by: Danny Chiao --- docs/reference/offline-stores/snowflake.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/reference/offline-stores/snowflake.md b/docs/reference/offline-stores/snowflake.md index fcf9a7a6fd3..f4c3c9f709e 100644 --- a/docs/reference/offline-stores/snowflake.md +++ b/docs/reference/offline-stores/snowflake.md @@ -7,7 +7,11 @@ The Snowflake offline store provides support for reading [SnowflakeSources](../d * Snowflake tables and views are allowed as sources. * All joins happen within Snowflake. * Entity dataframes can be provided as a SQL query or can be provided as a Pandas dataframe. Pandas dataframes will be uploaded to Snowflake in order to complete join operations. -* A [SnowflakeRetrievalJob](https://github.com/feast-dev/feast/blob/bf557bcb72c7878a16dccb48443bbbe9dc3efa49/sdk/python/feast/infra/offline_stores/snowflake.py#L185) is returned when calling `get_historical_features()`. +* A `SnowflakeRetrievalJob` is returned when calling `get_historical_features()`. + * This allows you to call + * `to_snowflake` to save the dataset into Snowflake + * `to_sql` to get the SQL query that would execute on `to_df` + * `to_arrow_chunks` to get the result in batches ([Snowflake python connector docs](https://docs.snowflake.com/en/user-guide/python-connector-api.html#get_result_batches)) ## Example From 5b2c251524f6ad32003e76cb8a024c1354912146 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 12:59:38 -0500 Subject: [PATCH 73/85] Update snowflake tutorial title Signed-off-by: Danny Chiao --- docs/SUMMARY.md | 2 +- ...er-stats-using-snowflake.md => driver-stats-on-snowflake.md} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/tutorials/{driver-stats-using-snowflake.md => driver-stats-on-snowflake.md} (100%) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 93a89cbb671..5d301ed749f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -33,7 +33,7 @@ * [Driver ranking](tutorials/driver-ranking-with-feast.md) * [Fraud detection on GCP](tutorials/fraud-detection.md) * [Real-time credit scoring on AWS](tutorials/real-time-credit-scoring-on-aws.md) -* [Driver Stats using Snowflake](tutorials/driver-stats-using-snowflake.md) +* [Driver Stats on Snowflake](tutorials/driver-stats-on-snowflake.md) ## How-to Guides diff --git a/docs/tutorials/driver-stats-using-snowflake.md b/docs/tutorials/driver-stats-on-snowflake.md similarity index 100% rename from docs/tutorials/driver-stats-using-snowflake.md rename to docs/tutorials/driver-stats-on-snowflake.md From 088c1b012a75e2f628d3ee63dbb49686035c5b6e Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 13:01:54 -0500 Subject: [PATCH 74/85] Update snowflake tutorial title Signed-off-by: Danny Chiao --- docs/SUMMARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5d301ed749f..d8e0af92172 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -33,7 +33,7 @@ * [Driver ranking](tutorials/driver-ranking-with-feast.md) * [Fraud detection on GCP](tutorials/fraud-detection.md) * [Real-time credit scoring on AWS](tutorials/real-time-credit-scoring-on-aws.md) -* [Driver Stats on Snowflake](tutorials/driver-stats-on-snowflake.md) +* [Driver stats on Snowflake](tutorials/driver-stats-on-snowflake.md) ## How-to Guides From cde86af28eb26eb49c7835d1414c77bdd64cfc19 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 13:03:31 -0500 Subject: [PATCH 75/85] Fix bug in snowflake tutorial referencing snowflake as online store Signed-off-by: Danny Chiao --- docs/tutorials/driver-stats-on-snowflake.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/driver-stats-on-snowflake.md b/docs/tutorials/driver-stats-on-snowflake.md index 3afd3c10afb..94ac109c942 100644 --- a/docs/tutorials/driver-stats-on-snowflake.md +++ b/docs/tutorials/driver-stats-on-snowflake.md @@ -12,7 +12,7 @@ Starting with data in a Snowflake table, we will register that table to the feat Our template will generate new data containing driver statistics. From there, we will show you code snippets that will call to the offline store for generating training datasets, and then the code for calling the online store to serve you the latest feature values to serve models in production. -## Snowflake Offline/Online Store Example +## Snowflake Offline Store Example #### Install feast-snowflake From c6ef615fa4d3f0a613299bfab8b5ebde3c10f5d2 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 13:07:32 -0500 Subject: [PATCH 76/85] Fix bug in snowflake reference pointing to broken config link Signed-off-by: Danny Chiao --- docs/reference/offline-stores/snowflake.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/offline-stores/snowflake.md b/docs/reference/offline-stores/snowflake.md index f4c3c9f709e..aa006b43bb0 100644 --- a/docs/reference/offline-stores/snowflake.md +++ b/docs/reference/offline-stores/snowflake.md @@ -31,4 +31,4 @@ offline_store: ``` {% endcode %} -Configuration options are available [here](https://github.com/feast-dev/feast/blob/bf557bcb72c7878a16dccb48443bbbe9dc3efa49/sdk/python/feast/infra/offline_stores/snowflake.py#L39). +Configuration options are available in [SnowflakeOfflineStoreConfig](https://github.com/feast-dev/feast/blob/master/sdk/python/feast/infra/offline_stores/snowflake.py#L56). From 311b1498277aec01229b2a6d24155309d16aed58 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 14:03:20 -0500 Subject: [PATCH 77/85] Add link to community plugin for Spark offline store (#2279) * Add link to community plugin for Spark offline store Signed-off-by: Danny Chiao * Fix links Signed-off-by: Danny Chiao --- README.md | 6 ++++-- docs/getting-started/third-party-integrations.md | 4 +++- docs/roadmap.md | 6 ++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4125972046e..2bc55ad9eac 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ The list below contains the functionality that contributors are planning to deve * [x] [Synapse source (community plugin)](https://github.com/Azure/feast-azure) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) - * [x] Kafka source (with [push support into the online store](reference/alpha-stream-ingestion.md)) + * [x] [Spark (community plugin)](https://github.com/Adyen/feast-spark-offline-store) + * [x] Kafka source (with [push support into the online store](https://docs.feast.dev/reference/alpha-stream-ingestion)) * [ ] HTTP source * **Offline Stores** * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) @@ -152,9 +153,10 @@ The list below contains the functionality that contributors are planning to deve * [x] [Synapse (community plugin)](https://github.com/Azure/feast-azure) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) + * [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) + * [x] [Spark (community plugin)](https://github.com/Adyen/feast-spark-offline-store) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/adding-a-new-offline-store) - * [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) * **Online Stores** * [x] [DynamoDB](https://docs.feast.dev/reference/online-stores/dynamodb) * [x] [Redis](https://docs.feast.dev/reference/online-stores/redis) diff --git a/docs/getting-started/third-party-integrations.md b/docs/getting-started/third-party-integrations.md index 9791b664904..ba1b360fc05 100644 --- a/docs/getting-started/third-party-integrations.md +++ b/docs/getting-started/third-party-integrations.md @@ -20,6 +20,7 @@ Don't see your offline store or online store of choice here? Check out our guide * [x] [Synapse source (community plugin)](https://github.com/Azure/feast-azure) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) +* [x] [Spark (community plugin)](https://github.com/Adyen/feast-spark-offline-store) * [x] Kafka source (with [push support into the online store](https://docs.feast.dev/reference/alpha-stream-ingestion)) * [ ] HTTP source @@ -31,9 +32,10 @@ Don't see your offline store or online store of choice here? Check out our guide * [x] [Synapse (community plugin)](https://github.com/Azure/feast-azure) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) +* [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) +* [x] [Spark (community plugin)](https://github.com/Adyen/feast-spark-offline-store) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/adding-a-new-offline-store) -* [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) ### Online Stores diff --git a/docs/roadmap.md b/docs/roadmap.md index addd3dbb9f7..83c43e313e9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,8 @@ The list below contains the functionality that contributors are planning to deve * [x] [Synapse source (community plugin)](https://github.com/Azure/feast-azure) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) - * [x] Kafka source (with [push support into the online store](reference/alpha-stream-ingestion.md)) + * [x] [Spark (community plugin)](https://github.com/Adyen/feast-spark-offline-store) + * [x] Kafka source (with [push support into the online store](https://docs.feast.dev/reference/alpha-stream-ingestion)) * [ ] HTTP source * **Offline Stores** * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) @@ -24,9 +25,10 @@ The list below contains the functionality that contributors are planning to deve * [x] [Synapse (community plugin)](https://github.com/Azure/feast-azure) * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) + * [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) + * [x] [Spark (community plugin)](https://github.com/Adyen/feast-spark-offline-store) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/adding-a-new-offline-store) - * [x] [Trino (communiuty plugin)](https://github.com/Shopify/feast-trino) * **Online Stores** * [x] [DynamoDB](https://docs.feast.dev/reference/online-stores/dynamodb) * [x] [Redis](https://docs.feast.dev/reference/online-stores/redis) From 2b23be339f7f1a51210b8d56a1b7baa89c0001f4 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 14:03:48 -0500 Subject: [PATCH 78/85] Making the DQM tutorial visible on the sidebar Signed-off-by: Danny Chiao --- docs/SUMMARY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index d8e0af92172..439742af9f5 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -34,6 +34,7 @@ * [Fraud detection on GCP](tutorials/fraud-detection.md) * [Real-time credit scoring on AWS](tutorials/real-time-credit-scoring-on-aws.md) * [Driver stats on Snowflake](tutorials/driver-stats-on-snowflake.md) +* [Validating historical features with Great Expectations](tutorials/validating-historical-features.md) ## How-to Guides From 467ef9fd840e492da2ecb2b91c8de5a67ffa8ca9 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 14:37:14 -0500 Subject: [PATCH 79/85] Updating title of DQM tutorial Signed-off-by: Danny Chiao --- docs/tutorials/tutorials-overview.md | 4 +--- docs/tutorials/validating-historical-features.md | 11 ++++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/tutorials-overview.md b/docs/tutorials/tutorials-overview.md index e28e5836f73..85a04731037 100644 --- a/docs/tutorials/tutorials-overview.md +++ b/docs/tutorials/tutorials-overview.md @@ -8,6 +8,4 @@ These Feast tutorials showcase how to use Feast to simplify end to end model tra {% page-ref page="real-time-credit-scoring-on-aws.md" %} -{% page-ref page="driver-stats-using-snowflake.md" %} - -{% page-ref page="validating-historical-features.md" %} +{% page-ref page="driver-stats-using-snowflake.md" %} \ No newline at end of file diff --git a/docs/tutorials/validating-historical-features.md b/docs/tutorials/validating-historical-features.md index 8dcf82c0115..19ae4ef434c 100644 --- a/docs/tutorials/validating-historical-features.md +++ b/docs/tutorials/validating-historical-features.md @@ -1,8 +1,13 @@ -# Data Quality Monitoring +# Validating historical features with Great Expectations -## Validating Historical Features with Great Expectations +In this tutorial, we will use the public dataset of Chicago taxi trips to present data validation capabilities of Feast. +- The original dataset is stored in BigQuery and consists of raw data for each taxi trip (one row per trip) since 2013. +- We will generate several training datasets (aka historical features in Feast) for different periods and evaluate expectations made on one dataset against another. -In this tutorial, we will use the public dataset of Chicago taxi trips to present data validation capabilities of Feast. The original dataset is stored in BigQuery and consists of raw data for each taxi trip (one row per trip) since 2013. We will generate several training datasets (aka historical features in Feast) for different periods and evaluate expectations made on one dataset against another. Our features will represent aggregations of raw data with daily intervals (eg, trips per day, average fare or speed for a specific day, etc.). We will craft some features using SQL while pulling data from BigQuery (like total trips time or total miles travelled). Another chunk of features will be implemented using Feast's on-demand transformations - features calculated on the fly when requested. +Types of features we're ingesting and generating: +- Features that aggregate raw data with daily intervals (eg, trips per day, average fare or speed for a specific day, etc.). +- Features using SQL while pulling data from BigQuery (like total trips time or total miles travelled). +- Features calculated on the fly when requested using Feast's on-demand transformations Our plan: From ac9236e0348597836039f5480c4714577b1a075e Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 14:40:39 -0500 Subject: [PATCH 80/85] Resurfacing DQM tutorial in overview Signed-off-by: Danny Chiao --- docs/tutorials/tutorials-overview.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/tutorials/tutorials-overview.md b/docs/tutorials/tutorials-overview.md index 85a04731037..e28e5836f73 100644 --- a/docs/tutorials/tutorials-overview.md +++ b/docs/tutorials/tutorials-overview.md @@ -8,4 +8,6 @@ These Feast tutorials showcase how to use Feast to simplify end to end model tra {% page-ref page="real-time-credit-scoring-on-aws.md" %} -{% page-ref page="driver-stats-using-snowflake.md" %} \ No newline at end of file +{% page-ref page="driver-stats-using-snowflake.md" %} + +{% page-ref page="validating-historical-features.md" %} From 2b0bff07b6c8bdde9f7a1f7d221641e1679fc329 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 14:42:03 -0500 Subject: [PATCH 81/85] Retriggering tutorial overview to re-render in Gitbook by removing + adding Signed-off-by: Danny Chiao --- docs/tutorials/tutorials-overview.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/tutorials/tutorials-overview.md b/docs/tutorials/tutorials-overview.md index e28e5836f73..85b4ed499d8 100644 --- a/docs/tutorials/tutorials-overview.md +++ b/docs/tutorials/tutorials-overview.md @@ -2,12 +2,4 @@ These Feast tutorials showcase how to use Feast to simplify end to end model training / serving. -{% page-ref page="fraud-detection.md" %} -{% page-ref page="driver-ranking-with-feast.md" %} - -{% page-ref page="real-time-credit-scoring-on-aws.md" %} - -{% page-ref page="driver-stats-using-snowflake.md" %} - -{% page-ref page="validating-historical-features.md" %} From 62f985c6799aabee44991d0e1bc409bd2da613d7 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 14:42:59 -0500 Subject: [PATCH 82/85] Retriggering tutorial overview to re-render in Gitbook by removing + adding Signed-off-by: Danny Chiao --- docs/tutorials/tutorials-overview.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/tutorials/tutorials-overview.md b/docs/tutorials/tutorials-overview.md index 85b4ed499d8..e28e5836f73 100644 --- a/docs/tutorials/tutorials-overview.md +++ b/docs/tutorials/tutorials-overview.md @@ -2,4 +2,12 @@ These Feast tutorials showcase how to use Feast to simplify end to end model training / serving. +{% page-ref page="fraud-detection.md" %} +{% page-ref page="driver-ranking-with-feast.md" %} + +{% page-ref page="real-time-credit-scoring-on-aws.md" %} + +{% page-ref page="driver-stats-using-snowflake.md" %} + +{% page-ref page="validating-historical-features.md" %} From 1c374b2776288a33aa9d71c17d22169b93981fb5 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 14:44:02 -0500 Subject: [PATCH 83/85] Fixing broken snowflake tutorial link in tutorial overview Signed-off-by: Danny Chiao --- docs/tutorials/tutorials-overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/tutorials-overview.md b/docs/tutorials/tutorials-overview.md index e28e5836f73..32e64071b06 100644 --- a/docs/tutorials/tutorials-overview.md +++ b/docs/tutorials/tutorials-overview.md @@ -8,6 +8,6 @@ These Feast tutorials showcase how to use Feast to simplify end to end model tra {% page-ref page="real-time-credit-scoring-on-aws.md" %} -{% page-ref page="driver-stats-using-snowflake.md" %} +{% page-ref page="driver-stats-on-snowflake.md" %} {% page-ref page="validating-historical-features.md" %} From 69ec9819d2800e456b1ed0545c9601881a0ea028 Mon Sep 17 00:00:00 2001 From: Danny Chiao Date: Thu, 3 Feb 2022 15:25:24 -0500 Subject: [PATCH 84/85] Fixing to be broken link on README Signed-off-by: Danny Chiao --- README.md | 2 +- infra/templates/README.md.jinja2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2bc55ad9eac..de972225dd2 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Please see our [documentation](https://docs.feast.dev/) for more information abo ## πŸ“ Architecture ![](docs/assets/feast-marchitecture.png) -The above architecture is the minimal Feast deployment. Want to run the full Feast on GCP/AWS? Click [here](https://docs.feast.dev/how-to-guides/feast-gcp-aws). +The above architecture is the minimal Feast deployment. Want to run the full Feast on Snowflake/GCP/AWS? Click [here](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws). ## 🐣 Getting Started diff --git a/infra/templates/README.md.jinja2 b/infra/templates/README.md.jinja2 index 737f8d8f52b..7d08c0d36f1 100644 --- a/infra/templates/README.md.jinja2 +++ b/infra/templates/README.md.jinja2 @@ -23,7 +23,7 @@ Please see our [documentation](https://docs.feast.dev/) for more information abo ## πŸ“ Architecture ![](docs/assets/feast-marchitecture.png) -The above architecture is the minimal Feast deployment. Want to run the full Feast on GCP/AWS? Click [here](https://docs.feast.dev/how-to-guides/feast-gcp-aws). +The above architecture is the minimal Feast deployment. Want to run the full Feast on Snowflake/GCP/AWS? Click [here](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws). ## 🐣 Getting Started From 886f07a00eef18eefcbb7db8d3c97d8331cef3d9 Mon Sep 17 00:00:00 2001 From: Tsotne Tabidze Date: Fri, 4 Feb 2022 16:58:33 -0800 Subject: [PATCH 85/85] Update CHANGELOG.md, pom.xml & Helm Charts for v0.18 (#2283) * Update CHANGELOG.md and pom.xml for v0.18 Signed-off-by: Tsotne Tabidze * update helm chart versions Signed-off-by: Tsotne Tabidze --- CHANGELOG.md | 78 +++++++++++++++++++ infra/charts/feast-python-server/Chart.yaml | 2 +- infra/charts/feast-python-server/README.md | 2 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 6 +- .../feast/charts/feature-server/Chart.yaml | 4 +- .../feast/charts/feature-server/README.md | 4 +- .../feast/charts/feature-server/values.yaml | 2 +- .../charts/transformation-service/Chart.yaml | 4 +- .../charts/transformation-service/README.md | 4 +- .../charts/transformation-service/values.yaml | 2 +- infra/charts/feast/requirements.yaml | 4 +- java/pom.xml | 2 +- 13 files changed, 97 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53514c5ad0d..bc0368cca25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog +## [v0.18.0](https://github.com/feast-dev/feast/tree/v0.18.0) (2022-02-05) + +[Full Changelog](https://github.com/feast-dev/feast/compare/v0.17.0...v0.18.0) + +**Implemented enhancements:** + +- Tutorial on validation of historical features [\#2277](https://github.com/feast-dev/feast/pull/2277) ([pyalex](https://github.com/pyalex)) +- Feast plan clean up [\#2256](https://github.com/feast-dev/feast/pull/2256) ([felixwang9817](https://github.com/felixwang9817)) +- Return `UNIX\_TIMESTAMP` as Python `datetime` [\#2244](https://github.com/feast-dev/feast/pull/2244) ([judahrand](https://github.com/judahrand)) +- Validating historical features against reference dataset with "great expectations" profiler [\#2243](https://github.com/feast-dev/feast/pull/2243) ([pyalex](https://github.com/pyalex)) +- Implement feature\_store.\_apply\_diffs to handle registry and infra diffs [\#2238](https://github.com/feast-dev/feast/pull/2238) ([felixwang9817](https://github.com/felixwang9817)) +- Compare Python objects instead of proto objects [\#2227](https://github.com/feast-dev/feast/pull/2227) ([felixwang9817](https://github.com/felixwang9817)) +- Modify feature\_store.plan to produce an InfraDiff [\#2211](https://github.com/feast-dev/feast/pull/2211) ([felixwang9817](https://github.com/felixwang9817)) +- Implement diff\_infra\_protos method for feast plan [\#2204](https://github.com/feast-dev/feast/pull/2204) ([felixwang9817](https://github.com/felixwang9817)) +- Persisting results of historical retrieval [\#2197](https://github.com/feast-dev/feast/pull/2197) ([pyalex](https://github.com/pyalex)) +- Merge feast-snowflake plugin into main repo with documentation [\#2193](https://github.com/feast-dev/feast/pull/2193) ([sfc-gh-madkins](https://github.com/sfc-gh-madkins)) +- Add InfraDiff class for feast plan [\#2190](https://github.com/feast-dev/feast/pull/2190) ([felixwang9817](https://github.com/felixwang9817)) +- Use FeatureViewProjection instead of FeatureView in ODFV [\#2186](https://github.com/feast-dev/feast/pull/2186) ([judahrand](https://github.com/judahrand)) + +**Fixed bugs:** + +- Set `created\_timestamp` and `last\_updated\_timestamp` fields [\#2266](https://github.com/feast-dev/feast/pull/2266) ([judahrand](https://github.com/judahrand)) +- Use `datetime.utcnow\(\)` to avoid timezone issues [\#2265](https://github.com/feast-dev/feast/pull/2265) ([judahrand](https://github.com/judahrand)) +- Fix Redis key serialization in java feature server [\#2264](https://github.com/feast-dev/feast/pull/2264) ([pyalex](https://github.com/pyalex)) +- modify registry.db s3 object initialization to work in S3 subdirectory with Java Feast Server [\#2259](https://github.com/feast-dev/feast/pull/2259) ([NalinGHub](https://github.com/NalinGHub)) +- Add snowflake environment variables to allow testing on snowflake infra [\#2258](https://github.com/feast-dev/feast/pull/2258) ([sfc-gh-madkins](https://github.com/sfc-gh-madkins)) +- Correct inconsistent dependency [\#2255](https://github.com/feast-dev/feast/pull/2255) ([judahrand](https://github.com/judahrand)) +- Fix for historical field mappings [\#2252](https://github.com/feast-dev/feast/pull/2252) ([michelle-rascati-sp](https://github.com/michelle-rascati-sp)) +- Add backticks to left\_table\_query\_string [\#2250](https://github.com/feast-dev/feast/pull/2250) ([dmille](https://github.com/dmille)) +- Fix inference of BigQuery ARRAY types. [\#2245](https://github.com/feast-dev/feast/pull/2245) ([judahrand](https://github.com/judahrand)) +- Fix Redshift data creator [\#2242](https://github.com/feast-dev/feast/pull/2242) ([felixwang9817](https://github.com/felixwang9817)) +- Delete entity key from Redis only when all attached feature views are gone [\#2240](https://github.com/feast-dev/feast/pull/2240) ([pyalex](https://github.com/pyalex)) +- Tests for transformation service integration in java feature server [\#2236](https://github.com/feast-dev/feast/pull/2236) ([pyalex](https://github.com/pyalex)) +- Feature server helm chart produces invalid YAML [\#2234](https://github.com/feast-dev/feast/pull/2234) ([pyalex](https://github.com/pyalex)) +- Docker build fails for java feature server [\#2230](https://github.com/feast-dev/feast/pull/2230) ([pyalex](https://github.com/pyalex)) +- Fix ValueType.UNIX\_TIMESTAMP conversions [\#2219](https://github.com/feast-dev/feast/pull/2219) ([judahrand](https://github.com/judahrand)) +- Add on demand feature views deletion [\#2203](https://github.com/feast-dev/feast/pull/2203) ([corentinmarek](https://github.com/corentinmarek)) +- Compare only specs in integration tests [\#2200](https://github.com/feast-dev/feast/pull/2200) ([felixwang9817](https://github.com/felixwang9817)) +- Bump log4j-core from 2.17.0 to 2.17.1 in /java [\#2189](https://github.com/feast-dev/feast/pull/2189) ([dependabot[bot]](https://github.com/apps/dependabot)) +- Support multiple application properties files \(incl from classpath\) [\#2187](https://github.com/feast-dev/feast/pull/2187) ([pyalex](https://github.com/pyalex)) +- Avoid requesting features from OnlineStore twice [\#2185](https://github.com/feast-dev/feast/pull/2185) ([judahrand](https://github.com/judahrand)) +- Speed up Datastore deletes by batch deletions with multithreading [\#2182](https://github.com/feast-dev/feast/pull/2182) ([ptoman-pa](https://github.com/ptoman-pa)) +- Fixes large payload runtime exception in Datastore \(issue 1633\) [\#2181](https://github.com/feast-dev/feast/pull/2181) ([ptoman-pa](https://github.com/ptoman-pa)) + +**Merged pull requests:** + +- Add link to community plugin for Spark offline store [\#2279](https://github.com/feast-dev/feast/pull/2279) ([adchia](https://github.com/adchia)) +- Fix broken links on documentation [\#2278](https://github.com/feast-dev/feast/pull/2278) ([adchia](https://github.com/adchia)) +- Publish alternative python package with FEAST\_USAGE=False by default [\#2275](https://github.com/feast-dev/feast/pull/2275) ([pyalex](https://github.com/pyalex)) +- Unify all helm charts versions [\#2274](https://github.com/feast-dev/feast/pull/2274) ([pyalex](https://github.com/pyalex)) +- Fix / update helm chart workflows to push the feast python server [\#2273](https://github.com/feast-dev/feast/pull/2273) ([adchia](https://github.com/adchia)) +- Update Feast Serving documentation with ways to run and debug locally [\#2272](https://github.com/feast-dev/feast/pull/2272) ([adchia](https://github.com/adchia)) +- Fix Snowflake docs [\#2270](https://github.com/feast-dev/feast/pull/2270) ([felixwang9817](https://github.com/felixwang9817)) +- Update local-feature-server.md [\#2269](https://github.com/feast-dev/feast/pull/2269) ([tsotnet](https://github.com/tsotnet)) +- Update docs to include Snowflake/DQM and removing unused docs from old versions of Feast [\#2268](https://github.com/feast-dev/feast/pull/2268) ([adchia](https://github.com/adchia)) +- Graduate Python feature server [\#2263](https://github.com/feast-dev/feast/pull/2263) ([felixwang9817](https://github.com/felixwang9817)) +- Fix benchmark tests at HEAD by passing in Snowflake secrets [\#2262](https://github.com/feast-dev/feast/pull/2262) ([adchia](https://github.com/adchia)) +- Refactor `pa\_to\_feast\_value\_type` [\#2246](https://github.com/feast-dev/feast/pull/2246) ([judahrand](https://github.com/judahrand)) +- Allow using pandas.StringDtype to support on-demand features with STRING type [\#2229](https://github.com/feast-dev/feast/pull/2229) ([pyalex](https://github.com/pyalex)) +- Bump jackson-databind from 2.10.1 to 2.10.5.1 in /java/common [\#2228](https://github.com/feast-dev/feast/pull/2228) ([dependabot[bot]](https://github.com/apps/dependabot)) +- Split apply total parse repo [\#2226](https://github.com/feast-dev/feast/pull/2226) ([mickey-liu](https://github.com/mickey-liu)) +- Publish renamed java packages to maven central \(via Sonatype\) [\#2225](https://github.com/feast-dev/feast/pull/2225) ([pyalex](https://github.com/pyalex)) +- Make online store nullable [\#2224](https://github.com/feast-dev/feast/pull/2224) ([mirayyuce](https://github.com/mirayyuce)) +- Optimize `\_populate\_result\_rows\_from\_feature\_view` [\#2223](https://github.com/feast-dev/feast/pull/2223) ([judahrand](https://github.com/judahrand)) +- Update to newer `redis-py` [\#2221](https://github.com/feast-dev/feast/pull/2221) ([judahrand](https://github.com/judahrand)) +- Adding a local feature server test [\#2217](https://github.com/feast-dev/feast/pull/2217) ([adchia](https://github.com/adchia)) +- replace GetOnlineFeaturesResponse with GetOnlineFeaturesResponseV2 in… [\#2214](https://github.com/feast-dev/feast/pull/2214) ([tsotnet](https://github.com/tsotnet)) +- Updates to click==8.\* [\#2210](https://github.com/feast-dev/feast/pull/2210) ([diogommartins](https://github.com/diogommartins)) +- Bump protobuf-java from 3.12.2 to 3.16.1 in /java [\#2208](https://github.com/feast-dev/feast/pull/2208) ([dependabot[bot]](https://github.com/apps/dependabot)) +- Add default priority for bug reports [\#2207](https://github.com/feast-dev/feast/pull/2207) ([adchia](https://github.com/adchia)) +- Modify issue templates to automatically attach labels [\#2205](https://github.com/feast-dev/feast/pull/2205) ([adchia](https://github.com/adchia)) +- Python FeatureServer optimization [\#2202](https://github.com/feast-dev/feast/pull/2202) ([judahrand](https://github.com/judahrand)) +- Refactor all importer logic to belong in feast.importer [\#2199](https://github.com/feast-dev/feast/pull/2199) ([felixwang9817](https://github.com/felixwang9817)) +- Refactor `OnlineResponse.to\_dict\(\)` [\#2196](https://github.com/feast-dev/feast/pull/2196) ([judahrand](https://github.com/judahrand)) +- \[Java feature server\] Converge ServingService API to make Python and Java feature servers consistent [\#2166](https://github.com/feast-dev/feast/pull/2166) ([pyalex](https://github.com/pyalex)) +- Add a unit test for the tag\_proto\_objects method [\#2163](https://github.com/feast-dev/feast/pull/2163) ([achals](https://github.com/achals)) + + ## [v0.17.0](https://github.com/feast-dev/feast/tree/v0.17.0) (2021-12-31) [Full Changelog](https://github.com/feast-dev/feast/compare/v0.16.1...v0.17.0) diff --git a/infra/charts/feast-python-server/Chart.yaml b/infra/charts/feast-python-server/Chart.yaml index c25b36b5d74..d7e9d7a1471 100644 --- a/infra/charts/feast-python-server/Chart.yaml +++ b/infra/charts/feast-python-server/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: feast-python-server description: Feast Feature Server in Python type: application -version: 0.17.0 +version: 0.18.0 keywords: - machine learning - big data diff --git a/infra/charts/feast-python-server/README.md b/infra/charts/feast-python-server/README.md index b7db8444729..45b5f73b3cb 100644 --- a/infra/charts/feast-python-server/README.md +++ b/infra/charts/feast-python-server/README.md @@ -1,6 +1,6 @@ # feast-python-server -![Version: 0.17.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) +![Version: 0.18.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) Feast Feature Server in Python diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index 88ff3238479..c0a3849e59e 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 description: Feature store for machine learning name: feast -version: 0.17.0 +version: 0.18.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index 25b207f22dd..40e67b0857c 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -10,7 +10,7 @@ This repo contains Helm charts for Feast components that are being installed on ## Chart: Feast -Feature store for machine learning Current chart version is `0.17.0` +Feature store for machine learning Current chart version is `0.18.0` ## Installation @@ -57,8 +57,8 @@ For more details, please see: https://docs.feast.dev/how-to-guides/running-feast | Repository | Name | Version | |------------|------|---------| | https://charts.helm.sh/stable | redis | 10.5.6 | -| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.17.0 | -| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.17.0 | +| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.18.0 | +| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.18.0 | ## Values diff --git a/infra/charts/feast/charts/feature-server/Chart.yaml b/infra/charts/feast/charts/feature-server/Chart.yaml index fa7809d9714..006acfc4b45 100644 --- a/infra/charts/feast/charts/feature-server/Chart.yaml +++ b/infra/charts/feast/charts/feature-server/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Feast Feature Server: Online feature serving service for Feast" name: feature-server -version: 0.17.0 -appVersion: v0.17.0 +version: 0.18.0 +appVersion: v0.18.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/feature-server/README.md b/infra/charts/feast/charts/feature-server/README.md index 5621237cf67..23ed4102b85 100644 --- a/infra/charts/feast/charts/feature-server/README.md +++ b/infra/charts/feast/charts/feature-server/README.md @@ -1,6 +1,6 @@ # feature-server -![Version: 0.17.0](https://img.shields.io/badge/Version-0.17.0-informational?style=flat-square) ![AppVersion: v0.17.0](https://img.shields.io/badge/AppVersion-v0.17.0-informational?style=flat-square) +![Version: 0.18.0](https://img.shields.io/badge/Version-0.18.0-informational?style=flat-square) ![AppVersion: v0.18.0](https://img.shields.io/badge/AppVersion-v0.18.0-informational?style=flat-square) Feast Feature Server: Online feature serving service for Feast @@ -17,7 +17,7 @@ Feast Feature Server: Online feature serving service for Feast | envOverrides | object | `{}` | Extra environment variables to set | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"feastdev/feature-server-java"` | Docker image for Feature Server repository | -| image.tag | string | `"0.17.0"` | Image tag | +| image.tag | string | `"0.18.0"` | Image tag | | ingress.grpc.annotations | object | `{}` | Extra annotations for the ingress | | ingress.grpc.auth.enabled | bool | `false` | Flag to enable auth | | ingress.grpc.class | string | `"nginx"` | Which ingress controller to use | diff --git a/infra/charts/feast/charts/feature-server/values.yaml b/infra/charts/feast/charts/feature-server/values.yaml index 92de49763c7..a6cf0f41b7c 100644 --- a/infra/charts/feast/charts/feature-server/values.yaml +++ b/infra/charts/feast/charts/feature-server/values.yaml @@ -5,7 +5,7 @@ image: # image.repository -- Docker image for Feature Server repository repository: feastdev/feature-server-java # image.tag -- Image tag - tag: 0.17.0 + tag: 0.18.0 # image.pullPolicy -- Image pull policy pullPolicy: IfNotPresent diff --git a/infra/charts/feast/charts/transformation-service/Chart.yaml b/infra/charts/feast/charts/transformation-service/Chart.yaml index 4af54a79c43..ea4f9ccfe5f 100644 --- a/infra/charts/feast/charts/transformation-service/Chart.yaml +++ b/infra/charts/feast/charts/transformation-service/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Transformation service: to compute on-demand features" name: transformation-service -version: 0.17.0 -appVersion: v0.17.0 +version: 0.18.0 +appVersion: v0.18.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/transformation-service/README.md b/infra/charts/feast/charts/transformation-service/README.md index 0ea413d8e06..f101f911b45 100644 --- a/infra/charts/feast/charts/transformation-service/README.md +++ b/infra/charts/feast/charts/transformation-service/README.md @@ -1,6 +1,6 @@ # transformation-service -![Version: 0.17.0](https://img.shields.io/badge/Version-0.17.0-informational?style=flat-square) ![AppVersion: v0.17.0](https://img.shields.io/badge/AppVersion-v0.17.0-informational?style=flat-square) +![Version: 0.18.0](https://img.shields.io/badge/Version-0.18.0-informational?style=flat-square) ![AppVersion: v0.18.0](https://img.shields.io/badge/AppVersion-v0.18.0-informational?style=flat-square) Transformation service: to compute on-demand features @@ -13,7 +13,7 @@ Transformation service: to compute on-demand features | envOverrides | object | `{}` | Extra environment variables to set | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"feastdev/feature-transformation-server"` | Docker image for Transformation Server repository | -| image.tag | string | `"0.17.0"` | Image tag | +| image.tag | string | `"0.18.0"` | Image tag | | nodeSelector | object | `{}` | Node labels for pod assignment | | podLabels | object | `{}` | Labels to be added to Feast Serving pods | | replicaCount | int | `1` | Number of pods that will be created | diff --git a/infra/charts/feast/charts/transformation-service/values.yaml b/infra/charts/feast/charts/transformation-service/values.yaml index 7babb5f6b62..e758a535963 100644 --- a/infra/charts/feast/charts/transformation-service/values.yaml +++ b/infra/charts/feast/charts/transformation-service/values.yaml @@ -5,7 +5,7 @@ image: # image.repository -- Docker image for Transformation Server repository repository: feastdev/feature-transformation-server # image.tag -- Image tag - tag: 0.17.0 + tag: 0.18.0 # image.pullPolicy -- Image pull policy pullPolicy: IfNotPresent diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index 795c352d1de..60eaf7f67de 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,12 +1,12 @@ dependencies: - name: feature-server alias: feature-server - version: 0.17.0 + version: 0.18.0 condition: feature-server.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: transformation-service alias: transformation-service - version: 0.17.0 + version: 0.18.0 condition: transformation-service.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: redis diff --git a/java/pom.xml b/java/pom.xml index 0431f881a5d..afbd6298055 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -38,7 +38,7 @@ - 0.17.1-SNAPSHOT + 0.18.0 https://github.com/feast-dev/feast UTF-8