From 3d1f16a06c14d8b2bbcfd525873f23a76f21ced0 Mon Sep 17 00:00:00 2001 From: Bhargav Dodla <13788369+EXPEbdodla@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:02:23 -0700 Subject: [PATCH 01/37] fix: Update Docker images to use official sources for integration tests (#260) * fix: Update Docker images to use official sources and improve workflow triggers * docs: Update README to include instructions for running integration tests --------- Co-authored-by: Bhargav Dodla --- go/integration_tests/valkey/docker-compose.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 go/integration_tests/valkey/docker-compose.yaml diff --git a/go/integration_tests/valkey/docker-compose.yaml b/go/integration_tests/valkey/docker-compose.yaml new file mode 100644 index 00000000000..a296bd5da7e --- /dev/null +++ b/go/integration_tests/valkey/docker-compose.yaml @@ -0,0 +1,7 @@ +services: + valkey: + image: valkey/valkey:latest + container_name: valkey + ports: + - "6390:6379" + \ No newline at end of file From 587a54eba976cf7dd749751e7e6848906a313f55 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 23 Jun 2025 13:45:52 -0700 Subject: [PATCH 02/37] =?UTF-8?q?fix:=20Only=20add=20sort=20key=20filter?= =?UTF-8?q?=20models=20to=20list=20if=20they=20meet=20the=20conditi?= =?UTF-8?q?=E2=80=A6=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Only add sort key filter models to list if they meet the conditions. Split read range int tests into its own file. * add pauses in int test to allow for apply/materialize to compelete * set int tests to not run in parallel --- Makefile | 8 +- .../cassandraonlinestore_integration_test.go | 23 +-- ...grpc_server_read_range_integration_test.go | 176 ++++++++++++++++++ 3 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 go/internal/feast/server/grpc_server_read_range_integration_test.go diff --git a/Makefile b/Makefile index f6f1a11b05c..94c48796bdf 100644 --- a/Makefile +++ b/Makefile @@ -449,11 +449,11 @@ test-go: compile-protos-go compile-protos-python install-feast-ci-locally CGO_ENABLED=1 go test -tags=unit -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html test-go-integration: compile-protos-go compile-protos-python install-feast-ci-locally - docker compose -f go/internal/feast/integration_tests/valkey/docker-compose.yaml up -d - docker compose -f go/internal/feast/integration_tests/scylladb/docker-compose.yaml up -d + docker compose -f go/integration_tests/valkey/docker-compose.yaml up -d + docker compose -f go/integration_tests/scylladb/docker-compose.yaml up -d go test -p 1 -tags=integration ./go/internal/... - docker compose -f go/internal/feast/integration_tests/valkey/docker-compose.yaml down - docker compose -f go/internal/feast/integration_tests/scylladb/docker-compose.yaml down + docker compose -f go/integration_tests/valkey/docker-compose.yaml down + docker compose -f go/integration_tests/scylladb/docker-compose.yaml down format-go: gofmt -s -w go/ diff --git a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go index 2da2ca9c895..162394c7b03 100644 --- a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go +++ b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go @@ -30,7 +30,7 @@ func TestMain(m *testing.M) { os.Exit(1) } - onlineStore, err = getCassandraOnlineStore(dir) + onlineStore, err = getCassandraOnlineStore() if err != nil { fmt.Printf("Failed to create CassandraOnlineStore: %v\n", err) os.Exit(1) @@ -49,7 +49,8 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } -func getCassandraOnlineStore(dir string) (*CassandraOnlineStore, error) { +func getCassandraOnlineStore() (*CassandraOnlineStore, error) { + dir := "../../../integration_tests/scylladb/" config, err := loadRepoConfig(dir) if err != nil { fmt.Printf("Failed to load repo config: %v\n", err) @@ -87,8 +88,8 @@ func TestCassandraOnlineStore_OnlineReadRange_withSingleEntityKey(t *testing.T) "array_byte_val", "array_timestamp_val", "null_array_timestamp_val", "event_timestamp"} sortKeyFilters := []*model.SortKeyFilter{{ SortKeyName: "event_timestamp", - RangeStart: time.Unix(1744769099, 0), - RangeEnd: time.Unix(1744779099, 0), + RangeStart: int64(1744769099919), + RangeEnd: int64(1744779099919), }} groupedRefs := &model.GroupedRangeFeatureRefs{ @@ -103,7 +104,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withSingleEntityKey(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 1, time.Unix(1744769099, 0), time.Unix(1744779099, 0)) + verifyResponseData(t, data, 1, int64(1744769099919), int64(1744779099919)) } func TestCassandraOnlineStore_OnlineReadRange_withMultipleEntityKeys(t *testing.T) { @@ -150,7 +151,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withMultipleEntityKeys(t *testing. data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3, time.Unix(1744769099, 0), time.Unix(17447690990, 0)) + verifyResponseData(t, data, 3, int64(1744769099919), int64(1744769099919*10)) } func TestCassandraOnlineStore_OnlineReadRange_withReverseSortOrder(t *testing.T) { @@ -199,7 +200,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withReverseSortOrder(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3, time.Unix(1744769099, 0), time.Unix(17447690990, 0)) + verifyResponseData(t, data, 3, int64(1744769099919), int64(1744769099919*10)) } func TestCassandraOnlineStore_OnlineReadRange_withNoSortKeyFilters(t *testing.T) { @@ -243,7 +244,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withNoSortKeyFilters(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3, time.Unix(0, 0), time.Unix(17447690990, 0)) + verifyResponseData(t, data, 3, int64(0), int64(1744769099919*10)) } func assertValueType(t *testing.T, actualValue interface{}, expectedType string) { @@ -251,7 +252,7 @@ func assertValueType(t *testing.T, actualValue interface{}, expectedType string) assert.Equal(t, expectedType, fmt.Sprintf("%T", actualValue.(*types.Value).GetVal()), expectedType) } -func verifyResponseData(t *testing.T, data [][]RangeFeatureData, numEntityKeys int, start time.Time, end time.Time) { +func verifyResponseData(t *testing.T, data [][]RangeFeatureData, numEntityKeys int, start int64, end int64) { assert.Equal(t, numEntityKeys, len(data)) for i := 0; i < numEntityKeys; i++ { @@ -406,8 +407,8 @@ func verifyResponseData(t *testing.T, data [][]RangeFeatureData, numEntityKeys i assert.NotNil(t, data[i][32].Values[0]) assert.IsType(t, time.Time{}, data[i][32].Values[0]) for _, timestamp := range data[i][32].Values { - assert.GreaterOrEqual(t, timestamp.(time.Time).Unix(), start.Unix(), "Timestamp should be greater than or equal to %v", start) - assert.LessOrEqual(t, timestamp.(time.Time).Unix(), end.Unix(), "Timestamp should be less than or equal to %v", end) + assert.GreaterOrEqual(t, timestamp.(time.Time).UnixMilli(), start, "Timestamp should be greater than or equal to %d", start) + assert.LessOrEqual(t, timestamp.(time.Time).UnixMilli(), end, "Timestamp should be less than or equal to %d", end) } } } diff --git a/go/internal/feast/server/grpc_server_read_range_integration_test.go b/go/internal/feast/server/grpc_server_read_range_integration_test.go new file mode 100644 index 00000000000..fb9f65da2c3 --- /dev/null +++ b/go/internal/feast/server/grpc_server_read_range_integration_test.go @@ -0,0 +1,176 @@ +//go:build integration + +package server + +import ( + "context" + "fmt" + "github.com/feast-dev/feast/go/internal/test" + "github.com/feast-dev/feast/go/protos/feast/serving" + "github.com/feast-dev/feast/go/protos/feast/types" + "github.com/stretchr/testify/assert" + "os" + "strings" + "testing" +) + +var client serving.ServingServiceClient +var ctx context.Context + +func TestMain(m *testing.M) { + dir := "../../../integration_tests/scylladb/" + err := test.SetupInitializedRepo(dir) + if err != nil { + fmt.Printf("Failed to set up test environment: %v\n", err) + os.Exit(1) + } + + ctx = context.Background() + var closer func() + + client, closer = getClient(ctx, "", dir, "") + + // Run the tests + exitCode := m.Run() + + // Clean up the test environment + test.CleanUpInitializedRepo(dir) + closer() + + // Exit with the appropriate code + if exitCode != 0 { + fmt.Printf("CassandraOnlineStore Int Tests failed with exit code %d\n", exitCode) + } + os.Exit(exitCode) +} + +func TestGetOnlineFeaturesRange(t *testing.T) { + entities := make(map[string]*types.RepeatedValue) + + entities["index_id"] = &types.RepeatedValue{ + Val: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: 1}}, + {Val: &types.Value_Int64Val{Int64Val: 2}}, + {Val: &types.Value_Int64Val{Int64Val: 3}}, + }, + } + + featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", + "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", + "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", + "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} + + var featureNamesWithFeatureView []string + + for _, featureName := range featureNames { + featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) + } + + request := &serving.GetOnlineFeaturesRangeRequest{ + Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ + Features: &serving.FeatureList{ + Val: featureNamesWithFeatureView, + }, + }, + Entities: entities, + SortKeyFilters: []*serving.SortKeyFilter{ + { + SortKeyName: "event_timestamp", + Query: &serving.SortKeyFilter_Range{ + Range: &serving.SortKeyFilter_RangeQuery{ + RangeStart: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 0}}, + }, + }, + }, + }, + Limit: 10, + } + response, err := client.GetOnlineFeaturesRange(ctx, request) + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, 33, len(response.Results)) + + for i, featureResult := range response.Results { + assert.Equal(t, 3, len(featureResult.Values)) + for _, value := range featureResult.Values { + if i == 0 { + // The first result is the entity key which should only have 1 entry + assert.NotNil(t, value) + assert.Equal(t, 1, len(value.Val), "Entity Key should have 1 value, got %d", len(value.Val)) + } else { + featureName := featureNames[i-1] // The first entry is the entity key + if strings.Contains(featureName, "null") { + // For null features, we expect the value to contain 1 entry with a nil value + assert.NotNil(t, value) + assert.Equal(t, 1, len(value.Val), "Feature %s should have one values, got %d", featureName, len(value.Val)) + assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) + } else { + assert.NotNil(t, value) + assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) + } + } + } + } +} + +func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { + entities := make(map[string]*types.RepeatedValue) + + entities["index_id"] = &types.RepeatedValue{ + Val: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: 1}}, + {Val: &types.Value_Int64Val{Int64Val: 2}}, + {Val: &types.Value_Int64Val{Int64Val: 3}}, + }, + } + + featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", + "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", + "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", + "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} + + var featureNamesWithFeatureView []string + + for _, featureName := range featureNames { + featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) + } + + request := &serving.GetOnlineFeaturesRangeRequest{ + Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ + Features: &serving.FeatureList{ + Val: featureNamesWithFeatureView, + }, + }, + Entities: entities, + SortKeyFilters: []*serving.SortKeyFilter{}, + Limit: 10, + } + response, err := client.GetOnlineFeaturesRange(ctx, request) + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, 33, len(response.Results)) + + for i, featureResult := range response.Results { + assert.Equal(t, 3, len(featureResult.Values)) + for _, value := range featureResult.Values { + if i == 0 { + // The first result is the entity key which should only have 1 entry + assert.NotNil(t, value) + assert.Equal(t, 1, len(value.Val), "Entity Key should have 1 value, got %d", len(value.Val)) + } else { + featureName := featureNames[i-1] // The first entry is the entity key + if strings.Contains(featureName, "null") { + // For null features, we expect the value to contain 1 entry with a nil value + assert.NotNil(t, value) + assert.Equal(t, 1, len(value.Val), "Feature %s should have one values, got %d", featureName, len(value.Val)) + assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) + } else { + assert.NotNil(t, value) + assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) + } + } + } + } +} From f335e47146c339f8cbe2de5d07efc95a59b11b1e Mon Sep 17 00:00:00 2001 From: Manisha Sudhir <30449541+Manisha4@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:09:07 -0700 Subject: [PATCH 03/37] fix: Adding Snake Case to Include Metadata Check (#264) * adding snake case to include metadata check * fixing formatting * added trim space to string function --- go/internal/feast/server/http_server.go | 33 +++++++++---------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index b1f7d0a9497..0df1df2a5b3 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -335,28 +335,7 @@ func parseIncludeMetadata(r *http.Request) (bool, error) { return strconv.ParseBool(raw) } -func (s *HttpServer) getVersion(w http.ResponseWriter, r *http.Request) { - span, _ := tracer.StartSpanFromContext(r.Context(), "getVersion", tracer.ResourceName("/get-version")) - defer span.Finish() - - logSpanContext := LogWithSpanContext(span) - - if r.Method != "GET" { - http.NotFound(w, r) - return - } - - versionInfo := version.GetVersionInfo() - w.Header().Set("Content-Type", "application/json") - err := json.NewEncoder(w).Encode(versionInfo) - if err != nil { - logSpanContext.Error().Err(err).Msg("Error encoding version response") - writeJSONError(w, fmt.Errorf("error encoding version response: %+v", err), http.StatusInternalServerError) - return - } -} - -func (s *HttpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { +func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { var err error var featureVectors []*onlineserving.FeatureVector @@ -370,6 +349,11 @@ func (s *HttpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { return } + includeMetadata, err := parseIncludeMetadata(r) + if err != nil { + logSpanContext.Error().Err(err).Msg("Error parsing includeMetadata query parameter") + writeJSONError(w, fmt.Errorf("error parsing includeMetadata query parameter: %w", err), http.StatusBadRequest) + return includeMetadata, err := parseIncludeMetadata(r) if err != nil { logSpanContext.Error().Err(err).Msg("Error parsing includeMetadata query parameter") @@ -549,6 +533,11 @@ func (s *HttpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque return } + includeMetadata, err := parseIncludeMetadata(r) + if err != nil { + logSpanContext.Error().Err(err).Msg("Error parsing includeMetadata query parameter") + writeJSONError(w, fmt.Errorf("error parsing includeMetadata query parameter: %w", err), http.StatusBadRequest) + return includeMetadata, err := parseIncludeMetadata(r) if err != nil { logSpanContext.Error().Err(err).Msg("Error parsing includeMetadata query parameter") From 5d52176fe0a1d70d6d006070a6b397523f514a33 Mon Sep 17 00:00:00 2001 From: piket Date: Thu, 26 Jun 2025 14:54:39 -0700 Subject: [PATCH 04/37] =?UTF-8?q?fix:=20Pack=20and=20unpack=20repeated=20l?= =?UTF-8?q?ist=20values=20into=20and=20out=20of=20arrow=20array=E2=80=A6?= =?UTF-8?q?=20(#263)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Pack and unpack repeated list values into and out of arrow arrays. Restructure integration tests to properly separate concerns * Throw error when requested feature service includes both view types * Re-use CopyProtoValuesToArrowArray instead of duplicating switch logic. --- Makefile | 8 +- .../valkey/docker-compose.yaml | 7 - go/internal/feast/featurestore.go | 51 ++-- .../scylladb/feature_repo/example_repo.py | 7 +- .../scylladb/scylladb_integration_test.go | 253 +++--------------- .../valkey/valkey_integration_test.go | 2 +- .../cassandraonlinestore_integration_test.go | 5 +- ...grpc_server_read_range_integration_test.go | 176 ------------ go/internal/feast/server/grpc_server_test.go | 6 +- go/internal/feast/server/server_test_utils.go | 2 +- go/types/typeconversion.go | 137 +++++++--- go/types/typeconversion_test.go | 24 +- 12 files changed, 193 insertions(+), 485 deletions(-) delete mode 100644 go/integration_tests/valkey/docker-compose.yaml delete mode 100644 go/internal/feast/server/grpc_server_read_range_integration_test.go diff --git a/Makefile b/Makefile index 94c48796bdf..f6f1a11b05c 100644 --- a/Makefile +++ b/Makefile @@ -449,11 +449,11 @@ test-go: compile-protos-go compile-protos-python install-feast-ci-locally CGO_ENABLED=1 go test -tags=unit -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html test-go-integration: compile-protos-go compile-protos-python install-feast-ci-locally - docker compose -f go/integration_tests/valkey/docker-compose.yaml up -d - docker compose -f go/integration_tests/scylladb/docker-compose.yaml up -d + docker compose -f go/internal/feast/integration_tests/valkey/docker-compose.yaml up -d + docker compose -f go/internal/feast/integration_tests/scylladb/docker-compose.yaml up -d go test -p 1 -tags=integration ./go/internal/... - docker compose -f go/integration_tests/valkey/docker-compose.yaml down - docker compose -f go/integration_tests/scylladb/docker-compose.yaml down + docker compose -f go/internal/feast/integration_tests/valkey/docker-compose.yaml down + docker compose -f go/internal/feast/integration_tests/scylladb/docker-compose.yaml down format-go: gofmt -s -w go/ diff --git a/go/integration_tests/valkey/docker-compose.yaml b/go/integration_tests/valkey/docker-compose.yaml deleted file mode 100644 index a296bd5da7e..00000000000 --- a/go/integration_tests/valkey/docker-compose.yaml +++ /dev/null @@ -1,7 +0,0 @@ -services: - valkey: - image: valkey/valkey:latest - container_name: valkey - ports: - - "6390:6379" - \ No newline at end of file diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 2a652ab8d4a..0428fcefcfd 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -186,24 +186,33 @@ func (fs *FeatureStore) GetOnlineFeatures( fullFeatureNames bool) ([]*onlineserving.FeatureVector, error) { var err error var requestedFeatureViews []*onlineserving.FeatureViewAndRefs + var requestedSortedFeatureViews []*onlineserving.SortedFeatureViewAndRefs var requestedOnDemandFeatureViews []*model.OnDemandFeatureView if featureService != nil { - requestedFeatureViews, requestedOnDemandFeatureViews, err = + requestedFeatureViews, requestedSortedFeatureViews, requestedOnDemandFeatureViews, err = onlineserving.GetFeatureViewsToUseByService(featureService, fs.registry, fs.config.Project) if err != nil { return nil, err } } else { - requestedFeatureViews, requestedOnDemandFeatureViews, err = + requestedFeatureViews, requestedSortedFeatureViews, requestedOnDemandFeatureViews, err = onlineserving.GetFeatureViewsToUseByFeatureRefs(featureRefs, fs.registry, fs.config.Project) - if err != nil { - return nil, err + } + if err != nil { + return nil, err + } + + if len(requestedSortedFeatureViews) > 0 { + sfvNames := make([]string, len(requestedSortedFeatureViews)) + for i, sfv := range requestedSortedFeatureViews { + sfvNames[i] = sfv.View.Base.Name } + return nil, fmt.Errorf("GetOnlineFeatures does not support sorted feature views %v", sfvNames) } if len(requestedFeatureViews) == 0 { - return nil, errors.GrpcNotFoundErrorf("no feature views found for the requested features") + return nil, fmt.Errorf("no feature views found for the requested features") } entityColumnMap := make(map[string]*model.Field) @@ -319,20 +328,24 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( var err error var requestedSortedFeatureViews []*onlineserving.SortedFeatureViewAndRefs - + var requestedFeatureViews []*onlineserving.FeatureViewAndRefs if featureService != nil { - requestedSortedFeatureViews, err = - onlineserving.GetSortedFeatureViewsToUseByService(featureService, fs.registry, fs.config.Project) - if err != nil { - return nil, err - } - + requestedFeatureViews, requestedSortedFeatureViews, _, err = + onlineserving.GetFeatureViewsToUseByService(featureService, fs.registry, fs.config.Project) } else { - requestedSortedFeatureViews, err = onlineserving.GetSortedFeatureViewsToUseByFeatureRefs( - featureRefs, fs.registry, fs.config.Project) - if err != nil { - return nil, err + requestedFeatureViews, requestedSortedFeatureViews, _, err = + onlineserving.GetFeatureViewsToUseByFeatureRefs(featureRefs, fs.registry, fs.config.Project) + } + if err != nil { + return nil, err + } + + if len(requestedFeatureViews) > 0 { + fvNames := make([]string, len(requestedFeatureViews)) + for i, fv := range requestedFeatureViews { + fvNames[i] = fv.View.Base.Name } + return nil, fmt.Errorf("GetOnlineFeaturesRange does not support standard feature views %v", fvNames) } if len(requestedSortedFeatureViews) == 0 { @@ -469,6 +482,12 @@ func (fs *FeatureStore) ParseFeatures(kind interface{}) (*Features, error) { return nil, err } return &Features{FeaturesRefs: nil, FeatureService: featureService}, nil + featureServiceRequest := kind.(*serving.GetOnlineFeaturesRangeRequest_FeatureService) + featureService, err := fs.registry.GetFeatureService(fs.config.Project, featureServiceRequest.FeatureService) + if err != nil { + return nil, err + } + return &Features{FeaturesRefs: nil, FeatureService: featureService}, nil default: return nil, errors.GrpcInvalidArgumentErrorf("cannot parse 'kind' of either a Feature Service or list of Features from request") } diff --git a/go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py b/go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py index 06a9c1331bd..7729cd5bb27 100644 --- a/go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py +++ b/go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py @@ -142,10 +142,5 @@ mlpfs_test_all_datatypes_service = FeatureService( name="test_service", - features=[mlpfs_test_all_datatypes_view], -) - -mlpfs_test_all_datatypes_sorted_service = FeatureService( - name="test_sorted_service", - features=[mlpfs_test_all_datatypes_sorted_view], + features=[mlpfs_test_all_datatypes_view, mlpfs_test_all_datatypes_sorted_view], ) diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index d11e1d7d127..62ee6fe30c6 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -17,11 +17,6 @@ import ( "testing" ) -const ( - ALL_SORTED_FEATURE_NAMES = "int_val,long_val,float_val,double_val,byte_val,string_val,timestamp_val,boolean_val,array_int_val,array_long_val,array_float_val,array_double_val,array_byte_val,array_string_val,array_timestamp_val,array_boolean_val,null_int_val,null_long_val,null_float_val,null_double_val,null_byte_val,null_string_val,null_timestamp_val,null_boolean_val,null_array_int_val,null_array_long_val,null_array_float_val,null_array_double_val,null_array_byte_val,null_array_string_val,null_array_timestamp_val,null_array_boolean_val,event_timestamp" - ALL_REGULAR_FEATURE_NAMES = "int_val,long_val,float_val,double_val,byte_val,string_val,timestamp_val,boolean_val,array_int_val,array_long_val,array_float_val,array_double_val,array_byte_val,array_string_val,array_timestamp_val,array_boolean_val,null_int_val,null_long_val,null_float_val,null_double_val,null_byte_val,null_string_val,null_timestamp_val,null_boolean_val,null_array_int_val,null_array_long_val,null_array_float_val,null_array_double_val,null_array_byte_val,null_array_string_val,null_array_timestamp_val,null_array_boolean_val" -) - var client serving.ServingServiceClient var ctx context.Context @@ -40,7 +35,7 @@ func TestMain(m *testing.M) { ctx = context.Background() var closer func() - client, closer = server.GetClient(ctx, dir, "") + client, closer = server.GetClient(ctx, "", dir, "") // Run the tests exitCode := m.Run() @@ -67,164 +62,11 @@ func TestGetOnlineFeaturesRange(t *testing.T) { }, } - featureNames := getAllSortedFeatureNames() - - var featureNamesWithFeatureView []string - - for _, featureName := range featureNames { - featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) - } - - request := &serving.GetOnlineFeaturesRangeRequest{ - Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ - Features: &serving.FeatureList{ - Val: featureNamesWithFeatureView, - }, - }, - Entities: entities, - SortKeyFilters: []*serving.SortKeyFilter{ - { - SortKeyName: "event_timestamp", - Query: &serving.SortKeyFilter_Range{ - Range: &serving.SortKeyFilter_RangeQuery{ - RangeStart: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 0}}, - }, - }, - }, - }, - Limit: 10, - IncludeMetadata: true, - } - response, err := client.GetOnlineFeaturesRange(ctx, request) - assert.NoError(t, err) - assertResponseData(t, response, featureNames, 3, true) -} - -func TestGetOnlineFeaturesRange_withOnlyEqualsFilter(t *testing.T) { - entities := make(map[string]*types.RepeatedValue) - - entities["index_id"] = &types.RepeatedValue{ - Val: []*types.Value{ - {Val: &types.Value_Int64Val{Int64Val: 2}}, - }, - } - - featureNames := getAllSortedFeatureNames() - - var featureNamesWithFeatureView []string - - for _, featureName := range featureNames { - featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) - } - - request := &serving.GetOnlineFeaturesRangeRequest{ - Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ - Features: &serving.FeatureList{ - Val: featureNamesWithFeatureView, - }, - }, - Entities: entities, - SortKeyFilters: []*serving.SortKeyFilter{ - { - SortKeyName: "event_timestamp", - Query: &serving.SortKeyFilter_Equals{ - Equals: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 1744769171}}, - }, - }, - }, - Limit: 10, - IncludeMetadata: true, - } - response, err := client.GetOnlineFeaturesRange(ctx, request) - assert.NoError(t, err) - assert.NotNil(t, response) - assert.Equal(t, 1, len(response.Entities)) - for i, featureResult := range response.Results { - assert.Equal(t, 1, len(featureResult.Values)) - assert.Equal(t, 1, len(featureResult.Statuses)) - assert.Equal(t, 1, len(featureResult.EventTimestamps)) - for j, value := range featureResult.Values { - assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val)) - featureName := featureNames[i] - if strings.Contains(featureName, "null") { - // For null features, we expect the value to contain 1 entry with a nil value - assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) - assert.Equal(t, serving.FieldStatus_NULL_VALUE, featureResult.Statuses[j].Status[0], "Feature %s should have a NULL_VALUE status but was %s", featureName, featureResult.Statuses[j].Status[0]) - } else { - assert.NotNil(t, value.Val[0].Val, "Feature %s should have a non-nil value", featureName) - assert.Equal(t, serving.FieldStatus_PRESENT, featureResult.Statuses[j].Status[0], "Feature %s should have a PRESENT status but was %s", featureName, featureResult.Statuses[j].Status[0]) - } - } - } -} - -func TestGetOnlineFeaturesRange_forNonExistentEntityKey(t *testing.T) { - entities := make(map[string]*types.RepeatedValue) - - entities["index_id"] = &types.RepeatedValue{ - Val: []*types.Value{ - {Val: &types.Value_Int64Val{Int64Val: -1}}, - }, - } - - featureNames := getAllRegularFeatureNames() - - var featureNamesWithFeatureView []string - - for _, featureName := range featureNames { - featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) - } - - request := &serving.GetOnlineFeaturesRangeRequest{ - Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ - Features: &serving.FeatureList{ - Val: featureNamesWithFeatureView, - }, - }, - Entities: entities, - SortKeyFilters: []*serving.SortKeyFilter{ - { - SortKeyName: "event_timestamp", - Query: &serving.SortKeyFilter_Range{ - Range: &serving.SortKeyFilter_RangeQuery{ - RangeStart: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 0}}, - }, - }, - }, - }, - Limit: 10, - IncludeMetadata: true, - } - response, err := client.GetOnlineFeaturesRange(ctx, request) - assert.NoError(t, err) - assert.NotNil(t, response) - assert.Equal(t, 1, len(response.Entities)) - for _, featureResult := range response.Results { - assert.Equal(t, 1, len(featureResult.Values)) - assert.Equal(t, 1, len(featureResult.Statuses)) - assert.Equal(t, 1, len(featureResult.EventTimestamps)) - for j, value := range featureResult.Values { - assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val)) - assert.Nil(t, value.Val[0].Val) - assert.Equal(t, serving.FieldStatus_NOT_FOUND, featureResult.Statuses[j].Status[0]) - } - } -} - -func TestGetOnlineFeaturesRange_includesDuplicatedRequestedFeatures(t *testing.T) { - entities := make(map[string]*types.RepeatedValue) - - entities["index_id"] = &types.RepeatedValue{ - Val: []*types.Value{ - {Val: &types.Value_Int64Val{Int64Val: 1}}, - {Val: &types.Value_Int64Val{Int64Val: 2}}, - {Val: &types.Value_Int64Val{Int64Val: 3}}, - }, - } - - featureNames := []string{"int_val", "int_val"} + featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", + "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", + "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", + "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} var featureNamesWithFeatureView []string @@ -253,7 +95,7 @@ func TestGetOnlineFeaturesRange_includesDuplicatedRequestedFeatures(t *testing.T } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames, 3, false) + assertResponseData(t, response, featureNames) } func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { @@ -267,7 +109,11 @@ func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { }, } - featureNames := getAllRegularFeatureNames() + featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", + "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", + "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", + "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} var featureNamesWithFeatureView []string @@ -287,7 +133,7 @@ func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames, 3, false) + assertResponseData(t, response, featureNames) } func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { @@ -303,7 +149,7 @@ func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { request := &serving.GetOnlineFeaturesRangeRequest{ Kind: &serving.GetOnlineFeaturesRangeRequest_FeatureService{ - FeatureService: "test_sorted_service", + FeatureService: "test_service", }, Entities: entities, SortKeyFilters: []*serving.SortKeyFilter{ @@ -318,11 +164,9 @@ func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { }, Limit: 10, } - response, err := client.GetOnlineFeaturesRange(ctx, request) - assert.NoError(t, err) - - featureNames := getAllSortedFeatureNames() - assertResponseData(t, response, featureNames, 3, false) + _, err := client.GetOnlineFeaturesRange(ctx, request) + require.Error(t, err, "Expected an error due to regular feature view requested for range query") + assert.Equal(t, "rpc error: code = Unknown desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") } func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { @@ -336,7 +180,11 @@ func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { }, } - featureNames := getAllRegularFeatureNames() + featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", + "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", + "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", + "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} var featureNamesWithFeatureView []string @@ -365,54 +213,31 @@ func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { } _, err := client.GetOnlineFeaturesRange(ctx, request) require.Error(t, err, "Expected an error due to regular feature view requested for range query") - assert.Contains(t, err.Error(), "sorted feature view all_dtypes doesn't exist", - "Expected error message for non-existent sorted feature view") + assert.Equal(t, "rpc error: code = Unknown desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") } -func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string, entitiesRequested int, includeMetadata bool) { +func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string) { assert.NotNil(t, response) - assert.Equal(t, 1, len(response.Entities), "Should have 1 list of entity") - indexIdEntity, exists := response.Entities["index_id"] - assert.True(t, exists, "Should have index_id entity") - assert.NotNil(t, indexIdEntity) - assert.Equal(t, entitiesRequested, len(indexIdEntity.Val), "Entity should have %d values", entitiesRequested) - assert.Equal(t, len(featureNames), len(response.Results), "Should have expected number of features") - + assert.Equal(t, len(featureNames)+1, len(response.Results), "Expected %d results, got %d", len(featureNames)+1, len(response.Results)) for i, featureResult := range response.Results { - assert.Equal(t, entitiesRequested, len(featureResult.Values)) - if includeMetadata { - assert.Equal(t, entitiesRequested, len(featureResult.Statuses)) - assert.Equal(t, entitiesRequested, len(featureResult.EventTimestamps), "Feature %s should have %d event timestamps", featureNames[i], entitiesRequested) - } - for j, value := range featureResult.Values { - featureName := featureNames[i] - if strings.Contains(featureName, "null") { - // For null features, we expect the value to contain 1 entry with a nil value + assert.Equal(t, 3, len(featureResult.Values)) + for _, value := range featureResult.Values { + if i == 0 { + // The first result is the entity key which should only have 1 entry assert.NotNil(t, value) - assert.Equal(t, 10, len(value.Val), "Feature %s should have one value, got %d %s", featureName, len(value.Val), value.Val) - assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) + assert.Equal(t, 1, len(value.Val), "Entity Key should have 1 value, got %d", len(value.Val)) } else { - assert.NotNil(t, value) - assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) - } - - if includeMetadata { - for k, _ := range value.Val { - if strings.Contains(featureName, "null") { - assert.Equal(t, serving.FieldStatus_NULL_VALUE, featureResult.Statuses[j].Status[k], "Feature %s should have NULL status", featureName) - } else { - assert.Equal(t, serving.FieldStatus_PRESENT, featureResult.Statuses[j].Status[k], "Feature %s should have PRESENT status", featureName) - } + featureName := featureNames[i-1] // The first entry is the entity key + if strings.Contains(featureName, "null") { + // For null features, we expect the value to contain 1 entry with a nil value + assert.NotNil(t, value) + assert.Equal(t, 1, len(value.Val), "Feature %s should have one values, got %d", featureName, len(value.Val)) + assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) + } else { + assert.NotNil(t, value) + assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) } } } } } - -func getAllSortedFeatureNames() []string { - return strings.Split(ALL_SORTED_FEATURE_NAMES, ",") -} - -func getAllRegularFeatureNames() []string { - return strings.Split(ALL_REGULAR_FEATURE_NAMES, ",") -} diff --git a/go/internal/feast/integration_tests/valkey/valkey_integration_test.go b/go/internal/feast/integration_tests/valkey/valkey_integration_test.go index f7dca18f0fc..bb9478f3a7b 100644 --- a/go/internal/feast/integration_tests/valkey/valkey_integration_test.go +++ b/go/internal/feast/integration_tests/valkey/valkey_integration_test.go @@ -38,7 +38,7 @@ func TestMain(m *testing.M) { ctx = context.Background() var closer func() - client, closer = server.GetClient(ctx, dir, "") + client, closer = server.GetClient(ctx, "", dir, "") // Run the tests exitCode := m.Run() diff --git a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go index 162394c7b03..629b04aa24f 100644 --- a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go +++ b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go @@ -30,7 +30,7 @@ func TestMain(m *testing.M) { os.Exit(1) } - onlineStore, err = getCassandraOnlineStore() + onlineStore, err = getCassandraOnlineStore(dir) if err != nil { fmt.Printf("Failed to create CassandraOnlineStore: %v\n", err) os.Exit(1) @@ -49,8 +49,7 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } -func getCassandraOnlineStore() (*CassandraOnlineStore, error) { - dir := "../../../integration_tests/scylladb/" +func getCassandraOnlineStore(dir string) (*CassandraOnlineStore, error) { config, err := loadRepoConfig(dir) if err != nil { fmt.Printf("Failed to load repo config: %v\n", err) diff --git a/go/internal/feast/server/grpc_server_read_range_integration_test.go b/go/internal/feast/server/grpc_server_read_range_integration_test.go deleted file mode 100644 index fb9f65da2c3..00000000000 --- a/go/internal/feast/server/grpc_server_read_range_integration_test.go +++ /dev/null @@ -1,176 +0,0 @@ -//go:build integration - -package server - -import ( - "context" - "fmt" - "github.com/feast-dev/feast/go/internal/test" - "github.com/feast-dev/feast/go/protos/feast/serving" - "github.com/feast-dev/feast/go/protos/feast/types" - "github.com/stretchr/testify/assert" - "os" - "strings" - "testing" -) - -var client serving.ServingServiceClient -var ctx context.Context - -func TestMain(m *testing.M) { - dir := "../../../integration_tests/scylladb/" - err := test.SetupInitializedRepo(dir) - if err != nil { - fmt.Printf("Failed to set up test environment: %v\n", err) - os.Exit(1) - } - - ctx = context.Background() - var closer func() - - client, closer = getClient(ctx, "", dir, "") - - // Run the tests - exitCode := m.Run() - - // Clean up the test environment - test.CleanUpInitializedRepo(dir) - closer() - - // Exit with the appropriate code - if exitCode != 0 { - fmt.Printf("CassandraOnlineStore Int Tests failed with exit code %d\n", exitCode) - } - os.Exit(exitCode) -} - -func TestGetOnlineFeaturesRange(t *testing.T) { - entities := make(map[string]*types.RepeatedValue) - - entities["index_id"] = &types.RepeatedValue{ - Val: []*types.Value{ - {Val: &types.Value_Int64Val{Int64Val: 1}}, - {Val: &types.Value_Int64Val{Int64Val: 2}}, - {Val: &types.Value_Int64Val{Int64Val: 3}}, - }, - } - - featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", - "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", - "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", - "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", - "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} - - var featureNamesWithFeatureView []string - - for _, featureName := range featureNames { - featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) - } - - request := &serving.GetOnlineFeaturesRangeRequest{ - Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ - Features: &serving.FeatureList{ - Val: featureNamesWithFeatureView, - }, - }, - Entities: entities, - SortKeyFilters: []*serving.SortKeyFilter{ - { - SortKeyName: "event_timestamp", - Query: &serving.SortKeyFilter_Range{ - Range: &serving.SortKeyFilter_RangeQuery{ - RangeStart: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 0}}, - }, - }, - }, - }, - Limit: 10, - } - response, err := client.GetOnlineFeaturesRange(ctx, request) - assert.NoError(t, err) - assert.NotNil(t, response) - assert.Equal(t, 33, len(response.Results)) - - for i, featureResult := range response.Results { - assert.Equal(t, 3, len(featureResult.Values)) - for _, value := range featureResult.Values { - if i == 0 { - // The first result is the entity key which should only have 1 entry - assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val), "Entity Key should have 1 value, got %d", len(value.Val)) - } else { - featureName := featureNames[i-1] // The first entry is the entity key - if strings.Contains(featureName, "null") { - // For null features, we expect the value to contain 1 entry with a nil value - assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val), "Feature %s should have one values, got %d", featureName, len(value.Val)) - assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) - } else { - assert.NotNil(t, value) - assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) - } - } - } - } -} - -func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { - entities := make(map[string]*types.RepeatedValue) - - entities["index_id"] = &types.RepeatedValue{ - Val: []*types.Value{ - {Val: &types.Value_Int64Val{Int64Val: 1}}, - {Val: &types.Value_Int64Val{Int64Val: 2}}, - {Val: &types.Value_Int64Val{Int64Val: 3}}, - }, - } - - featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", - "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", - "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", - "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", - "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} - - var featureNamesWithFeatureView []string - - for _, featureName := range featureNames { - featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) - } - - request := &serving.GetOnlineFeaturesRangeRequest{ - Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ - Features: &serving.FeatureList{ - Val: featureNamesWithFeatureView, - }, - }, - Entities: entities, - SortKeyFilters: []*serving.SortKeyFilter{}, - Limit: 10, - } - response, err := client.GetOnlineFeaturesRange(ctx, request) - assert.NoError(t, err) - assert.NotNil(t, response) - assert.Equal(t, 33, len(response.Results)) - - for i, featureResult := range response.Results { - assert.Equal(t, 3, len(featureResult.Values)) - for _, value := range featureResult.Values { - if i == 0 { - // The first result is the entity key which should only have 1 entry - assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val), "Entity Key should have 1 value, got %d", len(value.Val)) - } else { - featureName := featureNames[i-1] // The first entry is the entity key - if strings.Contains(featureName, "null") { - // For null features, we expect the value to contain 1 entry with a nil value - assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val), "Feature %s should have one values, got %d", featureName, len(value.Val)) - assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) - } else { - assert.NotNil(t, value) - assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) - } - } - } - } -} diff --git a/go/internal/feast/server/grpc_server_test.go b/go/internal/feast/server/grpc_server_test.go index 2a06ec53358..e281c5c8248 100644 --- a/go/internal/feast/server/grpc_server_test.go +++ b/go/internal/feast/server/grpc_server_test.go @@ -33,7 +33,7 @@ func TestGetFeastServingInfo(t *testing.T) { require.Nil(t, err) - client, closer := GetClient(ctx, dir, "") + client, closer := GetClient(ctx, "", dir, "") defer closer() response, err := client.GetFeastServingInfo(ctx, &serving.GetFeastServingInfoRequest{}) assert.Nil(t, err) @@ -49,7 +49,7 @@ func TestGetOnlineFeaturesSqlite(t *testing.T) { require.Nil(t, err) - client, closer := GetClient(ctx, dir, "") + client, closer := GetClient(ctx, "", dir, "") defer closer() entities := make(map[string]*types.RepeatedValue) entities["driver_id"] = &types.RepeatedValue{ @@ -110,7 +110,7 @@ func TestGetOnlineFeaturesSqliteWithLogging(t *testing.T) { require.Nil(t, err) logPath := t.TempDir() - client, closer := GetClient(ctx, dir, logPath) + client, closer := GetClient(ctx, "file", dir, logPath) defer closer() entities := make(map[string]*types.RepeatedValue) entities["driver_id"] = &types.RepeatedValue{ diff --git a/go/internal/feast/server/server_test_utils.go b/go/internal/feast/server/server_test_utils.go index 762da7a9dad..b496b754e7a 100644 --- a/go/internal/feast/server/server_test_utils.go +++ b/go/internal/feast/server/server_test_utils.go @@ -15,7 +15,7 @@ import ( ) // Starts a new grpc server, registers the serving service and returns a client. -func GetClient(ctx context.Context, basePath string, logPath string) (serving.ServingServiceClient, func()) { +func GetClient(ctx context.Context, offlineStoreType string, basePath string, logPath string) (serving.ServingServiceClient, func()) { buffer := 1024 * 1024 listener := bufconn.Listen(buffer) diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index aef89d07700..67bc15a9915 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -259,18 +259,63 @@ func ArrowListToProtoList(listArr *array.List, inputOffsets []int32) ([]*types.V pos := int(inputOffsets[0]) values := make([]*types.Value, len(offsets)) for idx := 0; idx < len(offsets); idx++ { - if listArr.IsValid(idx) { - value, err := arrowListValuesToProtoValue(listValues, int64(pos), int64(offsets[idx])) - if err != nil { - return nil, fmt.Errorf("error converting arrow list to proto Value: %v", err) + switch listValues.DataType() { + case arrow.PrimitiveTypes.Int32: + vals := make([]int32, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = listValues.(*array.Int32).Value(j) } - values[idx] = value - - } else { - values[idx] = &types.Value{} + values[idx] = &types.Value{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: vals}}} + case arrow.PrimitiveTypes.Int64: + vals := make([]int64, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = listValues.(*array.Int64).Value(j) + } + values[idx] = &types.Value{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: vals}}} + case arrow.PrimitiveTypes.Float32: + vals := make([]float32, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = listValues.(*array.Float32).Value(j) + } + values[idx] = &types.Value{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: vals}}} + case arrow.PrimitiveTypes.Float64: + vals := make([]float64, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = listValues.(*array.Float64).Value(j) + } + values[idx] = &types.Value{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: vals}}} + case arrow.BinaryTypes.Binary: + vals := make([][]byte, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = listValues.(*array.Binary).Value(j) + } + values[idx] = &types.Value{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: vals}}} + case arrow.BinaryTypes.String: + vals := make([]string, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = listValues.(*array.String).Value(j) + } + values[idx] = &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: vals}}} + case arrow.FixedWidthTypes.Boolean: + vals := make([]bool, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = listValues.(*array.Boolean).Value(j) + } + values[idx] = &types.Value{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: vals}}} + case arrow.FixedWidthTypes.Timestamp_s: + vals := make([]int64, int(offsets[idx])-pos) + for j := pos; j < int(offsets[idx]); j++ { + vals[j-pos] = int64(listValues.(*array.Timestamp).Value(j)) + } + values[idx] = &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: vals}}} + default: + return nil, fmt.Errorf("unsupported data type in list: %s", listValues.DataType()) } + + // set the end of current element as start of the next pos = int(offsets[idx]) } + return values, nil } @@ -432,10 +477,55 @@ func ArrowValuesToRepeatedProtoValues(arr arrow.Array) ([]*types.RepeatedValue, arrayList := arr.(*array.List) arrayListValues := arrayList.ListValues() - for i := 0; i < arrayList.Len(); i++ { - if !arrayList.IsValid(i) { - repeatedValues = append(repeatedValues, nil) - continue + values := make([]*types.Value, 0, int(offsets[i])-pos) + + if listOfLists, ok := listValues.(*array.List); ok { + start, end := listArr.ValueOffsets(i) + subOffsets := listOfLists.Offsets()[start : end+1] + var err error + values, err = ArrowListToProtoList(listOfLists, subOffsets) + if err != nil { + return nil, fmt.Errorf("error converting list to proto Value: %v", err) + } + repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) + continue + } + + for j := pos; j < int(offsets[i]); j++ { + if listValues.IsNull(j) { + values = append(values, &types.Value{}) + continue + } + + var protoVal *types.Value + + switch listValues.DataType() { + case arrow.PrimitiveTypes.Int32: + protoVal = &types.Value{Val: &types.Value_Int32Val{Int32Val: listValues.(*array.Int32).Value(j)}} + case arrow.PrimitiveTypes.Int64: + protoVal = &types.Value{Val: &types.Value_Int64Val{Int64Val: listValues.(*array.Int64).Value(j)}} + case arrow.PrimitiveTypes.Float32: + protoVal = &types.Value{Val: &types.Value_FloatVal{FloatVal: listValues.(*array.Float32).Value(j)}} + case arrow.PrimitiveTypes.Float64: + protoVal = &types.Value{Val: &types.Value_DoubleVal{DoubleVal: listValues.(*array.Float64).Value(j)}} + case arrow.BinaryTypes.Binary: + protoVal = &types.Value{Val: &types.Value_BytesVal{BytesVal: listValues.(*array.Binary).Value(j)}} + case arrow.BinaryTypes.String: + protoVal = &types.Value{Val: &types.Value_StringVal{StringVal: listValues.(*array.String).Value(j)}} + case arrow.FixedWidthTypes.Boolean: + protoVal = &types.Value{Val: &types.Value_BoolVal{BoolVal: listValues.(*array.Boolean).Value(j)}} + case arrow.FixedWidthTypes.Timestamp_s: + protoVal = &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: int64(listValues.(*array.Timestamp).Value(j))}} + default: + return nil, fmt.Errorf("unsupported data type in list: %s", listValues.DataType()) + } + + values = append(values, protoVal) + } + + repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) + // set the end of current element as start of the next + pos = int(offsets[i]) } start := int(arrayList.Offsets()[i]) @@ -484,26 +574,9 @@ func RepeatedProtoValuesToArrowArray(repeatedValues []*types.RepeatedValue, allo if err != nil { return nil, err } - - listBuilder := array.NewListBuilder(allocator, valueType) - defer listBuilder.Release() - valueBuilder := listBuilder.ValueBuilder() - - for _, repeatedValue := range repeatedValues { - if repeatedValue == nil { - listBuilder.AppendNull() - continue - } - - if len(repeatedValue.Val) == 0 && repeatedValue.Val == nil { - return nil, fmt.Errorf("represent it as an empty array instead of nil") - } - listBuilder.Append(true) - - err = CopyProtoValuesToArrowArray(valueBuilder, repeatedValue.Val) - if err != nil { - return nil, fmt.Errorf("error copying proto values to arrow array: %v", err) - } + err = CopyProtoValuesToArrowArray(valueBuilder, repeatedValue.Val) + if err != nil { + return nil, fmt.Errorf("error copying proto values to arrow array: %v", err) } return listBuilder.NewArray(), nil diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index f2479d116e4..9634ff5010c 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -192,54 +192,34 @@ var ( { {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{10, 11}}}}}}, {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 21}}}}}}, - {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{}}}}}}, - {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{}}}}}, - {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{10, 11}}}}}}, - {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 30}}}}}}, - nil, - {Val: []*types.Value{}}, - {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 21}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{30, 31}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{}}}}, {}}}, }, { {Val: []*types.Value{{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: []int64{100, 101}}}}}}, {Val: []*types.Value{{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: []int64{200, 201}}}}}}, - {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: []float32{1.1, 1.2}}}}}}, {Val: []*types.Value{{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: []float32{2.1, 2.2}}}}}}, - {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: []float64{1.1, 1.2}}}}}}, {Val: []*types.Value{{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: []float64{2.1, 2.2}}}}}}, - {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{1, 2}, {3, 4}}}}}}}, {Val: []*types.Value{{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{5, 6}, {7, 8}}}}}}}, - {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"row1", "row2"}}}}}}, {Val: []*types.Value{{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"row3", "row4"}}}}}}, - {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{true, false}}}}}}, {Val: []*types.Value{{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{false, true}}}}}}, - {Val: []*types.Value{}}, }, { - {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().Unix()}}}}}}, - {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().Unix() + 3600}}}}}}, - {Val: []*types.Value{}}, - }, - { - {Val: []*types.Value{}}, - {Val: []*types.Value{}}, - {Val: []*types.Value{}}, - {Val: []*types.Value{}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().UnixMilli()}}}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().UnixMilli() + 3600}}}}}}, }, } ) From e9170426a0600236b9bafad056c9f0b4153a19d9 Mon Sep 17 00:00:00 2001 From: Manisha Sudhir <30449541+Manisha4@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:13:19 -0700 Subject: [PATCH 05/37] feat: Adding Changes to Check if FV and SFV have Valid Updates (#254) * Adding update changes * fixing tests * fixing linting * addressing PR comments * fixing unit test * Added in sort order check * using feast object * removing return type from validation * Adding another exception type * Addressing PR comments to add an entity check * fixing tests * Addressing PR comments * fixing test assertion * fixing formatting --- sdk/python/feast/repo_operations.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index eb40d57a76a..d91eb9d9eed 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -371,8 +371,6 @@ def validate_objects_for_apply( current = registry.get_feature_view(obj.name, project_name) # type: ignore[assignment] else: current = None - # TODO: Add more exception types (FeatureServiceNotFoundException, etc.) as more compatibility checks are - # added for more object types. except (SortedFeatureViewNotFoundException, FeatureViewNotFoundException): logger.warning( "'%s' not found in registry; treating as new object.", From 06b64f39c9b7cd60735c4d286a08a4641aab051c Mon Sep 17 00:00:00 2001 From: piket Date: Fri, 27 Jun 2025 11:27:10 -0700 Subject: [PATCH 06/37] fix: Validate requested features exist in view. (#269) * fix: Validate requested features exist in view. * add test cases for invalid features in feature service * reduce time complexity and duplicate checks for feature validation * use if-else blocks --- go/internal/feast/onlineserving/serving.go | 75 +++++++-- .../feast/onlineserving/serving_test.go | 152 ++++++++++++++++++ 2 files changed, 214 insertions(+), 13 deletions(-) diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 90a4cb1e5dd..f2852671f58 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -205,6 +205,18 @@ func addFeaturesToValidationMap( } } +func addFeaturesToValidationMap( + viewName string, + fvFeatures []*model.Field, + validationMap map[string]map[string]bool) { + if _, ok := validationMap[viewName]; !ok { + validationMap[viewName] = make(map[string]bool) + for _, field := range fvFeatures { + validationMap[viewName][field.Name] = true + } + } +} + /* Return @@ -227,22 +239,55 @@ func GetFeatureViewsToUseByFeatureRefs( odFvToFeatures := make(map[string][]string) odFvToProjectWithFeatures := make(map[string]*model.OnDemandFeatureView) - for _, vf := range viewFeatures { - featureViewName := vf.ViewName - requestedFeatureNames := vf.Features + viewToFeaturesValidationMap := make(map[string]map[string]bool) + invalidFeatures := make([]string, 0) + for _, featureRef := range features { + featureViewName, featureName, err := ParseFeatureReference(featureRef) + if err != nil { + return nil, nil, nil, err + } + if fv, err := registry.GetFeatureView(projectName, featureViewName); err == nil { + addFeaturesToValidationMap(fv.Base.Name, fv.Base.Features, viewToFeaturesValidationMap) + if !viewToFeaturesValidationMap[fv.Base.Name][featureName] { + invalidFeatures = append(invalidFeatures, featureRef) + } else { + if viewAndRef, ok := viewNameToViewAndRefs[fv.Base.Name]; ok { + viewAndRef.FeatureRefs = addStringIfNotContains(viewAndRef.FeatureRefs, featureName) + } else { + viewNameToViewAndRefs[fv.Base.Name] = &FeatureViewAndRefs{ + View: fv, + FeatureRefs: []string{featureName}, + } + } + } + } else if sortedFv, err := registry.GetSortedFeatureView(projectName, featureViewName); err == nil { + addFeaturesToValidationMap(sortedFv.Base.Name, sortedFv.Base.Features, viewToFeaturesValidationMap) + if !viewToFeaturesValidationMap[sortedFv.Base.Name][featureName] { + invalidFeatures = append(invalidFeatures, featureRef) + } else { - if fv, fvErr := registry.GetFeatureView(projectName, featureViewName); fvErr == nil { - err := validateFeatures( - featureViewName, - requestedFeatureNames, - fv.Base.Features) - if err != nil { - return nil, nil, err + if viewAndRef, ok := viewNameToSortedViewAndRefs[sortedFv.Base.Name]; ok { + viewAndRef.FeatureRefs = addStringIfNotContains(viewAndRef.FeatureRefs, featureName) + } else { + viewNameToSortedViewAndRefs[sortedFv.Base.Name] = &SortedFeatureViewAndRefs{ + View: sortedFv, + FeatureRefs: []string{featureName}, + } + } } + } else if odfv, err := registry.GetOnDemandFeatureView(projectName, featureViewName); err == nil { + addFeaturesToValidationMap(odfv.Base.Name, odfv.Base.Features, viewToFeaturesValidationMap) + if !viewToFeaturesValidationMap[odfv.Base.Name][featureName] { + invalidFeatures = append(invalidFeatures, featureRef) + } else { - viewNameToViewAndRefs[fv.Base.Name] = &FeatureViewAndRefs{ - View: fv, - FeatureRefs: requestedFeatureNames, + if _, ok := odFvToFeatures[odfv.Base.Name]; !ok { + odFvToFeatures[odfv.Base.Name] = []string{featureName} + } else { + odFvToFeatures[odfv.Base.Name] = append( + odFvToFeatures[odfv.Base.Name], featureName) + } + odFvToProjectWithFeatures[odfv.Base.Name] = odfv } } else { odfv, odfvErr := registry.GetOnDemandFeatureView(projectName, featureViewName) @@ -265,6 +310,10 @@ func GetFeatureViewsToUseByFeatureRefs( } } + if len(invalidFeatures) > 0 { + return nil, nil, nil, fmt.Errorf("requested features are not valid: %s", strings.Join(invalidFeatures, ", ")) + } + odFvsToUse := make([]*model.OnDemandFeatureView, 0) for odFvName, featureNames := range odFvToFeatures { projectedOdFv, err := odFvToProjectWithFeatures[odFvName].ProjectWithFeatures(featureNames) diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index 675c6ce79c8..76c053de609 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -571,6 +571,158 @@ func TestGetSortedFeatureViewsToUseByFeatureRefs_ReturnsErrorWithInvalidFeatures assert.Contains(t, sfvErr.Error(), "featInvalid does not exist in feature view sortedViewA") } +func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidFeatures(t *testing.T) { + projectName := "test_project" + testRegistry, err := createRegistry(projectName) + assert.NoError(t, err) + + featASpec := test.CreateFeature("featA", types.ValueType_INT32) + featBSpec := test.CreateFeature("featB", types.ValueType_INT32) + featCSpec := test.CreateFeature("featC", types.ValueType_INT32) + featDSpec := test.CreateFeature("featD", types.ValueType_INT32) + featESpec := test.CreateFeature("featE", types.ValueType_FLOAT) + onDemandFeature1 := test.CreateFeature("featF", types.ValueType_FLOAT) + onDemandFeature2 := test.CreateFeature("featG", types.ValueType_FLOAT) + featSSpec := test.CreateFeature("featS", types.ValueType_FLOAT) + sortKeyA := test.CreateSortKeyProto("featS", core.SortOrder_DESC, types.ValueType_FLOAT) + + entities := []*core.Entity{test.CreateEntityProto("entity", types.ValueType_INT32, "entity")} + viewA := test.CreateFeatureViewProto("viewA", entities, featASpec, featBSpec) + viewB := test.CreateFeatureViewProto("viewB", entities, featCSpec, featDSpec) + viewC := test.CreateFeatureViewProto("viewC", entities, featESpec) + viewS := test.CreateSortedFeatureViewProto("viewS", entities, []*core.SortKey{sortKeyA}, featSSpec) + onDemandView := test.CreateOnDemandFeatureViewProto( + "odfv", + map[string][]*core.FeatureSpecV2{"viewB": {featCSpec}, "viewC": {featESpec}}, + onDemandFeature1, onDemandFeature2) + + featInvalidSpec := test.CreateFeature("featInvalid", types.ValueType_INT32) + fs := test.CreateFeatureService("service", map[string][]*core.FeatureSpecV2{ + "viewA": {featASpec, featBSpec}, + "viewB": {featCSpec, featInvalidSpec}, + "odfv": {onDemandFeature2}, + "viewS": {featSSpec}, + }) + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) + + _, _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) + assert.EqualError(t, invalidFeaturesErr, "the projection for viewB cannot be applied because it contains featInvalid which the FeatureView doesn't have") +} + +func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidOnDemandFeatures(t *testing.T) { + projectName := "test_project" + testRegistry, err := createRegistry(projectName) + assert.NoError(t, err) + + featASpec := test.CreateFeature("featA", types.ValueType_INT32) + featBSpec := test.CreateFeature("featB", types.ValueType_INT32) + featCSpec := test.CreateFeature("featC", types.ValueType_INT32) + featDSpec := test.CreateFeature("featD", types.ValueType_INT32) + featESpec := test.CreateFeature("featE", types.ValueType_FLOAT) + onDemandFeature1 := test.CreateFeature("featF", types.ValueType_FLOAT) + onDemandFeature2 := test.CreateFeature("featG", types.ValueType_FLOAT) + featSSpec := test.CreateFeature("featS", types.ValueType_FLOAT) + sortKeyA := test.CreateSortKeyProto("featS", core.SortOrder_DESC, types.ValueType_FLOAT) + + entities := []*core.Entity{test.CreateEntityProto("entity", types.ValueType_INT32, "entity")} + viewA := test.CreateFeatureViewProto("viewA", entities, featASpec, featBSpec) + viewB := test.CreateFeatureViewProto("viewB", entities, featCSpec, featDSpec) + viewC := test.CreateFeatureViewProto("viewC", entities, featESpec) + viewS := test.CreateSortedFeatureViewProto("viewS", entities, []*core.SortKey{sortKeyA}, featSSpec) + onDemandView := test.CreateOnDemandFeatureViewProto( + "odfv", + map[string][]*core.FeatureSpecV2{"viewB": {featCSpec}, "viewC": {featESpec}}, + onDemandFeature1, onDemandFeature2) + + featInvalidSpec := test.CreateFeature("featInvalid", types.ValueType_INT32) + fs := test.CreateFeatureService("service", map[string][]*core.FeatureSpecV2{ + "viewA": {featASpec, featBSpec}, + "viewB": {featCSpec}, + "odfv": {onDemandFeature2, featInvalidSpec}, + "viewS": {featSSpec}, + }) + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) + + _, _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) + assert.EqualError(t, invalidFeaturesErr, "the projection for odfv cannot be applied because it contains featInvalid which the FeatureView doesn't have") +} + +func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidSortedFeatures(t *testing.T) { + projectName := "test_project" + testRegistry, err := createRegistry(projectName) + assert.NoError(t, err) + + featASpec := test.CreateFeature("featA", types.ValueType_INT32) + featBSpec := test.CreateFeature("featB", types.ValueType_INT32) + featCSpec := test.CreateFeature("featC", types.ValueType_INT32) + featDSpec := test.CreateFeature("featD", types.ValueType_INT32) + featESpec := test.CreateFeature("featE", types.ValueType_FLOAT) + onDemandFeature1 := test.CreateFeature("featF", types.ValueType_FLOAT) + onDemandFeature2 := test.CreateFeature("featG", types.ValueType_FLOAT) + featSSpec := test.CreateFeature("featS", types.ValueType_FLOAT) + sortKeyA := test.CreateSortKeyProto("featS", core.SortOrder_DESC, types.ValueType_FLOAT) + + entities := []*core.Entity{test.CreateEntityProto("entity", types.ValueType_INT32, "entity")} + viewA := test.CreateFeatureViewProto("viewA", entities, featASpec, featBSpec) + viewB := test.CreateFeatureViewProto("viewB", entities, featCSpec, featDSpec) + viewC := test.CreateFeatureViewProto("viewC", entities, featESpec) + viewS := test.CreateSortedFeatureViewProto("viewS", entities, []*core.SortKey{sortKeyA}, featSSpec) + onDemandView := test.CreateOnDemandFeatureViewProto( + "odfv", + map[string][]*core.FeatureSpecV2{"viewB": {featCSpec}, "viewC": {featESpec}}, + onDemandFeature1, onDemandFeature2) + + featInvalidSpec := test.CreateFeature("featInvalid", types.ValueType_INT32) + fs := test.CreateFeatureService("service", map[string][]*core.FeatureSpecV2{ + "viewA": {featASpec, featBSpec}, + "viewB": {featCSpec}, + "odfv": {onDemandFeature2}, + "viewS": {featSSpec, featInvalidSpec}, + }) + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) + + _, _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) + assert.EqualError(t, invalidFeaturesErr, "the projection for viewS cannot be applied because it contains featInvalid which the FeatureView doesn't have") +} + +func TestGetFeatureViewsToUseByFeatureRefs_returnsErrorWithInvalidFeatures(t *testing.T) { + projectName := "test_project" + testRegistry, err := createRegistry(projectName) + assert.NoError(t, err) + + featASpec := test.CreateFeature("featA", types.ValueType_INT32) + featBSpec := test.CreateFeature("featB", types.ValueType_INT32) + featCSpec := test.CreateFeature("featC", types.ValueType_INT32) + featDSpec := test.CreateFeature("featD", types.ValueType_INT32) + featESpec := test.CreateFeature("featE", types.ValueType_FLOAT) + onDemandFeature1 := test.CreateFeature("featF", types.ValueType_FLOAT) + onDemandFeature2 := test.CreateFeature("featG", types.ValueType_FLOAT) + featSSpec := test.CreateFeature("featS", types.ValueType_FLOAT) + sortKeyA := test.CreateSortKeyProto("featS", core.SortOrder_DESC, types.ValueType_FLOAT) + + entities := []*core.Entity{test.CreateEntityProto("entity", types.ValueType_INT32, "entity")} + viewA := test.CreateFeatureViewProto("viewA", entities, featASpec, featBSpec) + viewB := test.CreateFeatureViewProto("viewB", entities, featCSpec, featDSpec) + viewC := test.CreateFeatureViewProto("viewC", entities, featESpec) + viewS := test.CreateSortedFeatureViewProto("viewS", entities, []*core.SortKey{sortKeyA}, featSSpec) + onDemandView := test.CreateOnDemandFeatureViewProto( + "odfv", + map[string][]*core.FeatureSpecV2{"viewB": {featCSpec}, "viewC": {featESpec}}, + onDemandFeature1, onDemandFeature2) + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) + + _, _, _, fvErr := GetFeatureViewsToUseByFeatureRefs( + []string{ + "viewA:featA", + "viewA:featB", + "viewB:featInvalid", + "odfv:odFeatInvalid", + "viewS:sortedFeatInvalid", + }, + testRegistry, projectName) + assert.EqualError(t, fvErr, "requested features are not valid: viewB:featInvalid, odfv:odFeatInvalid, viewS:sortedFeatInvalid") +} + func TestValidateSortKeyFilters_ValidFilters(t *testing.T) { sortKey1 := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) sortKey2 := test.CreateSortKeyProto("price", core.SortOrder_ASC, types.ValueType_DOUBLE) From c13e99d62b23ff0352bf6619232cefca6609aa8e Mon Sep 17 00:00:00 2001 From: omirandadev <136642003+omirandadev@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:39:45 -0500 Subject: [PATCH 07/37] feat: Separate entities from features in Range Query response (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: seperate entities into their own field in range query response * feat: seperate entities from features in http range query response * change range query response name back to results instead of features * fix http server unit test * add debugging logs * modify getOnlineFeaturesRange logic * fix grpc range query code and integration tests * fix: Javadoc errors (#270) * update the function description to fix javdoc errors. * fix: Formatting --------- Co-authored-by: vbhagwat * fix: dont add entities to feature vectors * fix: Formatting javadocs (#271) * update the function description to fix javdoc errors. * fix: Formatting * formatting * fix: formatting * fix linting errors * fix params --------- Co-authored-by: vbhagwat * fix: Formatting (#272) * update the function description to fix javdoc errors. * fix: Formatting * formatting * fix: formatting * fix linting errors * fix params * fix javadoc error --------- Co-authored-by: vbhagwat * fix: Formatting (#273) * update the function description to fix javdoc errors. * fix: Formatting * formatting * fix: formatting * fix linting errors * fix params * fix javadoc error * Update FeastClient.java --------- Co-authored-by: vbhagwat * feat: Add getOnlineFeature and getOnlineFeaturesRange overloaded methods wi… (#275) * feat: add getOnlineFeature and getOnlineFeaturesRange overloaded methods without project Co-authored-by: vbhagwat * update java client for separate entities in response * Update tests to reflect changes * fix processFeatureVector tests * throw exception when more rows than entity values * change based on pr feedback * pr feedback --------- Co-authored-by: omiranda Co-authored-by: vanitabhagwat <92561664+vanitabhagwat@users.noreply.github.com> Co-authored-by: vbhagwat --- .../scylladb/scylladb_integration_test.go | 28 +++++++++---------- go/internal/feast/server/grpc_server.go | 11 ++++---- go/internal/feast/server/http_server.go | 7 ++--- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index 62ee6fe30c6..6f3810e50e1 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -218,25 +218,25 @@ func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string) { assert.NotNil(t, response) - assert.Equal(t, len(featureNames)+1, len(response.Results), "Expected %d results, got %d", len(featureNames)+1, len(response.Results)) + assert.Equal(t, 1, len(response.Entities), "Should have 1 entity") + indexIdEntity, exists := response.Entities["index_id"] + assert.True(t, exists, "Should have index_id entity") + assert.NotNil(t, indexIdEntity) + assert.Equal(t, 3, len(indexIdEntity.Val), "Entity should have 3 values") + assert.Equal(t, len(featureNames), len(response.Results), "Should have expected number of features") + for i, featureResult := range response.Results { assert.Equal(t, 3, len(featureResult.Values)) for _, value := range featureResult.Values { - if i == 0 { - // The first result is the entity key which should only have 1 entry + featureName := featureNames[i] + if strings.Contains(featureName, "null") { + // For null features, we expect the value to contain 1 entry with a nil value assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val), "Entity Key should have 1 value, got %d", len(value.Val)) + assert.Equal(t, 1, len(value.Val), "Feature %s should have one value, got %d", featureName, len(value.Val)) + assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) } else { - featureName := featureNames[i-1] // The first entry is the entity key - if strings.Contains(featureName, "null") { - // For null features, we expect the value to contain 1 entry with a nil value - assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val), "Feature %s should have one values, got %d", featureName, len(value.Val)) - assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) - } else { - assert.NotNil(t, value) - assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) - } + assert.NotNil(t, value) + assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) } } } diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index 11e2c498613..b7a7b1c9b58 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -3,6 +3,8 @@ package server import ( "context" "fmt" + "google.golang.org/grpc/reflection" + "github.com/feast-dev/feast/go/internal/feast" "github.com/feast-dev/feast/go/internal/feast/errors" "github.com/feast-dev/feast/go/internal/feast/server/logging" @@ -13,7 +15,6 @@ import ( "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/health" - "google.golang.org/grpc/reflection" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" grpcPrometheus "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus" @@ -178,7 +179,7 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r rangeValues, err := types.ArrowValuesToRepeatedProtoValues(vector.RangeValues) if err != nil { logSpanContext.Error().Err(err).Msgf("Error converting feature '%s' from Arrow to Proto", vector.Name) - return nil, errors.GrpcFromError(err) + return nil, err } featureVector := &serving.GetOnlineFeaturesRangeResponse_RangeFeatureVector{ @@ -189,8 +190,8 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r rangeStatuses := make([]*serving.RepeatedFieldStatus, len(rangeValues)) for j := range rangeValues { statusValues := make([]serving.FieldStatus, len(vector.RangeStatuses[j])) - for k, fieldStatus := range vector.RangeStatuses[j] { - statusValues[k] = fieldStatus + for k, status := range vector.RangeStatuses[j] { + statusValues[k] = status } rangeStatuses[j] = &serving.RepeatedFieldStatus{Status: statusValues} } @@ -201,7 +202,7 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r for k, ts := range timestamps { timestampValues[k] = &prototypes.Value{ Val: &prototypes.Value_UnixTimestampVal{ - UnixTimestampVal: types.GetTimestampSeconds(ts), + UnixTimestampVal: types.GetTimestampMillis(ts), }, } } diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 0df1df2a5b3..742be2b3014 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -149,7 +149,7 @@ func processFeatureVectors( for entityName, entityProto := range entitiesProto { entityValues := make([]interface{}, len(entityProto.Val)) for j, val := range entityProto.Val { - entityValues[j] = types.ValueTypeToGoTypeTimestampAsString(val) + entityValues[j] = types.ValueTypeToGoType(val) } entities[entityName] = entityValues } @@ -176,11 +176,10 @@ func processFeatureVectors( rangeForEntity := make([]interface{}, len(repeatedValue.Val)) for k, val := range repeatedValue.Val { - goValue := types.ValueTypeToGoTypeTimestampAsString(val) - if goValue == nil { + if val == nil { rangeForEntity[k] = nil } else { - rangeForEntity[k] = goValue + rangeForEntity[k] = types.ValueTypeToGoType(val) } } simplifiedValues[j] = rangeForEntity From 0e8a8535eb5cc10048ffc1ba5a1e64f8bce68172 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 30 Jun 2025 10:11:23 -0700 Subject: [PATCH 08/37] fix: Keep duplicate requested features in response for range query. (#276) --- .../scylladb/scylladb_integration_test.go | 43 +++++++++++++++++++ go/internal/feast/onlineserving/serving.go | 34 +-------------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index 6f3810e50e1..43bd89a2314 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -98,6 +98,49 @@ func TestGetOnlineFeaturesRange(t *testing.T) { assertResponseData(t, response, featureNames) } +func TestGetOnlineFeaturesRange_includesDuplicatedRequestedFeatures(t *testing.T) { + entities := make(map[string]*types.RepeatedValue) + + entities["index_id"] = &types.RepeatedValue{ + Val: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: 1}}, + {Val: &types.Value_Int64Val{Int64Val: 2}}, + {Val: &types.Value_Int64Val{Int64Val: 3}}, + }, + } + + featureNames := []string{"int_val", "int_val"} + + var featureNamesWithFeatureView []string + + for _, featureName := range featureNames { + featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) + } + + request := &serving.GetOnlineFeaturesRangeRequest{ + Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ + Features: &serving.FeatureList{ + Val: featureNamesWithFeatureView, + }, + }, + Entities: entities, + SortKeyFilters: []*serving.SortKeyFilter{ + { + SortKeyName: "event_timestamp", + Query: &serving.SortKeyFilter_Range{ + Range: &serving.SortKeyFilter_RangeQuery{ + RangeStart: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 0}}, + }, + }, + }, + }, + Limit: 10, + } + response, err := client.GetOnlineFeaturesRange(ctx, request) + assert.NoError(t, err) + assertResponseData(t, response, featureNames) +} + func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { entities := make(map[string]*types.RepeatedValue) diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index f2852671f58..9e09c28a6d9 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -946,36 +946,6 @@ func getEventTimestamp(timestamps []timestamppb.Timestamp, index int) *timestamp return ×tamppb.Timestamp{} } -func buildDeduplicatedFeatureNamesMap(features []string) ([]ViewFeatures, error) { - var result []ViewFeatures - viewIndex := make(map[string]int) - featureSet := make(map[string]map[string]bool) - - for _, featureRef := range features { - featureViewName, featureName, err := ParseFeatureReference(featureRef) - if err != nil { - return nil, err - } - - if idx, exists := viewIndex[featureViewName]; exists { - if !featureSet[featureViewName][featureName] { - result[idx].Features = append(result[idx].Features, featureName) - featureSet[featureViewName][featureName] = true - } - } else { - viewIndex[featureViewName] = len(result) - result = append(result, ViewFeatures{ - ViewName: featureViewName, - Features: []string{featureName}, - }) - featureSet[featureViewName] = make(map[string]bool) - featureSet[featureViewName][featureName] = true - } - } - - return result, nil -} - func KeepOnlyRequestedFeatures[T any]( vectors []T, requestedFeatureRefs []string, @@ -992,7 +962,7 @@ func KeepOnlyRequestedFeatures[T any]( } else if rangeFeatureVector, ok := any(vector).(*RangeFeatureVector); ok { vectorsByName[rangeFeatureVector.Name] = vector } else { - return nil, errors.GrpcInternalErrorf("unsupported vector type: %T", vector) + return nil, fmt.Errorf("unsupported vector type: %T", vector) } } @@ -1029,7 +999,7 @@ func KeepOnlyRequestedFeatures[T any]( rangeFeatureVector.RangeValues.Release() } } else { - return nil, errors.GrpcInternalErrorf("unsupported vector type: %T", vector) + return nil, fmt.Errorf("unsupported vector type: %T", vector) } } From f4272340d3a2113b9c5f3f502115f284bb324f84 Mon Sep 17 00:00:00 2001 From: piket Date: Thu, 3 Jul 2025 08:04:09 -0700 Subject: [PATCH 09/37] fix: Http range timestamp values should return in rfc3339 format (#277) * fix: Http range timestamp values should return in rfc3339 format to match how get features timestamps are returned * use the exact timestamp format arrow marshalling uses --- .../scylladb/scylladb_integration_test.go | 2 +- .../valkey/valkey_integration_test.go | 2 +- go/internal/feast/server/grpc_server_test.go | 6 ++--- go/internal/feast/server/http_server.go | 4 +-- go/internal/feast/server/server_test_utils.go | 2 +- go/types/typeconversion.go | 12 +++------ go/types/typeconversion_test.go | 27 ++++++++++++++++--- 7 files changed, 36 insertions(+), 19 deletions(-) diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index 43bd89a2314..1339b7e48d7 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -35,7 +35,7 @@ func TestMain(m *testing.M) { ctx = context.Background() var closer func() - client, closer = server.GetClient(ctx, "", dir, "") + client, closer = server.GetClient(ctx, dir, "") // Run the tests exitCode := m.Run() diff --git a/go/internal/feast/integration_tests/valkey/valkey_integration_test.go b/go/internal/feast/integration_tests/valkey/valkey_integration_test.go index bb9478f3a7b..f7dca18f0fc 100644 --- a/go/internal/feast/integration_tests/valkey/valkey_integration_test.go +++ b/go/internal/feast/integration_tests/valkey/valkey_integration_test.go @@ -38,7 +38,7 @@ func TestMain(m *testing.M) { ctx = context.Background() var closer func() - client, closer = server.GetClient(ctx, "", dir, "") + client, closer = server.GetClient(ctx, dir, "") // Run the tests exitCode := m.Run() diff --git a/go/internal/feast/server/grpc_server_test.go b/go/internal/feast/server/grpc_server_test.go index e281c5c8248..2a06ec53358 100644 --- a/go/internal/feast/server/grpc_server_test.go +++ b/go/internal/feast/server/grpc_server_test.go @@ -33,7 +33,7 @@ func TestGetFeastServingInfo(t *testing.T) { require.Nil(t, err) - client, closer := GetClient(ctx, "", dir, "") + client, closer := GetClient(ctx, dir, "") defer closer() response, err := client.GetFeastServingInfo(ctx, &serving.GetFeastServingInfoRequest{}) assert.Nil(t, err) @@ -49,7 +49,7 @@ func TestGetOnlineFeaturesSqlite(t *testing.T) { require.Nil(t, err) - client, closer := GetClient(ctx, "", dir, "") + client, closer := GetClient(ctx, dir, "") defer closer() entities := make(map[string]*types.RepeatedValue) entities["driver_id"] = &types.RepeatedValue{ @@ -110,7 +110,7 @@ func TestGetOnlineFeaturesSqliteWithLogging(t *testing.T) { require.Nil(t, err) logPath := t.TempDir() - client, closer := GetClient(ctx, "file", dir, logPath) + client, closer := GetClient(ctx, dir, logPath) defer closer() entities := make(map[string]*types.RepeatedValue) entities["driver_id"] = &types.RepeatedValue{ diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 742be2b3014..a8a9ceb1f9a 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -149,7 +149,7 @@ func processFeatureVectors( for entityName, entityProto := range entitiesProto { entityValues := make([]interface{}, len(entityProto.Val)) for j, val := range entityProto.Val { - entityValues[j] = types.ValueTypeToGoType(val) + entityValues[j] = types.ValueTypeToGoTypeTimestampAsString(val) } entities[entityName] = entityValues } @@ -179,7 +179,7 @@ func processFeatureVectors( if val == nil { rangeForEntity[k] = nil } else { - rangeForEntity[k] = types.ValueTypeToGoType(val) + rangeForEntity[k] = types.ValueTypeToGoTypeTimestampAsString(val) } } simplifiedValues[j] = rangeForEntity diff --git a/go/internal/feast/server/server_test_utils.go b/go/internal/feast/server/server_test_utils.go index b496b754e7a..762da7a9dad 100644 --- a/go/internal/feast/server/server_test_utils.go +++ b/go/internal/feast/server/server_test_utils.go @@ -15,7 +15,7 @@ import ( ) // Starts a new grpc server, registers the serving service and returns a client. -func GetClient(ctx context.Context, offlineStoreType string, basePath string, logPath string) (serving.ServingServiceClient, func()) { +func GetClient(ctx context.Context, basePath string, logPath string) (serving.ServingServiceClient, func()) { buffer := 1024 * 1024 listener := bufconn.Listen(buffer) diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index 67bc15a9915..3115281b36e 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -870,22 +870,18 @@ func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bo return x.DoubleListVal.Val case *types.Value_UnixTimestampVal: if timestampAsString { - return time.Unix(x.UnixTimestampVal, 0).UTC().Format(TimestampFormat) + return time.UnixMilli(x.UnixTimestampVal).UTC().Format(TimestampFormat) } - return time.Unix(x.UnixTimestampVal, 0).UTC() + return x.UnixTimestampVal case *types.Value_UnixTimestampListVal: if timestampAsString { timestamps := make([]string, len(x.UnixTimestampListVal.Val)) for i, ts := range x.UnixTimestampListVal.Val { - timestamps[i] = time.Unix(ts, 0).UTC().Format(TimestampFormat) + timestamps[i] = time.UnixMilli(ts).UTC().Format(TimestampFormat) } return timestamps } - timestamps := make([]time.Time, len(x.UnixTimestampListVal.Val)) - for i, ts := range x.UnixTimestampListVal.Val { - timestamps[i] = time.Unix(ts, 0).UTC() - } - return timestamps + return x.UnixTimestampListVal.Val default: return nil } diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index 9634ff5010c..6cde9320de1 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -380,7 +380,7 @@ func TestValueTypeToGoType(t *testing.T) { {Val: &types.Value_FloatVal{FloatVal: 10.0}}, {Val: &types.Value_DoubleVal{DoubleVal: 10.0}}, {Val: &types.Value_BoolVal{BoolVal: true}}, - {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp.Unix()}}, + {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp}}, {Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"a", "b", "c"}}}}, {Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{1, 2}, {3, 4}}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{1, 2, 3}}}}, @@ -388,7 +388,7 @@ func TestValueTypeToGoType(t *testing.T) { {Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: []float32{7.1, 8.2}}}}, {Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: []float64{9.3, 10.4}}}}, {Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{true, false}}}}, - {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp.Unix(), timestamp.Unix() + 3600}}}}, + {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600}}}}, {Val: &types.Value_NullVal{NullVal: types.Null_NULL}}, nil, {}, @@ -411,7 +411,7 @@ func TestValueTypeToGoType(t *testing.T) { []float32{7.1, 8.2}, []float64{9.3, 10.4}, []bool{true, false}, - []time.Time{timestamp, timestamp.Add(3600 * time.Second)}, + []int64{timestamp, timestamp + 3600}, nil, nil, nil, @@ -453,6 +453,27 @@ func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { } } +func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { + timestamp := time.Now().UnixMilli() + testCases := []*types.Value{ + {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp}}, + {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600}}}}, + } + + expectedTypes := []interface{}{ + time.UnixMilli(timestamp).UTC().Format(TimestampFormat), + []string{ + time.UnixMilli(timestamp).UTC().Format(TimestampFormat), + time.UnixMilli(timestamp + 3600).UTC().Format(TimestampFormat), + }, + } + + for i, testCase := range testCases { + actual := ValueTypeToGoTypeTimestampAsString(testCase) + assert.Equal(t, expectedTypes[i], actual) + } +} + func TestConvertToValueType_String(t *testing.T) { testCases := []struct { input *types.Value From 7e7deee2d7374c6467d1c33695170599ac8311dd Mon Sep 17 00:00:00 2001 From: Manisha Sudhir <30449541+Manisha4@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:30:30 -0700 Subject: [PATCH 10/37] fix: Exception Handling in Updates Feature (#278) * added another exception type * raising exceptions in the http methods instead * calling handle_exception method instead of a raise * Added a TODO comment --- sdk/python/feast/repo_operations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index d91eb9d9eed..eb40d57a76a 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -371,6 +371,8 @@ def validate_objects_for_apply( current = registry.get_feature_view(obj.name, project_name) # type: ignore[assignment] else: current = None + # TODO: Add more exception types (FeatureServiceNotFoundException, etc.) as more compatibility checks are + # added for more object types. except (SortedFeatureViewNotFoundException, FeatureViewNotFoundException): logger.warning( "'%s' not found in registry; treating as new object.", From 646fd3e078d0fedc9f44d2152a520fb93e48cab5 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 7 Jul 2025 15:00:58 -0700 Subject: [PATCH 11/37] fix: Timestamps should be seconds percision. Return null status for found null values. (#279) * fix: Timestamps should be seconds percision. Return null status for found null values. * convert UnixTimestamp sort filters as time.Time * use consistent time for type conversion test * fix materializing timestamps as seconds and java converting time values into epoch seconds * fix linting * fix test * fix test --- .../scylladb/scylladb_integration_test.go | 85 +++++++++++++++++-- go/internal/feast/onlineserving/serving.go | 12 ++- .../cassandraonlinestore_integration_test.go | 18 ++-- go/internal/feast/server/grpc_server.go | 5 +- go/types/typeconversion.go | 12 ++- go/types/typeconversion_test.go | 18 ++-- 6 files changed, 117 insertions(+), 33 deletions(-) diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index 1339b7e48d7..a5a9bb2a984 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -91,11 +91,70 @@ func TestGetOnlineFeaturesRange(t *testing.T) { }, }, }, - Limit: 10, + Limit: 10, + IncludeMetadata: true, + } + response, err := client.GetOnlineFeaturesRange(ctx, request) + assert.NoError(t, err) + assertResponseData(t, response, featureNames, true) +} + +func TestGetOnlineFeaturesRange_forNonExistentEntityKey(t *testing.T) { + entities := make(map[string]*types.RepeatedValue) + + entities["index_id"] = &types.RepeatedValue{ + Val: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: -1}}, + }, + } + + featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", + "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", + "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", + "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} + + var featureNamesWithFeatureView []string + + for _, featureName := range featureNames { + featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) + } + + request := &serving.GetOnlineFeaturesRangeRequest{ + Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ + Features: &serving.FeatureList{ + Val: featureNamesWithFeatureView, + }, + }, + Entities: entities, + SortKeyFilters: []*serving.SortKeyFilter{ + { + SortKeyName: "event_timestamp", + Query: &serving.SortKeyFilter_Range{ + Range: &serving.SortKeyFilter_RangeQuery{ + RangeStart: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 0}}, + }, + }, + }, + }, + Limit: 10, + IncludeMetadata: true, } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames) + assert.NotNil(t, response) + assert.Equal(t, 1, len(response.Entities)) + for _, featureResult := range response.Results { + assert.Equal(t, 1, len(featureResult.Values)) + assert.Equal(t, 1, len(featureResult.Statuses)) + assert.Equal(t, 1, len(featureResult.EventTimestamps)) + for j, value := range featureResult.Values { + assert.NotNil(t, value) + assert.Equal(t, 1, len(value.Val)) + assert.Nil(t, value.Val[0].Val) + assert.Equal(t, serving.FieldStatus_NOT_FOUND, featureResult.Statuses[j].Status[0]) + } + } } func TestGetOnlineFeaturesRange_includesDuplicatedRequestedFeatures(t *testing.T) { @@ -138,7 +197,7 @@ func TestGetOnlineFeaturesRange_includesDuplicatedRequestedFeatures(t *testing.T } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames) + assertResponseData(t, response, featureNames, false) } func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { @@ -176,7 +235,7 @@ func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames) + assertResponseData(t, response, featureNames, false) } func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { @@ -259,7 +318,7 @@ func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { assert.Equal(t, "rpc error: code = Unknown desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") } -func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string) { +func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string, includeMetadata bool) { assert.NotNil(t, response) assert.Equal(t, 1, len(response.Entities), "Should have 1 entity") indexIdEntity, exists := response.Entities["index_id"] @@ -270,7 +329,11 @@ func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeRe for i, featureResult := range response.Results { assert.Equal(t, 3, len(featureResult.Values)) - for _, value := range featureResult.Values { + if includeMetadata { + assert.Equal(t, 3, len(featureResult.Statuses)) + assert.Equal(t, 3, len(featureResult.EventTimestamps), "Feature %s should have 3 event timestamps", featureNames[i]) + } + for j, value := range featureResult.Values { featureName := featureNames[i] if strings.Contains(featureName, "null") { // For null features, we expect the value to contain 1 entry with a nil value @@ -281,6 +344,16 @@ func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeRe assert.NotNil(t, value) assert.Equal(t, 10, len(value.Val), "Feature %s should have 10 values, got %d", featureName, len(value.Val)) } + + if includeMetadata { + for k, _ := range value.Val { + if strings.Contains(featureName, "null") { + assert.Equal(t, serving.FieldStatus_NULL_VALUE, featureResult.Statuses[j].Status[k], "Feature %s should have NULL status", featureName) + } else { + assert.Equal(t, serving.FieldStatus_PRESENT, featureResult.Statuses[j].Status[k], "Feature %s should have PRESENT status", featureName) + } + } + } } } } diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 9e09c28a6d9..cefbe25d042 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -907,8 +907,16 @@ func processFeatureRowData( rangeStatuses := make([]serving.FieldStatus, numValues) rangeTimestamps := make([]*timestamppb.Timestamp, numValues) - if len(featureData.Values) != len(featureData.Statuses) { - return nil, nil, nil, errors.GrpcInternalErrorf("mismatch in number of values and statuses for feature %s in feature view %s", featureData.FeatureName, featureViewName) + for i, val := range featureData.Values { + if val == nil { + rangeValues[i] = nil + if i < len(featureData.Statuses) { + rangeStatuses[i] = featureData.Statuses[i] + } else { + rangeStatuses[i] = serving.FieldStatus_NOT_FOUND + } + rangeTimestamps[i] = ×tamppb.Timestamp{} + continue } for i, val := range featureData.Values { diff --git a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go index 629b04aa24f..2da2ca9c895 100644 --- a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go +++ b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go @@ -87,8 +87,8 @@ func TestCassandraOnlineStore_OnlineReadRange_withSingleEntityKey(t *testing.T) "array_byte_val", "array_timestamp_val", "null_array_timestamp_val", "event_timestamp"} sortKeyFilters := []*model.SortKeyFilter{{ SortKeyName: "event_timestamp", - RangeStart: int64(1744769099919), - RangeEnd: int64(1744779099919), + RangeStart: time.Unix(1744769099, 0), + RangeEnd: time.Unix(1744779099, 0), }} groupedRefs := &model.GroupedRangeFeatureRefs{ @@ -103,7 +103,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withSingleEntityKey(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 1, int64(1744769099919), int64(1744779099919)) + verifyResponseData(t, data, 1, time.Unix(1744769099, 0), time.Unix(1744779099, 0)) } func TestCassandraOnlineStore_OnlineReadRange_withMultipleEntityKeys(t *testing.T) { @@ -150,7 +150,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withMultipleEntityKeys(t *testing. data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3, int64(1744769099919), int64(1744769099919*10)) + verifyResponseData(t, data, 3, time.Unix(1744769099, 0), time.Unix(17447690990, 0)) } func TestCassandraOnlineStore_OnlineReadRange_withReverseSortOrder(t *testing.T) { @@ -199,7 +199,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withReverseSortOrder(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3, int64(1744769099919), int64(1744769099919*10)) + verifyResponseData(t, data, 3, time.Unix(1744769099, 0), time.Unix(17447690990, 0)) } func TestCassandraOnlineStore_OnlineReadRange_withNoSortKeyFilters(t *testing.T) { @@ -243,7 +243,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withNoSortKeyFilters(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3, int64(0), int64(1744769099919*10)) + verifyResponseData(t, data, 3, time.Unix(0, 0), time.Unix(17447690990, 0)) } func assertValueType(t *testing.T, actualValue interface{}, expectedType string) { @@ -251,7 +251,7 @@ func assertValueType(t *testing.T, actualValue interface{}, expectedType string) assert.Equal(t, expectedType, fmt.Sprintf("%T", actualValue.(*types.Value).GetVal()), expectedType) } -func verifyResponseData(t *testing.T, data [][]RangeFeatureData, numEntityKeys int, start int64, end int64) { +func verifyResponseData(t *testing.T, data [][]RangeFeatureData, numEntityKeys int, start time.Time, end time.Time) { assert.Equal(t, numEntityKeys, len(data)) for i := 0; i < numEntityKeys; i++ { @@ -406,8 +406,8 @@ func verifyResponseData(t *testing.T, data [][]RangeFeatureData, numEntityKeys i assert.NotNil(t, data[i][32].Values[0]) assert.IsType(t, time.Time{}, data[i][32].Values[0]) for _, timestamp := range data[i][32].Values { - assert.GreaterOrEqual(t, timestamp.(time.Time).UnixMilli(), start, "Timestamp should be greater than or equal to %d", start) - assert.LessOrEqual(t, timestamp.(time.Time).UnixMilli(), end, "Timestamp should be less than or equal to %d", end) + assert.GreaterOrEqual(t, timestamp.(time.Time).Unix(), start.Unix(), "Timestamp should be greater than or equal to %v", start) + assert.LessOrEqual(t, timestamp.(time.Time).Unix(), end.Unix(), "Timestamp should be less than or equal to %v", end) } } } diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index b7a7b1c9b58..3edf0a8cf88 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -3,8 +3,6 @@ package server import ( "context" "fmt" - "google.golang.org/grpc/reflection" - "github.com/feast-dev/feast/go/internal/feast" "github.com/feast-dev/feast/go/internal/feast/errors" "github.com/feast-dev/feast/go/internal/feast/server/logging" @@ -15,6 +13,7 @@ import ( "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/health" + "google.golang.org/grpc/reflection" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" grpcPrometheus "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus" @@ -202,7 +201,7 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r for k, ts := range timestamps { timestampValues[k] = &prototypes.Value{ Val: &prototypes.Value_UnixTimestampVal{ - UnixTimestampVal: types.GetTimestampMillis(ts), + UnixTimestampVal: types.GetTimestampSeconds(ts), }, } } diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index 3115281b36e..67bc15a9915 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -870,18 +870,22 @@ func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bo return x.DoubleListVal.Val case *types.Value_UnixTimestampVal: if timestampAsString { - return time.UnixMilli(x.UnixTimestampVal).UTC().Format(TimestampFormat) + return time.Unix(x.UnixTimestampVal, 0).UTC().Format(TimestampFormat) } - return x.UnixTimestampVal + return time.Unix(x.UnixTimestampVal, 0).UTC() case *types.Value_UnixTimestampListVal: if timestampAsString { timestamps := make([]string, len(x.UnixTimestampListVal.Val)) for i, ts := range x.UnixTimestampListVal.Val { - timestamps[i] = time.UnixMilli(ts).UTC().Format(TimestampFormat) + timestamps[i] = time.Unix(ts, 0).UTC().Format(TimestampFormat) } return timestamps } - return x.UnixTimestampListVal.Val + timestamps := make([]time.Time, len(x.UnixTimestampListVal.Val)) + for i, ts := range x.UnixTimestampListVal.Val { + timestamps[i] = time.Unix(ts, 0).UTC() + } + return timestamps default: return nil } diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index 6cde9320de1..8f0efb7d0d2 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -218,8 +218,8 @@ var ( {Val: []*types.Value{{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{false, true}}}}}}, }, { - {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().UnixMilli()}}}}}}, - {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().UnixMilli() + 3600}}}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().Unix()}}}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().Unix() + 3600}}}}}}, }, } ) @@ -380,7 +380,7 @@ func TestValueTypeToGoType(t *testing.T) { {Val: &types.Value_FloatVal{FloatVal: 10.0}}, {Val: &types.Value_DoubleVal{DoubleVal: 10.0}}, {Val: &types.Value_BoolVal{BoolVal: true}}, - {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp}}, + {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp.Unix()}}, {Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"a", "b", "c"}}}}, {Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{1, 2}, {3, 4}}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{1, 2, 3}}}}, @@ -388,7 +388,7 @@ func TestValueTypeToGoType(t *testing.T) { {Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: []float32{7.1, 8.2}}}}, {Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: []float64{9.3, 10.4}}}}, {Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{true, false}}}}, - {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600}}}}, + {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp.Unix(), timestamp.Unix() + 3600}}}}, {Val: &types.Value_NullVal{NullVal: types.Null_NULL}}, nil, {}, @@ -411,7 +411,7 @@ func TestValueTypeToGoType(t *testing.T) { []float32{7.1, 8.2}, []float64{9.3, 10.4}, []bool{true, false}, - []int64{timestamp, timestamp + 3600}, + []time.Time{timestamp, timestamp.Add(3600 * time.Second)}, nil, nil, nil, @@ -454,17 +454,17 @@ func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { } func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { - timestamp := time.Now().UnixMilli() + timestamp := int64(1744769099) testCases := []*types.Value{ {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp}}, {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600}}}}, } expectedTypes := []interface{}{ - time.UnixMilli(timestamp).UTC().Format(TimestampFormat), + time.Unix(timestamp, 0).UTC().Format(TimestampFormat), []string{ - time.UnixMilli(timestamp).UTC().Format(TimestampFormat), - time.UnixMilli(timestamp + 3600).UTC().Format(TimestampFormat), + time.Unix(timestamp, 0).UTC().Format(TimestampFormat), + time.Unix(timestamp+3600, 0).UTC().Format(TimestampFormat), }, } From 800091bc9799a7e2d41b2225e9323a87d7c8dbbe Mon Sep 17 00:00:00 2001 From: piket Date: Thu, 10 Jul 2025 11:44:35 -0700 Subject: [PATCH 12/37] fix: Go server errors should be status errors with proper status codes. (#281) * fix: Go server errors should be status errors with proper status codes. FeastClient handles status exceptions as differentiated FeastExceptions. * fix and expand tests * add more test cases --- go/internal/feast/featurestore.go | 12 +++--- .../scylladb/scylladb_integration_test.go | 4 +- go/internal/feast/onlineserving/serving.go | 41 +++++-------------- .../feast/onlineserving/serving_test.go | 37 +++++++++++++++-- go/internal/feast/server/grpc_server.go | 6 +-- go/internal/feast/server/http_server.go | 25 ++++++++++- go/internal/test/go_integration_test_utils.go | 4 +- 7 files changed, 80 insertions(+), 49 deletions(-) diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 0428fcefcfd..18c7e9841c7 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -134,7 +134,7 @@ func sortKeyFilterTypeConversion(sortKeyFilters []*serving.SortKeyFilter, sortKe if filter.GetEquals() != nil { equals, err := types.ConvertToValueType(filter.GetEquals(), sk.ValueType) if err != nil { - return nil, errors.GrpcInvalidArgumentErrorf("error converting sort key filter equals for %s: %v", sk.FieldName, err) + return nil, errors.GrpcInternalErrorf("error converting sort key filter equals for %s: %v", sk.FieldName, err) } newFilters[i] = &serving.SortKeyFilter{ SortKeyName: sk.FieldName, @@ -147,14 +147,14 @@ func sortKeyFilterTypeConversion(sortKeyFilters []*serving.SortKeyFilter, sortKe if filter.GetRange().GetRangeStart() != nil { rangeStart, err = types.ConvertToValueType(filter.GetRange().GetRangeStart(), sk.ValueType) if err != nil { - return nil, errors.GrpcInvalidArgumentErrorf("error converting sort key filter range start for %s: %v", sk.FieldName, err) + return nil, errors.GrpcInternalErrorf("error converting sort key filter range start for %s: %v", sk.FieldName, err) } } var rangeEnd *prototypes.Value if filter.GetRange().GetRangeEnd() != nil { rangeEnd, err = types.ConvertToValueType(filter.GetRange().GetRangeEnd(), sk.ValueType) if err != nil { - return nil, errors.GrpcInvalidArgumentErrorf("error converting sort key filter range end for %s: %v", sk.FieldName, err) + return nil, errors.GrpcInternalErrorf("error converting sort key filter range end for %s: %v", sk.FieldName, err) } } newFilters[i] = &serving.SortKeyFilter{ @@ -208,11 +208,11 @@ func (fs *FeatureStore) GetOnlineFeatures( for i, sfv := range requestedSortedFeatureViews { sfvNames[i] = sfv.View.Base.Name } - return nil, fmt.Errorf("GetOnlineFeatures does not support sorted feature views %v", sfvNames) + return nil, errors.GrpcInvalidArgumentErrorf("GetOnlineFeatures does not support sorted feature views %v", sfvNames) } if len(requestedFeatureViews) == 0 { - return nil, fmt.Errorf("no feature views found for the requested features") + return nil, errors.GrpcNotFoundErrorf("no feature views found for the requested features") } entityColumnMap := make(map[string]*model.Field) @@ -345,7 +345,7 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( for i, fv := range requestedFeatureViews { fvNames[i] = fv.View.Base.Name } - return nil, fmt.Errorf("GetOnlineFeaturesRange does not support standard feature views %v", fvNames) + return nil, errors.GrpcInvalidArgumentErrorf("GetOnlineFeaturesRange does not support standard feature views %v", fvNames) } if len(requestedSortedFeatureViews) == 0 { diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index a5a9bb2a984..67258281b8c 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -268,7 +268,7 @@ func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { } _, err := client.GetOnlineFeaturesRange(ctx, request) require.Error(t, err, "Expected an error due to regular feature view requested for range query") - assert.Equal(t, "rpc error: code = Unknown desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") + assert.Equal(t, "rpc error: code = InvalidArgument desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") } func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { @@ -315,7 +315,7 @@ func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { } _, err := client.GetOnlineFeaturesRange(ctx, request) require.Error(t, err, "Expected an error due to regular feature view requested for range query") - assert.Equal(t, "rpc error: code = Unknown desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") + assert.Equal(t, "rpc error: code = InvalidArgument desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") } func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string, includeMetadata bool) { diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index cefbe25d042..be4361e5132 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -141,8 +141,8 @@ func GetFeatureViewsToUseByService( return nil, nil, err } } else { - log.Error().Errs("any feature view", []error{fvErr, odFvErr}).Msgf("Feature view %s not found", featureViewName) - return nil, nil, errors.GrpcInvalidArgumentErrorf("the provided feature service %s contains a reference to a feature View"+ + log.Error().Errs("any feature view", []error{fvErr, sortedFvErr, odFvErr}).Msgf("Feature view %s not found", featureViewName) + return nil, nil, nil, errors.GrpcInvalidArgumentErrorf("the provided feature service %s contains a reference to a feature View"+ "%s which doesn't exist, please make sure that you have created the feature View"+ "%s and that you have registered it by running \"apply\"", featureService.Name, featureViewName, featureViewName) } @@ -290,28 +290,13 @@ func GetFeatureViewsToUseByFeatureRefs( odFvToProjectWithFeatures[odfv.Base.Name] = odfv } } else { - odfv, odfvErr := registry.GetOnDemandFeatureView(projectName, featureViewName) - - if odfvErr == nil { - err := validateFeatures( - featureViewName, - requestedFeatureNames, - odfv.Base.Features) - if err != nil { - return nil, nil, err - } - - odFvToFeatures[odfv.Base.Name] = requestedFeatureNames - odFvToProjectWithFeatures[odfv.Base.Name] = odfv - } else { - return nil, nil, errors.GrpcInvalidArgumentErrorf("feature view %s doesn't exist, please make sure that you have created the"+ - " feature view %s and that you have registered it by running \"apply\"", featureViewName, featureViewName) - } + return nil, nil, nil, errors.GrpcInvalidArgumentErrorf("feature View %s doesn't exist, please make sure that you have created the"+ + " feature View %s and that you have registered it by running \"apply\"", featureViewName, featureViewName) } } if len(invalidFeatures) > 0 { - return nil, nil, nil, fmt.Errorf("requested features are not valid: %s", strings.Join(invalidFeatures, ", ")) + return nil, nil, nil, errors.GrpcInvalidArgumentErrorf("requested features are not valid: %s", strings.Join(invalidFeatures, ", ")) } odFvsToUse := make([]*model.OnDemandFeatureView, 0) @@ -676,11 +661,6 @@ func ValidateSortKeyFilterOrder(filters []*serving.SortKeyFilter, sortedViews [] return errors.GrpcInvalidArgumentErrorf("specify sort key filter in request for sort key: '%s' with query type equals", sortedView.View.SortKeys[i].FieldName) } - if filter.SortKeyName == lastFilter { - // Once the last filter is reached, we can ignore any further checks - break - } - if filter.GetEquals() == nil { return errors.GrpcInvalidArgumentErrorf("sort key filter for sort key '%s' must have query type equals instead of range", filter.SortKeyName) @@ -919,9 +899,10 @@ func processFeatureRowData( continue } - for i, val := range featureData.Values { - eventTimestamp := getEventTimestamp(featureData.EventTimestamps, i) - fieldStatus := featureData.Statuses[i] + protoVal, err := types.InterfaceToProtoValue(val) + if err != nil { + return nil, nil, nil, errors.GrpcInternalErrorf("error converting value for feature %s: %v", featureData.FeatureName, err) + } if val == nil { rangeValues[i] = nil @@ -970,7 +951,7 @@ func KeepOnlyRequestedFeatures[T any]( } else if rangeFeatureVector, ok := any(vector).(*RangeFeatureVector); ok { vectorsByName[rangeFeatureVector.Name] = vector } else { - return nil, fmt.Errorf("unsupported vector type: %T", vector) + return nil, errors.GrpcInternalErrorf("unsupported vector type: %T", vector) } } @@ -1007,7 +988,7 @@ func KeepOnlyRequestedFeatures[T any]( rangeFeatureVector.RangeValues.Release() } } else { - return nil, fmt.Errorf("unsupported vector type: %T", vector) + return nil, errors.GrpcInternalErrorf("unsupported vector type: %T", vector) } } diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index 76c053de609..5839e4c1cd7 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -606,7 +606,7 @@ func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidFeatures(t *testin testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) _, _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) - assert.EqualError(t, invalidFeaturesErr, "the projection for viewB cannot be applied because it contains featInvalid which the FeatureView doesn't have") + assert.EqualError(t, invalidFeaturesErr, "rpc error: code = InvalidArgument desc = the projection for viewB cannot be applied because it contains featInvalid which the FeatureView doesn't have") } func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidOnDemandFeatures(t *testing.T) { @@ -644,7 +644,7 @@ func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidOnDemandFeatures(t testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) _, _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) - assert.EqualError(t, invalidFeaturesErr, "the projection for odfv cannot be applied because it contains featInvalid which the FeatureView doesn't have") + assert.EqualError(t, invalidFeaturesErr, "rpc error: code = InvalidArgument desc = the projection for odfv cannot be applied because it contains featInvalid which the FeatureView doesn't have") } func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidSortedFeatures(t *testing.T) { @@ -682,7 +682,7 @@ func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidSortedFeatures(t * testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) _, _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) - assert.EqualError(t, invalidFeaturesErr, "the projection for viewS cannot be applied because it contains featInvalid which the FeatureView doesn't have") + assert.EqualError(t, invalidFeaturesErr, "rpc error: code = InvalidArgument desc = the projection for viewS cannot be applied because it contains featInvalid which the FeatureView doesn't have") } func TestGetFeatureViewsToUseByFeatureRefs_returnsErrorWithInvalidFeatures(t *testing.T) { @@ -720,7 +720,7 @@ func TestGetFeatureViewsToUseByFeatureRefs_returnsErrorWithInvalidFeatures(t *te "viewS:sortedFeatInvalid", }, testRegistry, projectName) - assert.EqualError(t, fvErr, "requested features are not valid: viewB:featInvalid, odfv:odFeatInvalid, viewS:sortedFeatInvalid") + assert.EqualError(t, fvErr, "rpc error: code = InvalidArgument desc = requested features are not valid: viewB:featInvalid, odfv:odFeatInvalid, viewS:sortedFeatInvalid") } func TestValidateSortKeyFilters_ValidFilters(t *testing.T) { @@ -863,6 +863,35 @@ func TestValidateSortKeyFilters_EmptyFilters(t *testing.T) { assert.NoError(t, err, "Valid filters should not produce an error") } +func TestValidateSortKeyFilters_EmptyFilters(t *testing.T) { + sortKey1 := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) + sortKey2 := test.CreateSortKeyProto("price", core.SortOrder_ASC, types.ValueType_DOUBLE) + sortKey3 := test.CreateSortKeyProto("name", core.SortOrder_ASC, types.ValueType_STRING) + + entity1 := test.CreateEntityProto("driver", types.ValueType_INT64, "driver") + entity2 := test.CreateEntityProto("customer", types.ValueType_STRING, "customer") + sfv1 := test.CreateSortedFeatureViewModel("sfv1", []*core.Entity{entity1}, + []*core.SortKey{sortKey1, sortKey2}, + test.CreateFeature("f1", types.ValueType_DOUBLE)) + + sfv2 := test.CreateSortedFeatureViewModel("sfv2", []*core.Entity{entity2}, + []*core.SortKey{sortKey3}, + test.CreateFeature("f2", types.ValueType_STRING)) + + sortedViews := []*SortedFeatureViewAndRefs{ + {View: sfv1, FeatureRefs: []string{"f1"}}, + {View: sfv2, FeatureRefs: []string{"f2"}}, + } + + validFilters := make([]*serving.SortKeyFilter, 0) + + err := ValidateSortKeyFilters(validFilters, sortedViews) + assert.NoError(t, err, "Valid filters should not produce an error") + + err = ValidateSortKeyFilters(nil, sortedViews) + assert.NoError(t, err, "Valid filters should not produce an error") +} + func TestValidateSortKeyFilters_NonExistentKey(t *testing.T) { sortKey1 := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) sortKey2 := test.CreateSortKeyProto("price", core.SortOrder_ASC, types.ValueType_DOUBLE) diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index 3edf0a8cf88..11e2c498613 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -178,7 +178,7 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r rangeValues, err := types.ArrowValuesToRepeatedProtoValues(vector.RangeValues) if err != nil { logSpanContext.Error().Err(err).Msgf("Error converting feature '%s' from Arrow to Proto", vector.Name) - return nil, err + return nil, errors.GrpcFromError(err) } featureVector := &serving.GetOnlineFeaturesRangeResponse_RangeFeatureVector{ @@ -189,8 +189,8 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r rangeStatuses := make([]*serving.RepeatedFieldStatus, len(rangeValues)) for j := range rangeValues { statusValues := make([]serving.FieldStatus, len(vector.RangeStatuses[j])) - for k, status := range vector.RangeStatuses[j] { - statusValues[k] = status + for k, fieldStatus := range vector.RangeStatuses[j] { + statusValues[k] = fieldStatus } rangeStatuses[j] = &serving.RepeatedFieldStatus{Status: statusValues} } diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index a8a9ceb1f9a..e068ee0f740 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/feast-dev/feast/go/internal/feast/version" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "net/http" @@ -691,7 +690,29 @@ func recoverMiddleware(next http.Handler) http.Handler { // Log the stack trace logStackTrace() - writeJSONError(w, fmt.Errorf("Internal Server Error: %v", r), http.StatusInternalServerError) + errorType := "Internal Server Error" + errorCode := http.StatusInternalServerError + var errVar error + if err := r.(error); err != nil { + if statusErr, ok := status.FromError(err); ok { + switch statusErr.Code() { + case codes.InvalidArgument: + errorType = "Invalid Argument" + errorCode = http.StatusBadRequest + case codes.NotFound: + errorType = "Not Found" + errorCode = http.StatusNotFound + default: + // For other gRPC errors, we can map them to Internal Server Error + } + errVar = statusErr.Err() + } else { + errVar = err + } + } else { + errVar = fmt.Errorf("%v", r) + } + writeJSONError(w, fmt.Errorf("%s: %v", errorType, errVar), errorCode) } }() next.ServeHTTP(w, r) diff --git a/go/internal/test/go_integration_test_utils.go b/go/internal/test/go_integration_test_utils.go index 307dc164cc2..6e07757a059 100644 --- a/go/internal/test/go_integration_test_utils.go +++ b/go/internal/test/go_integration_test_utils.go @@ -255,7 +255,7 @@ func SetupInitializedRepo(basePath string) error { return err } // Pause to ensure apply completes - time.Sleep(5 * time.Second) + time.Sleep(1 * time.Second) applyCommand.Dir = featureRepoPath out, err := applyCommand.CombinedOutput() if err != nil { @@ -277,7 +277,7 @@ func SetupInitializedRepo(basePath string) error { return err } // Pause to ensure materialization completes - time.Sleep(5 * time.Second) + time.Sleep(1 * time.Second) return nil } From 005f437bd03701e3ac6ff0e35b393b4af158ba42 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 14 Jul 2025 11:37:02 -0700 Subject: [PATCH 13/37] fix: Validation order for multiple missing end keys should pass (#283) * fix: Validation order for multiple missing end keys should pass * fix int test and create test for only an equals filter * add conversions of int32 to higher values for http --- .../scylladb/scylladb_integration_test.go | 83 ++++++++++++++++--- go/internal/feast/onlineserving/serving.go | 5 ++ .../feast/onlineserving/serving_test.go | 29 ------- 3 files changed, 78 insertions(+), 39 deletions(-) diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index 67258281b8c..39791583cd2 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -66,7 +66,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", - "array_byte_val", "array_timestamp_val", "null_array_timestamp_val"} + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val", "event_timestamp"} var featureNamesWithFeatureView []string @@ -96,7 +96,70 @@ func TestGetOnlineFeaturesRange(t *testing.T) { } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames, true) + assertResponseData(t, response, featureNames, 3, true) +} + +func TestGetOnlineFeaturesRange_withOnlyEqualsFilter(t *testing.T) { + entities := make(map[string]*types.RepeatedValue) + + entities["index_id"] = &types.RepeatedValue{ + Val: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: 2}}, + }, + } + + featureNames := []string{"int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", + "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", + "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", + "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", + "array_byte_val", "array_timestamp_val", "null_array_timestamp_val", "event_timestamp"} + + var featureNamesWithFeatureView []string + + for _, featureName := range featureNames { + featureNamesWithFeatureView = append(featureNamesWithFeatureView, "all_dtypes_sorted:"+featureName) + } + + request := &serving.GetOnlineFeaturesRangeRequest{ + Kind: &serving.GetOnlineFeaturesRangeRequest_Features{ + Features: &serving.FeatureList{ + Val: featureNamesWithFeatureView, + }, + }, + Entities: entities, + SortKeyFilters: []*serving.SortKeyFilter{ + { + SortKeyName: "event_timestamp", + Query: &serving.SortKeyFilter_Equals{ + Equals: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 1744769171}}, + }, + }, + }, + Limit: 10, + IncludeMetadata: true, + } + response, err := client.GetOnlineFeaturesRange(ctx, request) + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, 1, len(response.Entities)) + for i, featureResult := range response.Results { + assert.Equal(t, 1, len(featureResult.Values)) + assert.Equal(t, 1, len(featureResult.Statuses)) + assert.Equal(t, 1, len(featureResult.EventTimestamps)) + for j, value := range featureResult.Values { + assert.NotNil(t, value) + assert.Equal(t, 1, len(value.Val)) + featureName := featureNames[i] + if strings.Contains(featureName, "null") { + // For null features, we expect the value to contain 1 entry with a nil value + assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) + assert.Equal(t, serving.FieldStatus_NULL_VALUE, featureResult.Statuses[j].Status[0], "Feature %s should have a NULL_VALUE status but was %s", featureName, featureResult.Statuses[j].Status[0]) + } else { + assert.NotNil(t, value.Val[0].Val, "Feature %s should have a non-nil value", featureName) + assert.Equal(t, serving.FieldStatus_PRESENT, featureResult.Statuses[j].Status[0], "Feature %s should have a PRESENT status but was %s", featureName, featureResult.Statuses[j].Status[0]) + } + } + } } func TestGetOnlineFeaturesRange_forNonExistentEntityKey(t *testing.T) { @@ -197,7 +260,7 @@ func TestGetOnlineFeaturesRange_includesDuplicatedRequestedFeatures(t *testing.T } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames, false) + assertResponseData(t, response, featureNames, 3, false) } func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { @@ -235,7 +298,7 @@ func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assertResponseData(t, response, featureNames, false) + assertResponseData(t, response, featureNames, 3, false) } func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { @@ -318,20 +381,20 @@ func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { assert.Equal(t, "rpc error: code = InvalidArgument desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") } -func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string, includeMetadata bool) { +func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string, entitiesRequested int, includeMetadata bool) { assert.NotNil(t, response) - assert.Equal(t, 1, len(response.Entities), "Should have 1 entity") + assert.Equal(t, 1, len(response.Entities), "Should have 1 list of entity") indexIdEntity, exists := response.Entities["index_id"] assert.True(t, exists, "Should have index_id entity") assert.NotNil(t, indexIdEntity) - assert.Equal(t, 3, len(indexIdEntity.Val), "Entity should have 3 values") + assert.Equal(t, entitiesRequested, len(indexIdEntity.Val), "Entity should have %d values", entitiesRequested) assert.Equal(t, len(featureNames), len(response.Results), "Should have expected number of features") for i, featureResult := range response.Results { - assert.Equal(t, 3, len(featureResult.Values)) + assert.Equal(t, entitiesRequested, len(featureResult.Values)) if includeMetadata { - assert.Equal(t, 3, len(featureResult.Statuses)) - assert.Equal(t, 3, len(featureResult.EventTimestamps), "Feature %s should have 3 event timestamps", featureNames[i]) + assert.Equal(t, entitiesRequested, len(featureResult.Statuses)) + assert.Equal(t, entitiesRequested, len(featureResult.EventTimestamps), "Feature %s should have %d event timestamps", featureNames[i], entitiesRequested) } for j, value := range featureResult.Values { featureName := featureNames[i] diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index be4361e5132..6f270d45877 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -661,6 +661,11 @@ func ValidateSortKeyFilterOrder(filters []*serving.SortKeyFilter, sortedViews [] return errors.GrpcInvalidArgumentErrorf("specify sort key filter in request for sort key: '%s' with query type equals", sortedView.View.SortKeys[i].FieldName) } + if filter.SortKeyName == lastFilter { + // Once the last filter is reached, we can ignore any further checks + break + } + if filter.GetEquals() == nil { return errors.GrpcInvalidArgumentErrorf("sort key filter for sort key '%s' must have query type equals instead of range", filter.SortKeyName) diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index 5839e4c1cd7..fd081357340 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -863,35 +863,6 @@ func TestValidateSortKeyFilters_EmptyFilters(t *testing.T) { assert.NoError(t, err, "Valid filters should not produce an error") } -func TestValidateSortKeyFilters_EmptyFilters(t *testing.T) { - sortKey1 := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) - sortKey2 := test.CreateSortKeyProto("price", core.SortOrder_ASC, types.ValueType_DOUBLE) - sortKey3 := test.CreateSortKeyProto("name", core.SortOrder_ASC, types.ValueType_STRING) - - entity1 := test.CreateEntityProto("driver", types.ValueType_INT64, "driver") - entity2 := test.CreateEntityProto("customer", types.ValueType_STRING, "customer") - sfv1 := test.CreateSortedFeatureViewModel("sfv1", []*core.Entity{entity1}, - []*core.SortKey{sortKey1, sortKey2}, - test.CreateFeature("f1", types.ValueType_DOUBLE)) - - sfv2 := test.CreateSortedFeatureViewModel("sfv2", []*core.Entity{entity2}, - []*core.SortKey{sortKey3}, - test.CreateFeature("f2", types.ValueType_STRING)) - - sortedViews := []*SortedFeatureViewAndRefs{ - {View: sfv1, FeatureRefs: []string{"f1"}}, - {View: sfv2, FeatureRefs: []string{"f2"}}, - } - - validFilters := make([]*serving.SortKeyFilter, 0) - - err := ValidateSortKeyFilters(validFilters, sortedViews) - assert.NoError(t, err, "Valid filters should not produce an error") - - err = ValidateSortKeyFilters(nil, sortedViews) - assert.NoError(t, err, "Valid filters should not produce an error") -} - func TestValidateSortKeyFilters_NonExistentKey(t *testing.T) { sortKey1 := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) sortKey2 := test.CreateSortKeyProto("price", core.SortOrder_ASC, types.ValueType_DOUBLE) From 13b0cf3290ba14592be666086093aeec0bfc06b3 Mon Sep 17 00:00:00 2001 From: Bhargav Dodla <13788369+EXPEbdodla@users.noreply.github.com> Date: Mon, 14 Jul 2025 13:29:08 -0700 Subject: [PATCH 14/37] fix: Handle null values in Arrow list conversion to Proto values (#280) * fix: Handle null values in Arrow list conversion to Proto values * fix: Fixed the nil representation in http * fix: Correct variable name for null check in ArrowListToProtoList function * fix: Null value handling for types.Values * fix: Fixed the nil representation in http * fix: Fixed the issue with NOT_FOUND values representation and repeated null values * fix: Fixed the nil representation * fix: Fixed the nil representation in http * fix: Removed duplicate test * fix: Added status handling for feature values and ensured consistency in value-status mapping * fix: Fixed issue with timestamps in HTTP * fix: Fixed failing tests * fix: Simplified null value handling in type conversion --------- Co-authored-by: Bhargav Dodla --- go/internal/feast/featurestore_test.go | 8 +- .../scylladb/scylladb_integration_test.go | 2 +- go/internal/feast/onlineserving/serving.go | 19 +-- .../feast/onlineserving/serving_test.go | 4 +- go/internal/feast/server/http_server.go | 5 +- go/types/typeconversion.go | 155 ++++++++++-------- go/types/typeconversion_test.go | 41 ++++- 7 files changed, 143 insertions(+), 91 deletions(-) diff --git a/go/internal/feast/featurestore_test.go b/go/internal/feast/featurestore_test.go index 2c01265ec51..cee958dfec0 100644 --- a/go/internal/feast/featurestore_test.go +++ b/go/internal/feast/featurestore_test.go @@ -184,7 +184,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureName: "conv_rate", Values: []interface{}{0.85, 0.87, 0.89}, Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, - EventTimestamps: []timestamppb.Timestamp{ + EventTimestamps: []timestamp.Timestamp{ {Seconds: now.Unix() - 86400*3}, {Seconds: now.Unix() - 86400*2}, {Seconds: now.Unix() - 86400*1}, @@ -195,7 +195,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureName: "acc_rate", Values: []interface{}{0.91, 0.92, 0.94}, Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, - EventTimestamps: []timestamppb.Timestamp{ + EventTimestamps: []timestamp.Timestamp{ {Seconds: now.Unix() - 86400*3}, {Seconds: now.Unix() - 86400*2}, {Seconds: now.Unix() - 86400*1}, @@ -208,7 +208,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureName: "conv_rate", Values: []interface{}{0.78, 0.80}, Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, - EventTimestamps: []timestamppb.Timestamp{ + EventTimestamps: []timestamp.Timestamp{ {Seconds: now.Unix() - 86400*3}, {Seconds: now.Unix() - 86400*1}, }, @@ -218,7 +218,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureName: "acc_rate", Values: []interface{}{0.85, 0.88}, Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, - EventTimestamps: []timestamppb.Timestamp{ + EventTimestamps: []timestamp.Timestamp{ {Seconds: now.Unix() - 86400*3}, {Seconds: now.Unix() - 86400*1}, }, diff --git a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index 39791583cd2..42ebb3ddc10 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -401,7 +401,7 @@ func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeRe if strings.Contains(featureName, "null") { // For null features, we expect the value to contain 1 entry with a nil value assert.NotNil(t, value) - assert.Equal(t, 1, len(value.Val), "Feature %s should have one value, got %d", featureName, len(value.Val)) + assert.Equal(t, 10, len(value.Val), "Feature %s should have one value, got %d %s", featureName, len(value.Val), value.Val) assert.Nil(t, value.Val[0].Val, "Feature %s should have a nil value", featureName) } else { assert.NotNil(t, value) diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 6f270d45877..d3eaa1e2b6a 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -892,22 +892,13 @@ func processFeatureRowData( rangeStatuses := make([]serving.FieldStatus, numValues) rangeTimestamps := make([]*timestamppb.Timestamp, numValues) - for i, val := range featureData.Values { - if val == nil { - rangeValues[i] = nil - if i < len(featureData.Statuses) { - rangeStatuses[i] = featureData.Statuses[i] - } else { - rangeStatuses[i] = serving.FieldStatus_NOT_FOUND - } - rangeTimestamps[i] = ×tamppb.Timestamp{} - continue + if len(featureData.Values) != len(featureData.Statuses) { + return nil, nil, nil, errors.GrpcInternalErrorf("mismatch in number of values and statuses for feature %s in feature view %s", featureData.FeatureName, featureViewName) } - protoVal, err := types.InterfaceToProtoValue(val) - if err != nil { - return nil, nil, nil, errors.GrpcInternalErrorf("error converting value for feature %s: %v", featureData.FeatureName, err) - } + for i, val := range featureData.Values { + eventTimestamp := getEventTimestamp(featureData.EventTimestamps, i) + fieldStatus := featureData.Statuses[i] if val == nil { rangeValues[i] = nil diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index fd081357340..4db5782f264 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -1456,7 +1456,7 @@ func TestTransposeRangeFeatureRowsIntoColumns(t *testing.T) { FeatureName: "f1", Values: []interface{}{42.5, 43.2}, Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, - EventTimestamps: []timestamppb.Timestamp{ + EventTimestamps: []timestamp.Timestamp{ {Seconds: nowTime.Unix()}, {Seconds: yesterdayTime.Unix()}, }, @@ -1468,7 +1468,7 @@ func TestTransposeRangeFeatureRowsIntoColumns(t *testing.T) { FeatureName: "f1", Values: []interface{}{99.9}, Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, - EventTimestamps: []timestamppb.Timestamp{ + EventTimestamps: []timestamp.Timestamp{ {Seconds: nowTime.Unix()}, }, }, diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index e068ee0f740..057df20c6f7 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -175,10 +175,11 @@ func processFeatureVectors( rangeForEntity := make([]interface{}, len(repeatedValue.Val)) for k, val := range repeatedValue.Val { - if val == nil { + goValue := types.ValueTypeToGoTypeTimestampAsString(val) + if goValue == nil { rangeForEntity[k] = nil } else { - rangeForEntity[k] = types.ValueTypeToGoTypeTimestampAsString(val) + rangeForEntity[k] = goValue } } simplifiedValues[j] = rangeForEntity diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index 67bc15a9915..735ecd71d7a 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -474,74 +474,59 @@ func ArrowValuesToRepeatedProtoValues(arr arrow.Array) ([]*types.RepeatedValue, } repeatedValues := make([]*types.RepeatedValue, 0, arr.Len()) - arrayList := arr.(*array.List) - arrayListValues := arrayList.ListValues() - - values := make([]*types.Value, 0, int(offsets[i])-pos) - - if listOfLists, ok := listValues.(*array.List); ok { - start, end := listArr.ValueOffsets(i) - subOffsets := listOfLists.Offsets()[start : end+1] - var err error - values, err = ArrowListToProtoList(listOfLists, subOffsets) - if err != nil { - return nil, fmt.Errorf("error converting list to proto Value: %v", err) - } - repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) - continue + listArray := arr.(*array.List) + listValues := listArray.ListValues() + + for i := 0; i < listArray.Len(); i++ { + if listArray.IsNull(i) { + // Null RepeatedValue + repeatedValues = append(repeatedValues, nil) + continue + } + if listOfLists, ok := listValues.(*array.List); ok { + start, end := listArray.ValueOffsets(i) + subOffsets := listOfLists.Offsets()[start : end+1] + values, err := ArrowListToProtoList(listOfLists, subOffsets) + if err != nil { + return nil, fmt.Errorf("error converting list to proto Value: %v", err) } + repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) + } else { + start := int(listArray.Offsets()[i]) + end := int(listArray.Offsets()[i+1]) - for j := pos; j < int(offsets[i]); j++ { - if listValues.IsNull(j) { - values = append(values, &types.Value{}) - continue - } + values := make([]*types.Value, 0, end-start) + for j := start; j < end; j++ { var protoVal *types.Value - - switch listValues.DataType() { - case arrow.PrimitiveTypes.Int32: - protoVal = &types.Value{Val: &types.Value_Int32Val{Int32Val: listValues.(*array.Int32).Value(j)}} - case arrow.PrimitiveTypes.Int64: - protoVal = &types.Value{Val: &types.Value_Int64Val{Int64Val: listValues.(*array.Int64).Value(j)}} - case arrow.PrimitiveTypes.Float32: - protoVal = &types.Value{Val: &types.Value_FloatVal{FloatVal: listValues.(*array.Float32).Value(j)}} - case arrow.PrimitiveTypes.Float64: - protoVal = &types.Value{Val: &types.Value_DoubleVal{DoubleVal: listValues.(*array.Float64).Value(j)}} - case arrow.BinaryTypes.Binary: - protoVal = &types.Value{Val: &types.Value_BytesVal{BytesVal: listValues.(*array.Binary).Value(j)}} - case arrow.BinaryTypes.String: - protoVal = &types.Value{Val: &types.Value_StringVal{StringVal: listValues.(*array.String).Value(j)}} - case arrow.FixedWidthTypes.Boolean: - protoVal = &types.Value{Val: &types.Value_BoolVal{BoolVal: listValues.(*array.Boolean).Value(j)}} - case arrow.FixedWidthTypes.Timestamp_s: - protoVal = &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: int64(listValues.(*array.Timestamp).Value(j))}} - default: - return nil, fmt.Errorf("unsupported data type in list: %s", listValues.DataType()) + if listValues.IsNull(j) { + protoVal = &types.Value{} + } else { + switch listValues.DataType() { + case arrow.PrimitiveTypes.Int32: + protoVal = &types.Value{Val: &types.Value_Int32Val{Int32Val: listValues.(*array.Int32).Value(j)}} + case arrow.PrimitiveTypes.Int64: + protoVal = &types.Value{Val: &types.Value_Int64Val{Int64Val: listValues.(*array.Int64).Value(j)}} + case arrow.PrimitiveTypes.Float32: + protoVal = &types.Value{Val: &types.Value_FloatVal{FloatVal: listValues.(*array.Float32).Value(j)}} + case arrow.PrimitiveTypes.Float64: + protoVal = &types.Value{Val: &types.Value_DoubleVal{DoubleVal: listValues.(*array.Float64).Value(j)}} + case arrow.BinaryTypes.Binary: + protoVal = &types.Value{Val: &types.Value_BytesVal{BytesVal: listValues.(*array.Binary).Value(j)}} + case arrow.BinaryTypes.String: + protoVal = &types.Value{Val: &types.Value_StringVal{StringVal: listValues.(*array.String).Value(j)}} + case arrow.FixedWidthTypes.Boolean: + protoVal = &types.Value{Val: &types.Value_BoolVal{BoolVal: listValues.(*array.Boolean).Value(j)}} + case arrow.FixedWidthTypes.Timestamp_s: + protoVal = &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: int64(listValues.(*array.Timestamp).Value(j))}} + case arrow.Null: + protoVal = &types.Value{} + default: + return nil, fmt.Errorf("unsupported data type in list: %s", listValues.DataType()) + } } - values = append(values, protoVal) } - - repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) - // set the end of current element as start of the next - pos = int(offsets[i]) - } - - start := int(arrayList.Offsets()[i]) - end := int(arrayList.Offsets()[i+1]) - - if arrayListVals, ok := arrayListValues.(*array.List); ok { - repeatedValue, err := arrowNestedListToRepeatedValues(arrayListVals, start, end) - if err != nil { - return nil, err - } - repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: repeatedValue}) - } else { - values, err := arrowPrimitiveListToRepeatedValues(arrayListValues, start, end) - if err != nil { - return nil, err - } repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) } } @@ -574,9 +559,26 @@ func RepeatedProtoValuesToArrowArray(repeatedValues []*types.RepeatedValue, allo if err != nil { return nil, err } - err = CopyProtoValuesToArrowArray(valueBuilder, repeatedValue.Val) - if err != nil { - return nil, fmt.Errorf("error copying proto values to arrow array: %v", err) + + listBuilder := array.NewListBuilder(allocator, valueType) + defer listBuilder.Release() + valueBuilder := listBuilder.ValueBuilder() + + for _, repeatedValue := range repeatedValues { + if repeatedValue == nil { + listBuilder.AppendNull() + continue + } + + if len(repeatedValue.Val) == 0 && repeatedValue.Val == nil { + return nil, fmt.Errorf("represent it as an empty array instead of nil") + } + listBuilder.Append(true) + + err = CopyProtoValuesToArrowArray(valueBuilder, repeatedValue.Val) + if err != nil { + return nil, fmt.Errorf("error copying proto values to arrow array: %v", err) + } } return listBuilder.NewArray(), nil @@ -855,18 +857,39 @@ func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bo case *types.Value_BoolVal: return x.BoolVal case *types.Value_BoolListVal: + if len(x.BoolListVal.Val) == 0 { + return nil + } return x.BoolListVal.Val case *types.Value_StringListVal: + if len(x.StringListVal.Val) == 0 { + return nil + } return x.StringListVal.Val case *types.Value_BytesListVal: + if len(x.BytesListVal.Val) == 0 { + return nil + } return x.BytesListVal.Val case *types.Value_Int32ListVal: + if len(x.Int32ListVal.Val) == 0 { + return nil + } return x.Int32ListVal.Val case *types.Value_Int64ListVal: + if len(x.Int64ListVal.Val) == 0 { + return nil + } return x.Int64ListVal.Val case *types.Value_FloatListVal: + if len(x.FloatListVal.Val) == 0 { + return nil + } return x.FloatListVal.Val case *types.Value_DoubleListVal: + if len(x.DoubleListVal.Val) == 0 { + return nil + } return x.DoubleListVal.Val case *types.Value_UnixTimestampVal: if timestampAsString { @@ -881,6 +904,10 @@ func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bo } return timestamps } + + if len(x.UnixTimestampListVal.Val) == 0 { + return nil + } timestamps := make([]time.Time, len(x.UnixTimestampListVal.Val)) for i, ts := range x.UnixTimestampListVal.Val { timestamps[i] = time.Unix(ts, 0).UTC() diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index 8f0efb7d0d2..6e3553b60b3 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -50,7 +50,6 @@ var ( {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{0, 1, 2}}}}, {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{3, 4, 5}}}}, {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{}}}}, - {}, }, { {Val: &types.Value_Int64ListVal{&types.Int64List{Val: []int64{0, 1, 2, 553248634761893728}}}}, @@ -91,7 +90,7 @@ var ( var ( REPEATED_PROTO_VALUES = []*types.RepeatedValue{ nil, - {Val: []*types.Value{}}, + {Val: []*types.Value{}}, // Use this way to represent empty repeated values instead of {} {Val: []*types.Value{nil_or_null_val}}, {Val: []*types.Value{nil_or_null_val, nil_or_null_val}}, {Val: []*types.Value{{}, {}}}, @@ -136,7 +135,8 @@ var ( var ( MULTIPLE_REPEATED_PROTO_VALUES = [][]*types.RepeatedValue{ { - {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, {Val: &types.Value_Int32Val{}}, {}}}, + // nil and {} are represented as same during Arrow conversion + {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, {Val: &types.Value_Int32Val{}}, nil, {}}}, {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 20}}}}, {Val: []*types.Value{}}, // Empty Array nil, // NULL or Not Found Values @@ -192,34 +192,57 @@ var ( { {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{10, 11}}}}}}, {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 21}}}}}}, + {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{}}}}}}, + {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{}}}}}, + {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{10, 11}}}}}}, + {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 30}}}}}}, + nil, + {Val: []*types.Value{}}, + // TODO: Fix tests to render correctly for below case + // Arrow List Builder is of specific Type (Ex: Int32). + // So nil or null values are represented as Empty Arrays + //{Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 21}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{30, 31}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{}}}, nil, {}}}, }, { {Val: []*types.Value{{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: []int64{100, 101}}}}}}, {Val: []*types.Value{{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: []int64{200, 201}}}}}}, + {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: []float32{1.1, 1.2}}}}}}, {Val: []*types.Value{{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: []float32{2.1, 2.2}}}}}}, + {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: []float64{1.1, 1.2}}}}}}, {Val: []*types.Value{{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: []float64{2.1, 2.2}}}}}}, + {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{1, 2}, {3, 4}}}}}}}, {Val: []*types.Value{{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{5, 6}, {7, 8}}}}}}}, + {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"row1", "row2"}}}}}}, {Val: []*types.Value{{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"row3", "row4"}}}}}}, + {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{true, false}}}}}}, {Val: []*types.Value{{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{false, true}}}}}}, + {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().Unix()}}}}}}, {Val: []*types.Value{{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{time.Now().Unix() + 3600}}}}}}, + {Val: []*types.Value{}}, + }, + { + {Val: []*types.Value{}}, + {Val: []*types.Value{}}, + {Val: []*types.Value{}}, + {Val: []*types.Value{}}, }, } ) @@ -415,7 +438,9 @@ func TestValueTypeToGoType(t *testing.T) { nil, nil, nil, - []int32{}, + nil, + nil, + nil, } for i, testCase := range testCases { @@ -458,6 +483,8 @@ func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { testCases := []*types.Value{ {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp}}, {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600}}}}, + {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: math.MinInt64}}, + {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600, math.MinInt64}}}}, } expectedTypes := []interface{}{ @@ -466,6 +493,12 @@ func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { time.Unix(timestamp, 0).UTC().Format(TimestampFormat), time.Unix(timestamp+3600, 0).UTC().Format(TimestampFormat), }, + "292277026596-12-04 15:30:08Z", + []string{ + time.Unix(timestamp, 0).UTC().Format(TimestampFormat), + time.Unix(timestamp+3600, 0).UTC().Format(TimestampFormat), + "292277026596-12-04 15:30:08Z", + }, } for i, testCase := range testCases { From 277731f2cd96c55488fca1833bb1e6aabb978858 Mon Sep 17 00:00:00 2001 From: Bhargav Dodla <13788369+EXPEbdodla@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:01:47 -0700 Subject: [PATCH 15/37] fix: Improve Arrow to proto conversion to handle null and empty arrays (#286) Co-authored-by: Bhargav Dodla --- go/internal/test/go_integration_test_utils.go | 4 +- go/types/typeconversion.go | 146 +++--------------- go/types/typeconversion_test.go | 44 +----- 3 files changed, 30 insertions(+), 164 deletions(-) diff --git a/go/internal/test/go_integration_test_utils.go b/go/internal/test/go_integration_test_utils.go index 6e07757a059..307dc164cc2 100644 --- a/go/internal/test/go_integration_test_utils.go +++ b/go/internal/test/go_integration_test_utils.go @@ -255,7 +255,7 @@ func SetupInitializedRepo(basePath string) error { return err } // Pause to ensure apply completes - time.Sleep(1 * time.Second) + time.Sleep(5 * time.Second) applyCommand.Dir = featureRepoPath out, err := applyCommand.CombinedOutput() if err != nil { @@ -277,7 +277,7 @@ func SetupInitializedRepo(basePath string) error { return err } // Pause to ensure materialization completes - time.Sleep(1 * time.Second) + time.Sleep(5 * time.Second) return nil } diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index 735ecd71d7a..aef89d07700 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -259,63 +259,18 @@ func ArrowListToProtoList(listArr *array.List, inputOffsets []int32) ([]*types.V pos := int(inputOffsets[0]) values := make([]*types.Value, len(offsets)) for idx := 0; idx < len(offsets); idx++ { - switch listValues.DataType() { - case arrow.PrimitiveTypes.Int32: - vals := make([]int32, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = listValues.(*array.Int32).Value(j) - } - values[idx] = &types.Value{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: vals}}} - case arrow.PrimitiveTypes.Int64: - vals := make([]int64, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = listValues.(*array.Int64).Value(j) - } - values[idx] = &types.Value{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: vals}}} - case arrow.PrimitiveTypes.Float32: - vals := make([]float32, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = listValues.(*array.Float32).Value(j) - } - values[idx] = &types.Value{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: vals}}} - case arrow.PrimitiveTypes.Float64: - vals := make([]float64, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = listValues.(*array.Float64).Value(j) - } - values[idx] = &types.Value{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: vals}}} - case arrow.BinaryTypes.Binary: - vals := make([][]byte, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = listValues.(*array.Binary).Value(j) - } - values[idx] = &types.Value{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: vals}}} - case arrow.BinaryTypes.String: - vals := make([]string, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = listValues.(*array.String).Value(j) - } - values[idx] = &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: vals}}} - case arrow.FixedWidthTypes.Boolean: - vals := make([]bool, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = listValues.(*array.Boolean).Value(j) - } - values[idx] = &types.Value{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: vals}}} - case arrow.FixedWidthTypes.Timestamp_s: - vals := make([]int64, int(offsets[idx])-pos) - for j := pos; j < int(offsets[idx]); j++ { - vals[j-pos] = int64(listValues.(*array.Timestamp).Value(j)) + if listArr.IsValid(idx) { + value, err := arrowListValuesToProtoValue(listValues, int64(pos), int64(offsets[idx])) + if err != nil { + return nil, fmt.Errorf("error converting arrow list to proto Value: %v", err) } - values[idx] = &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: vals}}} - default: - return nil, fmt.Errorf("unsupported data type in list: %s", listValues.DataType()) - } + values[idx] = value - // set the end of current element as start of the next + } else { + values[idx] = &types.Value{} + } pos = int(offsets[idx]) } - return values, nil } @@ -474,58 +429,28 @@ func ArrowValuesToRepeatedProtoValues(arr arrow.Array) ([]*types.RepeatedValue, } repeatedValues := make([]*types.RepeatedValue, 0, arr.Len()) - listArray := arr.(*array.List) - listValues := listArray.ListValues() + arrayList := arr.(*array.List) + arrayListValues := arrayList.ListValues() - for i := 0; i < listArray.Len(); i++ { - if listArray.IsNull(i) { - // Null RepeatedValue + for i := 0; i < arrayList.Len(); i++ { + if !arrayList.IsValid(i) { repeatedValues = append(repeatedValues, nil) continue } - if listOfLists, ok := listValues.(*array.List); ok { - start, end := listArray.ValueOffsets(i) - subOffsets := listOfLists.Offsets()[start : end+1] - values, err := ArrowListToProtoList(listOfLists, subOffsets) + + start := int(arrayList.Offsets()[i]) + end := int(arrayList.Offsets()[i+1]) + + if arrayListVals, ok := arrayListValues.(*array.List); ok { + repeatedValue, err := arrowNestedListToRepeatedValues(arrayListVals, start, end) if err != nil { - return nil, fmt.Errorf("error converting list to proto Value: %v", err) + return nil, err } - repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) + repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: repeatedValue}) } else { - start := int(listArray.Offsets()[i]) - end := int(listArray.Offsets()[i+1]) - - values := make([]*types.Value, 0, end-start) - - for j := start; j < end; j++ { - var protoVal *types.Value - if listValues.IsNull(j) { - protoVal = &types.Value{} - } else { - switch listValues.DataType() { - case arrow.PrimitiveTypes.Int32: - protoVal = &types.Value{Val: &types.Value_Int32Val{Int32Val: listValues.(*array.Int32).Value(j)}} - case arrow.PrimitiveTypes.Int64: - protoVal = &types.Value{Val: &types.Value_Int64Val{Int64Val: listValues.(*array.Int64).Value(j)}} - case arrow.PrimitiveTypes.Float32: - protoVal = &types.Value{Val: &types.Value_FloatVal{FloatVal: listValues.(*array.Float32).Value(j)}} - case arrow.PrimitiveTypes.Float64: - protoVal = &types.Value{Val: &types.Value_DoubleVal{DoubleVal: listValues.(*array.Float64).Value(j)}} - case arrow.BinaryTypes.Binary: - protoVal = &types.Value{Val: &types.Value_BytesVal{BytesVal: listValues.(*array.Binary).Value(j)}} - case arrow.BinaryTypes.String: - protoVal = &types.Value{Val: &types.Value_StringVal{StringVal: listValues.(*array.String).Value(j)}} - case arrow.FixedWidthTypes.Boolean: - protoVal = &types.Value{Val: &types.Value_BoolVal{BoolVal: listValues.(*array.Boolean).Value(j)}} - case arrow.FixedWidthTypes.Timestamp_s: - protoVal = &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: int64(listValues.(*array.Timestamp).Value(j))}} - case arrow.Null: - protoVal = &types.Value{} - default: - return nil, fmt.Errorf("unsupported data type in list: %s", listValues.DataType()) - } - } - values = append(values, protoVal) + values, err := arrowPrimitiveListToRepeatedValues(arrayListValues, start, end) + if err != nil { + return nil, err } repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: values}) } @@ -857,39 +782,18 @@ func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bo case *types.Value_BoolVal: return x.BoolVal case *types.Value_BoolListVal: - if len(x.BoolListVal.Val) == 0 { - return nil - } return x.BoolListVal.Val case *types.Value_StringListVal: - if len(x.StringListVal.Val) == 0 { - return nil - } return x.StringListVal.Val case *types.Value_BytesListVal: - if len(x.BytesListVal.Val) == 0 { - return nil - } return x.BytesListVal.Val case *types.Value_Int32ListVal: - if len(x.Int32ListVal.Val) == 0 { - return nil - } return x.Int32ListVal.Val case *types.Value_Int64ListVal: - if len(x.Int64ListVal.Val) == 0 { - return nil - } return x.Int64ListVal.Val case *types.Value_FloatListVal: - if len(x.FloatListVal.Val) == 0 { - return nil - } return x.FloatListVal.Val case *types.Value_DoubleListVal: - if len(x.DoubleListVal.Val) == 0 { - return nil - } return x.DoubleListVal.Val case *types.Value_UnixTimestampVal: if timestampAsString { @@ -904,10 +808,6 @@ func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bo } return timestamps } - - if len(x.UnixTimestampListVal.Val) == 0 { - return nil - } timestamps := make([]time.Time, len(x.UnixTimestampListVal.Val)) for i, ts := range x.UnixTimestampListVal.Val { timestamps[i] = time.Unix(ts, 0).UTC() diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index 6e3553b60b3..f2479d116e4 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -50,6 +50,7 @@ var ( {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{0, 1, 2}}}}, {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{3, 4, 5}}}}, {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{}}}}, + {}, }, { {Val: &types.Value_Int64ListVal{&types.Int64List{Val: []int64{0, 1, 2, 553248634761893728}}}}, @@ -90,7 +91,7 @@ var ( var ( REPEATED_PROTO_VALUES = []*types.RepeatedValue{ nil, - {Val: []*types.Value{}}, // Use this way to represent empty repeated values instead of {} + {Val: []*types.Value{}}, {Val: []*types.Value{nil_or_null_val}}, {Val: []*types.Value{nil_or_null_val, nil_or_null_val}}, {Val: []*types.Value{{}, {}}}, @@ -135,8 +136,7 @@ var ( var ( MULTIPLE_REPEATED_PROTO_VALUES = [][]*types.RepeatedValue{ { - // nil and {} are represented as same during Arrow conversion - {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, {Val: &types.Value_Int32Val{}}, nil, {}}}, + {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, {Val: &types.Value_Int32Val{}}, {}}}, {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 20}}}}, {Val: []*types.Value{}}, // Empty Array nil, // NULL or Not Found Values @@ -198,10 +198,7 @@ var ( {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 30}}}}}}, nil, {Val: []*types.Value{}}, - // TODO: Fix tests to render correctly for below case - // Arrow List Builder is of specific Type (Ex: Int32). - // So nil or null values are represented as Empty Arrays - //{Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 21}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{30, 31}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{}}}, nil, {}}}, + {Val: []*types.Value{{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{20, 21}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{30, 31}}}}, {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{}}}}, {}}}, }, { {Val: []*types.Value{{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: []int64{100, 101}}}}}}, @@ -438,9 +435,7 @@ func TestValueTypeToGoType(t *testing.T) { nil, nil, nil, - nil, - nil, - nil, + []int32{}, } for i, testCase := range testCases { @@ -478,35 +473,6 @@ func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { } } -func TestValueTypeToGoTypeTimestampAsString(t *testing.T) { - timestamp := int64(1744769099) - testCases := []*types.Value{ - {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: timestamp}}, - {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600}}}}, - {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: math.MinInt64}}, - {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp, timestamp + 3600, math.MinInt64}}}}, - } - - expectedTypes := []interface{}{ - time.Unix(timestamp, 0).UTC().Format(TimestampFormat), - []string{ - time.Unix(timestamp, 0).UTC().Format(TimestampFormat), - time.Unix(timestamp+3600, 0).UTC().Format(TimestampFormat), - }, - "292277026596-12-04 15:30:08Z", - []string{ - time.Unix(timestamp, 0).UTC().Format(TimestampFormat), - time.Unix(timestamp+3600, 0).UTC().Format(TimestampFormat), - "292277026596-12-04 15:30:08Z", - }, - } - - for i, testCase := range testCases { - actual := ValueTypeToGoTypeTimestampAsString(testCase) - assert.Equal(t, expectedTypes[i], actual) - } -} - func TestConvertToValueType_String(t *testing.T) { testCases := []struct { input *types.Value From 39aa252bdaf48defd7483168a933392c1bfb59d5 Mon Sep 17 00:00:00 2001 From: Bhargav Dodla <13788369+EXPEbdodla@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:03:43 -0700 Subject: [PATCH 16/37] =?UTF-8?q?feat:=20Add=20versioning=20information=20?= =?UTF-8?q?to=20the=20server=20and=20expose=20it=20via=20a=20ne=E2=80=A6?= =?UTF-8?q?=20(#288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add versioning information to the server and expose it via a new endpoint 1. Version information is exposed via endpoints 2. It published information to datadog as well 3. Version information is printed on pod startup * fix: fixed test failures * fix: fixed test failures * fix: added comment * fix: update GetVersionInfo method and related proto definitions * fix: set server type in version information for HTTP, gRPC, and hybrid servers * fix: refactor Datadog version info publishing to a separate function * fix: improve error handling for statsd client flush and close operations --------- Co-authored-by: Bhargav Dodla --- go/internal/feast/server/http_server.go | 22 ++++++++++++++++++++++ go/internal/feast/server/server_commons.go | 8 ++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 057df20c6f7..83488dcdfa2 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/feast-dev/feast/go/internal/feast/version" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "net/http" @@ -334,6 +335,27 @@ func parseIncludeMetadata(r *http.Request) (bool, error) { return strconv.ParseBool(raw) } +func (s *httpServer) getVersion(w http.ResponseWriter, r *http.Request) { + span, _ := tracer.StartSpanFromContext(r.Context(), "getVersion", tracer.ResourceName("/get-version")) + defer span.Finish() + + logSpanContext := LogWithSpanContext(span) + + if r.Method != "GET" { + http.NotFound(w, r) + return + } + + versionInfo := version.GetVersionInfo() + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(versionInfo) + if err != nil { + logSpanContext.Error().Err(err).Msg("Error encoding version response") + writeJSONError(w, fmt.Errorf("error encoding version response: %+v", err), http.StatusInternalServerError) + return + } +} + func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { var err error var featureVectors []*onlineserving.FeatureVector diff --git a/go/internal/feast/server/server_commons.go b/go/internal/feast/server/server_commons.go index babb408bb1a..ae62b4a3b74 100644 --- a/go/internal/feast/server/server_commons.go +++ b/go/internal/feast/server/server_commons.go @@ -32,8 +32,12 @@ func CommonHttpHandlers(s *HttpServer, healthCheckHandler http.HandlerFunc) []Ha HandlerFunc: recoverMiddleware(http.HandlerFunc(s.getOnlineFeaturesRange)), }, { - Path: "/version", - HandlerFunc: recoverMiddleware(http.HandlerFunc(s.getVersion)), + path: "/version", + handlerFunc: recoverMiddleware(http.HandlerFunc(s.getVersion)), + }, + { + path: "/metrics", + handlerFunc: promhttp.Handler(), }, { Path: "/metrics", From ede1cc21e43fac46023c042d7c54853afa75078e Mon Sep 17 00:00:00 2001 From: piket Date: Fri, 18 Jul 2025 10:41:04 -0700 Subject: [PATCH 17/37] fix: Add http int tests and fix http error codes (#287) --- go/internal/feast/featurestore.go | 6 +- .../scylladb/http/http_integration_test.go | 145 +++++------ .../scylladb/http/valid_equals_response.json | 226 +++++++++--------- .../http/valid_nonexistent_key_response.json | 18 +- .../scylladb/http/valid_response.json | 34 +-- go/internal/feast/server/http_server.go | 28 +-- go/internal/feast/server/server_commons.go | 8 +- 7 files changed, 206 insertions(+), 259 deletions(-) diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 18c7e9841c7..c1770fe7436 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -134,7 +134,7 @@ func sortKeyFilterTypeConversion(sortKeyFilters []*serving.SortKeyFilter, sortKe if filter.GetEquals() != nil { equals, err := types.ConvertToValueType(filter.GetEquals(), sk.ValueType) if err != nil { - return nil, errors.GrpcInternalErrorf("error converting sort key filter equals for %s: %v", sk.FieldName, err) + return nil, errors.GrpcInvalidArgumentErrorf("error converting sort key filter equals for %s: %v", sk.FieldName, err) } newFilters[i] = &serving.SortKeyFilter{ SortKeyName: sk.FieldName, @@ -147,14 +147,14 @@ func sortKeyFilterTypeConversion(sortKeyFilters []*serving.SortKeyFilter, sortKe if filter.GetRange().GetRangeStart() != nil { rangeStart, err = types.ConvertToValueType(filter.GetRange().GetRangeStart(), sk.ValueType) if err != nil { - return nil, errors.GrpcInternalErrorf("error converting sort key filter range start for %s: %v", sk.FieldName, err) + return nil, errors.GrpcInvalidArgumentErrorf("error converting sort key filter range start for %s: %v", sk.FieldName, err) } } var rangeEnd *prototypes.Value if filter.GetRange().GetRangeEnd() != nil { rangeEnd, err = types.ConvertToValueType(filter.GetRange().GetRangeEnd(), sk.ValueType) if err != nil { - return nil, errors.GrpcInternalErrorf("error converting sort key filter range end for %s: %v", sk.FieldName, err) + return nil, errors.GrpcInvalidArgumentErrorf("error converting sort key filter range end for %s: %v", sk.FieldName, err) } } newFilters[i] = &serving.SortKeyFilter{ diff --git a/go/internal/feast/integration_tests/scylladb/http/http_integration_test.go b/go/internal/feast/integration_tests/scylladb/http/http_integration_test.go index 8179ba32440..88b57f71d4e 100644 --- a/go/internal/feast/integration_tests/scylladb/http/http_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/http/http_integration_test.go @@ -67,14 +67,6 @@ func TestGetOnlineFeaturesRange_Http(t *testing.T) { "all_dtypes_sorted:string_val", "all_dtypes_sorted:timestamp_val", "all_dtypes_sorted:boolean_val", - "all_dtypes_sorted:array_int_val", - "all_dtypes_sorted:array_long_val", - "all_dtypes_sorted:array_float_val", - "all_dtypes_sorted:array_double_val", - "all_dtypes_sorted:array_byte_val", - "all_dtypes_sorted:array_string_val", - "all_dtypes_sorted:array_timestamp_val", - "all_dtypes_sorted:array_boolean_val", "all_dtypes_sorted:null_int_val", "all_dtypes_sorted:null_long_val", "all_dtypes_sorted:null_float_val", @@ -89,8 +81,16 @@ func TestGetOnlineFeaturesRange_Http(t *testing.T) { "all_dtypes_sorted:null_array_double_val", "all_dtypes_sorted:null_array_byte_val", "all_dtypes_sorted:null_array_string_val", - "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:null_array_boolean_val", + "all_dtypes_sorted:array_int_val", + "all_dtypes_sorted:array_long_val", + "all_dtypes_sorted:array_float_val", + "all_dtypes_sorted:array_double_val", + "all_dtypes_sorted:array_string_val", + "all_dtypes_sorted:array_boolean_val", + "all_dtypes_sorted:array_byte_val", + "all_dtypes_sorted:array_timestamp_val", + "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:event_timestamp" ], "entities": { @@ -128,14 +128,6 @@ func TestGetOnlineFeaturesRange_Http_withOnlyEqualsFilter(t *testing.T) { "all_dtypes_sorted:string_val", "all_dtypes_sorted:timestamp_val", "all_dtypes_sorted:boolean_val", - "all_dtypes_sorted:array_int_val", - "all_dtypes_sorted:array_long_val", - "all_dtypes_sorted:array_float_val", - "all_dtypes_sorted:array_double_val", - "all_dtypes_sorted:array_byte_val", - "all_dtypes_sorted:array_string_val", - "all_dtypes_sorted:array_timestamp_val", - "all_dtypes_sorted:array_boolean_val", "all_dtypes_sorted:null_int_val", "all_dtypes_sorted:null_long_val", "all_dtypes_sorted:null_float_val", @@ -150,8 +142,16 @@ func TestGetOnlineFeaturesRange_Http_withOnlyEqualsFilter(t *testing.T) { "all_dtypes_sorted:null_array_double_val", "all_dtypes_sorted:null_array_byte_val", "all_dtypes_sorted:null_array_string_val", - "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:null_array_boolean_val", + "all_dtypes_sorted:array_int_val", + "all_dtypes_sorted:array_long_val", + "all_dtypes_sorted:array_float_val", + "all_dtypes_sorted:array_double_val", + "all_dtypes_sorted:array_string_val", + "all_dtypes_sorted:array_boolean_val", + "all_dtypes_sorted:array_byte_val", + "all_dtypes_sorted:array_timestamp_val", + "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:event_timestamp" ], "entities": { @@ -187,14 +187,6 @@ func TestGetOnlineFeaturesRange_Http_forNonExistentEntityKey(t *testing.T) { "all_dtypes_sorted:string_val", "all_dtypes_sorted:timestamp_val", "all_dtypes_sorted:boolean_val", - "all_dtypes_sorted:array_int_val", - "all_dtypes_sorted:array_long_val", - "all_dtypes_sorted:array_float_val", - "all_dtypes_sorted:array_double_val", - "all_dtypes_sorted:array_byte_val", - "all_dtypes_sorted:array_string_val", - "all_dtypes_sorted:array_timestamp_val", - "all_dtypes_sorted:array_boolean_val", "all_dtypes_sorted:null_int_val", "all_dtypes_sorted:null_long_val", "all_dtypes_sorted:null_float_val", @@ -209,8 +201,16 @@ func TestGetOnlineFeaturesRange_Http_forNonExistentEntityKey(t *testing.T) { "all_dtypes_sorted:null_array_double_val", "all_dtypes_sorted:null_array_byte_val", "all_dtypes_sorted:null_array_string_val", - "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:null_array_boolean_val", + "all_dtypes_sorted:array_int_val", + "all_dtypes_sorted:array_long_val", + "all_dtypes_sorted:array_float_val", + "all_dtypes_sorted:array_double_val", + "all_dtypes_sorted:array_string_val", + "all_dtypes_sorted:array_boolean_val", + "all_dtypes_sorted:array_byte_val", + "all_dtypes_sorted:array_timestamp_val", + "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:event_timestamp" ], "entities": { @@ -278,14 +278,6 @@ func TestGetOnlineFeaturesRange_Http_withEmptySortKeyFilter(t *testing.T) { "all_dtypes_sorted:string_val", "all_dtypes_sorted:timestamp_val", "all_dtypes_sorted:boolean_val", - "all_dtypes_sorted:array_int_val", - "all_dtypes_sorted:array_long_val", - "all_dtypes_sorted:array_float_val", - "all_dtypes_sorted:array_double_val", - "all_dtypes_sorted:array_byte_val", - "all_dtypes_sorted:array_string_val", - "all_dtypes_sorted:array_timestamp_val", - "all_dtypes_sorted:array_boolean_val", "all_dtypes_sorted:null_int_val", "all_dtypes_sorted:null_long_val", "all_dtypes_sorted:null_float_val", @@ -300,8 +292,16 @@ func TestGetOnlineFeaturesRange_Http_withEmptySortKeyFilter(t *testing.T) { "all_dtypes_sorted:null_array_double_val", "all_dtypes_sorted:null_array_byte_val", "all_dtypes_sorted:null_array_string_val", - "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:null_array_boolean_val", + "all_dtypes_sorted:array_int_val", + "all_dtypes_sorted:array_long_val", + "all_dtypes_sorted:array_float_val", + "all_dtypes_sorted:array_double_val", + "all_dtypes_sorted:array_string_val", + "all_dtypes_sorted:array_boolean_val", + "all_dtypes_sorted:array_byte_val", + "all_dtypes_sorted:array_timestamp_val", + "all_dtypes_sorted:null_array_timestamp_val", "all_dtypes_sorted:event_timestamp" ], "entities": { @@ -323,7 +323,7 @@ func TestGetOnlineFeaturesRange_Http_withEmptySortKeyFilter(t *testing.T) { func TestGetOnlineFeaturesRange_Http_withFeatureService(t *testing.T) { requestJson := []byte(`{ - "feature_service": "test_sorted_service", + "feature_service": "test_service", "entities": { "index_id": [1, 2, 3] }, @@ -342,63 +342,36 @@ func TestGetOnlineFeaturesRange_Http_withFeatureService(t *testing.T) { responseRecorder := httptest.NewRecorder() getOnlineFeaturesRangeHandler.ServeHTTP(responseRecorder, request) - assert.Equal(t, responseRecorder.Code, http.StatusOK, "Expected HTTP status code 200 OK response body is: %s", responseRecorder.Body.String()) - expectedResponse, err := loadResponse("valid_response.json") - require.NoError(t, err, "Failed to load expected response from file") - assert.JSONEq(t, string(expectedResponse), responseRecorder.Body.String(), "Response body does not match expected JSON") -} - -func TestGetOnlineFeaturesRange_Http_withInvalidFeatureService(t *testing.T) { - requestJson := []byte(`{ - "feature_service": "invalid_service", - "entities": { - "index_id": [1, 2, 3] - }, - "sort_key_filters": [ - { - "sort_key_name": "event_timestamp", - "range": { - "range_start": 0 - } - } - ], - "limit": 10 - }`) - - request := httptest.NewRequest(http.MethodPost, "/get-online-features-range", bytes.NewBuffer(requestJson)) - responseRecorder := httptest.NewRecorder() - - getOnlineFeaturesRangeHandler.ServeHTTP(responseRecorder, request) - assert.Equal(t, http.StatusNotFound, responseRecorder.Code) - assert.Contains(t, responseRecorder.Body.String(), "Error getting feature service from registry", "Response body does not contain expected error message") + assert.Equal(t, responseRecorder.Code, http.StatusBadRequest) + assert.Equal(t, `{"error":"GetOnlineFeaturesRange does not support standard feature views [all_dtypes]","status_code":400}`, responseRecorder.Body.String(), "Response body does not match expected error message") } -func TestGetOnlineFeaturesRange_Http_withInvalidSortedFeatureView(t *testing.T) { +func TestGetOnlineFeaturesRange_Http_withInvalidFeatureView(t *testing.T) { requestJson := []byte(`{ - "features": ["invalid_sorted_view:some_feature"], - "entities": { - "index_id": [1, 2, 3] - }, - "sort_key_filters": [ - { - "sort_key_name": "event_timestamp", - "range": { - "range_start": { - "unix_timestamp_val": 0 - } - } - } - ], - "limit": 10 - }`) + "features": [ + "all_dtypes:int_val" + ], + "entities": { + "index_id": [1, 2, 3] + }, + "sort_key_filters": [ + { + "sort_key_name": "event_timestamp", + "range": { + "range_start": 0 + } + } + ], + "limit": 10 + }`) request := httptest.NewRequest(http.MethodPost, "/get-online-features-range", bytes.NewBuffer(requestJson)) responseRecorder := httptest.NewRecorder() getOnlineFeaturesRangeHandler.ServeHTTP(responseRecorder, request) - assert.Equal(t, http.StatusBadRequest, responseRecorder.Code) - expectedErrorMessage := `{"error":"sorted feature view invalid_sorted_view doesn't exist, please make sure that you have created the sorted feature view invalid_sorted_view and that you have registered it by running \"apply\"","status_code":400}` - assert.JSONEq(t, expectedErrorMessage, responseRecorder.Body.String(), "Response body does not match expected error message") + assert.Equal(t, responseRecorder.Code, http.StatusBadRequest, "Expected HTTP status code 400 BadRequest response body is: %s", responseRecorder.Body.String()) + expectedErrorMessage := `{"error":"GetOnlineFeaturesRange does not support standard feature views [all_dtypes]","status_code":400}` + assert.Equal(t, expectedErrorMessage, responseRecorder.Body.String(), "Response body does not match expected error message") } func TestGetOnlineFeaturesRange_Http_withInvalidSortKeyFilter(t *testing.T) { diff --git a/go/internal/feast/integration_tests/scylladb/http/valid_equals_response.json b/go/internal/feast/integration_tests/scylladb/http/valid_equals_response.json index 5f837738990..686d4b45dac 100644 --- a/go/internal/feast/integration_tests/scylladb/http/valid_equals_response.json +++ b/go/internal/feast/integration_tests/scylladb/http/valid_equals_response.json @@ -14,14 +14,6 @@ "string_val", "timestamp_val", "boolean_val", - "array_int_val", - "array_long_val", - "array_float_val", - "array_double_val", - "array_byte_val", - "array_string_val", - "array_timestamp_val", - "array_boolean_val", "null_int_val", "null_long_val", "null_float_val", @@ -36,8 +28,16 @@ "null_array_double_val", "null_array_byte_val", "null_array_string_val", - "null_array_timestamp_val", "null_array_boolean_val", + "array_int_val", + "array_long_val", + "array_float_val", + "array_double_val", + "array_string_val", + "array_boolean_val", + "array_byte_val", + "array_timestamp_val", + "null_array_timestamp_val", "event_timestamp" ] }, @@ -101,144 +101,56 @@ { "values": [ [ - [ - 641, - 229, - 465, - 968, - 325, - 543, - 806, - 587, - 700, - 641 - ] + null ] ] }, { "values": [ [ - [ - 14617, - 4647, - 97806, - 88854, - 52201, - 45481, - 20690, - 34777, - 25993, - 20199 - ] + null ] ] }, { "values": [ [ - [ - 42.384342, - 77.91697, - 69.00426, - 80.79554, - 13.054096, - 35.700005, - 20.132544, - 97.402245, - 1.021711, - 14.016888 - ] + null ] ] }, { "values": [ [ - [ - 39.619735960926164, - 80.68860017001165, - 32.64254790114928, - 43.32240835268312, - 77.46765753551638, - 42.03371290943731, - 88.171018378022, - 80.54406477799951, - 98.17662710411794, - 74.97415732322717 - ] + null ] ] }, { "values": [ [ - [ - "v3CHIwQTKOsPlg==", - "9NpAUZtBRhQQdg==", - "mFIS2ujOt3nMNw==", - "cLpMChXX44TEqQ==", - "/QYIT7SunvM/BQ==", - "Ia6pzqLJsN+i/g==", - "LtVpAouLocySHw==", - "1SqOKGMZEXm/BQ==", - "xL95J5LcB6QmFw==", - "RpbGjz9Lo64HMA==" - ] + null ] ] }, { "values": [ [ - [ - "9QECZ", - "GLPQJ", - "RWCS4", - "4J2ZQ", - "S2ZJG", - "TLS1U", - "J3ZNM", - "CPILQ", - "9QPKL", - "ZJELB" - ] + null ] ] }, { "values": [ [ - [ - "2024-06-05 02:04:31Z", - "2024-09-20 02:04:31Z", - "2024-10-16 02:04:31Z", - "2024-05-26 02:04:31Z", - "2024-08-21 02:04:31Z", - "2025-01-11 03:04:31Z", - "2025-01-12 03:04:31Z", - "2025-04-16 02:04:31Z", - "2024-10-21 02:04:31Z", - "2025-04-07 02:04:31Z" - ] + null ] ] }, { "values": [ [ - [ - false, - true, - false, - true, - false, - true, - false, - false, - true, - false - ] + null ] ] }, @@ -294,56 +206,144 @@ { "values": [ [ - null + [ + 641, + 229, + 465, + 968, + 325, + 543, + 806, + 587, + 700, + 641 + ] ] ] }, { "values": [ [ - null + [ + 14617, + 4647, + 97806, + 88854, + 52201, + 45481, + 20690, + 34777, + 25993, + 20199 + ] ] ] }, { "values": [ [ - null + [ + 42.384342, + 77.91697, + 69.00426, + 80.79554, + 13.054096, + 35.700005, + 20.132544, + 97.402245, + 1.021711, + 14.016888 + ] ] ] }, { "values": [ [ - null + [ + 39.619735960926164, + 80.68860017001165, + 32.64254790114928, + 43.32240835268312, + 77.46765753551638, + 42.03371290943731, + 88.171018378022, + 80.54406477799951, + 98.17662710411794, + 74.97415732322717 + ] ] ] }, { "values": [ [ - null + [ + "9QECZ", + "GLPQJ", + "RWCS4", + "4J2ZQ", + "S2ZJG", + "TLS1U", + "J3ZNM", + "CPILQ", + "9QPKL", + "ZJELB" + ] ] ] }, { "values": [ [ - null + [ + false, + true, + false, + true, + false, + true, + false, + false, + true, + false + ] ] ] }, { "values": [ [ - null + [ + "v3CHIwQTKOsPlg==", + "9NpAUZtBRhQQdg==", + "mFIS2ujOt3nMNw==", + "cLpMChXX44TEqQ==", + "/QYIT7SunvM/BQ==", + "Ia6pzqLJsN+i/g==", + "LtVpAouLocySHw==", + "1SqOKGMZEXm/BQ==", + "xL95J5LcB6QmFw==", + "RpbGjz9Lo64HMA==" + ] ] ] }, { "values": [ [ - null + [ + "2024-06-05 02:04:31Z", + "2024-09-20 02:04:31Z", + "2024-10-16 02:04:31Z", + "2024-05-26 02:04:31Z", + "2024-08-21 02:04:31Z", + "2025-01-11 03:04:31Z", + "2025-01-12 03:04:31Z", + "2025-04-16 02:04:31Z", + "2024-10-21 02:04:31Z", + "2025-04-07 02:04:31Z" + ] ] ] }, diff --git a/go/internal/feast/integration_tests/scylladb/http/valid_nonexistent_key_response.json b/go/internal/feast/integration_tests/scylladb/http/valid_nonexistent_key_response.json index fb31952e8c9..296832af8d7 100644 --- a/go/internal/feast/integration_tests/scylladb/http/valid_nonexistent_key_response.json +++ b/go/internal/feast/integration_tests/scylladb/http/valid_nonexistent_key_response.json @@ -14,14 +14,6 @@ "string_val", "timestamp_val", "boolean_val", - "array_int_val", - "array_long_val", - "array_float_val", - "array_double_val", - "array_byte_val", - "array_string_val", - "array_timestamp_val", - "array_boolean_val", "null_int_val", "null_long_val", "null_float_val", @@ -36,8 +28,16 @@ "null_array_double_val", "null_array_byte_val", "null_array_string_val", - "null_array_timestamp_val", "null_array_boolean_val", + "array_int_val", + "array_long_val", + "array_float_val", + "array_double_val", + "array_string_val", + "array_boolean_val", + "array_byte_val", + "array_timestamp_val", + "null_array_timestamp_val", "event_timestamp" ] }, diff --git a/go/internal/feast/integration_tests/scylladb/http/valid_response.json b/go/internal/feast/integration_tests/scylladb/http/valid_response.json index 55db0470247..cac5be1634b 100644 --- a/go/internal/feast/integration_tests/scylladb/http/valid_response.json +++ b/go/internal/feast/integration_tests/scylladb/http/valid_response.json @@ -3,7 +3,7 @@ "index_id" : [ 1, 2, 3 ] }, "metadata" : { - "feature_names" : [ "int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_byte_val", "array_string_val", "array_timestamp_val", "array_boolean_val", "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", "null_array_timestamp_val", "null_array_boolean_val", "event_timestamp" ] + "feature_names" : [ "int_val", "long_val", "float_val", "double_val", "byte_val", "string_val", "timestamp_val", "boolean_val", "null_int_val", "null_long_val", "null_float_val", "null_double_val", "null_byte_val", "null_string_val", "null_timestamp_val", "null_boolean_val", "null_array_int_val", "null_array_long_val", "null_array_float_val", "null_array_double_val", "null_array_byte_val", "null_array_string_val", "null_array_boolean_val", "array_int_val", "array_long_val", "array_float_val", "array_double_val", "array_string_val", "array_boolean_val", "array_byte_val", "array_timestamp_val", "null_array_timestamp_val", "event_timestamp" ] }, "results" : [ { "values" : [ [ 729, 728, 727, 726, 725, 724, 723, 722, 721, 720 ], [ 730, 729, 728, 727, 726, 725, 724, 723, 722, 721 ], [ 730, 729, 728, 727, 726, 725, 724, 723, 722, 721 ] ] @@ -21,22 +21,6 @@ "values" : [ [ "2024-04-20 02:06:11Z", "2024-04-20 02:06:10Z", "2024-04-20 02:06:09Z", "2024-04-20 02:06:08Z", "2024-04-20 02:06:07Z", "2024-04-20 02:06:06Z", "2024-04-20 02:06:05Z", "2024-04-20 02:06:04Z", "2024-04-20 02:06:03Z", "2024-04-20 02:06:02Z" ], [ "2024-04-20 02:06:12Z", "2024-04-20 02:06:11Z", "2024-04-20 02:06:10Z", "2024-04-20 02:06:09Z", "2024-04-20 02:06:08Z", "2024-04-20 02:06:07Z", "2024-04-20 02:06:06Z", "2024-04-20 02:06:05Z", "2024-04-20 02:06:04Z", "2024-04-20 02:06:03Z" ], [ "2024-04-20 02:06:12Z", "2024-04-20 02:06:11Z", "2024-04-20 02:06:10Z", "2024-04-20 02:06:09Z", "2024-04-20 02:06:08Z", "2024-04-20 02:06:07Z", "2024-04-20 02:06:06Z", "2024-04-20 02:06:05Z", "2024-04-20 02:06:04Z", "2024-04-20 02:06:03Z" ] ] }, { "values" : [ [ true, true, true, true, true, true, true, true, true, true ], [ true, true, true, true, true, true, true, true, true, true ], [ true, true, true, true, true, true, true, true, true, true ] ] - }, { - "values" : [ [ [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ] ], [ [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ] ], [ [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ] ] ] - }, { - "values" : [ [ [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ] ], [ [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ] ], [ [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ] ] ] - }, { - "values" : [ [ [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ] ], [ [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ] ], [ [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ] ] ] - }, { - "values" : [ [ [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ] ], [ [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ] ], [ [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ] ] ] - }, { - "values" : [ [ [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ] ], [ [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ] ], [ [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ] ] ] - },{ - "values" : [ [ [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ] ], [ [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ] ], [ [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ] ] ] - }, { - "values" : [ [ [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ] ], [ [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ] ], [ [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ] ] ] - }, { - "values" : [ [ [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ] ], [ [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ] ], [ [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ] ] ] }, { "values" : [ [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ] ] }, { @@ -67,6 +51,22 @@ "values" : [ [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ] ] }, { "values" : [ [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ] ] + }, { + "values" : [ [ [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ] ], [ [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ] ], [ [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ], [ 641, 229, 465, 968, 325, 543, 806, 587, 700, 641 ] ] ] + }, { + "values" : [ [ [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ] ], [ [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ] ], [ [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ], [ 14617, 4647, 97806, 88854, 52201, 45481, 20690, 34777, 25993, 20199 ] ] ] + }, { + "values" : [ [ [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ] ], [ [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ] ], [ [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ], [ 42.384342, 77.91697, 69.00426, 80.79554, 13.054096, 35.700005, 20.132544, 97.402245, 1.021711, 14.016888 ] ] ] + }, { + "values" : [ [ [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ] ], [ [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ] ], [ [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ], [ 39.619735960926164, 80.68860017001165, 32.64254790114928, 43.32240835268312, 77.46765753551638, 42.03371290943731, 88.171018378022, 80.54406477799951, 98.17662710411794, 74.97415732322717 ] ] ] + }, { + "values" : [ [ [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ] ], [ [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ] ], [ [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ], [ "9QECZ", "GLPQJ", "RWCS4", "4J2ZQ", "S2ZJG", "TLS1U", "J3ZNM", "CPILQ", "9QPKL", "ZJELB" ] ] ] + }, { + "values" : [ [ [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ] ], [ [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ] ], [ [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ], [ false, true, false, true, false, true, false, false, true, false ] ] ] + }, { + "values" : [ [ [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ] ], [ [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ] ], [ [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ], [ "v3CHIwQTKOsPlg==", "9NpAUZtBRhQQdg==", "mFIS2ujOt3nMNw==", "cLpMChXX44TEqQ==", "/QYIT7SunvM/BQ==", "Ia6pzqLJsN+i/g==", "LtVpAouLocySHw==", "1SqOKGMZEXm/BQ==", "xL95J5LcB6QmFw==", "RpbGjz9Lo64HMA==" ] ] ] + }, { + "values" : [ [ [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ] ], [ [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ] ], [ [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ], [ "2024-06-05 02:04:31Z", "2024-09-20 02:04:31Z", "2024-10-16 02:04:31Z", "2024-05-26 02:04:31Z", "2024-08-21 02:04:31Z", "2025-01-11 03:04:31Z", "2025-01-12 03:04:31Z", "2025-04-16 02:04:31Z", "2024-10-21 02:04:31Z", "2025-04-07 02:04:31Z" ] ] ] }, { "values" : [ [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ], [ null, null, null, null, null, null, null, null, null, null ] ] }, { diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 83488dcdfa2..e161566cf5f 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -335,7 +335,7 @@ func parseIncludeMetadata(r *http.Request) (bool, error) { return strconv.ParseBool(raw) } -func (s *httpServer) getVersion(w http.ResponseWriter, r *http.Request) { +func (s *HttpServer) getVersion(w http.ResponseWriter, r *http.Request) { span, _ := tracer.StartSpanFromContext(r.Context(), "getVersion", tracer.ResourceName("/get-version")) defer span.Finish() @@ -356,7 +356,7 @@ func (s *httpServer) getVersion(w http.ResponseWriter, r *http.Request) { } } -func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { +func (s *HttpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { var err error var featureVectors []*onlineserving.FeatureVector @@ -713,29 +713,7 @@ func recoverMiddleware(next http.Handler) http.Handler { // Log the stack trace logStackTrace() - errorType := "Internal Server Error" - errorCode := http.StatusInternalServerError - var errVar error - if err := r.(error); err != nil { - if statusErr, ok := status.FromError(err); ok { - switch statusErr.Code() { - case codes.InvalidArgument: - errorType = "Invalid Argument" - errorCode = http.StatusBadRequest - case codes.NotFound: - errorType = "Not Found" - errorCode = http.StatusNotFound - default: - // For other gRPC errors, we can map them to Internal Server Error - } - errVar = statusErr.Err() - } else { - errVar = err - } - } else { - errVar = fmt.Errorf("%v", r) - } - writeJSONError(w, fmt.Errorf("%s: %v", errorType, errVar), errorCode) + writeJSONError(w, fmt.Errorf("Internal Server Error: %v", r), http.StatusInternalServerError) } }() next.ServeHTTP(w, r) diff --git a/go/internal/feast/server/server_commons.go b/go/internal/feast/server/server_commons.go index ae62b4a3b74..babb408bb1a 100644 --- a/go/internal/feast/server/server_commons.go +++ b/go/internal/feast/server/server_commons.go @@ -32,12 +32,8 @@ func CommonHttpHandlers(s *HttpServer, healthCheckHandler http.HandlerFunc) []Ha HandlerFunc: recoverMiddleware(http.HandlerFunc(s.getOnlineFeaturesRange)), }, { - path: "/version", - handlerFunc: recoverMiddleware(http.HandlerFunc(s.getVersion)), - }, - { - path: "/metrics", - handlerFunc: promhttp.Handler(), + Path: "/version", + HandlerFunc: recoverMiddleware(http.HandlerFunc(s.getVersion)), }, { Path: "/metrics", From 9a9fec429af9a8f0b8814741a3a9f02adfeddb4f Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Tue, 22 Jul 2025 08:34:47 -0500 Subject: [PATCH 18/37] feat: Add search RPCs and sql.py functions --- protos/feast/registry/RegistryServer.proto | 42 +++ sdk/python/feast/expedia_search.py | 381 +++++++++++++++++++++ sdk/python/feast/infra/registry/sql.py | 204 ++++++++++- sdk/python/feast/registry_server.py | 26 ++ 4 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 sdk/python/feast/expedia_search.py diff --git a/protos/feast/registry/RegistryServer.proto b/protos/feast/registry/RegistryServer.proto index f8841b0d7a9..40c6195ddaf 100644 --- a/protos/feast/registry/RegistryServer.proto +++ b/protos/feast/registry/RegistryServer.proto @@ -4,6 +4,7 @@ package feast.registry; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; import "feast/core/DataSource.proto"; import "feast/core/Entity.proto"; import "feast/core/FeatureService.proto"; @@ -93,6 +94,10 @@ service RegistryServer{ rpc Refresh (RefreshRequest) returns (google.protobuf.Empty) {} rpc Proto (google.protobuf.Empty) returns (feast.core.Registry) {} + // Expedia Search RPCs + rpc ExpediaSearchProjects (ExpediaSearchProjectsRequest) returns (ExpediaSearchProjectsResponse) {} + rpc ExpediaSearchFeatureViews (ExpediaSearchFeatureViewsRequest) returns (ExpediaSearchFeatureViewsResponse) {} + } message RefreshRequest { @@ -448,4 +453,41 @@ message ListProjectsResponse { message DeleteProjectRequest { string name = 1; bool commit = 2; +} + +// Expedia Search + +message ExpediaProjectAndRelatedFeatureViews { + feast.core.Project project = 1; + repeated feast.core.FeatureView feature_views = 2; +} + +message ExpediaSearchFeatureViewsRequest { + string search_text = 1; + google.protobuf.BoolValue online = 2; + string application = 3; + string team = 4; + google.protobuf.Timestamp created_at = 5; + google.protobuf.Timestamp updated_at = 6; + int32 page_size = 7; + int32 page_index = 8; +} + +message ExpediaSearchFeatureViewsResponse { + repeated feast.core.FeatureView feature_views = 1; + int32 total_feature_views = 2; + int32 total_page_indices = 3; +} + +message ExpediaSearchProjectsRequest { + string search_text = 1; + google.protobuf.Timestamp updated_at = 2; + int32 page_size = 3; + int32 page_index = 4; +} + +message ExpediaSearchProjectsResponse { + repeated ExpediaProjectAndRelatedFeatureViews projects_and_related_feature_views = 1; + int32 total_projects = 3; + int32 total_page_indices = 4; } \ No newline at end of file diff --git a/sdk/python/feast/expedia_search.py b/sdk/python/feast/expedia_search.py new file mode 100644 index 00000000000..591be539021 --- /dev/null +++ b/sdk/python/feast/expedia_search.py @@ -0,0 +1,381 @@ +from typing import List, Optional + +from google.protobuf.timestamp_pb2 import Timestamp +from google.protobuf.wrappers_pb2 import BoolValue + +from feast.feature_view import FeatureView +from feast.project import Project +from feast.protos.feast.registry.RegistryServer_pb2 import ( + ExpediaProjectAndRelatedFeatureViews as ExpediaProjectAndRelatedFeatureViewsProto, +) +from feast.protos.feast.registry.RegistryServer_pb2 import ( + ExpediaSearchFeatureViewsRequest as ExpediaSearchFeatureViewsRequestProto, +) +from feast.protos.feast.registry.RegistryServer_pb2 import ( + ExpediaSearchFeatureViewsResponse as ExpediaSearchFeatureViewsResponseProto, +) +from feast.protos.feast.registry.RegistryServer_pb2 import ( + ExpediaSearchProjectsRequest as ExpediaSearchProjectsRequestProto, +) +from feast.protos.feast.registry.RegistryServer_pb2 import ( + ExpediaSearchProjectsResponse as ExpediaSearchProjectsResponseProto, +) + + +class ExpediaProjectAndRelatedFeatureViews: + """ + Container for a Project and its related FeatureViews. + + Attributes: + project: The Feast Project object. + feature_views: List of FeatureView objects associated with the project. + """ + + project: Project + feature_views: List[FeatureView] + + def __init__(self, project: Project, feature_views: List[FeatureView]): + """ + Creates an ExpediaProjectAndRelatedFeatureViews object. + + Args: + project: The Feast Project object. + feature_views: List of FeatureView objects associated with the project. + """ + self.project = project + self.feature_views = feature_views + + @classmethod + def from_proto(cls, proto: ExpediaProjectAndRelatedFeatureViewsProto): + """ + Creates an ExpediaProjectAndRelatedFeatureViews object from its protobuf representation. + + Args: + proto: Protobuf representation. + + Returns: + ExpediaProjectAndRelatedFeatureViews object. + """ + return cls( + project=Project.from_proto(proto.project), + feature_views=[FeatureView.from_proto(fv) for fv in proto.feature_views], + ) + + def to_proto(self) -> ExpediaProjectAndRelatedFeatureViewsProto: + """ + Converts this object to its protobuf representation. + + Returns: + ExpediaProjectAndRelatedFeatureViewsProto protobuf. + """ + proto = ExpediaProjectAndRelatedFeatureViewsProto() + proto.project.CopyFrom(self.project.to_proto()) + proto.feature_views.extend([fv.to_proto() for fv in self.feature_views]) + return proto + + +class ExpediaSearchFeatureViewsRequest: + """ + Request object for searching FeatureViews. + + Attributes: + search_text: Text to search for. + online: Whether the feature view is online. + application: Application tag. + team: Team tag. + created_at: Creation timestamp. + updated_at: Last updated timestamp. + page_size: Number of results per page. + page_index: Page index for pagination. + """ + + search_text: str + online: Optional[bool] + application: str + team: str + created_at: Optional[Timestamp] + updated_at: Optional[Timestamp] + page_size: int + page_index: int + + def __init__( + self, + search_text: str = "", + online: Optional[bool] = None, + application: str = "", + team: str = "", + created_at: Optional[Timestamp] = None, + updated_at: Optional[Timestamp] = None, + page_size: int = 10, + page_index: int = 0, + ): + """ + Creates an ExpediaSearchFeatureViewsRequest object. + + Args: + search_text: Text to search for. + online: Whether the feature view is online. + application: Application tag. + team: Team tag. + created_at: Creation timestamp. + updated_at: Last updated timestamp. + page_size: Number of results per page. + page_index: Page index for pagination. + """ + self.search_text = search_text + self.online = online + self.application = application + self.team = team + self.created_at = created_at + self.updated_at = updated_at + self.page_size = page_size + self.page_index = page_index + + @classmethod + def from_proto(cls, proto: ExpediaSearchFeatureViewsRequestProto): + """ + Creates an ExpediaSearchFeatureViewsRequest object from its protobuf representation. + + Args: + proto: Protobuf representation. + + Returns: + ExpediaSearchFeatureViewsRequest object. + """ + online = proto.online.value if proto.HasField("online") else None + created_at = proto.created_at if proto.HasField("created_at") else None + updated_at = proto.updated_at if proto.HasField("updated_at") else None + return cls( + search_text=proto.search_text, + online=online, + application=proto.application, + team=proto.team, + created_at=created_at, + updated_at=updated_at, + page_size=proto.page_size, + page_index=proto.page_index, + ) + + def to_proto(self) -> ExpediaSearchFeatureViewsRequestProto: + """ + Converts this object to its protobuf representation. + + Returns: + ExpediaSearchFeatureViewsRequestProto protobuf. + """ + proto = ExpediaSearchFeatureViewsRequestProto() + proto.search_text = self.search_text + proto.application = self.application + proto.team = self.team + proto.page_size = self.page_size + proto.page_index = self.page_index + if self.online is not None: + proto.online.CopyFrom(BoolValue(value=self.online)) + if self.created_at is not None: + proto.created_at.CopyFrom(self.created_at) + if self.updated_at is not None: + proto.updated_at.CopyFrom(self.updated_at) + return proto + + +class ExpediaSearchFeatureViewsResponse: + """ + Response object for searching FeatureViews. + + Attributes: + feature_views: List of FeatureView objects. + total_feature_views: Total number of feature views found. + total_page_indices: Total number of pages. + """ + + feature_views: List[FeatureView] + total_feature_views: int + total_page_indices: int + + def __init__( + self, + feature_views: List[FeatureView], + total_feature_views: int, + total_page_indices: int, + ): + """ + Creates an ExpediaSearchFeatureViewsResponse object. + + Args: + feature_views: List of FeatureView objects. + total_feature_views: Total number of feature views found. + total_page_indices: Total number of pages. + """ + self.feature_views = feature_views + self.total_feature_views = total_feature_views + self.total_page_indices = total_page_indices + + @classmethod + def from_proto(cls, proto: ExpediaSearchFeatureViewsResponseProto): + """ + Creates an ExpediaSearchFeatureViewsResponse object from its protobuf representation. + + Args: + proto: Protobuf representation. + + Returns: + ExpediaSearchFeatureViewsResponse object. + """ + return cls( + feature_views=[FeatureView.from_proto(fv) for fv in proto.feature_views], + total_feature_views=proto.total_feature_views, + total_page_indices=proto.total_page_indices, + ) + + def to_proto(self) -> ExpediaSearchFeatureViewsResponseProto: + """ + Converts this object to its protobuf representation. + + Returns: + ExpediaSearchFeatureViewsResponseProto protobuf. + """ + proto = ExpediaSearchFeatureViewsResponseProto() + proto.feature_views.extend([fv.to_proto() for fv in self.feature_views]) + proto.total_feature_views = self.total_feature_views + proto.total_page_indices = self.total_page_indices + return proto + + +class ExpediaSearchProjectsRequest: + """ + Request object for searching Projects. + + Attributes: + search_text: Text to search for. + updated_at: Last updated timestamp. + page_size: Number of results per page. + page_index: Page index for pagination. + """ + + search_text: str + updated_at: Optional[Timestamp] + page_size: int + page_index: int + + def __init__( + self, + search_text: str = "", + updated_at: Optional[Timestamp] = None, + page_size: int = 10, + page_index: int = 0, + ): + """ + Creates an ExpediaSearchProjectsRequest object. + + Args: + search_text: Text to search for. + updated_at: Last updated timestamp. + page_size: Number of results per page. + page_index: Page index for pagination. + """ + self.search_text = search_text + self.updated_at = updated_at + self.page_size = page_size + self.page_index = page_index + + @classmethod + def from_proto(cls, proto: ExpediaSearchProjectsRequestProto): + """ + Creates an ExpediaSearchProjectsRequest object from its protobuf representation. + + Args: + proto: Protobuf representation. + + Returns: + ExpediaSearchProjectsRequest object. + """ + updated_at = proto.updated_at if proto.HasField("updated_at") else None + return cls( + search_text=proto.search_text, + updated_at=updated_at, + page_size=proto.page_size, + page_index=proto.page_index, + ) + + def to_proto(self) -> ExpediaSearchProjectsRequestProto: + """ + Converts this object to its protobuf representation. + + Returns: + ExpediaSearchProjectsRequestProto protobuf. + """ + proto = ExpediaSearchProjectsRequestProto() + proto.search_text = self.search_text + proto.page_size = self.page_size + proto.page_index = self.page_index + if self.updated_at is not None: + proto.updated_at.CopyFrom(self.updated_at) + return proto + + +class ExpediaSearchProjectsResponse: + """ + Response object for searching Projects. + + Attributes: + projects_and_related_feature_views: List of ExpediaProjectAndRelatedFeatureViews objects. + total_projects: Total number of projects found. + total_page_indices: Total number of pages. + """ + + projects_and_related_feature_views: List[ExpediaProjectAndRelatedFeatureViews] + total_projects: int + total_page_indices: int + + def __init__( + self, + projects_and_related_feature_views: List[ExpediaProjectAndRelatedFeatureViews], + total_projects: int, + total_page_indices: int, + ): + """ + Creates an ExpediaSearchProjectsResponse object. + + Args: + projects_and_related_feature_views: List of ExpediaProjectAndRelatedFeatureViews objects. + total_projects: Total number of projects found. + total_page_indices: Total number of pages. + """ + self.projects_and_related_feature_views = projects_and_related_feature_views + self.total_projects = total_projects + self.total_page_indices = total_page_indices + + @classmethod + def from_proto(cls, proto: ExpediaSearchProjectsResponseProto): + """ + Creates an ExpediaSearchProjectsResponse object from its protobuf representation. + + Args: + proto: Protobuf representation. + + Returns: + ExpediaSearchProjectsResponse object. + """ + return cls( + projects_and_related_feature_views=[ + ExpediaProjectAndRelatedFeatureViews.from_proto(p) + for p in proto.projects_and_related_feature_views + ], + total_projects=proto.total_projects, + total_page_indices=proto.total_page_indices, + ) + + def to_proto(self) -> ExpediaSearchProjectsResponseProto: + """ + Converts this object to its protobuf representation. + + Returns: + ExpediaSearchProjectsResponseProto protobuf. + """ + proto = ExpediaSearchProjectsResponseProto() + proto.projects_and_related_feature_views.extend( + [p.to_proto() for p in self.projects_and_related_feature_views] + ) + proto.total_projects = self.total_projects + proto.total_page_indices = self.total_page_indices + return proto diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 827f4579142..d7313dc0859 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1,7 +1,7 @@ import logging import time import uuid -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from enum import Enum from pathlib import Path @@ -18,6 +18,7 @@ Table, create_engine, delete, + func, insert, select, update, @@ -40,6 +41,11 @@ SortedFeatureViewNotFoundException, ValidationReferenceNotFound, ) +from feast.expedia_search import ( + ExpediaProjectAndRelatedFeatureViews, + ExpediaSearchFeatureViewsResponse, + ExpediaSearchProjectsResponse, +) from feast.expediagroup.pydantic_models.project_metadata_model import ( ProjectMetadataModel, ) @@ -1439,3 +1445,199 @@ def get_project_metadata( datetime.utcfromtimestamp(int(metadata_value)) ) return project_metadata_model + + def expedia_search_projects( + self, + search_text: str = "", + updated_at: Optional[datetime] = None, + page_size: int = 10, + page_index: int = 0, + ) -> ExpediaSearchProjectsResponse: + # 1. Query projects table for matching projects + with self.engine.begin() as conn: + count_stmt = select(func.count(projects.c.project_id.distinct())) + if search_text: + count_stmt = count_stmt.where( + projects.c.project_id.like(f"%{search_text}%") + ) + if updated_at is not None: + updated_at_timestamp = int(updated_at.timestamp()) + count_stmt = count_stmt.where( + projects.c.last_updated_timestamp >= updated_at_timestamp + ) + total_count = conn.execute(count_stmt).scalar() or 0 + total_page_indices = (total_count + page_size - 1) // page_size + + stmt = ( + select( + projects.c.project_id, + projects.c.project_proto, + ) + .order_by(projects.c.project_id) + .limit(page_size) + .offset(page_index * page_size) + ) + if search_text: + stmt = stmt.where(projects.c.project_id.like(f"%{search_text}%")) + if updated_at is not None: + updated_at_timestamp = int(updated_at.timestamp()) + stmt = stmt.where( + projects.c.last_updated_timestamp >= updated_at_timestamp + ) + rows = conn.execute(stmt).all() + + project_objs = [] + project_ids = [] + for row in rows: + project_id = row._mapping["project_id"] + project_proto = ProjectProto.FromString(row._mapping["project_proto"]) + project = Project.from_proto(project_proto) + project_objs.append(project) + project_ids.append(project_id) + + # 2. Fetch all feature views for these projects + with self.engine.begin() as conn: + feature_views_stmt = select( + feature_views.c.project_id, feature_views.c.feature_view_proto + ).where(feature_views.c.project_id.in_(project_ids)) + feature_view_rows = conn.execute(feature_views_stmt).all() + + feature_views_by_project: Dict[str, List[FeatureView]] = {} + for row in feature_view_rows: + project_id = row._mapping["project_id"] + feature_view_proto = FeatureViewProto.FromString( + row._mapping["feature_view_proto"] + ) + feature_view_proto.spec.project = project_id + fv = FeatureView.from_proto(feature_view_proto) + feature_views_by_project.setdefault(project_id, []).append(fv) + + # 3. Build ExpediaProjectAndRelatedFeatureViews objects + def process_project(project): + return ExpediaProjectAndRelatedFeatureViews( + project=project, + feature_views=feature_views_by_project.get(project.name, []), + ) + + projects_and_related_feature_views = [] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(process_project, project) for project in project_objs + ] + for future in as_completed(futures): + try: + projects_and_related_feature_views.append(future.result()) + except Exception as e: + logger.error(f"Error processing project: {e}") + + projects_and_related_feature_views.sort( + key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" + ) + + return ExpediaSearchProjectsResponse( + projects_and_related_feature_views=projects_and_related_feature_views, + total_projects=total_count, + total_page_indices=total_page_indices, + ) + + def expedia_search_feature_views( + self, + search_text: Optional[str] = None, + online: Optional[bool] = None, + application: Optional[str] = None, + team: Optional[str] = None, + created_at: Optional[datetime] = None, + updated_at: Optional[datetime] = None, + page_size: int = 10, + page_index: int = 0, + ) -> ExpediaSearchFeatureViewsResponse: + offset = page_index * page_size + results = [] + filtered_results = [] + + in_memory_filtering_required = any( + [online is not None, application, team, created_at, updated_at] + ) + + with self.engine.begin() as conn: + if not in_memory_filtering_required: + stmt = ( + select(feature_views) + .where(feature_views.c.feature_view_name.like(f"%{search_text}%")) + .order_by(feature_views.c.feature_view_name) + .limit(page_size) + .offset(offset) + ) + + rows = conn.execute(stmt).all() + + for row in rows: + feature_view_proto = FeatureViewProto.FromString( + row._mapping["feature_view_proto"] + ) + feature_view_proto.spec.project = row._mapping["project_id"] + fv = FeatureView.from_proto(feature_view_proto) + results.append(fv) + + total_stmt = ( + select(func.count()) + .select_from(feature_views) + .where(feature_views.c.feature_view_name.like(f"%{search_text}%")) + ) + total_count = conn.execute(total_stmt).scalar() or 0 + total_page_indices = (total_count + page_size - 1) // page_size + + return ExpediaSearchFeatureViewsResponse( + feature_views=results, + total_feature_views=total_count, + total_page_indices=total_page_indices, + ) + + stmt = select(feature_views) + if search_text: + stmt = stmt.where( + feature_views.c.feature_view_name.like(f"%{search_text}%") + ).order_by(feature_views.c.feature_view_name) + + rows = conn.execute(stmt).all() + + for row in rows: + feature_view_proto = FeatureViewProto.FromString( + row._mapping["feature_view_proto"] + ) + feature_view_proto.spec.project = row._mapping["project_id"] + fv = FeatureView.from_proto(feature_view_proto) + add_to_results = True + + if online is not None and fv.online != online: + add_to_results = False + + if application and fv.tags.get("application") != application: + add_to_results = False + + if team and fv.tags.get("team") != team: + add_to_results = False + + if created_at: + if fv.created_timestamp and fv.created_timestamp < created_at: + add_to_results = False + + if updated_at is not None: + if ( + fv.last_updated_timestamp + and fv.last_updated_timestamp < updated_at + ): + add_to_results = False + + if add_to_results: + filtered_results.append(fv) + + total_count = len(filtered_results) + total_page_indices = (total_count + page_size - 1) // page_size + paginated_results = filtered_results[offset : offset + page_size] + + return ExpediaSearchFeatureViewsResponse( + feature_views=paginated_results, + total_feature_views=total_count, + total_page_indices=total_page_indices, + ) diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index ef307446c20..5573040fdf4 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -797,6 +797,32 @@ def Refresh(self, request, context): def Proto(self, request, context): return self.proxied_registry.proto() + def ExpediaSearchProjects( + self, request: RegistryServer_pb2.ExpediaSearchProjectsRequest, context + ): + response = self.proxied_registry.expedia_search_projects( + search_text=request.search_text, + updated_at=request.updated_at, + page_size=request.page_size, + page_index=request.page_index, + ) + return response.to_proto() + + def ExpediaSearchFeatureViews( + self, request: RegistryServer_pb2.ExpediaSearchFeatureViewsRequest, context + ): + response = self.proxied_registry.expedia_search_feature_views( + search_text=request.search_text, + online=request.online, + application=request.application, + team=request.team, + created_at=request.created_at, + updated_at=request.updated_at, + page_size=request.page_size, + page_index=request.page_index, + ) + return response.to_proto() + def start_server(store: FeatureStore, port: int, wait_for_termination: bool = True): auth_manager_type = str_to_auth_manager_type(store.config.auth_config.type) From 7ac9a312b76c5b4ed49db40d066f132f93d7f248 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Tue, 22 Jul 2025 08:51:34 -0500 Subject: [PATCH 19/37] fix: linting --- sdk/python/feast/infra/registry/sql.py | 6 +++--- sdk/python/feast/registry_server.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index d7313dc0859..1bd6474f76a 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1454,7 +1454,7 @@ def expedia_search_projects( page_index: int = 0, ) -> ExpediaSearchProjectsResponse: # 1. Query projects table for matching projects - with self.engine.begin() as conn: + with self.read_engine.begin() as conn: count_stmt = select(func.count(projects.c.project_id.distinct())) if search_text: count_stmt = count_stmt.where( @@ -1496,7 +1496,7 @@ def expedia_search_projects( project_ids.append(project_id) # 2. Fetch all feature views for these projects - with self.engine.begin() as conn: + with self.read_engine.begin() as conn: feature_views_stmt = select( feature_views.c.project_id, feature_views.c.feature_view_proto ).where(feature_views.c.project_id.in_(project_ids)) @@ -1559,7 +1559,7 @@ def expedia_search_feature_views( [online is not None, application, team, created_at, updated_at] ) - with self.engine.begin() as conn: + with self.read_engine.begin() as conn: if not in_memory_filtering_required: stmt = ( select(feature_views) diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 5573040fdf4..8a795a1b58c 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -800,7 +800,8 @@ def Proto(self, request, context): def ExpediaSearchProjects( self, request: RegistryServer_pb2.ExpediaSearchProjectsRequest, context ): - response = self.proxied_registry.expedia_search_projects( + # Using `type: ignore[attr-defined]` because this should only be implemented in sql registry. + response = self.proxied_registry.expedia_search_projects( # type: ignore[attr-defined] search_text=request.search_text, updated_at=request.updated_at, page_size=request.page_size, @@ -811,7 +812,8 @@ def ExpediaSearchProjects( def ExpediaSearchFeatureViews( self, request: RegistryServer_pb2.ExpediaSearchFeatureViewsRequest, context ): - response = self.proxied_registry.expedia_search_feature_views( + # Using `type: ignore[attr-defined]` because this should only be implemented in sql registry. + response = self.proxied_registry.expedia_search_feature_views( # type: ignore[attr-defined] search_text=request.search_text, online=request.online, application=request.application, From 4ff69ede97eceeafdca16f9ec1e6736e59ea6041 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Tue, 22 Jul 2025 10:53:56 -0500 Subject: [PATCH 20/37] fix: default values if left empty in request in registry_server.py --- sdk/python/feast/registry_server.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 8a795a1b58c..6560ee68249 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -800,11 +800,16 @@ def Proto(self, request, context): def ExpediaSearchProjects( self, request: RegistryServer_pb2.ExpediaSearchProjectsRequest, context ): + # Convert gRPC Timestamp to Python datetime + updated_at = request.updated_at.ToDatetime() + # empty gRPC int defaults to 0, which breaks the search + page_size = request.page_size if request.page_size > 0 else 10 + # Using `type: ignore[attr-defined]` because this should only be implemented in sql registry. response = self.proxied_registry.expedia_search_projects( # type: ignore[attr-defined] search_text=request.search_text, - updated_at=request.updated_at, - page_size=request.page_size, + updated_at=updated_at, + page_size=page_size, page_index=request.page_index, ) return response.to_proto() @@ -812,15 +817,25 @@ def ExpediaSearchProjects( def ExpediaSearchFeatureViews( self, request: RegistryServer_pb2.ExpediaSearchFeatureViewsRequest, context ): + # request.online is of type google.protobuf.BoolValue to handle empty values default them to None + online = request.online.value if request.HasField('online') else None + + # Convert google.protobuf.Timestamp to Python datetime + created_at = request.created_at.ToDatetime() + updated_at = request.updated_at.ToDatetime() + + # empty gRPC int defaults to 0, which breaks the search + page_size = request.page_size if request.page_size > 0 else 10 + # Using `type: ignore[attr-defined]` because this should only be implemented in sql registry. response = self.proxied_registry.expedia_search_feature_views( # type: ignore[attr-defined] search_text=request.search_text, - online=request.online, + online=online, application=request.application, team=request.team, - created_at=request.created_at, - updated_at=request.updated_at, - page_size=request.page_size, + created_at=created_at, + updated_at=updated_at, + page_size=page_size, page_index=request.page_index, ) return response.to_proto() From 19c61f610c4e74da6771a9601ab7a1192f7b0f2e Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Tue, 22 Jul 2025 10:55:24 -0500 Subject: [PATCH 21/37] fix: formatting --- sdk/python/feast/registry_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 6560ee68249..dd8342bc0c4 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -804,7 +804,7 @@ def ExpediaSearchProjects( updated_at = request.updated_at.ToDatetime() # empty gRPC int defaults to 0, which breaks the search page_size = request.page_size if request.page_size > 0 else 10 - + # Using `type: ignore[attr-defined]` because this should only be implemented in sql registry. response = self.proxied_registry.expedia_search_projects( # type: ignore[attr-defined] search_text=request.search_text, @@ -818,7 +818,7 @@ def ExpediaSearchFeatureViews( self, request: RegistryServer_pb2.ExpediaSearchFeatureViewsRequest, context ): # request.online is of type google.protobuf.BoolValue to handle empty values default them to None - online = request.online.value if request.HasField('online') else None + online = request.online.value if request.HasField("online") else None # Convert google.protobuf.Timestamp to Python datetime created_at = request.created_at.ToDatetime() From e03e5f923930cde445e20b513ef823ea78705982 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Tue, 22 Jul 2025 14:41:19 -0500 Subject: [PATCH 22/37] chore: pass requests as python classes from protos --- sdk/python/feast/expedia_search.py | 64 ++++++++++++++++++++------ sdk/python/feast/infra/registry/sql.py | 36 ++++++++++----- sdk/python/feast/registry_server.py | 33 +++---------- 3 files changed, 79 insertions(+), 54 deletions(-) diff --git a/sdk/python/feast/expedia_search.py b/sdk/python/feast/expedia_search.py index 591be539021..816c867c303 100644 --- a/sdk/python/feast/expedia_search.py +++ b/sdk/python/feast/expedia_search.py @@ -1,6 +1,6 @@ +from datetime import datetime from typing import List, Optional -from google.protobuf.timestamp_pb2 import Timestamp from google.protobuf.wrappers_pb2 import BoolValue from feast.feature_view import FeatureView @@ -93,8 +93,8 @@ class ExpediaSearchFeatureViewsRequest: online: Optional[bool] application: str team: str - created_at: Optional[Timestamp] - updated_at: Optional[Timestamp] + created_at: Optional[datetime] + updated_at: Optional[datetime] page_size: int page_index: int @@ -104,8 +104,8 @@ def __init__( online: Optional[bool] = None, application: str = "", team: str = "", - created_at: Optional[Timestamp] = None, - updated_at: Optional[Timestamp] = None, + created_at: Optional[datetime] = None, + updated_at: Optional[datetime] = None, page_size: int = 10, page_index: int = 0, ): @@ -131,6 +131,21 @@ def __init__( self.page_size = page_size self.page_index = page_index + def __iter__(self): + """ + Allows iteration over the attributes of the request. + """ + yield from ( + self.search_text, + self.online, + self.application, + self.team, + self.created_at, + self.updated_at, + self.page_size, + self.page_index, + ) + @classmethod def from_proto(cls, proto: ExpediaSearchFeatureViewsRequestProto): """ @@ -143,8 +158,13 @@ def from_proto(cls, proto: ExpediaSearchFeatureViewsRequestProto): ExpediaSearchFeatureViewsRequest object. """ online = proto.online.value if proto.HasField("online") else None - created_at = proto.created_at if proto.HasField("created_at") else None - updated_at = proto.updated_at if proto.HasField("updated_at") else None + created_at = ( + proto.created_at.ToDatetime() if proto.HasField("created_at") else None + ) + updated_at = ( + proto.updated_at.ToDatetime() if proto.HasField("updated_at") else None + ) + page_size = proto.page_size if proto.page_size > 0 else 10 return cls( search_text=proto.search_text, online=online, @@ -152,7 +172,7 @@ def from_proto(cls, proto: ExpediaSearchFeatureViewsRequestProto): team=proto.team, created_at=created_at, updated_at=updated_at, - page_size=proto.page_size, + page_size=page_size, page_index=proto.page_index, ) @@ -172,9 +192,9 @@ def to_proto(self) -> ExpediaSearchFeatureViewsRequestProto: if self.online is not None: proto.online.CopyFrom(BoolValue(value=self.online)) if self.created_at is not None: - proto.created_at.CopyFrom(self.created_at) + proto.created_at.FromDatetime(self.created_at) if self.updated_at is not None: - proto.updated_at.CopyFrom(self.updated_at) + proto.updated_at.FromDatetime(self.updated_at) return proto @@ -253,14 +273,14 @@ class ExpediaSearchProjectsRequest: """ search_text: str - updated_at: Optional[Timestamp] + updated_at: Optional[datetime] page_size: int page_index: int def __init__( self, search_text: str = "", - updated_at: Optional[Timestamp] = None, + updated_at: Optional[datetime] = None, page_size: int = 10, page_index: int = 0, ): @@ -278,6 +298,17 @@ def __init__( self.page_size = page_size self.page_index = page_index + def __iter__(self): + """ + Allows iteration over the attributes of the request. + """ + yield from ( + self.search_text, + self.updated_at, + self.page_size, + self.page_index, + ) + @classmethod def from_proto(cls, proto: ExpediaSearchProjectsRequestProto): """ @@ -289,11 +320,14 @@ def from_proto(cls, proto: ExpediaSearchProjectsRequestProto): Returns: ExpediaSearchProjectsRequest object. """ - updated_at = proto.updated_at if proto.HasField("updated_at") else None + updated_at = ( + proto.updated_at.ToDatetime() if proto.HasField("updated_at") else None + ) + page_size = proto.page_size if proto.page_size > 0 else 10 return cls( search_text=proto.search_text, updated_at=updated_at, - page_size=proto.page_size, + page_size=page_size, page_index=proto.page_index, ) @@ -309,7 +343,7 @@ def to_proto(self) -> ExpediaSearchProjectsRequestProto: proto.page_size = self.page_size proto.page_index = self.page_index if self.updated_at is not None: - proto.updated_at.CopyFrom(self.updated_at) + proto.updated_at.FromDatetime(self.updated_at) return proto diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 1bd6474f76a..616c73ae10a 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -43,7 +43,9 @@ ) from feast.expedia_search import ( ExpediaProjectAndRelatedFeatureViews, + ExpediaSearchFeatureViewsRequest, ExpediaSearchFeatureViewsResponse, + ExpediaSearchProjectsRequest, ExpediaSearchProjectsResponse, ) from feast.expediagroup.pydantic_models.project_metadata_model import ( @@ -1448,11 +1450,16 @@ def get_project_metadata( def expedia_search_projects( self, - search_text: str = "", - updated_at: Optional[datetime] = None, - page_size: int = 10, - page_index: int = 0, + request: ExpediaSearchProjectsRequest, ) -> ExpediaSearchProjectsResponse: + # Unpack fields from the request object + ( + search_text, + updated_at, + page_size, + page_index, + ) = request + # 1. Query projects table for matching projects with self.read_engine.begin() as conn: count_stmt = select(func.count(projects.c.project_id.distinct())) @@ -1542,15 +1549,20 @@ def process_project(project): def expedia_search_feature_views( self, - search_text: Optional[str] = None, - online: Optional[bool] = None, - application: Optional[str] = None, - team: Optional[str] = None, - created_at: Optional[datetime] = None, - updated_at: Optional[datetime] = None, - page_size: int = 10, - page_index: int = 0, + request: ExpediaSearchFeatureViewsRequest, ) -> ExpediaSearchFeatureViewsResponse: + # Unpack fields from the request object + ( + search_text, + online, + application, + team, + created_at, + updated_at, + page_size, + page_index, + ) = request + offset = page_index * page_size results = [] filtered_results = [] diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index dd8342bc0c4..96fccc4aad8 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -12,6 +12,10 @@ from feast.data_source import DataSource from feast.entity import Entity from feast.errors import FeatureViewNotFoundException +from feast.expedia_search import ( + ExpediaSearchFeatureViewsRequest, + ExpediaSearchProjectsRequest, +) from feast.feast_object import FeastObject from feast.feature_view import FeatureView from feast.grpc_error_interceptor import ErrorInterceptor @@ -800,43 +804,18 @@ def Proto(self, request, context): def ExpediaSearchProjects( self, request: RegistryServer_pb2.ExpediaSearchProjectsRequest, context ): - # Convert gRPC Timestamp to Python datetime - updated_at = request.updated_at.ToDatetime() - # empty gRPC int defaults to 0, which breaks the search - page_size = request.page_size if request.page_size > 0 else 10 - # Using `type: ignore[attr-defined]` because this should only be implemented in sql registry. response = self.proxied_registry.expedia_search_projects( # type: ignore[attr-defined] - search_text=request.search_text, - updated_at=updated_at, - page_size=page_size, - page_index=request.page_index, + request=ExpediaSearchProjectsRequest.from_proto(request) ) return response.to_proto() def ExpediaSearchFeatureViews( self, request: RegistryServer_pb2.ExpediaSearchFeatureViewsRequest, context ): - # request.online is of type google.protobuf.BoolValue to handle empty values default them to None - online = request.online.value if request.HasField("online") else None - - # Convert google.protobuf.Timestamp to Python datetime - created_at = request.created_at.ToDatetime() - updated_at = request.updated_at.ToDatetime() - - # empty gRPC int defaults to 0, which breaks the search - page_size = request.page_size if request.page_size > 0 else 10 - # Using `type: ignore[attr-defined]` because this should only be implemented in sql registry. response = self.proxied_registry.expedia_search_feature_views( # type: ignore[attr-defined] - search_text=request.search_text, - online=online, - application=request.application, - team=request.team, - created_at=created_at, - updated_at=updated_at, - page_size=page_size, - page_index=request.page_index, + request=ExpediaSearchFeatureViewsRequest.from_proto(request) ) return response.to_proto() From 568ce221386c6d11406018e056cf10530551213f Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Wed, 23 Jul 2025 08:55:46 -0500 Subject: [PATCH 23/37] feat: add tests for expediagroup/search.py objects --- .../search.py} | 56 ++++++++ sdk/python/feast/infra/registry/sql.py | 2 +- sdk/python/feast/registry_server.py | 2 +- sdk/python/tests/expediagroup/test_search.py | 125 ++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) rename sdk/python/feast/{expedia_search.py => expediagroup/search.py} (84%) create mode 100644 sdk/python/tests/expediagroup/test_search.py diff --git a/sdk/python/feast/expedia_search.py b/sdk/python/feast/expediagroup/search.py similarity index 84% rename from sdk/python/feast/expedia_search.py rename to sdk/python/feast/expediagroup/search.py index 816c867c303..d4c377adc8b 100644 --- a/sdk/python/feast/expedia_search.py +++ b/sdk/python/feast/expediagroup/search.py @@ -44,6 +44,11 @@ def __init__(self, project: Project, feature_views: List[FeatureView]): """ self.project = project self.feature_views = feature_views + + def __eq__(self, other): + if not isinstance(other, ExpediaProjectAndRelatedFeatureViews): + return False + return self.project == other.project and self.feature_views == other.feature_views @classmethod def from_proto(cls, proto: ExpediaProjectAndRelatedFeatureViewsProto): @@ -197,6 +202,26 @@ def to_proto(self) -> ExpediaSearchFeatureViewsRequestProto: proto.updated_at.FromDatetime(self.updated_at) return proto + def __eq__(self, other): + if not isinstance(other, ExpediaSearchFeatureViewsRequest): + return False + return ( + self.search_text == other.search_text and + self.online == other.online and + self.application == other.application and + self.team == other.team and + ( + (self.created_at is None and other.created_at is None) or + (self.created_at is not None and other.created_at is not None and self.created_at.timestamp() == other.created_at.timestamp()) + ) and + ( + (self.updated_at is None and other.updated_at is None) or + (self.updated_at is not None and other.updated_at is not None and self.updated_at.timestamp() == other.updated_at.timestamp()) + ) and + self.page_size == other.page_size and + self.page_index == other.page_index + ) + class ExpediaSearchFeatureViewsResponse: """ @@ -260,6 +285,15 @@ def to_proto(self) -> ExpediaSearchFeatureViewsResponseProto: proto.total_page_indices = self.total_page_indices return proto + def __eq__(self, other): + if not isinstance(other, ExpediaSearchFeatureViewsResponse): + return False + return ( + self.feature_views == other.feature_views and + self.total_feature_views == other.total_feature_views and + self.total_page_indices == other.total_page_indices + ) + class ExpediaSearchProjectsRequest: """ @@ -346,6 +380,19 @@ def to_proto(self) -> ExpediaSearchProjectsRequestProto: proto.updated_at.FromDatetime(self.updated_at) return proto + def __eq__(self, other): + if not isinstance(other, ExpediaSearchProjectsRequest): + return False + return ( + self.search_text == other.search_text and + ( + (self.updated_at is None and other.updated_at is None) or + (self.updated_at is not None and other.updated_at is not None and self.updated_at.timestamp() == other.updated_at.timestamp()) + ) and + self.page_size == other.page_size and + self.page_index == other.page_index + ) + class ExpediaSearchProjectsResponse: """ @@ -413,3 +460,12 @@ def to_proto(self) -> ExpediaSearchProjectsResponseProto: proto.total_projects = self.total_projects proto.total_page_indices = self.total_page_indices return proto + + def __eq__(self, other): + if not isinstance(other, ExpediaSearchProjectsResponse): + return False + return ( + self.projects_and_related_feature_views == other.projects_and_related_feature_views and + self.total_projects == other.total_projects and + self.total_page_indices == other.total_page_indices + ) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 616c73ae10a..7298dffd882 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -41,7 +41,7 @@ SortedFeatureViewNotFoundException, ValidationReferenceNotFound, ) -from feast.expedia_search import ( +from feast.expediagroup.search import ( ExpediaProjectAndRelatedFeatureViews, ExpediaSearchFeatureViewsRequest, ExpediaSearchFeatureViewsResponse, diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 96fccc4aad8..54e51cb685c 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -12,7 +12,7 @@ from feast.data_source import DataSource from feast.entity import Entity from feast.errors import FeatureViewNotFoundException -from feast.expedia_search import ( +from feast.expediagroup.search import ( ExpediaSearchFeatureViewsRequest, ExpediaSearchProjectsRequest, ) diff --git a/sdk/python/tests/expediagroup/test_search.py b/sdk/python/tests/expediagroup/test_search.py new file mode 100644 index 00000000000..aeda462c8a1 --- /dev/null +++ b/sdk/python/tests/expediagroup/test_search.py @@ -0,0 +1,125 @@ +import unittest +from datetime import datetime + +from feast.expediagroup.search import ( + ExpediaProjectAndRelatedFeatureViews, + ExpediaSearchFeatureViewsRequest, + ExpediaSearchFeatureViewsResponse, + ExpediaSearchProjectsRequest, + ExpediaSearchProjectsResponse, +) +from feast.feature_view import FeatureView +from feast.infra.offline_stores.file_source import FileSource +from feast.project import Project + + +class TestExpediaSearch(unittest.TestCase): + def setUp(self): + self.project = Project(name="test_project") + self.source = FileSource(path="test_path") + self.feature_view = FeatureView(name="test_feature_view", source=self.source) + self.created_at = datetime(2024, 1, 1, 12, 0, 0) + self.updated_at = datetime(2024, 1, 2, 13, 30, 0) + + def test_expedia_project_and_related_feature_views_init(self): + obj = ExpediaProjectAndRelatedFeatureViews( + project=self.project, + feature_views=[self.feature_view], + ) + self.assertEqual(obj.project, self.project) + self.assertEqual(obj.feature_views, [self.feature_view]) + + def test_expedia_project_and_related_feature_views_proto_roundtrip(self): + obj = ExpediaProjectAndRelatedFeatureViews( + project=self.project, + feature_views=[self.feature_view], + ) + proto = obj.to_proto() + obj2 = ExpediaProjectAndRelatedFeatureViews.from_proto(proto) + self.assertEqual(obj, obj2) + + def test_expedia_search_feature_views_request_init(self): + req = ExpediaSearchFeatureViewsRequest( + search_text="foo", + online=True, + application="app", + team="team", + created_at=self.created_at, + updated_at=self.updated_at, + page_size=5, + page_index=2, + ) + self.assertEqual(req.search_text, "foo") + self.assertEqual(req.online, True) + self.assertEqual(req.application, "app") + self.assertEqual(req.team, "team") + self.assertEqual(req.created_at, self.created_at) + self.assertEqual(req.updated_at, self.updated_at) + self.assertEqual(req.page_size, 5) + self.assertEqual(req.page_index, 2) + + def test_expedia_search_feature_views_request_proto_roundtrip(self): + req = ExpediaSearchFeatureViewsRequest( + search_text="foo", + online=False, + application="app", + team="team", + created_at=self.created_at, + updated_at=self.updated_at, + page_size=7, + page_index=3, + ) + proto = req.to_proto() + req2 = ExpediaSearchFeatureViewsRequest.from_proto(proto) + self.assertEqual(req, req2) + + def test_expedia_search_feature_views_response_proto_roundtrip(self): + resp = ExpediaSearchFeatureViewsResponse( + feature_views=[self.feature_view], + total_feature_views=1, + total_page_indices=1, + ) + proto = resp.to_proto() + resp2 = ExpediaSearchFeatureViewsResponse.from_proto(proto) + self.assertEqual(resp, resp2) + + def test_expedia_search_projects_request_init(self): + req = ExpediaSearchProjectsRequest( + search_text="bar", + updated_at=self.updated_at, + page_size=8, + page_index=4, + ) + self.assertEqual(req.search_text, "bar") + self.assertEqual(req.updated_at, self.updated_at) + self.assertEqual(req.page_size, 8) + self.assertEqual(req.page_index, 4) + + def test_expedia_search_projects_request_proto_roundtrip(self): + req = ExpediaSearchProjectsRequest( + search_text="bar", + updated_at=self.updated_at, + page_size=8, + page_index=4, + ) + proto = req.to_proto() + req2 = ExpediaSearchProjectsRequest.from_proto(proto) + self.assertEqual(req, req2) + + def test_expedia_search_projects_response_proto_roundtrip(self): + obj = ExpediaProjectAndRelatedFeatureViews( + project=self.project, + feature_views=[self.feature_view], + ) + resp = ExpediaSearchProjectsResponse( + projects_and_related_feature_views=[obj], + total_projects=1, + total_page_indices=1, + ) + proto = resp.to_proto() + resp2 = ExpediaSearchProjectsResponse.from_proto(proto) + self.assertEqual(resp, resp2) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From b825edc2c84a2c6dc0eac782a96dc31dedd67def Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Wed, 23 Jul 2025 09:13:49 -0500 Subject: [PATCH 24/37] fix: formatting --- sdk/python/feast/expediagroup/search.py | 73 ++++++++++++-------- sdk/python/feast/infra/registry/sql.py | 6 +- sdk/python/tests/expediagroup/test_search.py | 2 +- 3 files changed, 48 insertions(+), 33 deletions(-) diff --git a/sdk/python/feast/expediagroup/search.py b/sdk/python/feast/expediagroup/search.py index d4c377adc8b..42e215a0a0a 100644 --- a/sdk/python/feast/expediagroup/search.py +++ b/sdk/python/feast/expediagroup/search.py @@ -44,11 +44,13 @@ def __init__(self, project: Project, feature_views: List[FeatureView]): """ self.project = project self.feature_views = feature_views - + def __eq__(self, other): if not isinstance(other, ExpediaProjectAndRelatedFeatureViews): return False - return self.project == other.project and self.feature_views == other.feature_views + return ( + self.project == other.project and self.feature_views == other.feature_views + ) @classmethod def from_proto(cls, proto: ExpediaProjectAndRelatedFeatureViewsProto): @@ -206,20 +208,28 @@ def __eq__(self, other): if not isinstance(other, ExpediaSearchFeatureViewsRequest): return False return ( - self.search_text == other.search_text and - self.online == other.online and - self.application == other.application and - self.team == other.team and - ( - (self.created_at is None and other.created_at is None) or - (self.created_at is not None and other.created_at is not None and self.created_at.timestamp() == other.created_at.timestamp()) - ) and - ( - (self.updated_at is None and other.updated_at is None) or - (self.updated_at is not None and other.updated_at is not None and self.updated_at.timestamp() == other.updated_at.timestamp()) - ) and - self.page_size == other.page_size and - self.page_index == other.page_index + self.search_text == other.search_text + and self.online == other.online + and self.application == other.application + and self.team == other.team + and ( + (self.created_at is None and other.created_at is None) + or ( + self.created_at is not None + and other.created_at is not None + and self.created_at.timestamp() == other.created_at.timestamp() + ) + ) + and ( + (self.updated_at is None and other.updated_at is None) + or ( + self.updated_at is not None + and other.updated_at is not None + and self.updated_at.timestamp() == other.updated_at.timestamp() + ) + ) + and self.page_size == other.page_size + and self.page_index == other.page_index ) @@ -289,9 +299,9 @@ def __eq__(self, other): if not isinstance(other, ExpediaSearchFeatureViewsResponse): return False return ( - self.feature_views == other.feature_views and - self.total_feature_views == other.total_feature_views and - self.total_page_indices == other.total_page_indices + self.feature_views == other.feature_views + and self.total_feature_views == other.total_feature_views + and self.total_page_indices == other.total_page_indices ) @@ -384,13 +394,17 @@ def __eq__(self, other): if not isinstance(other, ExpediaSearchProjectsRequest): return False return ( - self.search_text == other.search_text and - ( - (self.updated_at is None and other.updated_at is None) or - (self.updated_at is not None and other.updated_at is not None and self.updated_at.timestamp() == other.updated_at.timestamp()) - ) and - self.page_size == other.page_size and - self.page_index == other.page_index + self.search_text == other.search_text + and ( + (self.updated_at is None and other.updated_at is None) + or ( + self.updated_at is not None + and other.updated_at is not None + and self.updated_at.timestamp() == other.updated_at.timestamp() + ) + ) + and self.page_size == other.page_size + and self.page_index == other.page_index ) @@ -465,7 +479,8 @@ def __eq__(self, other): if not isinstance(other, ExpediaSearchProjectsResponse): return False return ( - self.projects_and_related_feature_views == other.projects_and_related_feature_views and - self.total_projects == other.total_projects and - self.total_page_indices == other.total_page_indices + self.projects_and_related_feature_views + == other.projects_and_related_feature_views + and self.total_projects == other.total_projects + and self.total_page_indices == other.total_page_indices ) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 7298dffd882..3e5d2895291 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -41,6 +41,9 @@ SortedFeatureViewNotFoundException, ValidationReferenceNotFound, ) +from feast.expediagroup.pydantic_models.project_metadata_model import ( + ProjectMetadataModel, +) from feast.expediagroup.search import ( ExpediaProjectAndRelatedFeatureViews, ExpediaSearchFeatureViewsRequest, @@ -48,9 +51,6 @@ ExpediaSearchProjectsRequest, ExpediaSearchProjectsResponse, ) -from feast.expediagroup.pydantic_models.project_metadata_model import ( - ProjectMetadataModel, -) from feast.feature_service import FeatureService from feast.feature_view import FeatureView from feast.infra.infra_object import Infra diff --git a/sdk/python/tests/expediagroup/test_search.py b/sdk/python/tests/expediagroup/test_search.py index aeda462c8a1..cc6c0c1f9e2 100644 --- a/sdk/python/tests/expediagroup/test_search.py +++ b/sdk/python/tests/expediagroup/test_search.py @@ -122,4 +122,4 @@ def test_expedia_search_projects_response_proto_roundtrip(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 6b2e4ffde6982187a1cc2f2856adc00031493177 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Wed, 23 Jul 2025 10:14:43 -0500 Subject: [PATCH 25/37] add log to note the grpc server has started --- sdk/python/feast/registry_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 54e51cb685c..29080de669e 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -1,3 +1,4 @@ +import logging from concurrent import futures from datetime import datetime, timezone from typing import Optional, Union, cast @@ -43,6 +44,8 @@ from feast.sorted_feature_view import SortedFeatureView from feast.stream_feature_view import StreamFeatureView +_logger = logging.getLogger(__name__) + def _build_any_feature_view_proto(feature_view: BaseFeatureView): if isinstance(feature_view, SortedFeatureView): @@ -850,6 +853,7 @@ def start_server(store: FeatureStore, port: int, wait_for_termination: bool = Tr server.add_insecure_port(f"[::]:{port}") server.start() + _logger.info(f"Registry server started on port {port}") if wait_for_termination: server.wait_for_termination() else: From ab78a459b80b7c92dd256c85aa55e34803b6c9a3 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Wed, 23 Jul 2025 10:29:32 -0500 Subject: [PATCH 26/37] set log level to info --- sdk/python/feast/registry_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 29080de669e..4c299fa8aee 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -44,7 +44,7 @@ from feast.sorted_feature_view import SortedFeatureView from feast.stream_feature_view import StreamFeatureView -_logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__).setLevel(logging.INFO) def _build_any_feature_view_proto(feature_view: BaseFeatureView): From 45f84342dd8a1e5c79c94cb4b48123b51821a0e4 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Wed, 23 Jul 2025 10:33:05 -0500 Subject: [PATCH 27/37] fix: lint error --- sdk/python/feast/registry_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 4c299fa8aee..2682e616609 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -44,7 +44,8 @@ from feast.sorted_feature_view import SortedFeatureView from feast.stream_feature_view import StreamFeatureView -_logger = logging.getLogger(__name__).setLevel(logging.INFO) +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.INFO) def _build_any_feature_view_proto(feature_view: BaseFeatureView): From 01cc153140af31260d2d8115f99254b75848af6a Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Wed, 13 Aug 2025 09:27:05 -0500 Subject: [PATCH 28/37] feat: add option for SqlFallbackRegistry in feature_store.py and prevent sql injection --- sdk/python/feast/feature_store.py | 6 ++ sdk/python/feast/infra/registry/sql.py | 76 +++++++++++++++++--------- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 3d7df683b24..071a161fad0 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -151,6 +151,12 @@ def __init__( registry_config = self.config.registry if registry_config.registry_type == "sql": self._registry = SqlRegistry(registry_config, self.config.project, None) + elif registry_config.registry_type == "sql-fallback": + from feast.infra.registry.sql_fallback import SqlFallbackRegistry + + self._registry = SqlFallbackRegistry( + registry_config, self.config.project, None + ) elif registry_config.registry_type == "http": self._registry = HttpRegistry(registry_config, self.config.project, None) elif registry_config.registry_type == "snowflake.registry": diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 3e5d2895291..daee9598ba7 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -16,6 +16,7 @@ MetaData, String, Table, + bindparam, create_engine, delete, func, @@ -1463,16 +1464,21 @@ def expedia_search_projects( # 1. Query projects table for matching projects with self.read_engine.begin() as conn: count_stmt = select(func.count(projects.c.project_id.distinct())) + params = {} + if search_text: count_stmt = count_stmt.where( - projects.c.project_id.like(f"%{search_text}%") + projects.c.project_id.like(bindparam("search_pattern")) ) + params["search_pattern"] = f"%{search_text}%" + if updated_at is not None: updated_at_timestamp = int(updated_at.timestamp()) count_stmt = count_stmt.where( projects.c.last_updated_timestamp >= updated_at_timestamp ) - total_count = conn.execute(count_stmt).scalar() or 0 + + total_count = conn.execute(count_stmt, params).scalar() or 0 total_page_indices = (total_count + page_size - 1) // page_size stmt = ( @@ -1484,14 +1490,20 @@ def expedia_search_projects( .limit(page_size) .offset(page_index * page_size) ) + if search_text: - stmt = stmt.where(projects.c.project_id.like(f"%{search_text}%")) + stmt = stmt.where( + projects.c.project_id.like(bindparam("search_pattern")) + ) + params["search_pattern"] = f"%{search_text}%" + if updated_at is not None: updated_at_timestamp = int(updated_at.timestamp()) stmt = stmt.where( projects.c.last_updated_timestamp >= updated_at_timestamp ) - rows = conn.execute(stmt).all() + + rows = conn.execute(stmt, params).all() project_objs = [] project_ids = [] @@ -1566,7 +1578,6 @@ def expedia_search_feature_views( offset = page_index * page_size results = [] filtered_results = [] - in_memory_filtering_required = any( [online is not None, application, team, created_at, updated_at] ) @@ -1575,13 +1586,17 @@ def expedia_search_feature_views( if not in_memory_filtering_required: stmt = ( select(feature_views) - .where(feature_views.c.feature_view_name.like(f"%{search_text}%")) + .where( + feature_views.c.feature_view_name.like( + bindparam("search_pattern") + ) + ) .order_by(feature_views.c.feature_view_name) .limit(page_size) .offset(offset) ) - rows = conn.execute(stmt).all() + rows = conn.execute(stmt, {"search_pattern": f"%{search_text}%"}).all() for row in rows: feature_view_proto = FeatureViewProto.FromString( @@ -1594,9 +1609,19 @@ def expedia_search_feature_views( total_stmt = ( select(func.count()) .select_from(feature_views) - .where(feature_views.c.feature_view_name.like(f"%{search_text}%")) + .where( + feature_views.c.feature_view_name.like( + bindparam("search_pattern") + ) + ) + ) + + total_count = ( + conn.execute( + total_stmt, {"search_pattern": f"%{search_text}%"} + ).scalar() + or 0 ) - total_count = conn.execute(total_stmt).scalar() or 0 total_page_indices = (total_count + page_size - 1) // page_size return ExpediaSearchFeatureViewsResponse( @@ -1608,10 +1633,12 @@ def expedia_search_feature_views( stmt = select(feature_views) if search_text: stmt = stmt.where( - feature_views.c.feature_view_name.like(f"%{search_text}%") - ).order_by(feature_views.c.feature_view_name) + feature_views.c.feature_view_name.like(bindparam("search_pattern")) + ) - rows = conn.execute(stmt).all() + rows = conn.execute( + stmt, {"search_pattern": f"%{search_text}%"} if search_text else {} + ).all() for row in rows: feature_view_proto = FeatureViewProto.FromString( @@ -1623,23 +1650,22 @@ def expedia_search_feature_views( if online is not None and fv.online != online: add_to_results = False - if application and fv.tags.get("application") != application: add_to_results = False - if team and fv.tags.get("team") != team: add_to_results = False - - if created_at: - if fv.created_timestamp and fv.created_timestamp < created_at: - add_to_results = False - - if updated_at is not None: - if ( - fv.last_updated_timestamp - and fv.last_updated_timestamp < updated_at - ): - add_to_results = False + if ( + created_at is not None + and fv.created_timestamp + and fv.created_timestamp < created_at + ): + add_to_results = False + if ( + updated_at is not None + and fv.last_updated_timestamp + and fv.last_updated_timestamp < updated_at + ): + add_to_results = False if add_to_results: filtered_results.append(fv) From c28013b92fdd5c81164f43957fefdf1332886576 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Wed, 13 Aug 2025 10:29:27 -0500 Subject: [PATCH 29/37] fix: add sql-fallback to REGISTRY_CLASS_FOR_TYPE --- sdk/python/feast/repo_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index da12b6340a5..b7abf593008 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -39,6 +39,7 @@ REGISTRY_CLASS_FOR_TYPE = { "file": "feast.infra.registry.registry.Registry", "sql": "feast.infra.registry.sql.SqlRegistry", + "sql-fallback": "feast.infra.registry.sql_fallback.SqlFallbackRegistry", "snowflake.registry": "feast.infra.registry.snowflake.SnowflakeRegistry", "http": "feast.infra.registry.http.HttpRegistry", "remote": "feast.infra.registry.remote.RemoteRegistry", From fb5d634a341527d64186cedce1fd18d81536c2e8 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Thu, 21 Aug 2025 09:28:54 -0500 Subject: [PATCH 30/37] sequential search projects --- sdk/python/feast/infra/registry/sql.py | 42 +++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index daee9598ba7..20c1af7dc10 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1532,26 +1532,34 @@ def expedia_search_projects( feature_views_by_project.setdefault(project_id, []).append(fv) # 3. Build ExpediaProjectAndRelatedFeatureViews objects - def process_project(project): - return ExpediaProjectAndRelatedFeatureViews( + projects_and_related_feature_views = [] + for project in project_objs: + obj = ExpediaProjectAndRelatedFeatureViews( project=project, feature_views=feature_views_by_project.get(project.name, []), ) - - projects_and_related_feature_views = [] - with ThreadPoolExecutor() as executor: - futures = [ - executor.submit(process_project, project) for project in project_objs - ] - for future in as_completed(futures): - try: - projects_and_related_feature_views.append(future.result()) - except Exception as e: - logger.error(f"Error processing project: {e}") - - projects_and_related_feature_views.sort( - key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" - ) + projects_and_related_feature_views.append(obj) + + # def process_project(project): + # return ExpediaProjectAndRelatedFeatureViews( + # project=project, + # feature_views=feature_views_by_project.get(project.name, []), + # ) + + # projects_and_related_feature_views = [] + # with ThreadPoolExecutor() as executor: + # futures = [ + # executor.submit(process_project, project) for project in project_objs + # ] + # for future in as_completed(futures): + # try: + # projects_and_related_feature_views.append(future.result()) + # except Exception as e: + # logger.error(f"Error processing project: {e}") + + # projects_and_related_feature_views.sort( + # key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" + # ) return ExpediaSearchProjectsResponse( projects_and_related_feature_views=projects_and_related_feature_views, From 39467fbcd0b1e773d9efba76e9bf1a48c11873c8 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Thu, 21 Aug 2025 10:40:51 -0500 Subject: [PATCH 31/37] use ProcessPoolExecutor --- sdk/python/feast/infra/registry/sql.py | 50 +++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 20c1af7dc10..454c3c40b12 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1,7 +1,7 @@ import logging import time import uuid -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ProcessPoolExecutor, as_completed from datetime import datetime, timezone from enum import Enum from pathlib import Path @@ -1532,34 +1532,34 @@ def expedia_search_projects( feature_views_by_project.setdefault(project_id, []).append(fv) # 3. Build ExpediaProjectAndRelatedFeatureViews objects - projects_and_related_feature_views = [] - for project in project_objs: - obj = ExpediaProjectAndRelatedFeatureViews( - project=project, - feature_views=feature_views_by_project.get(project.name, []), - ) - projects_and_related_feature_views.append(obj) - - # def process_project(project): - # return ExpediaProjectAndRelatedFeatureViews( + # projects_and_related_feature_views = [] + # for project in project_objs: + # obj = ExpediaProjectAndRelatedFeatureViews( # project=project, # feature_views=feature_views_by_project.get(project.name, []), # ) + # projects_and_related_feature_views.append(obj) - # projects_and_related_feature_views = [] - # with ThreadPoolExecutor() as executor: - # futures = [ - # executor.submit(process_project, project) for project in project_objs - # ] - # for future in as_completed(futures): - # try: - # projects_and_related_feature_views.append(future.result()) - # except Exception as e: - # logger.error(f"Error processing project: {e}") - - # projects_and_related_feature_views.sort( - # key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" - # ) + def process_project(project): + return ExpediaProjectAndRelatedFeatureViews( + project=project, + feature_views=feature_views_by_project.get(project.name, []), + ) + + projects_and_related_feature_views = [] + with ProcessPoolExecutor() as executor: + futures = [ + executor.submit(process_project, project) for project in project_objs + ] + for future in as_completed(futures): + try: + projects_and_related_feature_views.append(future.result()) + except Exception as e: + logger.error(f"Error processing project: {e}") + + projects_and_related_feature_views.sort( + key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" + ) return ExpediaSearchProjectsResponse( projects_and_related_feature_views=projects_and_related_feature_views, From 45aed7846fd05fd49eff778ebfcf680540ebddae Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Thu, 21 Aug 2025 11:21:22 -0500 Subject: [PATCH 32/37] fix: add back ThreadPoolExecutor import --- sdk/python/feast/infra/registry/sql.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 454c3c40b12..9fbee0f5ef3 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1,7 +1,7 @@ import logging import time import uuid -from concurrent.futures import ProcessPoolExecutor, as_completed +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed from datetime import datetime, timezone from enum import Enum from pathlib import Path @@ -1532,13 +1532,13 @@ def expedia_search_projects( feature_views_by_project.setdefault(project_id, []).append(fv) # 3. Build ExpediaProjectAndRelatedFeatureViews objects - # projects_and_related_feature_views = [] - # for project in project_objs: - # obj = ExpediaProjectAndRelatedFeatureViews( - # project=project, - # feature_views=feature_views_by_project.get(project.name, []), - # ) - # projects_and_related_feature_views.append(obj) + projects_and_related_feature_views = [] + for project in project_objs: + obj = ExpediaProjectAndRelatedFeatureViews( + project=project, + feature_views=feature_views_by_project.get(project.name, []), + ) + projects_and_related_feature_views.append(obj) def process_project(project): return ExpediaProjectAndRelatedFeatureViews( From ab8d1977df399410dbddff225872211cc86bab13 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Thu, 21 Aug 2025 11:36:32 -0500 Subject: [PATCH 33/37] sequential search projects again --- sdk/python/feast/infra/registry/sql.py | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 9fbee0f5ef3..a8d3cbfe126 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1,7 +1,7 @@ import logging import time import uuid -from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from enum import Enum from pathlib import Path @@ -1540,27 +1540,6 @@ def expedia_search_projects( ) projects_and_related_feature_views.append(obj) - def process_project(project): - return ExpediaProjectAndRelatedFeatureViews( - project=project, - feature_views=feature_views_by_project.get(project.name, []), - ) - - projects_and_related_feature_views = [] - with ProcessPoolExecutor() as executor: - futures = [ - executor.submit(process_project, project) for project in project_objs - ] - for future in as_completed(futures): - try: - projects_and_related_feature_views.append(future.result()) - except Exception as e: - logger.error(f"Error processing project: {e}") - - projects_and_related_feature_views.sort( - key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" - ) - return ExpediaSearchProjectsResponse( projects_and_related_feature_views=projects_and_related_feature_views, total_projects=total_count, From 934c3121fc49867e54b094b63cb36e435340323c Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Fri, 22 Aug 2025 09:15:21 -0500 Subject: [PATCH 34/37] use threadpoolexecutor again to see if that fixes serialization --- sdk/python/feast/infra/registry/sql.py | 36 ++++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index a8d3cbfe126..7fb90f060d5 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1,7 +1,7 @@ import logging import time import uuid -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from enum import Enum from pathlib import Path @@ -1505,8 +1505,8 @@ def expedia_search_projects( rows = conn.execute(stmt, params).all() - project_objs = [] - project_ids = [] + project_objs: List[Project] = [] + project_ids: List[str] = [] for row in rows: project_id = row._mapping["project_id"] project_proto = ProjectProto.FromString(row._mapping["project_proto"]) @@ -1527,18 +1527,38 @@ def expedia_search_projects( feature_view_proto = FeatureViewProto.FromString( row._mapping["feature_view_proto"] ) - feature_view_proto.spec.project = project_id fv = FeatureView.from_proto(feature_view_proto) feature_views_by_project.setdefault(project_id, []).append(fv) # 3. Build ExpediaProjectAndRelatedFeatureViews objects - projects_and_related_feature_views = [] - for project in project_objs: - obj = ExpediaProjectAndRelatedFeatureViews( + # projects_and_related_feature_views = [] + # for project in project_objs: + # obj = ExpediaProjectAndRelatedFeatureViews( + # project=project, + # feature_views=feature_views_by_project.get(project.name, []), + # ) + # projects_and_related_feature_views.append(obj) + + def process_project(project): + return ExpediaProjectAndRelatedFeatureViews( project=project, feature_views=feature_views_by_project.get(project.name, []), ) - projects_and_related_feature_views.append(obj) + + projects_and_related_feature_views = [] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(process_project, project) for project in project_objs + ] + for future in as_completed(futures): + try: + projects_and_related_feature_views.append(future.result()) + except Exception as e: + logger.error(f"Error processing project: {e}") + + projects_and_related_feature_views.sort( + key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" + ) return ExpediaSearchProjectsResponse( projects_and_related_feature_views=projects_and_related_feature_views, From 63028ea93e0a1cbbf73bea50cd029d9c326b9eeb Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Fri, 22 Aug 2025 10:03:14 -0500 Subject: [PATCH 35/37] add project field to feature views --- sdk/python/feast/feature_view.py | 7 ++++++ sdk/python/feast/infra/registry/sql.py | 31 +++++--------------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 46332e8aedd..25988b9c177 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -64,6 +64,7 @@ class FeatureView(BaseFeatureView): Attributes: name: The unique name of the feature view. + project: The name of the Feast project this feature view belongs to. entities: The list of names of entities that this feature view is associated with. ttl: The amount of time this group of features lives. A ttl of 0 indicates that this group of features lives forever. Note that large ttl's or a ttl of 0 @@ -87,6 +88,7 @@ class FeatureView(BaseFeatureView): """ name: str + project: str entities: List[str] ttl: Optional[timedelta] batch_source: DataSource @@ -103,6 +105,7 @@ def __init__( self, *, name: str, + project: str, source: DataSource, schema: Optional[List[Field]] = None, entities: Optional[List[Entity]] = None, @@ -117,6 +120,7 @@ def __init__( Args: name: The unique name of the feature view. + project: The name of the Feast project this feature view belongs to. source: The source of data for this group of features. May be a stream source, or a batch source. If a stream source, the source should contain a batch_source for backfills & batch materialization. schema (optional): The schema of the feature view, including feature, timestamp, @@ -137,6 +141,7 @@ def __init__( ValueError: A field mapping conflicts with an Entity or a Feature. """ self.name = name + self.project = project self.entities = [e.name for e in entities] if entities else [DUMMY_ENTITY_NAME] self.ttl = ttl @@ -375,6 +380,7 @@ def to_proto(self) -> FeatureViewProto: spec = FeatureViewSpecProto( name=self.name, + project=self.project, entities=self.entities, entity_columns=[field.to_proto() for field in self.entity_columns], features=[field.to_proto() for field in self.features], @@ -469,6 +475,7 @@ def from_proto(cls, feature_view_proto: FeatureViewProto): ) feature_view = cls( name=feature_view_proto.spec.name, + project=feature_view_proto.spec.project, description=feature_view_proto.spec.description, tags=dict(feature_view_proto.spec.tags), owner=feature_view_proto.spec.owner, diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 7fb90f060d5..df43c226acd 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1,7 +1,7 @@ import logging import time import uuid -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from enum import Enum from pathlib import Path @@ -1531,34 +1531,13 @@ def expedia_search_projects( feature_views_by_project.setdefault(project_id, []).append(fv) # 3. Build ExpediaProjectAndRelatedFeatureViews objects - # projects_and_related_feature_views = [] - # for project in project_objs: - # obj = ExpediaProjectAndRelatedFeatureViews( - # project=project, - # feature_views=feature_views_by_project.get(project.name, []), - # ) - # projects_and_related_feature_views.append(obj) - - def process_project(project): - return ExpediaProjectAndRelatedFeatureViews( + projects_and_related_feature_views = [] + for project in project_objs: + obj = ExpediaProjectAndRelatedFeatureViews( project=project, feature_views=feature_views_by_project.get(project.name, []), ) - - projects_and_related_feature_views = [] - with ThreadPoolExecutor() as executor: - futures = [ - executor.submit(process_project, project) for project in project_objs - ] - for future in as_completed(futures): - try: - projects_and_related_feature_views.append(future.result()) - except Exception as e: - logger.error(f"Error processing project: {e}") - - projects_and_related_feature_views.sort( - key=lambda x: x.project.name.lower() if hasattr(x.project, "name") else "" - ) + projects_and_related_feature_views.append(obj) return ExpediaSearchProjectsResponse( projects_and_related_feature_views=projects_and_related_feature_views, From 0a3c6c0dea3817da29fce89f67d27e0ccb49c026 Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Fri, 22 Aug 2025 10:04:33 -0500 Subject: [PATCH 36/37] undo that feature view change --- sdk/python/feast/feature_view.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 25988b9c177..46332e8aedd 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -64,7 +64,6 @@ class FeatureView(BaseFeatureView): Attributes: name: The unique name of the feature view. - project: The name of the Feast project this feature view belongs to. entities: The list of names of entities that this feature view is associated with. ttl: The amount of time this group of features lives. A ttl of 0 indicates that this group of features lives forever. Note that large ttl's or a ttl of 0 @@ -88,7 +87,6 @@ class FeatureView(BaseFeatureView): """ name: str - project: str entities: List[str] ttl: Optional[timedelta] batch_source: DataSource @@ -105,7 +103,6 @@ def __init__( self, *, name: str, - project: str, source: DataSource, schema: Optional[List[Field]] = None, entities: Optional[List[Entity]] = None, @@ -120,7 +117,6 @@ def __init__( Args: name: The unique name of the feature view. - project: The name of the Feast project this feature view belongs to. source: The source of data for this group of features. May be a stream source, or a batch source. If a stream source, the source should contain a batch_source for backfills & batch materialization. schema (optional): The schema of the feature view, including feature, timestamp, @@ -141,7 +137,6 @@ def __init__( ValueError: A field mapping conflicts with an Entity or a Feature. """ self.name = name - self.project = project self.entities = [e.name for e in entities] if entities else [DUMMY_ENTITY_NAME] self.ttl = ttl @@ -380,7 +375,6 @@ def to_proto(self) -> FeatureViewProto: spec = FeatureViewSpecProto( name=self.name, - project=self.project, entities=self.entities, entity_columns=[field.to_proto() for field in self.entity_columns], features=[field.to_proto() for field in self.features], @@ -475,7 +469,6 @@ def from_proto(cls, feature_view_proto: FeatureViewProto): ) feature_view = cls( name=feature_view_proto.spec.name, - project=feature_view_proto.spec.project, description=feature_view_proto.spec.description, tags=dict(feature_view_proto.spec.tags), owner=feature_view_proto.spec.owner, From 64ea0e894a0818a4d892b7580b621c6cd6f9fe4c Mon Sep 17 00:00:00 2001 From: Zach Barnett Date: Mon, 25 Aug 2025 09:55:26 -0500 Subject: [PATCH 37/37] fix: add project name to fv spec --- sdk/python/feast/expediagroup/search.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/expediagroup/search.py b/sdk/python/feast/expediagroup/search.py index 42e215a0a0a..8d317b1f7ee 100644 --- a/sdk/python/feast/expediagroup/search.py +++ b/sdk/python/feast/expediagroup/search.py @@ -77,7 +77,12 @@ def to_proto(self) -> ExpediaProjectAndRelatedFeatureViewsProto: """ proto = ExpediaProjectAndRelatedFeatureViewsProto() proto.project.CopyFrom(self.project.to_proto()) - proto.feature_views.extend([fv.to_proto() for fv in self.feature_views]) + # FeatureView protos support project field, but their Python class does not. + # We need to manually set the project field here. + for fv in self.feature_views: + fv_proto = fv.to_proto() + fv_proto.spec.project = self.project.name + proto.feature_views.append(fv_proto) return proto