From 19014842ac14c088e5bc92930afde347d873ef66 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/25] 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 --- .github/workflows/go_integration_test.yml | 5 +---- go/README.md | 8 ++++++++ go/integration_tests/scylladb/docker-compose.yaml | 4 ++-- go/integration_tests/valkey/docker-compose.yaml | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/go_integration_test.yml b/.github/workflows/go_integration_test.yml index 78207931916..9129ceed137 100644 --- a/.github/workflows/go_integration_test.yml +++ b/.github/workflows/go_integration_test.yml @@ -1,9 +1,6 @@ name: go-integration-test -on: - pull_request: - paths: - - 'go/**' +on: [pull_request] jobs: integration-test-go-local: diff --git a/go/README.md b/go/README.md index b2c34d37caa..346b0c6c8bb 100644 --- a/go/README.md +++ b/go/README.md @@ -6,4 +6,12 @@ To build and run the Go Feature Server locally, create a feature_store.yaml file ```bash go build -o feast ./go/main.go ./feast --type=http --port=8080 +``` + +## Running Integration Tests + +To run go Integration tests, run below command + +```bash + make test-go-integration ``` \ No newline at end of file diff --git a/go/integration_tests/scylladb/docker-compose.yaml b/go/integration_tests/scylladb/docker-compose.yaml index ae6466953a6..0dd4ba33533 100644 --- a/go/integration_tests/scylladb/docker-compose.yaml +++ b/go/integration_tests/scylladb/docker-compose.yaml @@ -1,12 +1,12 @@ services: scylla: - image: artifactory-edge.expedia.biz/all-docker-virtual/scylladb/scylla:latest + image: scylladb/scylla:latest container_name: scylla ports: - "9042:9042" - "10000:10000" init-scylla: - image: artifactory-edge.expedia.biz/all-docker-virtual/scylladb/scylla:latest + image: scylladb/scylla:latest depends_on: - scylla entrypoint: ["/bin/sh", "-c"] diff --git a/go/integration_tests/valkey/docker-compose.yaml b/go/integration_tests/valkey/docker-compose.yaml index 873b613f281..a296bd5da7e 100644 --- a/go/integration_tests/valkey/docker-compose.yaml +++ b/go/integration_tests/valkey/docker-compose.yaml @@ -1,6 +1,6 @@ services: valkey: - image: artifactory-edge.expedia.biz/all-docker-virtual/valkey/valkey:latest + image: valkey/valkey:latest container_name: valkey ports: - "6390:6379" From 1f1644f99cf6662658e9fa3009314772dfd57d56 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 23 Jun 2025 13:45:52 -0700 Subject: [PATCH 02/25] =?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 | 2 +- go/internal/feast/onlineserving/serving.go | 8 +- .../cassandraonlinestore_integration_test.go | 88 +++++++-- .../server/grpc_server_integration_test.go | 79 -------- ...grpc_server_read_range_integration_test.go | 176 ++++++++++++++++++ go/internal/test/go_integration_test_utils.go | 6 +- 6 files changed, 255 insertions(+), 104 deletions(-) create mode 100644 go/internal/feast/server/grpc_server_read_range_integration_test.go diff --git a/Makefile b/Makefile index 7f7f82ad6e3..e8cd76610ea 100644 --- a/Makefile +++ b/Makefile @@ -443,7 +443,7 @@ test-go: compile-protos-go compile-protos-python install-feast-ci-locally 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 - go test -tags=integration ./go/internal/... + 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 diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 889d9cabb69..4e09892ac84 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -1086,17 +1086,17 @@ func GroupSortedFeatureRefs( sortOrder = &flipped // non-nil only when sort key order is reversed } - var filterModel *model.SortKeyFilter if filter, ok := sortKeyFilterMap[sortKey.FieldName]; ok { - filterModel = model.NewSortKeyFilterFromProto(filter, sortOrder) + filterModel := model.NewSortKeyFilterFromProto(filter, sortOrder) + sortKeyFilterModels = append(sortKeyFilterModels, filterModel) } else if reverseSortOrder { - filterModel = &model.SortKeyFilter{ + filterModel := &model.SortKeyFilter{ SortKeyName: sortKey.FieldName, Order: model.NewSortOrderFromProto(*sortOrder), } + sortKeyFilterModels = append(sortKeyFilterModels, filterModel) } - sortKeyFilterModels = append(sortKeyFilterModels, filterModel) } if _, ok := groups[groupKey]; !ok { diff --git a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go index b61a4adbe97..447aafe7dcb 100644 --- a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go +++ b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go @@ -18,6 +18,9 @@ import ( "time" ) +var onlineStore *CassandraOnlineStore +var ctx = context.Background() + func TestMain(m *testing.M) { // Initialize the test environment dir := "../../../integration_tests/scylladb/" @@ -27,6 +30,12 @@ func TestMain(m *testing.M) { os.Exit(1) } + onlineStore, err = getCassandraOnlineStore() + if err != nil { + fmt.Printf("Failed to create CassandraOnlineStore: %v\n", err) + os.Exit(1) + } + // Run the tests exitCode := m.Run() @@ -40,16 +49,19 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } -func getCassandraOnlineStore(t *testing.T) (*CassandraOnlineStore, context.Context) { - ctx := context.Background() +func getCassandraOnlineStore() (*CassandraOnlineStore, error) { dir := "../../../integration_tests/scylladb/" config, err := loadRepoConfig(dir) - require.NoError(t, err) - assert.Equal(t, "scylladb", config.OnlineStore["type"]) + if err != nil { + fmt.Printf("Failed to load repo config: %v\n", err) + return nil, err + } - onlineStore, err := NewCassandraOnlineStore("feature_integration_repo", config, config.OnlineStore) - require.NoError(t, err) - return onlineStore, ctx + store, err := NewCassandraOnlineStore("feature_integration_repo", config, config.OnlineStore) + if err != nil { + return nil, err + } + return store, nil } func loadRepoConfig(basePath string) (*registry.RepoConfig, error) { @@ -62,8 +74,6 @@ func loadRepoConfig(basePath string) (*registry.RepoConfig, error) { } func TestCassandraOnlineStore_OnlineReadRange_withSingleEntityKey(t *testing.T) { - onlineStore, ctx := getCassandraOnlineStore(t) - entityKeys := []*types.EntityKey{{ JoinKeys: []string{"index_id"}, EntityValues: []*types.Value{ @@ -79,6 +89,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withSingleEntityKey(t *testing.T) sortKeyFilters := []*model.SortKeyFilter{{ SortKeyName: "event_timestamp", RangeStart: int64(1744769099919), + RangeEnd: int64(1744779099919), }} groupedRefs := &model.GroupedRangeFeatureRefs{ @@ -93,12 +104,10 @@ func TestCassandraOnlineStore_OnlineReadRange_withSingleEntityKey(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 1) + verifyResponseData(t, data, 1, int64(1744769099919), int64(1744779099919)) } func TestCassandraOnlineStore_OnlineReadRange_withMultipleEntityKeys(t *testing.T) { - onlineStore, ctx := getCassandraOnlineStore(t) - entityKeys := []*types.EntityKey{ { JoinKeys: []string{"index_id"}, @@ -142,12 +151,10 @@ func TestCassandraOnlineStore_OnlineReadRange_withMultipleEntityKeys(t *testing. data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3) + verifyResponseData(t, data, 3, int64(1744769099919), int64(1744769099919*10)) } func TestCassandraOnlineStore_OnlineReadRange_withReverseSortOrder(t *testing.T) { - onlineStore, ctx := getCassandraOnlineStore(t) - entityKeys := []*types.EntityKey{ { JoinKeys: []string{"index_id"}, @@ -193,7 +200,51 @@ func TestCassandraOnlineStore_OnlineReadRange_withReverseSortOrder(t *testing.T) data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) require.NoError(t, err) - verifyResponseData(t, data, 3) + verifyResponseData(t, data, 3, int64(1744769099919), int64(1744769099919*10)) +} + +func TestCassandraOnlineStore_OnlineReadRange_withNoSortKeyFilters(t *testing.T) { + entityKeys := []*types.EntityKey{ + { + JoinKeys: []string{"index_id"}, + EntityValues: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: 1}}, + }, + }, + { + JoinKeys: []string{"index_id"}, + EntityValues: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: 2}}, + }, + }, + { + JoinKeys: []string{"index_id"}, + EntityValues: []*types.Value{ + {Val: &types.Value_Int64Val{Int64Val: 3}}, + }, + }, + } + featureViewNames := []string{"all_dtypes_sorted"} + 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"} + sortKeyFilters := []*model.SortKeyFilter{} + + groupedRefs := &model.GroupedRangeFeatureRefs{ + EntityKeys: entityKeys, + FeatureViewNames: featureViewNames, + FeatureNames: featureNames, + SortKeyFilters: sortKeyFilters, + Limit: 10, + IsReverseSortOrder: true, + SortKeyNames: map[string]bool{"event_timestamp": true}, + } + + data, err := onlineStore.OnlineReadRange(ctx, groupedRefs) + require.NoError(t, err) + verifyResponseData(t, data, 3, int64(0), int64(1744769099919*10)) } func assertValueType(t *testing.T, actualValue interface{}, expectedType string) { @@ -201,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) { +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++ { @@ -356,7 +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).UnixMilli(), int64(1744769099919), "Timestamp should be greater than or equal to 1744769099919") + 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_integration_test.go b/go/internal/feast/server/grpc_server_integration_test.go index d2c029f6bf2..b3b549fa631 100644 --- a/go/internal/feast/server/grpc_server_integration_test.go +++ b/go/internal/feast/server/grpc_server_integration_test.go @@ -107,85 +107,6 @@ func TestGetOnlineFeaturesValkey(t *testing.T) { assert.Equal(t, len(response.Results), len(featureNames)+1) } -func TestGetOnlineFeaturesRange(t *testing.T) { - ctx := context.Background() - dir := "../../../integration_tests/scylladb/" - err := test.SetupInitializedRepo(dir) - defer test.CleanUpInitializedRepo(dir) - require.NoError(t, err, "Failed to setup initialized repo with err: %v", err) - - client, closer := getClient(ctx, "", dir, "") - defer closer() - - 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 getValueType(value interface{}, featureName string) *types.Value { if value == nil { return &types.Value{} 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)) + } + } + } + } +} diff --git a/go/internal/test/go_integration_test_utils.go b/go/internal/test/go_integration_test_utils.go index 258a3f8d9a2..34a65976faa 100644 --- a/go/internal/test/go_integration_test_utils.go +++ b/go/internal/test/go_integration_test_utils.go @@ -255,8 +255,8 @@ func SetupInitializedRepo(basePath string) error { if err != nil { return err } - // var stderr bytes.Buffer - // var stdout bytes.Buffer + // Pause to ensure apply completes + time.Sleep(5 * time.Second) applyCommand.Dir = featureRepoPath out, err := applyCommand.CombinedOutput() if err != nil { @@ -277,6 +277,8 @@ func SetupInitializedRepo(basePath string) error { log.Println(string(out)) return err } + // Pause to ensure materialization completes + time.Sleep(5 * time.Second) return nil } From 42ef2f07cd61e477d052ed8379b94c52568edf3e 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/25] 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 | 41 +++++++++++++------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 88e4245d01c..554ff535cb3 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -361,6 +361,18 @@ func NewHttpServer(fs *feast.FeatureStore, loggingService *logging.LoggingServic return &httpServer{fs: fs, loggingService: loggingService} } +func parseIncludeMetadata(r *http.Request) (bool, error) { + q := r.URL.Query() + raw := strings.TrimSpace(q.Get("includeMetadata")) + if raw == "" { + raw = strings.TrimSpace(q.Get("include_metadata")) + } + if raw == "" { + return false, nil + } + return strconv.ParseBool(raw) +} + func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { var err error var featureVectors []*onlineserving.FeatureVector @@ -375,16 +387,11 @@ func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { return } - includeMetadataQuery := r.URL.Query().Get("includeMetadata") - - includeMetadata := false - if includeMetadataQuery != "" { - includeMetadata, err = strconv.ParseBool(includeMetadataQuery) - if err != nil { - logSpanContext.Error().Err(err).Msg("Error parsing includeMetadata query parameter") - writeJSONError(w, fmt.Errorf("Error parsing includeMetadata query parameter: %+v", err), http.StatusBadRequest) - 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 } decoder := json.NewDecoder(r.Body) @@ -559,15 +566,11 @@ func (s *httpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque return } - includeMetadataQuery := r.URL.Query().Get("includeMetadata") - includeMetadata := false - if includeMetadataQuery != "" { - includeMetadata, err = strconv.ParseBool(includeMetadataQuery) - 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") + writeJSONError(w, fmt.Errorf("error parsing includeMetadata query parameter: %w", err), http.StatusBadRequest) + return } decoder := json.NewDecoder(r.Body) From f05b168e2d6e9e23642d50160310287a39bec40f Mon Sep 17 00:00:00 2001 From: piket Date: Thu, 26 Jun 2025 13:10:10 -0700 Subject: [PATCH 04/25] fix: Move use_write_time_for_ttl config to sfv instead of an online store config (#267) * fix: Move use_write_time_for_ttl config to SortedFeatureView instead of an online store config * re-order lines in proto so indexes are in order --- protos/feast/core/SortedFeatureView.proto | 33 ++++++++++--------- .../pydantic_models/feature_view_model.py | 3 ++ .../cassandra_online_store.py | 8 +---- sdk/python/feast/sorted_feature_view.py | 5 +++ 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/protos/feast/core/SortedFeatureView.proto b/protos/feast/core/SortedFeatureView.proto index 162ef4e9f46..789df470d6c 100644 --- a/protos/feast/core/SortedFeatureView.proto +++ b/protos/feast/core/SortedFeatureView.proto @@ -51,21 +51,9 @@ message SortedFeatureViewSpec { // List of specifications for each feature defined as part of this feature view. repeated FeatureSpecV2 features = 4; - // List of specifications for each entity defined as part of this feature view. - repeated FeatureSpecV2 entity_columns = 12; - - // List of sort keys for this feature view. - repeated SortKey sort_keys = 13; - - // Description of the feature view. - string description = 10; - // User defined metadata map tags = 5; - // Owner of the feature view. - string owner = 11; - // Features in this feature view can only be retrieved from online serving // younger than ttl. Ttl is measured as the duration of time between // the feature's event timestamp and when the feature is retrieved @@ -75,16 +63,31 @@ message SortedFeatureViewSpec { // Batch/Offline DataSource where this view can retrieve offline feature data. DataSource batch_source = 7; + // Whether these features should be served online or not + bool online = 8; + // Streaming DataSource from where this view can consume "online" feature data. DataSource stream_source = 9; - // Whether these features should be served online or not - bool online = 8; + // Description of the feature view. + string description = 10; + + // Owner of the feature view. + string owner = 11; + + // List of specifications for each entity defined as part of this feature view. + repeated FeatureSpecV2 entity_columns = 12; + + // List of sort keys for this feature view. + repeated SortKey sort_keys = 13; + + // Whether to use the write time or event time for TTL calculations. + bool use_write_time_for_ttl = 14; // User-specified specifications of this entity. // Adding higher index to avoid conflicts in future // if Feast adds more fields - repeated Entity original_entities = 30; + repeated Entity original_entities = 15; } // Defines the sorting criteria for range-based feature queries. diff --git a/sdk/python/feast/expediagroup/pydantic_models/feature_view_model.py b/sdk/python/feast/expediagroup/pydantic_models/feature_view_model.py index fa7e5ebc606..4000f040519 100644 --- a/sdk/python/feast/expediagroup/pydantic_models/feature_view_model.py +++ b/sdk/python/feast/expediagroup/pydantic_models/feature_view_model.py @@ -367,6 +367,7 @@ class SortedFeatureViewModel(FeatureViewModel): """ sort_keys: List[SortedFeatureViewSortKeyModel] + use_write_time_for_ttl: bool = False def to_feature_view(self) -> SortedFeatureView: """ @@ -396,6 +397,7 @@ def to_feature_view(self) -> SortedFeatureView: tags=self.tags or None, owner=self.owner, sort_keys=[sk.to_sort_key() for sk in self.sort_keys], + use_write_time_for_ttl=self.use_write_time_for_ttl, ) sorted_fv.materialization_intervals = self.materialization_intervals sorted_fv.created_timestamp = self.created_timestamp @@ -452,6 +454,7 @@ def from_feature_view( SortedFeatureViewSortKeyModel.from_sort_key(sk) for sk in sorted_feature_view.sort_keys ], + use_write_time_for_ttl=sorted_feature_view.use_write_time_for_ttl, ) diff --git a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py index 17f64ba0346..7f373496ed3 100644 --- a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py +++ b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py @@ -242,11 +242,6 @@ class CassandraLoadBalancingPolicy(FeastConfigBaseModel): Table names should be quoted to make them case sensitive. """ - use_write_time_for_ttl: Optional[bool] = False - """ - If True, the expiration time is always calculated as now() on the Coordinator + TTL where, now() is the wall clock during the corresponding write operation. - """ - class CassandraOnlineStore(OnlineStore): """ @@ -489,10 +484,9 @@ def on_failure(exc, concurrent_queue): batch = BatchStatement(batch_type=BatchType.UNLOGGED) batch_count = 0 for entity_key, feat_dict, timestamp, created_ts in batch_to_write: - # TODO: move use_write_time_for_ttl config to SortedFeatureView level ttl = CassandraOnlineStore._get_ttl( ttl_feature_view, - online_store_config.use_write_time_for_ttl, + table.use_write_time_for_ttl, timestamp, ) if ttl < 0: diff --git a/sdk/python/feast/sorted_feature_view.py b/sdk/python/feast/sorted_feature_view.py index 8e68c67da5a..ff10c67e658 100644 --- a/sdk/python/feast/sorted_feature_view.py +++ b/sdk/python/feast/sorted_feature_view.py @@ -45,6 +45,7 @@ def __init__( tags: Optional[Dict[str, str]] = None, owner: str = "", sort_keys: Optional[List[SortKey]] = None, + use_write_time_for_ttl: bool = False, _skip_validation: bool = False, # only skipping validation for proto creation, internal use only ): super().__init__( @@ -59,6 +60,7 @@ def __init__( owner=owner, ) self.sort_keys = sort_keys if sort_keys is not None else [] + self.use_write_time_for_ttl = use_write_time_for_ttl if not _skip_validation: self.ensure_valid() @@ -77,6 +79,7 @@ def __copy__(self): tags=copy.deepcopy(self.tags), owner=self.owner, sort_keys=copy.copy(self.sort_keys), + use_write_time_for_ttl=self.use_write_time_for_ttl, ) sfv.entities = self.entities sfv.features = copy.copy(self.features) @@ -190,6 +193,7 @@ def to_proto(self): stream_source=stream_source_proto, online=self.online, original_entities=original_entities, + use_write_time_for_ttl=self.use_write_time_for_ttl, ) return SortedFeatureViewProto(spec=spec, meta=meta) @@ -224,6 +228,7 @@ def from_proto(cls, sfv_proto): schema=None, entities=None, sort_keys=[SortKey.from_proto(sk) for sk in spec.sort_keys], + use_write_time_for_ttl=spec.use_write_time_for_ttl, _skip_validation=True, ) From c6d91d44e58573bb07f3ac2fac6a6ea8c3a3ae20 Mon Sep 17 00:00:00 2001 From: piket Date: Thu, 26 Jun 2025 14:54:39 -0700 Subject: [PATCH 05/25] =?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 +- go/internal/feast/featurestore.go | 38 +++- .../integration_tests/scylladb/README.md | 0 .../scylladb/docker-compose.yaml | 0 .../scylladb/feature_repo/__init__.py | 0 .../scylladb/feature_repo/data.parquet | Bin .../scylladb/feature_repo/example_repo.py | 55 +++++- .../scylladb/feature_repo/feature_store.yaml | 0 .../scylladb/scylladb_integration_test.go} | 127 +++++++++--- .../feast}/integration_tests/valkey/README.md | 0 .../valkey/docker-compose.yaml | 0 .../valkey/feature_repo/__init__.py | 0 .../valkey/feature_repo/data.parquet | Bin .../valkey/feature_repo/example_repo.py | 8 +- .../valkey/feature_repo/feature_store.yaml | 0 .../valkey/valkey_integration_test.go} | 49 ++++- .../cassandraonlinestore_integration_test.go | 9 +- go/internal/feast/server/grpc_server_test.go | 6 +- ...ver_utils_test.go => server_test_utils.go} | 2 +- go/internal/test/go_integration_test_utils.go | 5 +- go/types/typeconversion.go | 187 +++++++++--------- go/types/typeconversion_test.go | 32 +++ 22 files changed, 362 insertions(+), 164 deletions(-) rename go/{ => internal/feast}/integration_tests/scylladb/README.md (100%) rename go/{ => internal/feast}/integration_tests/scylladb/docker-compose.yaml (100%) rename go/{ => internal/feast}/integration_tests/scylladb/feature_repo/__init__.py (100%) rename go/{ => internal/feast}/integration_tests/scylladb/feature_repo/data.parquet (100%) rename go/{ => internal/feast}/integration_tests/scylladb/feature_repo/example_repo.py (56%) rename go/{ => internal/feast}/integration_tests/scylladb/feature_repo/feature_store.yaml (100%) rename go/internal/feast/{server/grpc_server_read_range_integration_test.go => integration_tests/scylladb/scylladb_integration_test.go} (56%) rename go/{ => internal/feast}/integration_tests/valkey/README.md (100%) rename go/{ => internal/feast}/integration_tests/valkey/docker-compose.yaml (100%) rename go/{ => internal/feast}/integration_tests/valkey/feature_repo/__init__.py (100%) rename go/{ => internal/feast}/integration_tests/valkey/feature_repo/data.parquet (100%) rename go/{ => internal/feast}/integration_tests/valkey/feature_repo/example_repo.py (95%) rename go/{ => internal/feast}/integration_tests/valkey/feature_repo/feature_store.yaml (100%) rename go/internal/feast/{server/grpc_server_integration_test.go => integration_tests/valkey/valkey_integration_test.go} (90%) rename go/internal/feast/server/{grpc_server_utils_test.go => server_test_utils.go} (97%) diff --git a/Makefile b/Makefile index e8cd76610ea..964df12b548 100644 --- a/Makefile +++ b/Makefile @@ -441,11 +441,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/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 851fe8df7da..b7bc2ae7b07 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -186,20 +186,32 @@ func (fs *FeatureStore) GetOnlineFeatures( fullFeatureNames bool) ([]*onlineserving.FeatureVector, error) { var err error var requestedFeatureViews []*onlineserving.FeatureViewAndRefs + var requestedSortedFeatureViews []*onlineserving.SortedFeatureViewAndRefs var requestedOnDemandFeatureViews []*model.OnDemandFeatureView - // TODO: currently ignores SortedFeatureViews, need to either implement get for them or throw some kind of error/warning if featureService != nil { - requestedFeatureViews, _, requestedOnDemandFeatureViews, err = + requestedFeatureViews, requestedSortedFeatureViews, requestedOnDemandFeatureViews, err = onlineserving.GetFeatureViewsToUseByService(featureService, fs.registry, fs.config.Project) } else { - requestedFeatureViews, _, requestedOnDemandFeatureViews, err = + requestedFeatureViews, requestedSortedFeatureViews, requestedOnDemandFeatureViews, err = onlineserving.GetFeatureViewsToUseByFeatureRefs(featureRefs, fs.registry, fs.config.Project) } 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, fmt.Errorf("no feature views found for the requested features") + } + entityColumnMap := make(map[string]*model.Field) for _, featuresAndView := range requestedFeatureViews { for _, entityColumn := range featuresAndView.View.EntityColumns { @@ -313,17 +325,26 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( var err error var requestedSortedFeatureViews []*onlineserving.SortedFeatureViewAndRefs + var requestedFeatureViews []*onlineserving.FeatureViewAndRefs if featureService != nil { - _, requestedSortedFeatureViews, _, err = + requestedFeatureViews, requestedSortedFeatureViews, _, err = onlineserving.GetFeatureViewsToUseByService(featureService, fs.registry, fs.config.Project) } else { - _, requestedSortedFeatureViews, _, 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 { return nil, fmt.Errorf("no sorted feature views found for the requested features") } @@ -453,7 +474,12 @@ func (fs *FeatureStore) ParseFeatures(kind interface{}) (*Features, error) { } return &Features{FeaturesRefs: nil, FeatureService: featureService}, nil case *serving.GetOnlineFeaturesRangeRequest_FeatureService: - return nil, errors.New("range requests only support 'kind' of a list of Features") + 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.New("cannot parse 'kind' of either a Feature Service or list of Features from request") } diff --git a/go/integration_tests/scylladb/README.md b/go/internal/feast/integration_tests/scylladb/README.md similarity index 100% rename from go/integration_tests/scylladb/README.md rename to go/internal/feast/integration_tests/scylladb/README.md diff --git a/go/integration_tests/scylladb/docker-compose.yaml b/go/internal/feast/integration_tests/scylladb/docker-compose.yaml similarity index 100% rename from go/integration_tests/scylladb/docker-compose.yaml rename to go/internal/feast/integration_tests/scylladb/docker-compose.yaml diff --git a/go/integration_tests/scylladb/feature_repo/__init__.py b/go/internal/feast/integration_tests/scylladb/feature_repo/__init__.py similarity index 100% rename from go/integration_tests/scylladb/feature_repo/__init__.py rename to go/internal/feast/integration_tests/scylladb/feature_repo/__init__.py diff --git a/go/integration_tests/scylladb/feature_repo/data.parquet b/go/internal/feast/integration_tests/scylladb/feature_repo/data.parquet similarity index 100% rename from go/integration_tests/scylladb/feature_repo/data.parquet rename to go/internal/feast/integration_tests/scylladb/feature_repo/data.parquet diff --git a/go/integration_tests/scylladb/feature_repo/example_repo.py b/go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py similarity index 56% rename from go/integration_tests/scylladb/feature_repo/example_repo.py rename to go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py index cb3fd934153..7729cd5bb27 100644 --- a/go/integration_tests/scylladb/feature_repo/example_repo.py +++ b/go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py @@ -2,7 +2,7 @@ from datetime import timedelta -from feast import Entity, FeatureView, Field, FileSource, Project, SortedFeatureView +from feast import Entity, FeatureService, Field, FileSource, Project, SortedFeatureView, FeatureView from feast.sort_key import SortKey from feast.protos.feast.core.SortedFeatureView_pb2 import SortOrder from feast.types import ( @@ -40,7 +40,53 @@ path="data.parquet", timestamp_field="event_timestamp" ) -mlpfs_test_all_datatypes_view: SortedFeatureView = SortedFeatureView( +mlpfs_test_all_datatypes_view: FeatureView = FeatureView( + name="all_dtypes", + entities=[index_entity], + ttl=timedelta(days=0), + source=mlpfs_test_all_datatypes_source, + tags=tags, + description="Feature View with all supported feast datatypes", + owner=owner, + online=True, + schema=[ + Field(name="index_id", dtype=Int64), + Field(name="int_val", dtype=Int32), + Field(name="long_val", dtype=Int64), + Field(name="float_val", dtype=Float32), + Field(name="double_val", dtype=Float64), + Field(name="byte_val", dtype=Bytes), + Field(name="string_val", dtype=String), + Field(name="timestamp_val", dtype=UnixTimestamp), + Field(name="boolean_val", dtype=Bool), + Field(name="array_int_val", dtype=Array(Int32)), + Field(name="array_long_val", dtype=Array(Int64)), + Field(name="array_float_val", dtype=Array(Float32)), + Field(name="array_double_val", dtype=Array(Float64)), + Field(name="array_byte_val", dtype=Array(Bytes)), + Field(name="array_string_val", dtype=Array(String)), + Field(name="array_timestamp_val", dtype=Array(UnixTimestamp)), + Field(name="array_boolean_val", dtype=Array(Bool)), + Field(name="null_int_val", dtype=Int32), + Field(name="null_long_val", dtype=Int64), + Field(name="null_float_val", dtype=Float32), + Field(name="null_double_val", dtype=Float64), + Field(name="null_byte_val", dtype=Bytes), + Field(name="null_string_val", dtype=String), + Field(name="null_timestamp_val", dtype=UnixTimestamp), + Field(name="null_boolean_val", dtype=Bool), + Field(name="null_array_int_val", dtype=Array(Int32)), + Field(name="null_array_long_val", dtype=Array(Int64)), + Field(name="null_array_float_val", dtype=Array(Float32)), + Field(name="null_array_double_val", dtype=Array(Float64)), + Field(name="null_array_byte_val", dtype=Array(Bytes)), + Field(name="null_array_string_val", dtype=Array(String)), + Field(name="null_array_timestamp_val", dtype=Array(UnixTimestamp)), + Field(name="null_array_boolean_val", dtype=Array(Bool)), + ], +) + +mlpfs_test_all_datatypes_sorted_view: SortedFeatureView = SortedFeatureView( name="all_dtypes_sorted", entities=[index_entity], ttl=timedelta(), @@ -93,3 +139,8 @@ Field(name="event_timestamp", dtype=UnixTimestamp), ], ) + +mlpfs_test_all_datatypes_service = FeatureService( + name="test_service", + features=[mlpfs_test_all_datatypes_view, mlpfs_test_all_datatypes_sorted_view], +) diff --git a/go/integration_tests/scylladb/feature_repo/feature_store.yaml b/go/internal/feast/integration_tests/scylladb/feature_repo/feature_store.yaml similarity index 100% rename from go/integration_tests/scylladb/feature_repo/feature_store.yaml rename to go/internal/feast/integration_tests/scylladb/feature_repo/feature_store.yaml diff --git a/go/internal/feast/server/grpc_server_read_range_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go similarity index 56% rename from go/internal/feast/server/grpc_server_read_range_integration_test.go rename to go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index fb9f65da2c3..62ee6fe30c6 100644 --- a/go/internal/feast/server/grpc_server_read_range_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -1,15 +1,18 @@ //go:build integration -package server +package scylladb import ( "context" "fmt" + "github.com/feast-dev/feast/go/internal/feast/server" "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" + "github.com/stretchr/testify/require" "os" + "path/filepath" "strings" "testing" ) @@ -18,8 +21,12 @@ var client serving.ServingServiceClient var ctx context.Context func TestMain(m *testing.M) { - dir := "../../../integration_tests/scylladb/" - err := test.SetupInitializedRepo(dir) + dir, err := filepath.Abs("./") + if err != nil { + fmt.Printf("Failed to get absolute path: %v\n", err) + os.Exit(1) + } + err = test.SetupInitializedRepo(dir) if err != nil { fmt.Printf("Failed to set up test environment: %v\n", err) os.Exit(1) @@ -28,7 +35,7 @@ func TestMain(m *testing.M) { ctx = context.Background() var closer func() - client, closer = getClient(ctx, "", dir, "") + client, closer = server.GetClient(ctx, "", dir, "") // Run the tests exitCode := m.Run() @@ -88,30 +95,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { } 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)) - } - } - } - } + assertResponseData(t, response, featureNames) } func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { @@ -149,9 +133,92 @@ func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { } response, err := client.GetOnlineFeaturesRange(ctx, request) assert.NoError(t, err) - assert.NotNil(t, response) - assert.Equal(t, 33, len(response.Results)) + assertResponseData(t, response, featureNames) +} + +func TestGetOnlineFeaturesRange_withFeatureService(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}}, + }, + } + + request := &serving.GetOnlineFeaturesRangeRequest{ + Kind: &serving.GetOnlineFeaturesRangeRequest_FeatureService{ + FeatureService: "test_service", + }, + 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, + } + _, 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) { + 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:"+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, + } + _, 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 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)) for i, featureResult := range response.Results { assert.Equal(t, 3, len(featureResult.Values)) for _, value := range featureResult.Values { diff --git a/go/integration_tests/valkey/README.md b/go/internal/feast/integration_tests/valkey/README.md similarity index 100% rename from go/integration_tests/valkey/README.md rename to go/internal/feast/integration_tests/valkey/README.md diff --git a/go/integration_tests/valkey/docker-compose.yaml b/go/internal/feast/integration_tests/valkey/docker-compose.yaml similarity index 100% rename from go/integration_tests/valkey/docker-compose.yaml rename to go/internal/feast/integration_tests/valkey/docker-compose.yaml diff --git a/go/integration_tests/valkey/feature_repo/__init__.py b/go/internal/feast/integration_tests/valkey/feature_repo/__init__.py similarity index 100% rename from go/integration_tests/valkey/feature_repo/__init__.py rename to go/internal/feast/integration_tests/valkey/feature_repo/__init__.py diff --git a/go/integration_tests/valkey/feature_repo/data.parquet b/go/internal/feast/integration_tests/valkey/feature_repo/data.parquet similarity index 100% rename from go/integration_tests/valkey/feature_repo/data.parquet rename to go/internal/feast/integration_tests/valkey/feature_repo/data.parquet diff --git a/go/integration_tests/valkey/feature_repo/example_repo.py b/go/internal/feast/integration_tests/valkey/feature_repo/example_repo.py similarity index 95% rename from go/integration_tests/valkey/feature_repo/example_repo.py rename to go/internal/feast/integration_tests/valkey/feature_repo/example_repo.py index fa5073a68a6..7216ea161d2 100644 --- a/go/integration_tests/valkey/feature_repo/example_repo.py +++ b/go/internal/feast/integration_tests/valkey/feature_repo/example_repo.py @@ -2,7 +2,7 @@ from datetime import timedelta -from feast import Entity, FeatureView, Field, FileSource, Project +from feast import Entity, FeatureView, Field, FileSource, Project, FeatureService from feast.types import ( Array, Bool, @@ -83,3 +83,9 @@ Field(name="null_array_boolean_val", dtype=Array(Bool)), ], ) + +mlpfs_test_all_datatypes_service = FeatureService( + name="test_service", + features=[mlpfs_test_all_datatypes_view], +) + diff --git a/go/integration_tests/valkey/feature_repo/feature_store.yaml b/go/internal/feast/integration_tests/valkey/feature_repo/feature_store.yaml similarity index 100% rename from go/integration_tests/valkey/feature_repo/feature_store.yaml rename to go/internal/feast/integration_tests/valkey/feature_repo/feature_store.yaml diff --git a/go/internal/feast/server/grpc_server_integration_test.go b/go/internal/feast/integration_tests/valkey/valkey_integration_test.go similarity index 90% rename from go/internal/feast/server/grpc_server_integration_test.go rename to go/internal/feast/integration_tests/valkey/valkey_integration_test.go index b3b549fa631..bb9478f3a7b 100644 --- a/go/internal/feast/server/grpc_server_integration_test.go +++ b/go/internal/feast/integration_tests/valkey/valkey_integration_test.go @@ -1,31 +1,60 @@ //go:build integration -package server +package valkey import ( "context" + fmt "fmt" + "os" "path/filepath" "reflect" "strings" "testing" + "github.com/feast-dev/feast/go/internal/feast/server" "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" - "github.com/stretchr/testify/require" ) -func TestGetOnlineFeaturesValkey(t *testing.T) { - ctx := context.Background() - dir := "../../../integration_tests/valkey/" - err := test.SetupInitializedRepo(dir) - defer test.CleanUpInitializedRepo(dir) - require.Nil(t, err) +var client serving.ServingServiceClient +var ctx context.Context +var dir string + +func TestMain(m *testing.M) { + var err error + dir, err = filepath.Abs("./") + if err != nil { + fmt.Printf("Failed to get absolute path: %v\n", err) + os.Exit(1) + } + 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 = server.GetClient(ctx, "", dir, "") - client, closer := getClient(ctx, "", dir, "") - defer closer() + // 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 TestGetOnlineFeaturesValkey(t *testing.T) { entities := make(map[string]*types.RepeatedValue) entities["index_id"] = &types.RepeatedValue{ diff --git a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go index 447aafe7dcb..1684b10d1dc 100644 --- a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go +++ b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go @@ -23,14 +23,14 @@ var ctx = context.Background() func TestMain(m *testing.M) { // Initialize the test environment - dir := "../../../integration_tests/scylladb/" - err := test.SetupInitializedRepo(dir) + dir, err := filepath.Abs("./../integration_tests/scylladb/") + err = test.SetupInitializedRepo(dir) if err != nil { fmt.Printf("Failed to set up test environment: %v\n", err) 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_test.go b/go/internal/feast/server/grpc_server_test.go index b83d1ff4159..5ec71709046 100644 --- a/go/internal/feast/server/grpc_server_test.go +++ b/go/internal/feast/server/grpc_server_test.go @@ -32,7 +32,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) @@ -48,7 +48,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{ @@ -109,7 +109,7 @@ func TestGetOnlineFeaturesSqliteWithLogging(t *testing.T) { require.Nil(t, err) logPath := t.TempDir() - client, closer := getClient(ctx, "file", 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/grpc_server_utils_test.go b/go/internal/feast/server/server_test_utils.go similarity index 97% rename from go/internal/feast/server/grpc_server_utils_test.go rename to go/internal/feast/server/server_test_utils.go index 380e7b0acd5..346defa521c 100644 --- a/go/internal/feast/server/grpc_server_utils_test.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, offlineStoreType string, basePath string, logPath string) (serving.ServingServiceClient, func()) { buffer := 1024 * 1024 listener := bufconn.Listen(buffer) diff --git a/go/internal/test/go_integration_test_utils.go b/go/internal/test/go_integration_test_utils.go index 34a65976faa..307dc164cc2 100644 --- a/go/internal/test/go_integration_test_utils.go +++ b/go/internal/test/go_integration_test_utils.go @@ -3,13 +3,11 @@ package test import ( "context" "fmt" - "log" - "github.com/apache/arrow/go/v17/arrow" "github.com/apache/arrow/go/v17/arrow/memory" "github.com/apache/arrow/go/v17/parquet/file" "github.com/apache/arrow/go/v17/parquet/pqarrow" - + "log" "os" "os/exec" "path/filepath" @@ -245,6 +243,7 @@ func SetupCleanFeatureRepo(basePath string) error { } func SetupInitializedRepo(basePath string) error { + log.Printf("Setting up initialized repo at %s", basePath) path, err := filepath.Abs(basePath) if err != nil { return err diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index 48dc8bb673d..6e1f9315354 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -166,82 +166,16 @@ func CopyProtoValuesToArrowArray(builder array.Builder, values []*types.Value) e } func ArrowValuesToProtoValues(arr arrow.Array) ([]*types.Value, error) { - values := make([]*types.Value, 0) - if listArr, ok := arr.(*array.List); ok { - listValues := listArr.ListValues() - offsets := listArr.Offsets()[1:] - pos := 0 - for idx := 0; idx < listArr.Len(); 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 = append(values, - &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 = append(values, - &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 = append(values, - &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 = append(values, - &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 = append(values, - &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 = append(values, - &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 = append(values, - &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 = append(values, - &types.Value{Val: &types.Value_UnixTimestampListVal{ - UnixTimestampListVal: &types.Int64List{Val: vals}}}) - - } - - // set the end of current element as start of the next - pos = int(offsets[idx]) + valueList, err := ArrowListToProtoList(listArr, listArr.Offsets()) + if err != nil { + return nil, fmt.Errorf("error converting list to proto Value: %v", err) } - - return values, nil + return valueList, nil } + values := make([]*types.Value, 0) + switch arr.DataType() { case arrow.PrimitiveTypes.Int32: for idx := 0; idx < arr.Len(); idx++ { @@ -318,6 +252,72 @@ func ArrowValuesToProtoValues(arr arrow.Array) ([]*types.Value, error) { return values, nil } +func ArrowListToProtoList(listArr *array.List, inputOffsets []int32) ([]*types.Value, error) { + listValues := listArr.ListValues() + offsets := inputOffsets[1:] + 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)) + } + 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 +} + func ProtoValuesToArrowArray(protoValues []*types.Value, arrowAllocator memory.Allocator, numRows int) (arrow.Array, error) { var fieldType arrow.DataType var err error @@ -362,11 +362,24 @@ func ArrowValuesToRepeatedProtoValues(arr arrow.Array) ([]*types.RepeatedValue, 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() { @@ -455,33 +468,9 @@ func RepeatedProtoValuesToArrowArray(repeatedValues []*types.RepeatedValue, allo if repeatedValue == nil || len(repeatedValue.Val) == 0 { continue } - - for _, val := range repeatedValue.Val { - if val == nil || val.Val == nil { - appendNullByType(valueBuilder) - continue - } - - switch v := val.Val.(type) { - case *types.Value_Int32Val: - valueBuilder.(*array.Int32Builder).Append(v.Int32Val) - case *types.Value_Int64Val: - valueBuilder.(*array.Int64Builder).Append(v.Int64Val) - case *types.Value_FloatVal: - valueBuilder.(*array.Float32Builder).Append(v.FloatVal) - case *types.Value_DoubleVal: - valueBuilder.(*array.Float64Builder).Append(v.DoubleVal) - case *types.Value_BoolVal: - valueBuilder.(*array.BooleanBuilder).Append(v.BoolVal) - case *types.Value_StringVal: - valueBuilder.(*array.StringBuilder).Append(v.StringVal) - case *types.Value_BytesVal: - valueBuilder.(*array.BinaryBuilder).Append(v.BytesVal) - case *types.Value_UnixTimestampVal: - valueBuilder.(*array.TimestampBuilder).Append(arrow.Timestamp(v.UnixTimestampVal)) - default: - appendNullByType(valueBuilder) - } + err = CopyProtoValuesToArrowArray(valueBuilder, repeatedValue.Val) + if err != nil { + return nil, fmt.Errorf("error copying proto values to arrow array: %v", err) } } diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index c6ae701f99c..b7757d2761f 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -182,6 +182,38 @@ var ( {Val: &types.Value_Int32Val{Int32Val: 50}}, }}, }, + { + {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_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_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_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_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_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_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_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 f0d762c58a2fd2521f2f1b4ed548c16efbee0a6d Mon Sep 17 00:00:00 2001 From: kpulipati29 Date: Thu, 26 Jun 2025 16:59:44 -0500 Subject: [PATCH 06/25] fix: fix misplaced reset indexes (#266) --- sdk/python/feast/infra/contrib/spark_kafka_processor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sdk/python/feast/infra/contrib/spark_kafka_processor.py b/sdk/python/feast/infra/contrib/spark_kafka_processor.py index ad3923e18c2..549cae61d33 100644 --- a/sdk/python/feast/infra/contrib/spark_kafka_processor.py +++ b/sdk/python/feast/infra/contrib/spark_kafka_processor.py @@ -266,13 +266,12 @@ def batch_write(row: DataFrame, batch_id: int): .groupby(self.join_keys) .nth(0) ) + # Reset indices to ensure the dataframe has all the required columns. + rows = rows.reset_index() # Created column is not used anywhere in the code, but it is added to the dataframe. # Commenting this out as it is not used anywhere in the code # rows["created"] = pd.to_datetime("now", utc=True) - # Reset indices to ensure the dataframe has all the required columns. - rows = rows.reset_index() - # Optionally execute preprocessor before writing to the online store. if self.preprocess_fn: rows = self.preprocess_fn(rows) From d7d00868f31dc7e77206dcf7b8b018ebaef91b23 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 07/25] 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/feature_view.py | 43 ++ sdk/python/feast/repo_operations.py | 60 ++- sdk/python/feast/sorted_feature_view.py | 33 +- sdk/python/tests/unit/test_feature_views.py | 102 +++- sdk/python/tests/unit/test_repo_operations.py | 435 ++++++++++++++++++ .../tests/unit/test_sorted_feature_view.py | 166 +++++++ 6 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 sdk/python/tests/unit/test_repo_operations.py diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 1898fe2847f..46332e8aedd 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import copy +import logging import warnings from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple, Type @@ -53,6 +54,8 @@ ONLINE_STORE_TAG_SUFFIX = "online_store_" +logger = logging.getLogger(__name__) + @typechecked class FeatureView(BaseFeatureView): @@ -407,6 +410,46 @@ def get_ttl_duration(self): ttl_duration.FromTimedelta(self.ttl) return ttl_duration + def is_update_compatible_with(self, updated) -> Tuple[bool, List[str]]: + """ + Checks if updating this FeatureView to 'updated' is compatible. + Returns a tuple: + (True, []) if compatible; + (False, [reasons...]) otherwise. + """ + reasons: List[str] = [] + old_fields = {f.name: f.dtype for f in self.schema} + new_fields = {f.name: f.dtype for f in updated.schema} + + # TODO: Think about how the following check should be handled for stream FVs + if self.materialization_intervals: + if updated.entities != self.entities: + reasons.append( + f"entity definitions cannot change for FeatureView: {self.name}" + ) + + removed = old_fields.keys() - new_fields.keys() + for fname in sorted(removed): + if fname in self.entities: + reasons.append( + f"feature '{fname}' removed from FeatureView '{self.name}' is an entity key and cannot be removed" + ) + else: + logger.info( + "Feature '%s' removed from FeatureView '%s'.", fname, self.name + ) + else: + logger.info("No materialization intervals: skipping entity related checks.") + for fname, old_dtype in old_fields.items(): + if fname in new_fields and new_fields[fname] != old_dtype: + logger.warning( + f"feature '{fname}' type changed ({old_dtype} to {new_fields[fname]}), this is " + "generally an illegal operation, please re-materialize all data in order for this " + f"change to reflect correctly for this feature view {self.name}." + ) + + return len(reasons) == 0, reasons + @classmethod def from_proto(cls, feature_view_proto: FeatureViewProto): """ diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index 9a5145b4dc4..d91eb9d9eed 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -21,6 +21,11 @@ from feast.data_source import DataSource, KafkaSource, KinesisSource from feast.diff.registry_diff import extract_objects_for_keep_delete_update_add from feast.entity import Entity +from feast.errors import ( + FeatureViewNotFoundException, + SortedFeatureViewNotFoundException, +) +from feast.feast_object import FeastObject from feast.feature_service import FeatureService from feast.feature_store import FeatureStore from feast.feature_view import DUMMY_ENTITY, FeatureView @@ -341,6 +346,53 @@ def extract_objects_for_apply_delete(project, registry, repo): ) +def validate_objects_for_apply( + all_to_apply: List[FeastObject], + registry: BaseRegistry, + project_name: str, +): + """ + Validates objects in `all_to_apply` against existing registry entries + by calling each object’s `is_update_compatible_with`, unpacking the + returned (ok, reasons). Collects every reason and raises one ValueError + if any incompatibility is found. + """ + errors: List[str] = [] + validated: List[FeastObject] = [] + + for obj in all_to_apply: + incompatible = False + + if hasattr(obj, "is_update_compatible_with"): + try: + if isinstance(obj, SortedFeatureView): + current = registry.get_sorted_feature_view(obj.name, project_name) + elif isinstance(obj, FeatureView): + current = registry.get_feature_view(obj.name, project_name) # type: ignore[assignment] + else: + current = None + except (SortedFeatureViewNotFoundException, FeatureViewNotFoundException): + logger.warning( + "'%s' not found in registry; treating as new object.", + obj.name, + ) + current = None + + if current is not None: + ok, reasons = current.is_update_compatible_with(obj) + if not ok: + for r in reasons: + errors.append(f"'{obj.name}': {r}") + incompatible = True + + if not incompatible: + validated.append(obj) + + # Fail with full report + if errors: + raise ValueError("Compatibility check failed for:\n" + "\n".join(errors)) + + def apply_total_with_repo_instance( store: FeatureStore, project_name: str, @@ -363,6 +415,8 @@ def apply_total_with_repo_instance( views_to_delete, ) = extract_objects_for_apply_delete(project_name, registry, repo) + validate_objects_for_apply(all_to_apply, registry, project_name) + if store._should_use_plan(): registry_diff, infra_diff, new_infra = store.plan(repo) click.echo(registry_diff.to_string()) @@ -370,7 +424,11 @@ def apply_total_with_repo_instance( store._apply_diffs(registry_diff, infra_diff, new_infra) click.echo(infra_diff.to_string()) else: - store.apply(all_to_apply, objects_to_delete=all_to_delete, partial=False) + store.apply( + objects=all_to_apply, + objects_to_delete=all_to_delete, + partial=False, + ) log_infra_changes(views_to_keep, views_to_delete) diff --git a/sdk/python/feast/sorted_feature_view.py b/sdk/python/feast/sorted_feature_view.py index ff10c67e658..8aea00cb405 100644 --- a/sdk/python/feast/sorted_feature_view.py +++ b/sdk/python/feast/sorted_feature_view.py @@ -1,7 +1,8 @@ import copy +import logging import warnings from datetime import timedelta -from typing import Dict, List, Optional, Type +from typing import Dict, List, Optional, Tuple, Type from google.protobuf.message import Message from typeguard import typechecked @@ -21,6 +22,7 @@ from feast.sort_key import SortKey warnings.simplefilter("ignore", DeprecationWarning) +logger = logging.getLogger(__name__) @typechecked @@ -151,6 +153,35 @@ def ensure_valid(self): f"the expected feature value type {expected_value_type} for feature '{sort_key.name}'." ) + def is_update_compatible_with(self, updated) -> Tuple[bool, List[str]]: + """ + Checks if updating this SortedFeatureView to `updated` is compatible. + Returns (True, []) if compatible; otherwise (False, [reasons...]). + """ + reasons: List[str] = [] + + # Base FeatureView compatibility + base_ok, base_reasons = super().is_update_compatible_with(updated) # type: ignore + if not base_ok: + reasons.extend(base_reasons) + + # Sort key check + old_keys = [sk.name for sk in self.sort_keys] + new_keys = [sk.name for sk in updated.sort_keys] + if old_keys != new_keys: + reasons.append( + f"sort keys cannot change (old: {old_keys}, new: {new_keys})" + ) + + for old_sk, new_sk in zip(self.sort_keys, updated.sort_keys): + if old_sk.default_sort_order != new_sk.default_sort_order: + reasons.append( + f"sort key '{old_sk.name}' sort order changed " + f"({old_sk.default_sort_order} to {new_sk.default_sort_order})" + ) + + return len(reasons) == 0, reasons + @property def proto_class(self) -> Type[Message]: return SortedFeatureViewProto diff --git a/sdk/python/tests/unit/test_feature_views.py b/sdk/python/tests/unit/test_feature_views.py index 23400a22827..24c9cc08e04 100644 --- a/sdk/python/tests/unit/test_feature_views.py +++ b/sdk/python/tests/unit/test_feature_views.py @@ -1,3 +1,4 @@ +import copy from datetime import timedelta import pytest @@ -11,10 +12,46 @@ from feast.field import Field from feast.infra.offline_stores.file_source import FileSource from feast.protos.feast.types.Value_pb2 import ValueType -from feast.types import Float32 +from feast.types import Float32, Int64, String from feast.utils import _utc_now, make_tzaware +def make_fv(name, fields, entities, ttl): + """ + Helper to create a simple FeatureView with given field names and dtypes. + """ + source = FileSource(path="dummy_path") + schema = [Field(name=fname, dtype=ftype) for fname, ftype in fields] + fv = FeatureView( + name=name, + source=source, + entities=entities, + schema=schema, + ttl=ttl, + ) + current_time = _utc_now() + start_date = make_tzaware(current_time - timedelta(days=1)) + end_date = make_tzaware(current_time) + fv.materialization_intervals.append((start_date, end_date)) + + return fv + + +def make_fv_no_materialization_interval(name, fields, entities, ttl): + """ + Helper to create a simple FeatureView with given field names and dtypes. + """ + source = FileSource(path="dummy_path") + schema = [Field(name=fname, dtype=ftype) for fname, ftype in fields] + return FeatureView( + name=name, + source=source, + entities=entities, + schema=schema, + ttl=ttl, + ) + + def test_create_feature_view_with_conflicting_entities(): user1 = Entity(name="user1", join_keys=["user_id"]) user2 = Entity(name="user2", join_keys=["user_id"]) @@ -196,3 +233,66 @@ def test_get_online_store_tags_none_when_not_set(): ) expected = {} assert feature_view.get_online_store_tags == expected + + +def test_fv_compatibility_same(): + """ + Two identical FeatureViews should be compatible with no reasons. + """ + entity = Entity(name="e1", join_keys=["e1_id"]) + fv1 = make_fv( + "fv", [("feat1", Int64), ("feat2", String)], [entity], timedelta(days=1) + ) + fv2 = copy.copy(fv1) + + ok, reasons = fv1.is_update_compatible_with(fv2) + assert ok + assert reasons == [] + + +def test_fv_compatibility_remove_non_entity_feature(caplog): + """ + Removing a non-entity feature should be compatible, warning logged. + """ + entity = Entity(name="e1", join_keys=["e1_id"]) + fv1 = make_fv( + "fv", [("feat1", Int64), ("feat2", String)], [entity], timedelta(days=1) + ) + fv2 = make_fv("fv", [("feat1", Int64)], [entity], timedelta(days=1)) + caplog.set_level("INFO") + ok, reasons = fv1.is_update_compatible_with(fv2) + assert ok + assert reasons == [] + assert "Feature 'feat2' removed from FeatureView 'fv'." in caplog.text + + +def test_fv_compatibility_change_entities(): + """ + Changing the entity list should be incompatible and list reason. + """ + ent1 = Entity(name="e1", join_keys=["e1_id"]) + ent2 = Entity(name="e2", join_keys=["e2_id"]) + fv1 = make_fv("fv", [("feat1", Int64)], [ent1], timedelta(days=1)) + fv2 = make_fv("fv", [("feat1", Int64)], [ent2], timedelta(days=1)) + + ok, reasons = fv1.is_update_compatible_with(fv2) + assert not ok + assert "entity definitions cannot change for FeatureView: fv" in reasons + + +def test_fv_compatibility_change_entities_with_no_materialization_interval(): + """ + Changing the entity list should be ok. + """ + ent1 = Entity(name="e1", join_keys=["e1_id"]) + ent2 = Entity(name="e2", join_keys=["e2_id"]) + fv1 = make_fv_no_materialization_interval( + "fv", [("feat1", Int64)], [ent1], timedelta(days=1) + ) + fv2 = make_fv_no_materialization_interval( + "fv", [("feat1", Int64)], [ent2], timedelta(days=1) + ) + + ok, reasons = fv1.is_update_compatible_with(fv2) + assert ok + assert reasons == [] diff --git a/sdk/python/tests/unit/test_repo_operations.py b/sdk/python/tests/unit/test_repo_operations.py new file mode 100644 index 00000000000..a4d537eb507 --- /dev/null +++ b/sdk/python/tests/unit/test_repo_operations.py @@ -0,0 +1,435 @@ +from datetime import timedelta + +import pytest + +from feast import ( + Entity, + FeatureStore, + FeatureView, + Field, + FileSource, + Project, + SortedFeatureView, + SparkSource, + ValueType, +) +from feast.infra.registry.registry import Registry +from feast.repo_config import RegistryConfig +from feast.repo_contents import RepoContents +from feast.repo_operations import apply_total_with_repo_instance +from feast.sort_key import SortKey +from feast.types import Float64, Int64, String +from feast.utils import _utc_now, make_tzaware + + +@pytest.fixture +def driver_entity(): + return Entity(name="driver", join_keys=["driver_id"], value_type=ValueType.INT64) + + +@pytest.fixture +def file_source(): + return FileSource(path="file:///data.parquet", event_timestamp_column="ts") + + +@pytest.fixture +def spark_source(): + return SparkSource( + name="datasource1", + description="datasource1", + query="""select entity1, val1, val2, val3, val4, val5, CURRENT_DATE AS event_timestamp from table1 WHERE entity1 < 100000""", + timestamp_field="event_timestamp", + tags={"tag1": "val1", "tag2": "val2", "tag3": "val3"}, + owner="x@xyz.com", + ) + + +@pytest.fixture +def entity1(): + return Entity( + name="entity1", + join_keys=["entity1"], + value_type=ValueType.INT64, + description="entity1", + owner="x@xyz.com", + tags={"tag1": "val1", "tag2": "val2", "tag3": "val3"}, + ) + + +@pytest.fixture +def dummy_repo_contents_fv(driver_entity, spark_source, file_source, entity1): + fv = FeatureView( + name="fv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[ + Field(name="entity1", dtype=Int64), + Field(name="val1", dtype=String), + Field(name="val2", dtype=String), + ], + source=spark_source, + description="view1", + owner="x@xyz.com", + ) + return RepoContents( + projects=[Project(name="my_project")], + data_sources=[file_source], + entities=[driver_entity], + feature_views=[fv], + sorted_feature_views=[], + on_demand_feature_views=[], + stream_feature_views=[], + feature_services=[], + permissions=[], + ) + + +@pytest.fixture +def dummy_repo_contents_fv_updated(driver_entity, spark_source, file_source, entity1): + fv = FeatureView( + name="fv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[ + Field(name="entity1", dtype=Int64), + Field(name="val1", dtype=String), + Field(name="val2", dtype=String), + Field(name="val3", dtype=Int64), + ], + source=spark_source, + description="view1", + owner="x@xyz.com", + ) + return RepoContents( + projects=[Project(name="my_project")], + data_sources=[file_source], + entities=[driver_entity], + feature_views=[fv], + sorted_feature_views=[], + on_demand_feature_views=[], + stream_feature_views=[], + feature_services=[], + permissions=[], + ) + + +@pytest.fixture +def dummy_repo_contents_sfv(driver_entity, file_source, entity1): + key = SortKey(name="f1", value_type=ValueType.INT64, default_sort_order=1) + sfv = SortedFeatureView( + name="sfv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[Field(name="f1", dtype=Int64)], + source=file_source, + sort_keys=[key], + _skip_validation=True, + ) + return RepoContents( + projects=[Project(name="my_project")], + data_sources=[file_source], + entities=[driver_entity], + feature_views=[], + sorted_feature_views=[sfv], + on_demand_feature_views=[], + stream_feature_views=[], + feature_services=[], + permissions=[], + ) + + +@pytest.fixture +def dummy_repo_contents_sfv_and_fv(driver_entity, file_source, entity1): + key = SortKey(name="f1", value_type=ValueType.INT64, default_sort_order=0) + sfv = SortedFeatureView( + name="sfv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[Field(name="f1", dtype=Int64)], + source=file_source, + sort_keys=[key], + _skip_validation=True, + ) + fv = FeatureView( + name="fv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[ + Field(name="entity1", dtype=Int64), + Field(name="val1", dtype=String), + Field(name="val2", dtype=String), + ], + source=spark_source, + description="view1", + owner="x@xyz.com", + ) + return RepoContents( + projects=[Project(name="my_project")], + data_sources=[file_source], + entities=[driver_entity], + feature_views=[fv], + sorted_feature_views=[sfv], + on_demand_feature_views=[], + stream_feature_views=[], + feature_services=[], + permissions=[], + ) + + +@pytest.fixture +def dummy_repo_contents_sfv_update(driver_entity, file_source, entity1): + key = SortKey(name="f1", value_type=ValueType.INT64, default_sort_order=1) + sfv = SortedFeatureView( + name="sfv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[Field(name="f1", dtype=Int64), Field(name="f2", dtype=Float64)], + source=file_source, + sort_keys=[key], + _skip_validation=True, + ) + return RepoContents( + projects=[Project(name="my_project")], + data_sources=[file_source], + entities=[driver_entity], + feature_views=[], + sorted_feature_views=[sfv], + on_demand_feature_views=[], + stream_feature_views=[], + feature_services=[], + permissions=[], + ) + + +@pytest.fixture +def dummy_repo_contents_sfv_invalid_update(driver_entity, file_source, entity1): + key = SortKey(name="f1", value_type=ValueType.INT64, default_sort_order=0) + sfv = SortedFeatureView( + name="sfv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[Field(name="f2", dtype=Float64)], + source=file_source, + sort_keys=[key], + _skip_validation=True, + ) + return RepoContents( + projects=[Project(name="my_project")], + data_sources=[file_source], + entities=[driver_entity], + feature_views=[], + sorted_feature_views=[sfv], + on_demand_feature_views=[], + stream_feature_views=[], + feature_services=[], + permissions=[], + ) + + +@pytest.fixture +def dummy_repo_contents_fv_invalid_update( + driver_entity, spark_source, file_source, entity1 +): + fv = FeatureView( + name="fv1", + entities=[entity1], + ttl=timedelta(days=365), + schema=[ + Field(name="val1", dtype=String), + Field(name="val2", dtype=String), + Field(name="val3", dtype=Int64), + ], + source=spark_source, + description="view1", + owner="x@xyz.com", + ) + return RepoContents( + projects=[Project(name="my_project")], + data_sources=[file_source], + entities=[driver_entity], + feature_views=[fv], + sorted_feature_views=[], + on_demand_feature_views=[], + stream_feature_views=[], + feature_services=[], + permissions=[], + ) + + +@pytest.fixture +def registry(tmp_path): + registry_cfg = RegistryConfig(path=str(tmp_path / "reg.db"), cache_ttl_seconds=0) + return Registry( + project="my_project", + registry_config=registry_cfg, + repo_path=None, + ) + + +@pytest.fixture +def store_mock(mocker): + store = mocker.create_autospec(FeatureStore, instance=True) + store._should_use_plan.return_value = False + return store + + +def test_apply_only_calls_store_apply_for_new_fv( + dummy_repo_contents_fv, registry, store_mock +): + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_fv, + skip_source_validation=True, + ) + # Verify that the apply method was called with the correct parameters + expected_apply_list = ( + dummy_repo_contents_fv.projects + + dummy_repo_contents_fv.data_sources + + dummy_repo_contents_fv.entities + + dummy_repo_contents_fv.feature_views + ) + + store_mock.apply.assert_called_once_with( + expected_apply_list, + objects_to_delete=[], + partial=False, + ) + + +def test_apply_persists_feature_view(dummy_repo_contents_fv, registry, store_mock): + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_fv, + skip_source_validation=True, + ) + (ds,) = dummy_repo_contents_fv.data_sources + (fv,) = dummy_repo_contents_fv.feature_views + expected = ( + dummy_repo_contents_fv.projects + [ds] + dummy_repo_contents_fv.entities + [fv] + ) + store_mock.apply.assert_called_once_with( + expected, + objects_to_delete=[], + partial=False, + ) + + +def test_update_feature_view_add_field( + dummy_repo_contents_fv, dummy_repo_contents_fv_updated, registry, store_mock +): + # First apply original FV + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_fv, + skip_source_validation=True, + ) + + # Manually persist the FV into the registry so get_feature_view() will succeed: + original_fv = dummy_repo_contents_fv.feature_views[0] + registry.apply_feature_view(original_fv, project="my_project", commit=True) + + store_mock.apply.reset_mock() + + # Re-apply + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_fv_updated, + skip_source_validation=True, + ) + + (ds,) = dummy_repo_contents_fv_updated.data_sources + (fv1,) = dummy_repo_contents_fv_updated.feature_views + expected = ( + dummy_repo_contents_fv_updated.projects + + [ds] + + dummy_repo_contents_fv_updated.entities + + [fv1] + ) + store_mock.apply.assert_called_once_with( + expected, + objects_to_delete=[], + partial=False, + ) + + +def test_update_feature_view_remove_entity_key_fails( + dummy_repo_contents_fv, + dummy_repo_contents_fv_invalid_update, + file_source, + registry, + store_mock, +): + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_fv, + skip_source_validation=True, + ) + + original_fv = dummy_repo_contents_fv.feature_views[0] + current_time = _utc_now() + start_date = make_tzaware(current_time - timedelta(days=1)) + end_date = make_tzaware(current_time) + original_fv.materialization_intervals.append((start_date, end_date)) + + registry.apply_feature_view(original_fv, project="my_project", commit=True) + + with pytest.raises(ValueError) as excinfo: + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_fv_invalid_update, + skip_source_validation=True, + ) + + assert "entity key and cannot be removed" in str(excinfo.value) + + +def test_update_sorted_feature_view( + dummy_repo_contents_sfv, dummy_repo_contents_sfv_update, registry, store_mock +): + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_sfv, + skip_source_validation=True, + ) + + # Manually persist the FV into the registry so get_feature_view() will succeed: + original_fv = dummy_repo_contents_sfv.sorted_feature_views[0] + registry.apply_feature_view(original_fv, project="my_project", commit=True) + + store_mock.apply.reset_mock() + + apply_total_with_repo_instance( + store=store_mock, + project_name="my_project", + registry=registry, + repo=dummy_repo_contents_sfv_update, + skip_source_validation=True, + ) + + (ds,) = dummy_repo_contents_sfv_update.data_sources + (fv1,) = dummy_repo_contents_sfv_update.sorted_feature_views + expected = ( + dummy_repo_contents_sfv_update.projects + + [ds] + + dummy_repo_contents_sfv_update.entities + + [fv1] + ) + store_mock.apply.assert_called_once_with( + expected, + objects_to_delete=[], + partial=False, + ) diff --git a/sdk/python/tests/unit/test_sorted_feature_view.py b/sdk/python/tests/unit/test_sorted_feature_view.py index 56f4ee800f9..53fba818f1a 100644 --- a/sdk/python/tests/unit/test_sorted_feature_view.py +++ b/sdk/python/tests/unit/test_sorted_feature_view.py @@ -481,3 +481,169 @@ def test_sorted_feature_view_invalid_sort_key_order_int(): value_type=ValueType.INT64, default_sort_order=99, ) + + +def test_sfv_compatibility_same(): + """ + Two identical SortedFeatureViews should be compatible with no reasons. + """ + source = FileSource(path="dummy", event_timestamp_column="ts") + entity = Entity(name="e1", join_keys=["e1_id"]) + schema = [Field(name="f1", dtype=Int64), Field(name="f2", dtype=String)] + sort_key = SortKey( + name="f1", value_type=ValueType.INT64, default_sort_order=SortOrder.ASC + ) + sfv1 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity], + schema=schema, + ttl=timedelta(days=1), + sort_keys=[sort_key], + ) + sfv2 = copy.copy(sfv1) + + ok, reasons = sfv1.is_update_compatible_with(sfv2) + assert ok + assert reasons == [] + + +def test_sfv_compatibility_remove_non_sort_feature(): + """ + Removing a feature not in sort_keys should still be compatible. + """ + source = FileSource(path="dummy", event_timestamp_column="ts") + entity = Entity(name="e1", join_keys=["e1_id"]) + schema1 = [Field(name="f1", dtype=Int64), Field(name="f2", dtype=String)] + schema2 = [Field(name="f1", dtype=Int64)] + sort_key = SortKey( + name="f1", value_type=ValueType.INT64, default_sort_order=SortOrder.ASC + ) + sfv1 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity], + schema=schema1, + ttl=timedelta(days=1), + sort_keys=[sort_key], + ) + sfv2 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity], + schema=schema2, + ttl=timedelta(days=1), + sort_keys=[sort_key], + ) + + ok, reasons = sfv1.is_update_compatible_with(sfv2) + assert ok + assert reasons == [] + + +def test_sfv_compatibility_change_sort_keys(): + """ + Changing sort_keys should produce incompatibility reasons. + """ + source = FileSource(path="dummy", event_timestamp_column="ts") + entity = Entity(name="e1", join_keys=["e1_id"]) + schema = [Field(name="k1", dtype=Int64), Field(name="k2", dtype=Int64)] + sort_key1 = SortKey( + name="k1", value_type=ValueType.INT64, default_sort_order=SortOrder.ASC + ) + sort_key2 = SortKey( + name="k2", value_type=ValueType.INT64, default_sort_order=SortOrder.ASC + ) + sfv1 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity], + schema=schema, + ttl=timedelta(days=1), + sort_keys=[sort_key1], + ) + sfv2 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity], + schema=schema, + ttl=timedelta(days=1), + sort_keys=[sort_key2], + ) + + ok, reasons = sfv1.is_update_compatible_with(sfv2) + assert not ok + assert any("sort keys cannot change" in r for r in reasons) + + +def test_sfv_compatibility_change_entity(): + """ + Changing the entity list should produce incompatibility reasons. + """ + source = FileSource(path="dummy", event_timestamp_column="ts") + entity1 = Entity(name="e1", join_keys=["e1_id"]) + entity2 = Entity(name="e2", join_keys=["e2_id"]) + schema = [Field(name="f1", dtype=Int64)] + sort_key = SortKey( + name="f1", value_type=ValueType.INT64, default_sort_order=SortOrder.ASC + ) + sfv1 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity1], + schema=schema, + ttl=timedelta(days=1), + sort_keys=[sort_key], + ) + sfv2 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity2], + schema=schema, + ttl=timedelta(days=1), + sort_keys=[sort_key], + ) + + current_time = _utc_now() + start_date = make_tzaware(current_time - timedelta(days=1)) + end_date = make_tzaware(current_time) + sfv1.materialization_intervals.append((start_date, end_date)) + + ok, reasons = sfv1.is_update_compatible_with(sfv2) + assert not ok + assert any("entity definitions cannot change" in r for r in reasons) + + +def test_sfv_compatibility_change_sort_key_dtype(): + """ + Changing the sort key's dtype should produce incompatibility reasons. + """ + source = FileSource(path="dummy", event_timestamp_column="ts") + entity = Entity(name="e1", join_keys=["e1_id"]) + schema = [Field(name="f1", dtype=Int64)] + sort_key1 = SortKey( + name="f1", value_type=ValueType.INT64, default_sort_order=SortOrder.ASC + ) + sort_key2 = SortKey( + name="f1", value_type=ValueType.INT64, default_sort_order=SortOrder.DESC + ) + sfv1 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity], + schema=schema, + ttl=timedelta(days=1), + sort_keys=[sort_key1], + ) + sfv2 = SortedFeatureView( + name="sfv", + source=source, + entities=[entity], + schema=schema, + ttl=timedelta(days=1), + sort_keys=[sort_key2], + ) + + ok, reasons = sfv1.is_update_compatible_with(sfv2) + assert not ok + assert any("sort key 'f1' sort order changed" in r for r in reasons) From f4de1fac7ec8b7c3cb099f5f73738517d186d30b Mon Sep 17 00:00:00 2001 From: piket Date: Fri, 27 Jun 2025 11:27:10 -0700 Subject: [PATCH 08/25] 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 | 65 ++++++-- .../feast/onlineserving/serving_test.go | 152 ++++++++++++++++++ 2 files changed, 202 insertions(+), 15 deletions(-) diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 4e09892ac84..fce73d28138 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -173,6 +173,18 @@ func GetFeatureViewsToUseByService( return fvsToUse, sortedFvsToUse, odFvsToUse, nil } +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 @@ -190,43 +202,66 @@ func GetFeatureViewsToUseByFeatureRefs( odFvToFeatures := make(map[string][]string) odFvToProjectWithFeatures := make(map[string]*model.OnDemandFeatureView) + 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 { - if viewAndRef, ok := viewNameToViewAndRefs[fv.Base.Name]; ok { - viewAndRef.FeatureRefs = addStringIfNotContains(viewAndRef.FeatureRefs, featureName) + addFeaturesToValidationMap(fv.Base.Name, fv.Base.Features, viewToFeaturesValidationMap) + if !viewToFeaturesValidationMap[fv.Base.Name][featureName] { + invalidFeatures = append(invalidFeatures, featureRef) } else { - viewNameToViewAndRefs[fv.Base.Name] = &FeatureViewAndRefs{ - View: fv, - FeatureRefs: []string{featureName}, + 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 { - if viewAndRef, ok := viewNameToSortedViewAndRefs[sortedFv.Base.Name]; ok { - viewAndRef.FeatureRefs = addStringIfNotContains(viewAndRef.FeatureRefs, featureName) + addFeaturesToValidationMap(sortedFv.Base.Name, sortedFv.Base.Features, viewToFeaturesValidationMap) + if !viewToFeaturesValidationMap[sortedFv.Base.Name][featureName] { + invalidFeatures = append(invalidFeatures, featureRef) } else { - viewNameToSortedViewAndRefs[sortedFv.Base.Name] = &SortedFeatureViewAndRefs{ - View: sortedFv, - FeatureRefs: []string{featureName}, + + 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 { - if _, ok := odFvToFeatures[odfv.Base.Name]; !ok { - odFvToFeatures[odfv.Base.Name] = []string{featureName} + addFeaturesToValidationMap(odfv.Base.Name, odfv.Base.Features, viewToFeaturesValidationMap) + if !viewToFeaturesValidationMap[odfv.Base.Name][featureName] { + invalidFeatures = append(invalidFeatures, featureRef) } else { - odFvToFeatures[odfv.Base.Name] = append( - odFvToFeatures[odfv.Base.Name], featureName) + + 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 } - odFvToProjectWithFeatures[odfv.Base.Name] = odfv } else { return nil, nil, nil, fmt.Errorf("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, ", ")) + } + odFvsToUse := make([]*model.OnDemandFeatureView, 0) for odFvName, featureNames := range odFvToFeatures { diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index 531f21298b5..f48f264a6d9 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -316,6 +316,158 @@ func TestUnpackFeatureViewsByReferences(t *testing.T) { assertCorrectUnpacking(t, fvs, sortedFvs, odfvs, err) } +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 0430456e2b3bab54d178f0c6b22244568fed8609 Mon Sep 17 00:00:00 2001 From: Manisha Sudhir <30449541+Manisha4@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:07:19 -0700 Subject: [PATCH 09/25] fix: Clean Up Error Messages (#274) * writing clearer error messages * addressing PR comments: fixing tests --- sdk/python/feast/sorted_feature_view.py | 24 ++++++++++++------- .../tests/unit/test_sorted_feature_view.py | 12 ++++++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/sdk/python/feast/sorted_feature_view.py b/sdk/python/feast/sorted_feature_view.py index 8aea00cb405..5e3d750a8cb 100644 --- a/sdk/python/feast/sorted_feature_view.py +++ b/sdk/python/feast/sorted_feature_view.py @@ -109,40 +109,47 @@ def ensure_valid(self): for field in self.features: if field.name in reserved_columns: raise ValueError( - f"Field name '{field.name}' is reserved and cannot be used as a feature name." + f"For SortedFeatureView: {self.name}: Field name '{field.name}' is reserved and cannot be used as " + f"a feature name." ) if field.name in self.entities: raise ValueError( - f"Feature name '{field.name}' is an entity name and cannot be used as a feature." + f"For SortedFeatureView: {self.name}: Feature name '{field.name}' is an entity name and cannot be " + f"used as a feature." ) if field.name in feature_map: - raise ValueError(f"Duplicate feature name found: '{field.name}'.") + raise ValueError( + f"For SortedFeatureView: {self.name}: Duplicate feature name found: '{field.name}'." + ) feature_map[field.name] = field valid_feature_names = list(feature_map.keys()) if not self.sort_keys: raise ValueError( - "SortedFeatureView must have at least one sort key defined." + f"For SortedFeatureView: {self.name}, must have at least one sort key defined." ) seen_sort_keys = set() for sort_key in self.sort_keys: # Check for duplicate sort keys if sort_key.name in seen_sort_keys: - raise ValueError(f"Duplicate sort key found: '{sort_key.name}'.") + raise ValueError( + f"Duplicate sort key found: '{sort_key.name}' in SortedFeatureView: {self.name}." + ) seen_sort_keys.add(sort_key.name) # Sort keys should not conflict with entity names. if sort_key.name in self.entities: raise ValueError( - f"Sort key '{sort_key.name}' cannot be part of entity columns." + f"For SortedFeatureView: {self.name}, Sort key '{sort_key.name}' refers to an entity column and cannot be used as a sort key. " + f"Valid sort key names are feature names: {valid_feature_names}" ) # Validate that the sort key corresponds to a feature. if sort_key.name not in feature_map: raise ValueError( - f"Sort key '{sort_key.name}' does not match any feature name. " + f"Sort key '{sort_key.name}' does not match any feature name in SortedFeatureView: {self.name}. " f"Valid options are: {valid_feature_names}" ) @@ -150,7 +157,8 @@ def ensure_valid(self): if sort_key.value_type != expected_value_type: raise ValueError( f"Sort key '{sort_key.name}' has value type {sort_key.value_type} which does not match " - f"the expected feature value type {expected_value_type} for feature '{sort_key.name}'." + f"the expected feature value type {expected_value_type} for feature '{sort_key.name}' in " + f"SortedFeatureView: {self.name}." ) def is_update_compatible_with(self, updated) -> Tuple[bool, List[str]]: diff --git a/sdk/python/tests/unit/test_sorted_feature_view.py b/sdk/python/tests/unit/test_sorted_feature_view.py index 53fba818f1a..7728db03405 100644 --- a/sdk/python/tests/unit/test_sorted_feature_view.py +++ b/sdk/python/tests/unit/test_sorted_feature_view.py @@ -72,7 +72,10 @@ def test_sorted_feature_view_ensure_valid(): sort_keys=[], ) - assert "must have at least one sort key defined" in str(excinfo.value) + assert ( + "For SortedFeatureView: invalid_sorted_feature_view, must have at least one sort key defined" + in str(excinfo.value) + ) def test_sorted_feature_view_ensure_valid_sort_key_in_entity_columns(): @@ -89,7 +92,7 @@ def test_sorted_feature_view_ensure_valid_sort_key_in_entity_columns(): ) # Create a SortedFeatureView with a sort key that conflicts. - with pytest.raises(ValueError): + with pytest.raises(ValueError) as excinfo: SortedFeatureView( name="invalid_sorted_feature_view", source=source, @@ -97,6 +100,11 @@ def test_sorted_feature_view_ensure_valid_sort_key_in_entity_columns(): sort_keys=[sort_key], ) + assert ( + "For SortedFeatureView: invalid_sorted_feature_view, Sort key 'entity1' refers to an entity column and cannot be used as a sort key." + in str(excinfo.value) + ) + def test_sorted_feature_view_copy(): """ From 46d477e9e40775c936f02475bef30452a95329a1 Mon Sep 17 00:00:00 2001 From: Manisha Sudhir <30449541+Manisha4@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:28:53 -0700 Subject: [PATCH 10/25] feat: Adding Alter Table to Support Schema Updates for Cassandra (#262) * Adding update changes to alter table if fv schema changes * addressing PR comments * removing redundant lines * addressing PR comments * editing makefile to get python tests to run * fixing go integration tests * fixing formatting * fixing failing tests * changing number of workers to 1 * removing the igmore to run all python tests * adding back the ignore statement, seems to be running tests twice * reverting back Makefile * removing ignore statement * reverting back * aadding to unit_tests.yml file * aadding to unit_tests.yml file * aadding to unit_tests.yml file * MacOS tests removed due to MacOS runners not supporting docker --- .github/workflows/unit_tests.yml | 6 +- Makefile | 2 +- .../cassandra_online_store.py | 50 +++- .../elasticsearch_online_store_creator.py | 2 +- .../test_cassandra_online_store.py | 217 +++++++++++++++++- .../test_elasticsearch_online_store.py | 2 +- 6 files changed, 270 insertions(+), 9 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 45e7ddd3e71..00b468786cf 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -44,9 +44,9 @@ jobs: make install-go-ci-dependencies COMPILE_GO=true python setup.py develop CGO_LDFLAGS_ALLOW=".*" COMPILE_GO=True python setup.py build_ext --inplace - - name: Test Milvus tests + - name: Test EG tests if: matrix.os == 'ubuntu-latest' - run: python -m pytest -n 1 --color=yes sdk/python/tests/expediagroup/test_eg_milvus_online_store.py + run: python -m pytest -n 1 --color=yes sdk/python/tests/expediagroup/ - name: Test Python if: matrix.os == 'ubuntu-latest' run: make test-python-unit @@ -133,4 +133,4 @@ jobs: - uses: actions/upload-artifact@v4 with: name: java-coverage-report - path: ${{ github.workspace }}/docs/coverage/java/target/site/jacoco-aggregate/ + path: ${{ github.workspace }}/docs/coverage/java/target/site/jacoco-aggregate/ \ No newline at end of file diff --git a/Makefile b/Makefile index 964df12b548..e9d98c5c411 100644 --- a/Makefile +++ b/Makefile @@ -563,4 +563,4 @@ build-helm-docs: # Note: requires node and yarn to be installed build-ui: - cd $(ROOT_DIR)/sdk/python/feast/ui && yarn upgrade @feast-dev/feast-ui --latest && yarn install && npm run build --omit=dev + cd $(ROOT_DIR)/sdk/python/feast/ui && yarn upgrade @feast-dev/feast-ui --latest && yarn install && npm run build --omit=dev \ No newline at end of file diff --git a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py index 7f373496ed3..0e6e1d1ec7a 100644 --- a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py +++ b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py @@ -709,7 +709,10 @@ def update( project = config.project for table in tables_to_keep: - self._create_table(config, project, table) + if self._table_exists(config, project, table): + self._alter_table(config, project, table) + else: + self._create_table(config, project, table) for table in tables_to_delete: self._drop_table(config, project, table) @@ -893,6 +896,51 @@ def _create_table( ) session.execute(create_cql) + def _resolve_table_names( + self, config: RepoConfig, project: str, table: FeatureView + ) -> Tuple[str, str]: + """ + Returns (fqtable, plain_table_name) for a given FeatureView, + where fqtable is '"keyspace"."table"' and plain_table_name + is the lower-cased unquoted table identifier. + """ + fqtable = CassandraOnlineStore._fq_table_name( + self._keyspace, + project, + table, + config.online_store.table_name_format_version, + ) + # extract bare identifier: split off keyspace, strip quotes, lower-case + quoted = fqtable.split(".", 1)[1] + plain_table_name = quoted.strip('"') + return fqtable, plain_table_name + + def _table_exists( + self, config: RepoConfig, project: str, table: FeatureView + ) -> bool: + self._get_session(config) + _, plain_table_name = self._resolve_table_names(config, project, table) + ks_meta = self._cluster.metadata.keyspaces[self._keyspace] + return plain_table_name in ks_meta.tables + + def _alter_table(self, config: RepoConfig, project: str, table: FeatureView): + session = self._get_session(config) + fqtable, plain_table_name = self._resolve_table_names(config, project, table) + + ks_meta = self._cluster.metadata.keyspaces[self._keyspace] + existing_cols = set(ks_meta.tables[plain_table_name].columns.keys()) + + desired_cols = {f.name for f in table.features} + new_cols = desired_cols - existing_cols + if new_cols: + cql_type = "BLOB" # Default type for features + col_defs = ", ".join(f"{col} {cql_type}" for col in new_cols) + alter_cql = f"ALTER TABLE {fqtable} ADD ({col_defs})" + session.execute(alter_cql) + logger.info( + f"Added columns [{', '.join(sorted(new_cols))}] to table: {fqtable}" + ) + def _build_sorted_table_cql( self, project: str, table: SortedFeatureView, fqtable: str ) -> str: diff --git a/sdk/python/tests/expediagroup/elasticsearch_online_store_creator.py b/sdk/python/tests/expediagroup/elasticsearch_online_store_creator.py index ee5361a600d..d846c50987d 100644 --- a/sdk/python/tests/expediagroup/elasticsearch_online_store_creator.py +++ b/sdk/python/tests/expediagroup/elasticsearch_online_store_creator.py @@ -17,7 +17,7 @@ def __init__(self, project_name: str): self.es_port = 9200 self.elasticsearch_container = ElasticSearchContainer( image="docker.elastic.co/elasticsearch/elasticsearch:8.8.2", - port_to_expose=self.es_port, + port=self.es_port, ) def create_online_store(self): diff --git a/sdk/python/tests/expediagroup/test_cassandra_online_store.py b/sdk/python/tests/expediagroup/test_cassandra_online_store.py index 452362c39e9..32470117538 100644 --- a/sdk/python/tests/expediagroup/test_cassandra_online_store.py +++ b/sdk/python/tests/expediagroup/test_cassandra_online_store.py @@ -92,6 +92,25 @@ def repo_config(cassandra_online_store_config, setup_keyspace) -> RepoConfig: ) +@pytest.fixture(scope="session") +def long_name_repo_config(cassandra_online_store_config, setup_keyspace) -> RepoConfig: + return RepoConfig( + registry=REGISTRY, + project=PROJECT, + provider=PROVIDER, + online_store=CassandraOnlineStoreConfig( + hosts=cassandra_online_store_config["hosts"], + port=cassandra_online_store_config["port"], + keyspace=setup_keyspace, + table_name_format_version=cassandra_online_store_config.get( + "table_name_format_version", 2 + ), + ), + offline_store=DaskOfflineStoreConfig(), + entity_key_serialization_version=ENTITY_KEY_SERIALIZATION_VERSION, + ) + + @pytest.fixture(scope="session") def online_store(repo_config) -> CassandraOnlineStore: store = CassandraOnlineStore() @@ -199,8 +218,8 @@ def test_create_table_from_sorted_feature_view( expected_columns = { "entity_key": "text", - "feature1": "bigint", - "feature2": "list", + "feature1": "blob", + "feature2": "blob", "sort_key1": "bigint", "sort_key2": "text", "event_ts": "timestamp", @@ -741,3 +760,197 @@ def _create_n_test_sample_features_all_datatypes(self, n=10): None, ) ] + + +def test_update_alters_existing_table_adds_new_column( + cassandra_session, repo_config, online_store +): + session, keyspace = cassandra_session + + fv1 = SortedFeatureView( + name="fv_alter_test", + entities=[Entity(name="id")], + source=FileSource(name="src", path="x.parquet", timestamp_field="ts"), + schema=[ + Field(name="sort_key", dtype=Int32), + Field(name="f1", dtype=String), + ], + sort_keys=[ + SortKey( + name="sort_key", + value_type=ValueType.INT32, + default_sort_order=SortOrder.Enum.ASC, + ) + ], + ) + + online_store._create_table(repo_config, repo_config.project, fv1) + + online_store_table = ( + online_store._fq_table_name( + keyspace, + repo_config.project, + fv1, + repo_config.online_store.table_name_format_version, + ) + .split(".", 1)[1] + .strip('"') + ) + + cols = session.execute( + textwrap.dedent(f""" + SELECT column_name + FROM system_schema.columns + WHERE keyspace_name='{keyspace}' AND table_name='{online_store_table}'; + """) + ) + names = {r.column_name for r in cols} + assert "f1" in names and "f2" not in names + + fv2 = SortedFeatureView( + name="fv_alter_test", + entities=[Entity(name="id")], + source=FileSource(name="src", path="x.parquet", timestamp_field="ts"), + schema=[ + Field(name="sort_key", dtype=Int32), + Field(name="f1", dtype=String), + Field(name="f2", dtype=String), + ], + sort_keys=fv1.sort_keys, + ) + + online_store.update( + config=repo_config, + tables_to_delete=[], + tables_to_keep=[fv2], + entities_to_delete=[], + entities_to_keep=[], + partial=False, + ) + + cols = session.execute( + textwrap.dedent(f""" + SELECT column_name + FROM system_schema.columns + WHERE keyspace_name='{keyspace}' AND table_name='{online_store_table}'; + """) + ) + names = {r.column_name for r in cols} + assert {"f1", "f2", "sort_key", "entity_key", "event_ts", "created_ts"}.issubset( + names + ) + + +def test_update_noop_when_schema_unchanged( + cassandra_session, repo_config, online_store +): + session, keyspace = cassandra_session + + fv = SortedFeatureView( + name="fv_noop_test", + entities=[Entity(name="id")], + source=FileSource(name="src", path="x.parquet", timestamp_field="ts"), + schema=[ + Field(name="sort_key", dtype=Int32), + Field(name="f1", dtype=String), + ], + sort_keys=[ + SortKey( + name="sort_key", + value_type=ValueType.INT32, + default_sort_order=SortOrder.Enum.ASC, + ) + ], + ) + + online_store.update( + config=repo_config, + tables_to_delete=[], + tables_to_keep=[fv], + entities_to_delete=[], + entities_to_keep=[], + partial=False, + ) + + online_store_table = ( + online_store._fq_table_name( + keyspace, + repo_config.project, + fv, + repo_config.online_store.table_name_format_version, + ) + .split(".", 1)[1] + .strip('"') + ) + + before = { + r.column_name + for r in session.execute( + f"SELECT column_name FROM system_schema.columns " + f"WHERE keyspace_name='{keyspace}' AND table_name='{online_store_table}';" + ) + } + + # run update again with identical fv + online_store.update( + config=repo_config, + tables_to_delete=[], + tables_to_keep=[fv], + entities_to_delete=[], + entities_to_keep=[], + partial=False, + ) + + after = { + r.column_name + for r in session.execute( + f"SELECT column_name FROM system_schema.columns " + f"WHERE keyspace_name='{keyspace}' AND table_name='{online_store_table}';" + ) + } + + assert before == after + + +def test_resolve_table_names_v2_preserves_case( + cassandra_session, long_name_repo_config, online_store +): + session, keyspace = cassandra_session + # build a feature view name so long that V2 hashing will trigger mixed-case + fv_name = "VeryLongFeatureViewName_" + "X" * 60 + sfv = SortedFeatureView( + name=fv_name, + entities=[Entity(name="id", join_keys=["id"])], + source=FileSource(name="src", path="path", timestamp_field="ts"), + schema=[ + Field(name="ts", dtype=UnixTimestamp), + ], + sort_keys=[ + SortKey( + name="ts", + value_type=ValueType.UNIX_TIMESTAMP, + default_sort_order=SortOrder.Enum.ASC, + ) + ], + ) + + # compute the fully‐qualified name + fqtable = online_store._fq_table_name( + keyspace, + long_name_repo_config.project, + sfv, + long_name_repo_config.online_store.table_name_format_version, + ) + + quoted = fqtable.split(".", 1)[1] + expected_plain = quoted.strip('"') + + _, actual_plain = online_store._resolve_table_names( + long_name_repo_config, long_name_repo_config.project, sfv + ) + + assert actual_plain == expected_plain, ( + f"resolve_table_names lowercased the identifier:\n" + f" expected: {expected_plain}\n" + f" got: {actual_plain}" + ) diff --git a/sdk/python/tests/expediagroup/test_elasticsearch_online_store.py b/sdk/python/tests/expediagroup/test_elasticsearch_online_store.py index 9fe7b54780c..def52e263d2 100644 --- a/sdk/python/tests/expediagroup/test_elasticsearch_online_store.py +++ b/sdk/python/tests/expediagroup/test_elasticsearch_online_store.py @@ -98,7 +98,7 @@ def setup_method(self, repo_config): @pytest.mark.parametrize("index_params", index_param_list) def test_elasticsearch_update_add_index(self, repo_config, caplog, index_params): - dimensions = 16 + dimensions = "16" vector_type = Float32 vector_tags = { "is_primary": "False", From da6f72caa57ef6021634249da43fd801f99a245e 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 11/25] 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 --- go/internal/feast/featurestore.go | 8 +- go/internal/feast/featurestore_test.go | 15 +-- .../scylladb/scylladb_integration_test.go | 28 ++--- .../feast/onlineserving/serving_test.go | 54 --------- go/internal/feast/server/grpc_server.go | 73 ++++++------- go/internal/feast/server/http_server.go | 103 +++++++----------- go/internal/feast/server/http_server_test.go | 34 ++++-- .../src/main/java/dev/feast/FeastClient.java | 79 +++++++++++--- .../test/java/dev/feast/FeastClientTest.java | 2 + protos/feast/serving/ServingService.proto | 5 +- 10 files changed, 186 insertions(+), 215 deletions(-) diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index b7bc2ae7b07..cd9915cabf5 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -407,14 +407,8 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( addDummyEntityIfNeeded(entitylessCase, joinKeyToEntityValues, numRows) arrowMemory := memory.NewGoAllocator() - entityColumns, err := onlineserving.EntitiesToRangeFeatureVectors( - joinKeyToEntityValues, arrowMemory, numRows) - if err != nil { - return nil, err - } - result := make([]*onlineserving.RangeFeatureVector, 0, len(entityColumns)) - result = append(result, entityColumns...) + result := make([]*onlineserving.RangeFeatureVector, 0) groupedRangeRefs, err := onlineserving.GroupSortedFeatureRefs( requestedSortedFeatureViews, diff --git a/go/internal/feast/featurestore_test.go b/go/internal/feast/featurestore_test.go index c2a146ea5cd..729c6ff30a4 100644 --- a/go/internal/feast/featurestore_test.go +++ b/go/internal/feast/featurestore_test.go @@ -278,12 +278,10 @@ func TestGetOnlineFeaturesRange(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, 3, len(result), "Should have 3 vectors (1 entity + 2 features)") - var driverIdVector, accRateVector, convRateVector *onlineserving.RangeFeatureVector + assert.Equal(t, 2, len(result), "Should have 2 vectors") + var accRateVector, convRateVector *onlineserving.RangeFeatureVector for _, r := range result { switch r.Name { - case "driver_id": - driverIdVector = r case "driver_stats__acc_rate": accRateVector = r case "driver_stats__conv_rate": @@ -291,7 +289,6 @@ func TestGetOnlineFeaturesRange(t *testing.T) { } } - assert.NotNil(t, driverIdVector) assert.NotNil(t, accRateVector) assert.NotNil(t, convRateVector) @@ -366,14 +363,8 @@ func testGetOnlineFeaturesRange( } arrowAllocator := memory.NewGoAllocator() - entityColumns, err := onlineserving.EntitiesToRangeFeatureVectors( - joinKeyToEntityValues, arrowAllocator, numRows) - if err != nil { - return nil, err - } - result := make([]*onlineserving.RangeFeatureVector, 0, len(entityColumns)) - result = append(result, entityColumns...) + result := make([]*onlineserving.RangeFeatureVector, 0) groupedRangeRefs, err := onlineserving.GroupSortedFeatureRefs( sortedFeatureViews, 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/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index f48f264a6d9..06a3706f8ef 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -1112,60 +1112,6 @@ func TestGetUniqueEntityRows_MultipleJoinKeys(t *testing.T) { assert.Equal(t, []int{1}, mappingIndices[1]) } -func TestEntitiesToRangeFeatureVectors(t *testing.T) { - entityColumns := map[string]*types.RepeatedValue{ - "driver_id": {Val: []*types.Value{ - {Val: &types.Value_Int32Val{Int32Val: 1}}, - {Val: &types.Value_Int32Val{Int32Val: 2}}, - {Val: &types.Value_Int32Val{Int32Val: 3}}, - }}, - "customer_id": {Val: []*types.Value{ - {Val: &types.Value_StringVal{StringVal: "A"}}, - {Val: &types.Value_StringVal{StringVal: "B"}}, - {Val: &types.Value_StringVal{StringVal: "C"}}, - }}, - } - - arrowAllocator := memory.NewGoAllocator() - numRows := 3 - - vectors, err := EntitiesToRangeFeatureVectors(entityColumns, arrowAllocator, numRows) - - assert.NoError(t, err) - assert.Len(t, vectors, 2) - - var driverVector, customerVector *RangeFeatureVector - for _, vector := range vectors { - if vector.Name == "driver_id" { - driverVector = vector - } else if vector.Name == "customer_id" { - customerVector = vector - } - } - - require.NotNil(t, driverVector) - assert.Equal(t, "driver_id", driverVector.Name) - assert.Len(t, driverVector.RangeStatuses, numRows) - assert.Len(t, driverVector.RangeTimestamps, numRows) - - for i := 0; i < numRows; i++ { - assert.Len(t, driverVector.RangeStatuses[i], 1) - assert.Equal(t, serving.FieldStatus_PRESENT, driverVector.RangeStatuses[i][0]) - assert.Len(t, driverVector.RangeTimestamps[i], 1) - } - - require.NotNil(t, customerVector) - assert.Equal(t, "customer_id", customerVector.Name) - assert.Len(t, customerVector.RangeStatuses, numRows) - assert.Len(t, customerVector.RangeTimestamps, numRows) - - assert.NotNil(t, driverVector.RangeValues) - assert.NotNil(t, customerVector.RangeValues) - - driverVector.RangeValues.Release() - customerVector.RangeValues.Release() -} - func TestTransposeRangeFeatureRowsIntoColumns(t *testing.T) { arrowAllocator := memory.NewGoAllocator() numRows := 2 diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index a63afaa0a22..240b8e06f48 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -3,7 +3,6 @@ package server import ( "context" "fmt" - "google.golang.org/grpc/reflection" "github.com/feast-dev/feast/go/internal/feast" @@ -14,7 +13,6 @@ import ( "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/health" - "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" grpcPrometheus "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus" @@ -154,70 +152,61 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r return nil, err } - resp := &serving.GetOnlineFeaturesRangeResponse{ - Results: make([]*serving.GetOnlineFeaturesRangeResponse_RangeFeatureVector, 0), - Metadata: &serving.GetOnlineFeaturesResponseMetadata{ - FeatureNames: &serving.FeatureList{Val: make([]string, 0)}, - }, - } + entities := request.GetEntities() + results := make([]*serving.GetOnlineFeaturesRangeResponse_RangeFeatureVector, 0, len(rangeFeatureVectors)) + featureNames := make([]string, 0, len(rangeFeatureVectors)) for _, vector := range rangeFeatureVectors { - resp.Metadata.FeatureNames.Val = append(resp.Metadata.FeatureNames.Val, vector.Name) - } + featureNames = append(featureNames, vector.Name) - for _, vector := range rangeFeatureVectors { rangeValues, err := types.ArrowValuesToRepeatedProtoValues(vector.RangeValues) if err != nil { - logSpanContext.Error().Err(err).Msg("Error converting Arrow range values to proto values") + logSpanContext.Error().Err(err).Msgf("Error converting feature '%s' from Arrow to Proto", vector.Name) return nil, err } - 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 - } - rangeStatuses[j] = &serving.RepeatedFieldStatus{Status: statusValues} + featureVector := &serving.GetOnlineFeaturesRangeResponse_RangeFeatureVector{ + Values: rangeValues, } - timeValues := make([]*prototypes.RepeatedValue, len(rangeValues)) - for j, timestamps := range vector.RangeTimestamps { - timestampValues := make([]*prototypes.Value, len(timestamps)) - for k, ts := range timestamps { - timestampValues[k] = &prototypes.Value{ - Val: &prototypes.Value_UnixTimestampVal{ - UnixTimestampVal: types.GetTimestampMillis(ts), - }, + if request.GetIncludeMetadata() { + 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 } + rangeStatuses[j] = &serving.RepeatedFieldStatus{Status: statusValues} } - if len(timestampValues) == 0 { - now := timestamppb.Now() - timestampValues = []*prototypes.Value{ - { + timeValues := make([]*prototypes.RepeatedValue, len(rangeValues)) + for j, timestamps := range vector.RangeTimestamps { + timestampValues := make([]*prototypes.Value, len(timestamps)) + for k, ts := range timestamps { + timestampValues[k] = &prototypes.Value{ Val: &prototypes.Value_UnixTimestampVal{ - UnixTimestampVal: types.GetTimestampMillis(now), + UnixTimestampVal: types.GetTimestampMillis(ts), }, - }, + } } - } - timeValues[j] = &prototypes.RepeatedValue{Val: timestampValues} - } - featureVector := &serving.GetOnlineFeaturesRangeResponse_RangeFeatureVector{ - Values: rangeValues, - } + timeValues[j] = &prototypes.RepeatedValue{Val: timestampValues} + } - if request.GetIncludeMetadata() { featureVector.Statuses = rangeStatuses featureVector.EventTimestamps = timeValues } - resp.Results = append(resp.Results, featureVector) + results = append(results, featureVector) } - // TODO: Implement logging for GetOnlineFeaturesRange for feature services when support for feature services is added + resp := &serving.GetOnlineFeaturesRangeResponse{ + Metadata: &serving.GetOnlineFeaturesResponseMetadata{ + FeatureNames: &serving.FeatureList{Val: featureNames}, + }, + Entities: entities, + Results: results, + } return resp, nil } diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 554ff535cb3..6b1457d6236 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -137,77 +137,51 @@ func parseValueFromJSON(data json.RawMessage) (*prototypes.Value, error) { return nil, fmt.Errorf("could not parse JSON value: %s", string(data)) } -func processFeatureVectors(vectors []*onlineserving.RangeFeatureVector, includeMetadata bool, entitiesProto map[string]*prototypes.RepeatedValue) ([]string, []map[string]interface{}) { - featureNames := make([]string, len(vectors)) - results := make([]map[string]interface{}, len(vectors)) +func processFeatureVectors( + vectors []*onlineserving.RangeFeatureVector, + includeMetadata bool, + entitiesProto map[string]*prototypes.RepeatedValue) ([]string, map[string]interface{}, []map[string]interface{}, error) { - entityNames := make(map[string]bool) - for entityName := range entitiesProto { - entityNames[entityName] = true + entities := make(map[string]interface{}) + for entityName, entityProto := range entitiesProto { + entityValues := make([]interface{}, len(entityProto.Val)) + for j, val := range entityProto.Val { + entityValues[j] = types.ValueTypeToGoType(val) + } + entities[entityName] = entityValues } - for i, vector := range vectors { - featureNames[i] = vector.Name - result := make(map[string]interface{}) + featureNames := make([]string, 0, len(vectors)) + results := make([]map[string]interface{}, 0, len(vectors)) + + for _, vector := range vectors { + featureNames = append(featureNames, vector.Name) rangeValues, err := types.ArrowValuesToRepeatedProtoValues(vector.RangeValues) if err != nil { - result["values"] = []interface{}{} - results[i] = result - continue + return nil, nil, nil, fmt.Errorf("error converting feature '%s' from Arrow to Proto: %w", vector.Name, err) } - isEntity := entityNames[vector.Name] + result := make(map[string]interface{}) - if isEntity { - entityValues := make([]interface{}, len(rangeValues)) - for j, repeatedValue := range rangeValues { - if repeatedValue == nil || len(repeatedValue.Val) == 0 { - entityValues[j] = nil - } else { - if j < len(vector.RangeStatuses) && len(vector.RangeStatuses[j]) > 0 { - statusCode := vector.RangeStatuses[j][0] - if statusCode == serving.FieldStatus_NOT_FOUND || - statusCode == serving.FieldStatus_NULL_VALUE { - entityValues[j] = nil - } else { - entityValues[j] = types.ValueTypeToGoType(repeatedValue.Val[0]) - } - } else { - entityValues[j] = types.ValueTypeToGoType(repeatedValue.Val[0]) - } - } + simplifiedValues := make([]interface{}, len(rangeValues)) + for j, repeatedValue := range rangeValues { + if repeatedValue == nil || len(repeatedValue.Val) == 0 { + simplifiedValues[j] = nil + continue } - result["values"] = entityValues - } else { - simplifiedValues := make([]interface{}, len(rangeValues)) - for j, repeatedValue := range rangeValues { - if repeatedValue == nil || len(repeatedValue.Val) == 0 { - simplifiedValues[j] = nil - continue - } - rangeForEntity := make([]interface{}, len(repeatedValue.Val)) - for k, val := range repeatedValue.Val { - if j < len(vector.RangeStatuses) && k < len(vector.RangeStatuses[j]) { - statusCode := vector.RangeStatuses[j][k] - if statusCode == serving.FieldStatus_NOT_FOUND || - statusCode == serving.FieldStatus_NULL_VALUE { - rangeForEntity[k] = nil - continue - } - } - - if val == nil { - rangeForEntity[k] = nil - } else { - rangeForEntity[k] = types.ValueTypeToGoType(val) - } + rangeForEntity := make([]interface{}, len(repeatedValue.Val)) + for k, val := range repeatedValue.Val { + if val == nil { + rangeForEntity[k] = nil + } else { + rangeForEntity[k] = types.ValueTypeToGoType(val) } - simplifiedValues[j] = rangeForEntity } - result["values"] = simplifiedValues + simplifiedValues[j] = rangeForEntity } + result["values"] = simplifiedValues if includeMetadata { if len(vector.RangeStatuses) > 0 { @@ -245,10 +219,10 @@ func processFeatureVectors(vectors []*onlineserving.RangeFeatureVector, includeM } } - results[i] = result + results = append(results, result) } - return featureNames, results + return featureNames, entities, results, nil } func (u *repeatedValue) ToProto() *prototypes.RepeatedValue { @@ -631,13 +605,20 @@ func (s *httpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque return } - featureNames, results := processFeatureVectors(rangeFeatureVectors, includeMetadata, entitiesProto) + featureNames, entities, results, err := processFeatureVectors( + rangeFeatureVectors, includeMetadata, entitiesProto) + if err != nil { + logSpanContext.Error().Err(err).Msg("Error processing feature vectors") + writeJSONError(w, err, http.StatusInternalServerError) + return + } response := map[string]interface{}{ "metadata": map[string]interface{}{ "feature_names": featureNames, }, - "results": results, + "entities": entities, + "results": results, } w.Header().Set("Content-Type", "application/json") diff --git a/go/internal/feast/server/http_server_test.go b/go/internal/feast/server/http_server_test.go index 7277f6377dc..b5d140fc8b4 100644 --- a/go/internal/feast/server/http_server_test.go +++ b/go/internal/feast/server/http_server_test.go @@ -247,7 +247,7 @@ func TestProcessFeatureVectors_NotFoundReturnsNull(t *testing.T) { // Entity 1: NOT_FOUND featureBuilder.Append(true) - valueBuilder.Append(0) + valueBuilder.AppendNull() featureBuilder.Append(true) valueBuilder.Append(42) @@ -265,14 +265,22 @@ func TestProcessFeatureVectors_NotFoundReturnsNull(t *testing.T) { } defer featureVector.RangeValues.Release() - featureNames, results := processFeatureVectors( + featureNames, entities, results, err := processFeatureVectors( []*onlineserving.RangeFeatureVector{featureVector}, false, entitiesProto, ) + assert.NoError(t, err, "Error processing feature vectors") assert.Equal(t, []string{"feature_2"}, featureNames) + + entityValues := entities["entity_key"].([]interface{}) + assert.Equal(t, 2, len(entityValues)) + assert.Equal(t, "entity_1", entityValues[0]) + assert.Equal(t, "entity_2", entityValues[1]) + values := results[0]["values"].([]interface{}) + assert.Equal(t, 2, len(values)) entity1Values := values[0].([]interface{}) assert.Equal(t, 1, len(entity1Values)) assert.Nil(t, entity1Values[0]) @@ -317,12 +325,13 @@ func TestProcessFeatureVectors_TimestampHandling(t *testing.T) { } defer featureVector.RangeValues.Release() - featureNames, results := processFeatureVectors( + featureNames, _, results, err := processFeatureVectors( []*onlineserving.RangeFeatureVector{featureVector}, true, entitiesProto, ) + assert.NoError(t, err, "Error processing feature vectors") assert.Equal(t, []string{"feature_3"}, featureNames) timestamps := results[0]["event_timestamps"].([][]interface{}) assert.Nil(t, timestamps[0][0]) @@ -346,7 +355,7 @@ func TestProcessFeatureVectors_NullValueReturnsNull(t *testing.T) { valueBuilder := featureBuilder.ValueBuilder().(*array.Float32Builder) featureBuilder.Append(true) - valueBuilder.Append(0.0) + valueBuilder.AppendNull() featureVector := &onlineserving.RangeFeatureVector{ Name: "feature_4", @@ -360,17 +369,24 @@ func TestProcessFeatureVectors_NullValueReturnsNull(t *testing.T) { } defer featureVector.RangeValues.Release() - featureNames, results := processFeatureVectors( + featureNames, entities, results, err := processFeatureVectors( []*onlineserving.RangeFeatureVector{featureVector}, true, entitiesProto, ) + assert.NoError(t, err, "Error processing feature vectors") assert.Equal(t, []string{"feature_4"}, featureNames) - values := results[0]["values"].([]interface{}) - entity1Values := values[0].([]interface{}) - assert.Equal(t, 1, len(entity1Values)) - assert.Nil(t, entity1Values[0]) + + entityValues := entities["entity_key"].([]interface{}) + assert.Equal(t, 1, len(entityValues)) + assert.Equal(t, "entity_1", entityValues[0]) + + featureValues := results[0]["values"].([]interface{}) + entityFeatureValues := featureValues[0].([]interface{}) + assert.Equal(t, 1, len(entityFeatureValues)) + assert.Nil(t, entityFeatureValues[0]) + timestamps := results[0]["event_timestamps"].([][]interface{}) assert.Nil(t, timestamps[0][0]) } diff --git a/java/serving-client/src/main/java/dev/feast/FeastClient.java b/java/serving-client/src/main/java/dev/feast/FeastClient.java index fa827873c43..c238ed4a01a 100644 --- a/java/serving-client/src/main/java/dev/feast/FeastClient.java +++ b/java/serving-client/src/main/java/dev/feast/FeastClient.java @@ -386,6 +386,9 @@ public List getOnlineFeatures(List featureRefs, List rows, Str return getOnlineFeatures(featureRefs, rows, false); } + public List getOnlineFeatures(List featureRefs, List rows) { + return getOnlineFeatures(featureRefs, rows, false); + } /** * Get online features from Feast given a feature service name. Internally feature service calls * resolve featureViews via a call to the feature registry. @@ -413,10 +416,12 @@ public List getOnlineFeatures(String featureService, List rows, String } /** - * Get online features from Feast given a getOnlineFeaturesRequest proto object. + * Retrieves online features from Feast using the provided request, entity rows, and project name. * - * @param getOnlineFeaturesRequest getOnlineFeaturesRequest proto object - * @return list of {@link Row} containing retrieved data fields. + * @param getOnlineFeaturesRequest The request object containing feature references. + * @param entities List of {@link Row} objects representing the entities to retrieve features for. + * @param project The Feast project to retrieve features from. + * @return A list of {@link Row} containing the retrieved feature data. */ public List getOnlineFeatures( GetOnlineFeaturesRequest getOnlineFeaturesRequest, List entities, String project) { @@ -424,17 +429,13 @@ public List getOnlineFeatures( } /** - * Get online features range from Feast, without indicating project, will use `default`. + * Get online features range from Feast without indicating a project — uses the default project. * - *

See {@link #getOnlineFeaturesRange(List, List, List, int, boolean, String)} + *

See {@link #getOnlineFeaturesRange(List, List, List, int, boolean, String)} for + * project-specific queries. * - * @param featureRefs list of string feature references to retrieve in the following format - * featureTable:feature, where 'featureTable' and 'feature' refer to the FeatureTable and - * Feature names respectively. Only the Feature name is required. - * @param entities list of {@link RangeRow} to select the entities to retrieve the features for. - * @param sortKeyFilters - * @param limit - * @param reverseSortOrder + * @param request {@link GetOnlineFeaturesRangeRequest} containing the request parameters. + * @param entities list of {@link Row} to select the entities to retrieve the features for. * @return list of {@link RangeRow} containing retrieved data fields. */ public List getOnlineFeaturesRange( @@ -460,6 +461,18 @@ public List getOnlineFeaturesRange( return results; } + for (Map.Entry entityEntry : + response.getEntitiesMap().entrySet()) { + if (entityEntry.getValue().getValCount() != response.getResults(0).getValuesCount()) { + throw new IllegalStateException( + String.format( + "Entity %s has different number of values (%d) than feature rows (%d)", + entityEntry.getKey(), + entityEntry.getValue().getValCount(), + response.getResults(0).getValuesCount())); + } + } + for (int rowIdx = 0; rowIdx < response.getResults(0).getValuesCount(); rowIdx++) { RangeRow row = RangeRow.create(); for (int featureIdx = 0; featureIdx < response.getResultsCount(); featureIdx++) { @@ -479,9 +492,10 @@ public List getOnlineFeaturesRange( .collect(Collectors.toList())); } } - for (Map.Entry entry : - entities.get(rowIdx).getFields().entrySet()) { - row.setEntity(entry.getKey(), entry.getValue()); + + for (Map.Entry entityEntry : + response.getEntitiesMap().entrySet()) { + row.setEntity(entityEntry.getKey(), entityEntry.getValue().getVal(rowIdx)); } results.add(row); @@ -489,6 +503,39 @@ public List getOnlineFeaturesRange( return results; } + public List getOnlineFeaturesRange( + List featureRefs, + List rows, + List sortKeyFilters, + int limit, + boolean reverseSortOrder) { + GetOnlineFeaturesRangeRequest request = + GetOnlineFeaturesRangeRequest.newBuilder() + .setFeatures(ServingAPIProto.FeatureList.newBuilder().addAllVal(featureRefs).build()) + .putAllEntities(transposeEntitiesOntoColumns(rows)) + .addAllSortKeyFilters( + sortKeyFilters.stream() + .map(SortKeyFilterModel::toProto) + .collect(Collectors.toList())) + .setLimit(limit) + .setReverseSortOrder(reverseSortOrder) + .setIncludeMetadata(false) + .build(); + return getOnlineFeaturesRange(request, rows); + } + + /** + * Get online features from Feast via feature reference(s) with range support. + * + * @param featureRefs List of string feature references to retrieve in the format {@code + * featureTable:feature}. + * @param rows List of {@link Row} to select the entities to retrieve the features for. + * @param sortKeyFilters List of field names to use for sorting the feature results. + * @param limit Maximum number of results to return. + * @param reverseSortOrder If true, the results will be returned in descending order. + * @param project The Feast project to retrieve features from. + * @return List of {@link RangeRow} containing retrieved data fields. + */ public List getOnlineFeaturesRange( List featureRefs, List rows, @@ -499,6 +546,7 @@ public List getOnlineFeaturesRange( GetOnlineFeaturesRangeRequest request = GetOnlineFeaturesRangeRequest.newBuilder() .setFeatures(ServingAPIProto.FeatureList.newBuilder().addAllVal(featureRefs).build()) + .putAllEntities(transposeEntitiesOntoColumns(rows)) .addAllSortKeyFilters( sortKeyFilters.stream() .map(SortKeyFilterModel::toProto) @@ -521,6 +569,7 @@ public List getOnlineFeaturesRange( GetOnlineFeaturesRangeRequest request = GetOnlineFeaturesRangeRequest.newBuilder() .setFeatures(ServingAPIProto.FeatureList.newBuilder().addAllVal(featureRefs).build()) + .putAllEntities(transposeEntitiesOntoColumns(rows)) .addAllSortKeyFilters( sortKeyFilters.stream() .map(SortKeyFilterModel::toProto) diff --git a/java/serving-client/src/test/java/dev/feast/FeastClientTest.java b/java/serving-client/src/test/java/dev/feast/FeastClientTest.java index b14f0372499..14a868337bf 100644 --- a/java/serving-client/src/test/java/dev/feast/FeastClientTest.java +++ b/java/serving-client/src/test/java/dev/feast/FeastClientTest.java @@ -299,6 +299,7 @@ private static GetOnlineFeaturesRangeRequest getFakeOnlineFeaturesRangeRequest() .addVal("driver:rating") .addVal("driver:null_value") .build()) + .putEntities("driver_id", ValueProto.RepeatedValue.newBuilder().addVal(intValue(1)).build()) .addAllSortKeyFilters( Arrays.asList( ServingAPIProto.SortKeyFilter.newBuilder() @@ -342,6 +343,7 @@ private static GetOnlineFeaturesRangeResponse getFakeOnlineFeaturesRangeResponse .addVal("driver:rating") .addVal("driver:null_value")) .build()) + .putEntities("driver_id", ValueProto.RepeatedValue.newBuilder().addVal(intValue(1)).build()) .build(); } diff --git a/protos/feast/serving/ServingService.proto b/protos/feast/serving/ServingService.proto index 81a0a8ed100..662720a5ed2 100644 --- a/protos/feast/serving/ServingService.proto +++ b/protos/feast/serving/ServingService.proto @@ -193,9 +193,12 @@ message GetOnlineFeaturesRangeRequest { message GetOnlineFeaturesRangeResponse { GetOnlineFeaturesResponseMetadata metadata = 1; + // Entities used to retrieve the features. + map entities = 2; + // Length of "results" array should match length of requested features. // We also preserve the same order of features here as in metadata.feature_names - repeated RangeFeatureVector results = 2; + repeated RangeFeatureVector results = 3; message RangeFeatureVector { // Each values entry contains multiple values for a feature From ec400378ae429e0d82fc1ac386f98a6570f81498 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 30 Jun 2025 10:11:23 -0700 Subject: [PATCH 12/25] fix: Keep duplicate requested features in response for range query. (#276) --- go/internal/feast/featurestore.go | 5 +++ .../scylladb/scylladb_integration_test.go | 43 +++++++++++++++++++ go/internal/feast/onlineserving/serving.go | 30 +++++++++---- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index cd9915cabf5..73d6ca618df 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -442,6 +442,11 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( result = append(result, vectors...) } + result, err = onlineserving.KeepOnlyRequestedFeatures(result, featureRefs, featureService, fullFeatureNames) + if err != nil { + return nil, err + } + return result, nil } 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 fce73d28138..2dc3edee38a 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -815,18 +815,24 @@ func getEventTimestamp(timestamps []timestamp.Timestamp, index int) *timestamppb return ×tamppb.Timestamp{} } -func KeepOnlyRequestedFeatures( - vectors []*FeatureVector, +func KeepOnlyRequestedFeatures[T any]( + vectors []T, requestedFeatureRefs []string, featureService *model.FeatureService, - fullFeatureNames bool) ([]*FeatureVector, error) { - vectorsByName := make(map[string]*FeatureVector) - expectedVectors := make([]*FeatureVector, 0) + fullFeatureNames bool) ([]T, error) { + vectorsByName := make(map[string]T) + expectedVectors := make([]T, 0) usedVectors := make(map[string]bool) for _, vector := range vectors { - vectorsByName[vector.Name] = vector + if featureVector, ok := any(vector).(*FeatureVector); ok { + vectorsByName[featureVector.Name] = vector + } else if rangeFeatureVector, ok := any(vector).(*RangeFeatureVector); ok { + vectorsByName[rangeFeatureVector.Name] = vector + } else { + return nil, fmt.Errorf("unsupported vector type: %T", vector) + } } if featureService != nil { @@ -853,8 +859,16 @@ func KeepOnlyRequestedFeatures( // Free arrow arrays for vectors that were not used. for _, vector := range vectors { - if _, ok := usedVectors[vector.Name]; !ok { - vector.Values.Release() + if featureVector, ok := any(vector).(*FeatureVector); ok { + if _, ok := usedVectors[featureVector.Name]; !ok { + featureVector.Values.Release() + } + } else if rangeFeatureVector, ok := any(vector).(*RangeFeatureVector); ok { + if _, ok := usedVectors[rangeFeatureVector.Name]; !ok { + rangeFeatureVector.RangeValues.Release() + } + } else { + return nil, fmt.Errorf("unsupported vector type: %T", vector) } } From 85e754e26c0771917c36b2a9807a5909daf07303 Mon Sep 17 00:00:00 2001 From: piket Date: Thu, 3 Jul 2025 08:04:09 -0700 Subject: [PATCH 13/25] 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 | 20 ++++++++++ go/types/typeconversion_test.go | 37 +++++++++++++++++++ 7 files changed, 65 insertions(+), 8 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 5ec71709046..ac8a9fab069 100644 --- a/go/internal/feast/server/grpc_server_test.go +++ b/go/internal/feast/server/grpc_server_test.go @@ -32,7 +32,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) @@ -48,7 +48,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{ @@ -109,7 +109,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 6b1457d6236..63e3ad630a5 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -146,7 +146,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 } @@ -176,7 +176,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 346defa521c..872fce4f960 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 6e1f9315354..5ec00a515e2 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -698,6 +698,16 @@ func appendNullByType(builder array.Builder) { } func ValueTypeToGoType(value *types.Value) interface{} { + return valueTypeToGoTypeTimestampAsString(value, false) +} + +func ValueTypeToGoTypeTimestampAsString(value *types.Value) interface{} { + return valueTypeToGoTypeTimestampAsString(value, true) +} + +var TimestampFormat = "2006-01-02 15:04:05.999999999Z0700" + +func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bool) interface{} { if value == nil || value.Val == nil { return nil } @@ -732,8 +742,18 @@ func ValueTypeToGoType(value *types.Value) interface{} { case *types.Value_DoubleListVal: return x.DoubleListVal.Val case *types.Value_UnixTimestampVal: + if timestampAsString { + return time.UnixMilli(x.UnixTimestampVal).UTC().Format(TimestampFormat) + } 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.UnixMilli(ts).UTC().Format(TimestampFormat) + } + return timestamps + } return x.UnixTimestampListVal.Val default: return nil diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index b7757d2761f..cbeba4f6430 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -474,6 +474,14 @@ func TestValueTypeToGoType(t *testing.T) { {Val: &types.Value_DoubleVal{DoubleVal: 10.0}}, {Val: &types.Value_BoolVal{BoolVal: true}}, {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}}}}, + {Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: []int64{4, 5, 6}}}}, + {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_NullVal{NullVal: types.Null_NULL}}, nil, } @@ -487,6 +495,14 @@ func TestValueTypeToGoType(t *testing.T) { float64(10.0), true, timestamp, + []string{"a", "b", "c"}, + [][]byte{{1, 2}, {3, 4}}, + []int32{1, 2, 3}, + []int64{4, 5, 6}, + []float32{7.1, 8.2}, + []float64{9.3, 10.4}, + []bool{true, false}, + []int64{timestamp, timestamp + 3600}, nil, nil, } @@ -497,6 +513,27 @@ func TestValueTypeToGoType(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 93f5b680c50ca8bf406431dd6c91d4b6b794ca1a 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 14/25] 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/infra/registry/http.py | 25 +++++++++++++++---------- sdk/python/feast/repo_operations.py | 2 ++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/sdk/python/feast/infra/registry/http.py b/sdk/python/feast/infra/registry/http.py index 6dfadbb8599..6273fa88179 100644 --- a/sdk/python/feast/infra/registry/http.py +++ b/sdk/python/feast/infra/registry/http.py @@ -7,6 +7,7 @@ from typing import Any, List, Optional, Set, Union import httpx +from httpx import HTTPStatusError from pydantic import StrictStr from feast.base_feature_view import BaseFeatureView @@ -506,11 +507,11 @@ def get_feature_view( # type: ignore[return] url = f"{self.base_url}/projects/{project}/feature_views/{name}" response_data = self._send_request("GET", url) return FeatureViewModel.model_validate(response_data).to_feature_view() - except FeatureViewNotFoundException as exception: - logger.error( - f"FeatureView {name} requested does not exist: %s", str(exception) - ) - raise httpx.HTTPError(message=f"FeatureView: {name} not found") + except HTTPStatusError as http_exc: + if http_exc.response.status_code == 404: + logger.error("FeatureView %s not found", name) + raise FeatureViewNotFoundException(name, project) + self._handle_exception(http_exc) except Exception as exception: self._handle_exception(exception) @@ -550,11 +551,15 @@ def get_sorted_feature_view( # type: ignore[return] return SortedFeatureViewModel.model_validate( response_data ).to_feature_view() - except SortedFeatureViewNotFoundException as exception: - logger.error( - f"SortedFeatureView {name} requested does not exist: {str(exception)}" - ) - raise httpx.HTTPError(message=f"SortedFeatureView: {name} not found") + except HTTPStatusError as http_exc: + if http_exc.response.status_code == 404: + logger.error( + "SortedFeatureView %s not found.", + name, + ) + raise SortedFeatureViewNotFoundException(name, project) + self._handle_exception(http_exc) + except Exception as exception: self._handle_exception(exception) 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 048cd81fd8834990bb4ad443f43099b4e4052cd8 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 7 Jul 2025 15:00:58 -0700 Subject: [PATCH 15/25] 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 --- go/embedded/online_features.go | 2 +- .../scylladb/scylladb_integration_test.go | 85 +++++++++++++++++-- go/internal/feast/onlineserving/serving.go | 6 +- .../feast/onlineserving/serving_test.go | 6 +- .../feast/onlinestore/cassandraonlinestore.go | 1 + .../cassandraonlinestore_integration_test.go | 22 ++--- go/internal/feast/server/grpc_server.go | 5 +- go/types/typeconversion.go | 24 +++--- go/types/typeconversion_test.go | 60 ++++++------- .../src/main/java/dev/feast/RequestUtil.java | 36 +++++++- .../test/java/dev/feast/FeastClientTest.java | 2 +- .../test/java/dev/feast/RequestUtilTest.java | 32 ++++++- .../cassandra_online_store.py | 30 ++++--- 13 files changed, 224 insertions(+), 87 deletions(-) diff --git a/go/embedded/online_features.go b/go/embedded/online_features.go index d353381d67d..382fcf80a76 100644 --- a/go/embedded/online_features.go +++ b/go/embedded/online_features.go @@ -240,7 +240,7 @@ func (s *OnlineFeatureService) GetOnlineFeatures( tsColumnBuilder := array.NewInt64Builder(pool) for _, ts := range featureVector.Timestamps { - tsColumnBuilder.Append(types.GetTimestampMillis(ts)) + tsColumnBuilder.Append(types.GetTimestampSeconds(ts)) } tsColumn := tsColumnBuilder.NewArray() outputColumns = append(outputColumns, tsColumn) 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 2dc3edee38a..53afb3cf66f 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -767,7 +767,11 @@ func processFeatureRowData( for i, val := range featureData.Values { if val == nil { rangeValues[i] = nil - rangeStatuses[i] = serving.FieldStatus_NOT_FOUND + if i < len(featureData.Statuses) { + rangeStatuses[i] = featureData.Statuses[i] + } else { + rangeStatuses[i] = serving.FieldStatus_NOT_FOUND + } rangeTimestamps[i] = ×tamppb.Timestamp{} continue } diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index 06a3706f8ef..bac540d0f69 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -871,7 +871,7 @@ func TestGroupSortedFeatureRefs(t *testing.T) { assert.Equal(t, 1, len(group.SortKeyFilters)) if group.SortKeyFilters[0].SortKeyName == "timestamp" { assert.Equal(t, sortKeyFilters[0].SortKeyName, group.SortKeyFilters[0].SortKeyName) - assert.Equal(t, sortKeyFilters[0].GetEquals().GetUnixTimestampVal(), group.SortKeyFilters[0].Equals) + assert.Equal(t, sortKeyFilters[0].GetEquals().GetUnixTimestampVal(), group.SortKeyFilters[0].Equals.(time.Time).Unix()) assert.Nil(t, group.SortKeyFilters[0].RangeStart) assert.Nil(t, group.SortKeyFilters[0].RangeEnd) assert.Nil(t, group.SortKeyFilters[0].Order) @@ -977,8 +977,8 @@ func TestGroupSortedFeatureRefs_withReverseSortOrder(t *testing.T) { for _, group := range refGroups { assert.Equal(t, 2, len(group.SortKeyFilters)) assert.Equal(t, sortKeyFilters[0].SortKeyName, group.SortKeyFilters[0].SortKeyName) - assert.Equal(t, sortKeyFilters[0].GetRange().RangeStart.GetUnixTimestampVal(), group.SortKeyFilters[0].RangeStart) - assert.Equal(t, sortKeyFilters[0].GetRange().RangeEnd.GetUnixTimestampVal(), group.SortKeyFilters[0].RangeEnd) + assert.Equal(t, sortKeyFilters[0].GetRange().RangeStart.GetUnixTimestampVal(), group.SortKeyFilters[0].RangeStart.(time.Time).Unix()) + assert.Equal(t, sortKeyFilters[0].GetRange().RangeEnd.GetUnixTimestampVal(), group.SortKeyFilters[0].RangeEnd.(time.Time).Unix()) assert.Equal(t, sortKeyFilters[0].GetRange().StartInclusive, group.SortKeyFilters[0].StartInclusive) assert.Equal(t, sortKeyFilters[0].GetRange().EndInclusive, group.SortKeyFilters[0].EndInclusive) assert.Equal(t, "ASC", group.SortKeyFilters[0].Order.Order.String()) diff --git a/go/internal/feast/onlinestore/cassandraonlinestore.go b/go/internal/feast/onlinestore/cassandraonlinestore.go index 33b11283978..e8a1f0943ce 100644 --- a/go/internal/feast/onlinestore/cassandraonlinestore.go +++ b/go/internal/feast/onlinestore/cassandraonlinestore.go @@ -509,6 +509,7 @@ func (c *CassandraOnlineStore) OnlineRead(ctx context.Context, entityKeys []*typ }, } } else { + // TODO: return not found status to differentiate between nulls and not found features results[serializedEntityKeyToIndex[keyString]][featureNamesToIdx[featName]] = FeatureData{ Reference: serving.FeatureReferenceV2{ FeatureViewName: featureViewName, diff --git a/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go b/go/internal/feast/onlinestore/cassandraonlinestore_integration_test.go index 1684b10d1dc..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) { @@ -135,7 +135,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withMultipleEntityKeys(t *testing. "array_byte_val", "array_timestamp_val", "null_array_timestamp_val", "event_timestamp"} sortKeyFilters := []*model.SortKeyFilter{{ SortKeyName: "event_timestamp", - RangeStart: int64(1744769099919), + RangeStart: time.Unix(1744769099, 0), }} groupedRefs := &model.GroupedRangeFeatureRefs{ @@ -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) { @@ -182,7 +182,7 @@ func TestCassandraOnlineStore_OnlineReadRange_withReverseSortOrder(t *testing.T) "array_byte_val", "array_timestamp_val", "null_array_timestamp_val", "event_timestamp"} sortKeyFilters := []*model.SortKeyFilter{{ SortKeyName: "event_timestamp", - RangeStart: int64(1744769099919), + RangeStart: time.Unix(1744769099, 0), // The SortKey is defined as DESC in the SortedFeatureView, so we need to set the reverse order of ASC Order: &model.SortOrder{Order: core.SortOrder_ASC}, }} @@ -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 240b8e06f48..3a556fc71ab 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/server/logging" "github.com/feast-dev/feast/go/protos/feast/serving" @@ -13,6 +11,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" @@ -185,7 +184,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 5ec00a515e2..ca17cb6c669 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -504,9 +504,9 @@ func InterfaceToProtoValue(val interface{}) (*types.Value, error) { case bool: protoVal.Val = &types.Value_BoolVal{BoolVal: v} case time.Time: - protoVal.Val = &types.Value_UnixTimestampVal{UnixTimestampVal: v.UnixMilli()} + protoVal.Val = &types.Value_UnixTimestampVal{UnixTimestampVal: v.Unix()} case *timestamppb.Timestamp: - protoVal.Val = &types.Value_UnixTimestampVal{UnixTimestampVal: GetTimestampMillis(v)} + protoVal.Val = &types.Value_UnixTimestampVal{UnixTimestampVal: GetTimestampSeconds(v)} case [][]byte: bytesList := &types.BytesList{Val: v} @@ -574,7 +574,7 @@ func InterfaceToProtoValue(val interface{}) (*types.Value, error) { case []time.Time: timestamps := make([]int64, len(v)) for j, t := range v { - timestamps[j] = t.UnixMilli() + timestamps[j] = t.Unix() } timestampList := &types.Int64List{Val: timestamps} protoVal.Val = &types.Value_UnixTimestampListVal{UnixTimestampListVal: timestampList} @@ -582,7 +582,7 @@ func InterfaceToProtoValue(val interface{}) (*types.Value, error) { case []*timestamppb.Timestamp: timestamps := make([]int64, len(v)) for j, t := range v { - timestamps[j] = GetTimestampMillis(t) + timestamps[j] = GetTimestampSeconds(t) } timestampList := &types.Int64List{Val: timestamps} protoVal.Val = &types.Value_UnixTimestampListVal{UnixTimestampListVal: timestampList} @@ -743,18 +743,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 } @@ -912,9 +916,9 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty return nil, err } -func GetTimestampMillis(ts *timestamppb.Timestamp) int64 { +func GetTimestampSeconds(ts *timestamppb.Timestamp) int64 { if ts == nil { return 0 } - return (ts.GetSeconds() * 1000) + int64(ts.GetNanos()/1_000_000) + return ts.GetSeconds() } diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index cbeba4f6430..c985636d4db 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -42,9 +42,9 @@ var ( {{Val: &types.Value_BytesVal{[]byte{1, 2, 3}}}, {Val: &types.Value_BytesVal{[]byte{4, 5, 6}}}}, {nil_or_null_val, {Val: &types.Value_BoolVal{false}}}, {{Val: &types.Value_BoolVal{true}}, {Val: &types.Value_BoolVal{false}}}, - {{Val: &types.Value_UnixTimestampVal{time.Now().UnixMilli()}}, nil_or_null_val}, - {{Val: &types.Value_UnixTimestampVal{time.Now().UnixMilli()}}, {Val: &types.Value_UnixTimestampVal{time.Now().UnixMilli()}}}, - {{Val: &types.Value_UnixTimestampVal{time.Now().UnixMilli()}}, {Val: &types.Value_UnixTimestampVal{time.Now().UnixMilli()}}, {Val: &types.Value_UnixTimestampVal{-9223372036854775808}}}, + {{Val: &types.Value_UnixTimestampVal{time.Now().Unix()}}, nil_or_null_val}, + {{Val: &types.Value_UnixTimestampVal{time.Now().Unix()}}, {Val: &types.Value_UnixTimestampVal{time.Now().Unix()}}}, + {{Val: &types.Value_UnixTimestampVal{time.Now().Unix()}}, {Val: &types.Value_UnixTimestampVal{time.Now().Unix()}}, {Val: &types.Value_UnixTimestampVal{-9223372036854775808}}}, { {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{0, 1, 2}}}}, @@ -75,13 +75,13 @@ var ( {Val: &types.Value_BoolListVal{&types.BoolList{Val: []bool{true, true}}}}, }, { - {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().UnixMilli()}}}}, - {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().UnixMilli()}}}}, + {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().Unix()}}}}, + {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().Unix()}}}}, }, { - {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().UnixMilli(), time.Now().UnixMilli()}}}}, - {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().UnixMilli(), time.Now().UnixMilli()}}}}, - {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{-9223372036854775808, time.Now().UnixMilli()}}}}, + {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().Unix(), time.Now().Unix()}}}}, + {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{time.Now().Unix(), time.Now().Unix()}}}}, + {Val: &types.Value_UnixTimestampListVal{&types.Int64List{Val: []int64{-9223372036854775808, time.Now().Unix()}}}}, }, } ) @@ -120,10 +120,10 @@ var ( {Val: []*types.Value{{Val: &types.Value_BoolVal{BoolVal: true}}}}, {Val: []*types.Value{{Val: &types.Value_BoolVal{BoolVal: true}}, {Val: &types.Value_BoolVal{BoolVal: false}}}}, {Val: []*types.Value{nil_or_null_val, {Val: &types.Value_BoolVal{BoolVal: false}}}}, - {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli()}}}}, - {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli()}}, {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli() + 3600}}}}, - {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli()}}, nil_or_null_val}}, - {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli()}}, {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: -9223372036854775808}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix()}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix()}}, {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix() + 3600}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix()}}, nil_or_null_val}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix()}}, {Val: &types.Value_UnixTimestampVal{UnixTimestampVal: -9223372036854775808}}}}, } ) @@ -163,8 +163,8 @@ var ( {Val: []*types.Value{{Val: &types.Value_BoolVal{BoolVal: false}}}}, }, { - {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli()}}}}, - {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli() + 3600}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix()}}}}, + {Val: []*types.Value{{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix() + 3600}}}}, }, { {Val: []*types.Value{{Val: &types.Value_BytesVal{BytesVal: []byte{1, 2, 3}}}}}, @@ -211,8 +211,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}}}}}}, }, } ) @@ -439,8 +439,8 @@ func TestInterfaceToProtoValue(t *testing.T) { {float32(30.5), &types.Value{Val: &types.Value_FloatVal{FloatVal: 30.5}}}, {float64(40.5), &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 40.5}}}, {true, &types.Value{Val: &types.Value_BoolVal{BoolVal: true}}}, - {testTime, &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: testTime.UnixMilli()}}}, - {×tamppb.Timestamp{Seconds: testTime.Unix(), Nanos: int32(testTime.Nanosecond())}, &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: testTime.UnixMilli()}}}, + {testTime, &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: testTime.Unix()}}}, + {×tamppb.Timestamp{Seconds: testTime.Unix(), Nanos: int32(testTime.Nanosecond())}, &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: testTime.Unix()}}}, {[][]byte{{1, 2}, {3, 4}}, &types.Value{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{1, 2}, {3, 4}}}}}}, {[]string{"a", "b"}, &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"a", "b"}}}}}, {[]int{1, 2}, &types.Value{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{1, 2}}}}}, @@ -449,8 +449,8 @@ func TestInterfaceToProtoValue(t *testing.T) { {[]float32{5.5, 6.6}, &types.Value{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: []float32{5.5, 6.6}}}}}, {[]float64{7.7, 8.8}, &types.Value{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: []float64{7.7, 8.8}}}}}, {[]bool{true, false}, &types.Value{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: []bool{true, false}}}}}, - {[]time.Time{testTime, testTime.Add(time.Hour)}, &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{testTime.UnixMilli(), testTime.Add(time.Hour).UnixMilli()}}}}}, - {[]*timestamppb.Timestamp{{Seconds: testTime.Unix(), Nanos: int32(testTime.Nanosecond())}, {Seconds: testTime.Add(time.Hour).Unix(), Nanos: int32(testTime.Add(time.Hour).Nanosecond())}}, &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{testTime.UnixMilli(), testTime.Add(time.Hour).UnixMilli()}}}}}, + {[]time.Time{testTime, testTime.Add(time.Hour)}, &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{testTime.Unix(), testTime.Add(time.Hour).Unix()}}}}}, + {[]*timestamppb.Timestamp{{Seconds: testTime.Unix(), Nanos: int32(testTime.Nanosecond())}, {Seconds: testTime.Add(time.Hour).Unix(), Nanos: int32(testTime.Add(time.Hour).Nanosecond())}}, &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{testTime.Unix(), testTime.Add(time.Hour).Unix()}}}}}, {&types.Value{Val: &types.Value_NullVal{NullVal: types.Null_NULL}}, &types.Value{Val: &types.Value_NullVal{NullVal: types.Null_NULL}}}, {&types.Value{Val: &types.Value_StringVal{StringVal: "test"}}, &types.Value{Val: &types.Value_StringVal{StringVal: "test"}}}, } @@ -464,7 +464,7 @@ func TestInterfaceToProtoValue(t *testing.T) { } func TestValueTypeToGoType(t *testing.T) { - timestamp := time.Now().UnixMilli() + timestamp := time.Unix(1744769099, 0).UTC() testCases := []*types.Value{ {Val: &types.Value_StringVal{StringVal: "test"}}, {Val: &types.Value_BytesVal{BytesVal: []byte{1, 2, 3}}}, @@ -473,7 +473,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}}}}, @@ -481,7 +481,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, } @@ -502,7 +502,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, } @@ -514,17 +514,17 @@ func TestValueTypeToGoType(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), }, } @@ -654,8 +654,8 @@ func TestConvertToValueType_Timestamp(t *testing.T) { input *types.Value expected interface{} }{ - {input: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().UnixMilli()}}, expected: time.Now().UnixMilli()}, - {input: &types.Value{Val: &types.Value_Int64Val{Int64Val: time.Now().UnixMilli()}}, expected: time.Now().UnixMilli()}, + {input: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: time.Now().Unix()}}, expected: time.Now().Unix()}, + {input: &types.Value{Val: &types.Value_Int64Val{Int64Val: time.Now().Unix()}}, expected: time.Now().Unix()}, } for _, tc := range testCases { diff --git a/java/serving-client/src/main/java/dev/feast/RequestUtil.java b/java/serving-client/src/main/java/dev/feast/RequestUtil.java index 94345a0bbb8..1d4ad94e734 100644 --- a/java/serving-client/src/main/java/dev/feast/RequestUtil.java +++ b/java/serving-client/src/main/java/dev/feast/RequestUtil.java @@ -104,15 +104,15 @@ public static Value objectToValue(Object value) { case "java.time.LocalDateTime": return Value.newBuilder() .setUnixTimestampVal( - ((java.time.LocalDateTime) value).toInstant(ZoneOffset.UTC).toEpochMilli()) + ((java.time.LocalDateTime) value).toInstant(ZoneOffset.UTC).getEpochSecond()) .build(); case "java.time.Instant": return Value.newBuilder() - .setUnixTimestampVal(((java.time.Instant) value).toEpochMilli()) + .setUnixTimestampVal(((java.time.Instant) value).getEpochSecond()) .build(); case "java.time.OffsetDateTime": return Value.newBuilder() - .setUnixTimestampVal(((java.time.OffsetDateTime) value).toInstant().toEpochMilli()) + .setUnixTimestampVal(((java.time.OffsetDateTime) value).toInstant().getEpochSecond()) .build(); case "java.util.Arrays.ArrayList": if (((List) value).isEmpty()) { @@ -158,6 +158,36 @@ public static Value objectToValue(Object value) { .setBoolListVal( ValueProto.BoolList.newBuilder().addAllVal((List) value).build()) .build(); + case "java.time.LocalDateTime": + List timestamps = + ((List) value) + .stream() + .map(dt -> dt.toInstant(ZoneOffset.UTC).getEpochSecond()) + .collect(Collectors.toList()); + return Value.newBuilder() + .setUnixTimestampListVal( + ValueProto.Int64List.newBuilder().addAllVal(timestamps).build()) + .build(); + case "java.time.Instant": + List instantTimestamps = + ((List) value) + .stream() + .map(instant -> instant.getEpochSecond()) + .collect(Collectors.toList()); + return Value.newBuilder() + .setUnixTimestampListVal( + ValueProto.Int64List.newBuilder().addAllVal(instantTimestamps).build()) + .build(); + case "java.time.OffsetDateTime": + List offsetTimestamps = + ((List) value) + .stream() + .map(offsetDateTime -> offsetDateTime.toInstant().getEpochSecond()) + .collect(Collectors.toList()); + return Value.newBuilder() + .setUnixTimestampListVal( + ValueProto.Int64List.newBuilder().addAllVal(offsetTimestamps).build()) + .build(); default: throw new IllegalArgumentException( String.format( diff --git a/java/serving-client/src/test/java/dev/feast/FeastClientTest.java b/java/serving-client/src/test/java/dev/feast/FeastClientTest.java index 14a868337bf..4fb5eb7959f 100644 --- a/java/serving-client/src/test/java/dev/feast/FeastClientTest.java +++ b/java/serving-client/src/test/java/dev/feast/FeastClientTest.java @@ -304,7 +304,7 @@ private static GetOnlineFeaturesRangeRequest getFakeOnlineFeaturesRangeRequest() Arrays.asList( ServingAPIProto.SortKeyFilter.newBuilder() .setSortKeyName("event_timestamp") - .setEquals(Value.newBuilder().setUnixTimestampVal(1746057600000L).build()) + .setEquals(Value.newBuilder().setUnixTimestampVal(1746057600L).build()) .build(), ServingAPIProto.SortKeyFilter.newBuilder() .setSortKeyName("sort_key") diff --git a/java/serving-client/src/test/java/dev/feast/RequestUtilTest.java b/java/serving-client/src/test/java/dev/feast/RequestUtilTest.java index 69bff2835d6..f3b4ba27eb2 100644 --- a/java/serving-client/src/test/java/dev/feast/RequestUtilTest.java +++ b/java/serving-client/src/test/java/dev/feast/RequestUtilTest.java @@ -121,17 +121,18 @@ public void objectToValueTest() { byte[] bytes = "test".getBytes(); assertArrayEquals(RequestUtil.objectToValue(bytes).getBytesVal().toByteArray(), bytes); assertTrue(RequestUtil.objectToValue(true).getBoolVal()); - Instant instant = Instant.now(); + Long timestampSeconds = 1751920985L; + Instant instant = Instant.ofEpochMilli(timestampSeconds * 1000); assertEquals( RequestUtil.objectToValue(LocalDateTime.ofInstant(instant, ZoneId.of("UTC"))) .getUnixTimestampVal(), - instant.toEpochMilli()); + timestampSeconds); assertEquals( RequestUtil.objectToValue( OffsetDateTime.ofInstant(instant, ZoneId.of("America/Los_Angeles"))) .getUnixTimestampVal(), - instant.toEpochMilli()); - assertEquals(RequestUtil.objectToValue(instant).getUnixTimestampVal(), instant.toEpochMilli()); + timestampSeconds); + assertEquals(RequestUtil.objectToValue(instant).getUnixTimestampVal(), timestampSeconds); assertEquals(RequestUtil.objectToValue(null).getNullVal(), ValueProto.Null.NULL); assertEquals( RequestUtil.objectToValue(Arrays.asList(1, 2, 3)).getInt32ListVal().getValList(), @@ -158,6 +159,29 @@ public void objectToValueTest() { assertEquals( RequestUtil.objectToValue(Arrays.asList(true, false)).getBoolListVal().getValList(), Arrays.asList(true, false)); + Long timestampSeconds2 = 1751920986L; + Instant instant2 = Instant.ofEpochMilli(timestampSeconds2 * 1000); + assertEquals( + RequestUtil.objectToValue(Arrays.asList(instant, instant2)) + .getUnixTimestampListVal() + .getValList(), + Arrays.asList(timestampSeconds, timestampSeconds2)); + assertEquals( + RequestUtil.objectToValue( + Arrays.asList( + LocalDateTime.ofInstant(instant, ZoneId.of("UTC")), + LocalDateTime.ofInstant(instant2, ZoneId.of("UTC")))) + .getUnixTimestampListVal() + .getValList(), + Arrays.asList(timestampSeconds, timestampSeconds2)); + assertEquals( + RequestUtil.objectToValue( + Arrays.asList( + OffsetDateTime.ofInstant(instant, ZoneId.of("America/Los_Angeles")), + OffsetDateTime.ofInstant(instant2, ZoneId.of("America/Los_Angeles")))) + .getUnixTimestampListVal() + .getValList(), + Arrays.asList(timestampSeconds, timestampSeconds2)); } @Test diff --git a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py index 0e6e1d1ec7a..9a62cee611c 100644 --- a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py +++ b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py @@ -476,9 +476,7 @@ def on_failure(exc, concurrent_queue): params_str = ", ".join(["?"] * (len(feature_names) + 2)) # Write each batch with same entity key in to the online store - timestamp_field_name = table.batch_source.timestamp_field sort_key_names = [sort_key.name for sort_key in table.sort_keys] - is_timestamp_sort_key = timestamp_field_name in sort_key_names for entity_key_bin, batch_to_write in entity_dict.items(): batch = BatchStatement(batch_type=BatchType.UNLOGGED) @@ -495,21 +493,25 @@ def on_failure(exc, concurrent_queue): feature_values: tuple[Any, ...] = () for feature_name, valProto in feat_dict.items(): - # When the event timestamp is added as a feature, it is converted in to UNIX_TIMESTAMP - # feast type. Hence, its value must be reassigned before inserting in to online store - if ( - is_timestamp_sort_key - and feature_name == timestamp_field_name - ): - feature_value = timestamp - elif feature_name in sort_key_names: + if feature_name in sort_key_names: feast_value_type = valProto.WhichOneof("val") - if feast_value_type is None: + if feast_value_type == "unix_timestamp_val": + feature_value = ( + valProto.unix_timestamp_val * 1000 + ) # Convert to milliseconds + elif feast_value_type is None: feature_value = None elif feast_value_type in feast_array_types: - feature_value = getattr( - valProto, str(feast_value_type) - ).val + if feast_value_type == "unix_timestamp_list_val": + # Convert list of timestamps to milliseconds + feature_value = [ + ts * 1000 + for ts in valProto.unix_timestamp_list_val.val # type:ignore + ] + else: + feature_value = getattr( + valProto, str(feast_value_type) + ).val else: feature_value = getattr(valProto, str(feast_value_type)) else: From 9409973f9b51fd4c15544345181d9ec3525f58cd Mon Sep 17 00:00:00 2001 From: piket Date: Thu, 10 Jul 2025 11:44:35 -0700 Subject: [PATCH 16/25] 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/errors/grpc_error.go | 29 +++ go/internal/feast/featurestore.go | 36 ++-- go/internal/feast/featurestore_test.go | 6 +- .../scylladb/scylladb_integration_test.go | 4 +- go/internal/feast/model/basefeatureview.go | 7 +- go/internal/feast/onlineserving/serving.go | 62 +++--- .../feast/onlineserving/serving_test.go | 46 ++++- go/internal/feast/registry/registry.go | 22 +-- go/internal/feast/registry/registry_test.go | 10 +- go/internal/feast/server/grpc_server.go | 13 +- go/internal/feast/server/http_server.go | 26 ++- go/internal/feast/server/http_server_test.go | 30 +++ .../feast/transformation/transformation.go | 4 +- go/internal/test/go_integration_test_utils.go | 4 +- .../src/main/java/dev/feast/FeastClient.java | 16 +- .../exception/FeastBadRequestException.java | 31 +++ .../dev/feast/exception/FeastException.java | 49 +++++ .../exception/FeastInternalException.java | 31 +++ .../exception/FeastNotFoundException.java | 31 +++ .../test/java/dev/feast/FeastClientTest.java | 176 ++++++++++++++++++ 20 files changed, 547 insertions(+), 86 deletions(-) create mode 100644 go/internal/feast/errors/grpc_error.go create mode 100644 java/serving-client/src/main/java/dev/feast/exception/FeastBadRequestException.java create mode 100644 java/serving-client/src/main/java/dev/feast/exception/FeastException.java create mode 100644 java/serving-client/src/main/java/dev/feast/exception/FeastInternalException.java create mode 100644 java/serving-client/src/main/java/dev/feast/exception/FeastNotFoundException.java diff --git a/go/internal/feast/errors/grpc_error.go b/go/internal/feast/errors/grpc_error.go new file mode 100644 index 00000000000..a90b20c6e3b --- /dev/null +++ b/go/internal/feast/errors/grpc_error.go @@ -0,0 +1,29 @@ +package errors + +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func GrpcErrorf(code codes.Code, format string, args ...interface{}) error { + return status.Newf(code, format, args...).Err() +} + +func GrpcFromError(err error) error { + if s, ok := status.FromError(err); ok { + return s.Err() + } + return status.Error(codes.Internal, err.Error()) +} + +func GrpcInternalErrorf(format string, args ...interface{}) error { + return GrpcErrorf(codes.Internal, format, args...) +} + +func GrpcInvalidArgumentErrorf(format string, args ...interface{}) error { + return GrpcErrorf(codes.InvalidArgument, format, args...) +} + +func GrpcNotFoundErrorf(format string, args ...interface{}) error { + return GrpcErrorf(codes.NotFound, format, args...) +} diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 73d6ca618df..3bd7e413bca 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -2,8 +2,8 @@ package feast import ( "context" - "errors" "fmt" + "github.com/feast-dev/feast/go/internal/feast/errors" "github.com/feast-dev/feast/go/types" "os" "strings" @@ -114,7 +114,7 @@ func entityTypeConversion(entityMap map[string]*prototypes.RepeatedValue, entity for _, value := range entityValue.Val { newVal, err := types.ConvertToValueType(value, entityColumn.Dtype) if err != nil { - return fmt.Errorf("error converting entity value for %s: %w", entityName, err) + return errors.GrpcInternalErrorf("error converting entity value for %s: %v", entityName, err) } newEntityValue.Val = append(newEntityValue.Val, newVal) } @@ -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, fmt.Errorf("error converting sort key filter equals for %s: %w", 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, fmt.Errorf("error converting sort key filter range start for %s: %w", 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, fmt.Errorf("error converting sort key filter range end for %s: %w", sk.FieldName, err) + return nil, errors.GrpcInternalErrorf("error converting sort key filter range end for %s: %v", sk.FieldName, err) } } newFilters[i] = &serving.SortKeyFilter{ @@ -169,7 +169,7 @@ func sortKeyFilterTypeConversion(sortKeyFilters []*serving.SortKeyFilter, sortKe }, } } else { - return nil, fmt.Errorf("sort key %s not found in sort keys", filter.SortKeyName) + return nil, errors.GrpcInvalidArgumentErrorf("sort key %s not found in sort keys", filter.SortKeyName) } } return newFilters, nil @@ -205,11 +205,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) @@ -263,7 +263,7 @@ func (fs *FeatureStore) GetOnlineFeatures( for _, groupRef := range groupedRefs { featureData, err := fs.readFromOnlineStore(ctx, groupRef.EntityKeys, groupRef.FeatureViewNames, groupRef.FeatureNames) if err != nil { - return nil, err + return nil, errors.GrpcFromError(err) } vectors, err := onlineserving.TransposeFeatureRowsIntoColumns( @@ -293,7 +293,7 @@ func (fs *FeatureStore) GetOnlineFeatures( fullFeatureNames, ) if err != nil { - return nil, err + return nil, errors.GrpcFromError(err) } result = append(result, onDemandFeatures...) } @@ -342,11 +342,11 @@ 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 { - return nil, fmt.Errorf("no sorted feature views found for the requested features") + return nil, errors.GrpcNotFoundErrorf("no sorted feature views found for the requested features") } // Note: We're ignoring on-demand feature views for now. @@ -377,7 +377,7 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( } if len(expectedJoinKeysSet) == 0 { - return nil, fmt.Errorf("no entity join keys found, check feature view entity definition is well defined") + return nil, errors.GrpcInvalidArgumentErrorf("no entity join keys found, check feature view entity definition is well defined") } err = onlineserving.ValidateSortedFeatureRefs(requestedSortedFeatureViews, fullFeatureNames) @@ -387,11 +387,11 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( numRows, err := onlineserving.ValidateEntityValues(joinKeyToEntityValues, requestData, expectedJoinKeysSet) if err != nil { - return nil, fmt.Errorf("entity validation failed: %w", err) + return nil, errors.GrpcInvalidArgumentErrorf("entity validation failed: %v", err) } if numRows <= 0 { - return nil, fmt.Errorf("invalid number of entity rows: %d", numRows) + return nil, errors.GrpcInvalidArgumentErrorf("invalid number of entity rows: %d", numRows) } err = onlineserving.ValidateSortKeyFilters(sortKeyFilters, requestedSortedFeatureViews) @@ -400,7 +400,7 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( } if limit < 0 { - return nil, fmt.Errorf("limit must be non-negative, got %d", limit) + return nil, errors.GrpcInvalidArgumentErrorf("limit must be non-negative, got %d", limit) } entitylessCase := checkEntitylessCase(requestedSortedFeatureViews) @@ -425,7 +425,7 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( for _, groupRef := range groupedRangeRefs { featureData, err := fs.readRangeFromOnlineStore(ctx, groupRef) if err != nil { - return nil, err + return nil, errors.GrpcFromError(err) } vectors, err := onlineserving.TransposeRangeFeatureRowsIntoColumns( @@ -480,7 +480,7 @@ func (fs *FeatureStore) ParseFeatures(kind interface{}) (*Features, error) { } return &Features{FeaturesRefs: nil, FeatureService: featureService}, nil default: - return nil, errors.New("cannot parse 'kind' of either a Feature Service or list of Features from request") + return nil, errors.GrpcInvalidArgumentErrorf("cannot parse 'kind' of either a Feature Service or list of Features from request") } } diff --git a/go/internal/feast/featurestore_test.go b/go/internal/feast/featurestore_test.go index 729c6ff30a4..705476f2851 100644 --- a/go/internal/feast/featurestore_test.go +++ b/go/internal/feast/featurestore_test.go @@ -449,9 +449,9 @@ func TestEntityTypeConversion_WithInvalidValues(t *testing.T) { {"bytes": {Name: "bytes", Dtype: types.ValueType_BYTES}}, } expectedErrors := []string{ - "error converting entity value for int32: unsupported value type for conversion: INT32 for actual value type: *types.Value_StringVal", - "error converting entity value for float: unsupported value type for conversion: FLOAT for actual value type: *types.Value_Int64Val", - "error converting entity value for bytes: unsupported value type for conversion: BYTES for actual value type: *types.Value_Int64Val", + "rpc error: code = Internal desc = error converting entity value for int32: unsupported value type for conversion: INT32 for actual value type: *types.Value_StringVal", + "rpc error: code = Internal desc = error converting entity value for float: unsupported value type for conversion: FLOAT for actual value type: *types.Value_Int64Val", + "rpc error: code = Internal desc = error converting entity value for bytes: unsupported value type for conversion: BYTES for actual value type: *types.Value_Int64Val", } for i, entityMap := range entityMaps { 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/model/basefeatureview.go b/go/internal/feast/model/basefeatureview.go index 1bdf614c25a..2c76afc9105 100644 --- a/go/internal/feast/model/basefeatureview.go +++ b/go/internal/feast/model/basefeatureview.go @@ -1,8 +1,7 @@ package model import ( - "fmt" - + "github.com/feast-dev/feast/go/internal/feast/errors" "github.com/feast-dev/feast/go/protos/feast/core" ) @@ -25,7 +24,7 @@ func NewBaseFeatureView(name string, featureProtos []*core.FeatureSpecV2) *BaseF func (fv *BaseFeatureView) WithProjection(projection *FeatureViewProjection) (*BaseFeatureView, error) { if projection.Name != fv.Name { - return nil, fmt.Errorf("the projection for the %s FeatureView cannot be applied because it differs "+ + return nil, errors.GrpcInvalidArgumentErrorf("the projection for the %s FeatureView cannot be applied because it differs "+ "in Name; the projection is named %s and the Name indicates which "+ "FeatureView the projection is for", fv.Name, projection.Name) } @@ -35,7 +34,7 @@ func (fv *BaseFeatureView) WithProjection(projection *FeatureViewProjection) (*B } for _, feature := range projection.Features { if _, ok := features[feature.Name]; !ok { - return nil, fmt.Errorf("the projection for %s cannot be applied because it contains %s which the "+ + return nil, errors.GrpcInvalidArgumentErrorf("the projection for %s cannot be applied because it contains %s which the "+ "FeatureView doesn't have", projection.Name, feature.Name) } } diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 53afb3cf66f..0d61dd80253 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -2,8 +2,10 @@ package onlineserving import ( "crypto/sha256" - "errors" "fmt" + "github.com/feast-dev/feast/go/internal/feast/errors" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "sort" "strings" @@ -155,7 +157,7 @@ func GetFeatureViewsToUseByService( } } else { log.Error().Errs("any feature view", []error{fvErr, sortedFvErr, odFvErr}).Msgf("Feature view %s not found", featureViewName) - return nil, nil, nil, fmt.Errorf("the provided feature service %s contains a reference to a feature View"+ + 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) } @@ -253,13 +255,13 @@ func GetFeatureViewsToUseByFeatureRefs( odFvToProjectWithFeatures[odfv.Base.Name] = odfv } } else { - return nil, nil, nil, fmt.Errorf("feature View %s doesn't exist, please make sure that you have created the"+ + 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) @@ -356,7 +358,7 @@ func GetEntityMaps(requestedFeatureViews []*FeatureViewAndRefs, registry *regist for _, entityName := range featureView.EntityNames { entity, err := registry.GetEntity(projectName, entityName) if err != nil { - return nil, nil, fmt.Errorf("entity %s doesn't exist in the registry", entityName) + return nil, nil, errors.GrpcNotFoundErrorf("entity %s doesn't exist in the registry", entityName) } entityNameToJoinKeyMap[entityName] = entity.JoinKey @@ -387,7 +389,7 @@ func GetEntityMapsForSortedViews(sortedViews []*SortedFeatureViewAndRefs, regist for _, entityName := range featureView.EntityNames { entity, err := registry.GetEntity(projectName, entityName) if err != nil { - return nil, nil, fmt.Errorf("entity %s doesn't exist in the registry", entityName) + return nil, nil, err } entityNameToJoinKeyMap[entityName] = entity.JoinKey @@ -418,7 +420,7 @@ func ValidateEntityValues(joinKeyValues map[string]*prototypes.RepeatedValue, if numRows < 0 { numRows = len(values.Val) } else if len(values.Val) != numRows { - return -1, errors.New("valueError: All entity rows must have the same columns") + return -1, errors.GrpcInvalidArgumentErrorf("valueError: All entity rows must have the same columns") } } @@ -486,6 +488,10 @@ func ValidateSortedFeatureRefs(sortedViews []*SortedFeatureViewAndRefs, fullFeat } func ValidateSortKeyFilters(filters []*serving.SortKeyFilter, sortedViews []*SortedFeatureViewAndRefs) error { + if len(filters) == 0 { + return nil + } + sortKeyTypes := make(map[string]prototypes.ValueType_Enum) for _, sortedView := range sortedViews { @@ -497,29 +503,29 @@ func ValidateSortKeyFilters(filters []*serving.SortKeyFilter, sortedViews []*Sor for _, filter := range filters { expectedType, exists := sortKeyTypes[filter.SortKeyName] if !exists { - return fmt.Errorf("sort key '%s' not found in any of the requested sorted feature views", + return errors.GrpcInvalidArgumentErrorf("sort key '%s' not found in any of the requested sorted feature views", filter.SortKeyName) } if filter.GetEquals() != nil { if !isValueTypeCompatible(filter.GetEquals(), expectedType, false) { - return fmt.Errorf("equals value for sort key '%s' has incompatible type: expected %s", + return errors.GrpcInvalidArgumentErrorf("equals value for sort key '%s' has incompatible type: expected %s", filter.SortKeyName, valueTypeToString(expectedType)) } } else if filter.GetRange() == nil { - return fmt.Errorf("sort key filter for sort key '%s' must have either equals or range_query set", + return errors.GrpcInvalidArgumentErrorf("sort key filter for sort key '%s' must have either equals or range_query set", filter.SortKeyName) } else { if filter.GetRange().RangeStart != nil { if !isValueTypeCompatible(filter.GetRange().RangeStart, expectedType, true) { - return fmt.Errorf("range_start value for sort key '%s' has incompatible type: expected %s", + return errors.GrpcInvalidArgumentErrorf("range_start value for sort key '%s' has incompatible type: expected %s", filter.SortKeyName, valueTypeToString(expectedType)) } } if filter.GetRange().RangeEnd != nil { if !isValueTypeCompatible(filter.GetRange().RangeEnd, expectedType, true) { - return fmt.Errorf("range_end value for sort key '%s' has incompatible type: expected %s", + return errors.GrpcInvalidArgumentErrorf("range_end value for sort key '%s' has incompatible type: expected %s", filter.SortKeyName, valueTypeToString(expectedType)) } } @@ -545,11 +551,11 @@ func ValidateSortKeyFilterOrder(filters []*serving.SortKeyFilter, sortedViews [] for i, filter := range orderedFilters[:len(orderedFilters)-1] { if filter == nil { - return fmt.Errorf("specify sort key filter in request for sort key: '%s' with query type equals", sortedView.View.SortKeys[i].FieldName) + return errors.GrpcInvalidArgumentErrorf("specify sort key filter in request for sort key: '%s' with query type equals", sortedView.View.SortKeys[i].FieldName) } if filter.GetEquals() == nil { - return fmt.Errorf("sort key filter for sort key '%s' must have query type equals instead of range", + return errors.GrpcInvalidArgumentErrorf("sort key filter for sort key '%s' must have query type equals instead of range", filter.SortKeyName) } } @@ -673,7 +679,7 @@ func TransposeFeatureRowsIntoColumns(featureData2D [][]onlinestore.FeatureData, } arrowValues, err := types.ProtoValuesToArrowArray(protoValues, arrowAllocator, numRows) if err != nil { - return nil, err + return nil, errors.GrpcFromError(err) } currentVector.Values = arrowValues } @@ -722,7 +728,7 @@ func TransposeRangeFeatureRowsIntoColumns( arrowRangeValues, err := types.RepeatedProtoValuesToArrowArray(rangeValuesByRow, arrowAllocator, numRows) if err != nil { - return nil, err + return nil, errors.GrpcFromError(err) } currentVector.RangeValues = arrowRangeValues } @@ -756,7 +762,7 @@ func processFeatureRowData( sfv, exists := sfvs[featureViewName] if !exists { - return nil, nil, nil, fmt.Errorf("feature view '%s' not found in the provided sorted feature views", featureViewName) + return nil, nil, nil, errors.GrpcNotFoundErrorf("feature view '%s' not found in the provided sorted feature views", featureViewName) } numValues := len(featureData.Values) @@ -778,7 +784,7 @@ func processFeatureRowData( protoVal, err := types.InterfaceToProtoValue(val) if err != nil { - return nil, nil, nil, fmt.Errorf("error converting value for feature %s: %v", featureData.FeatureName, err) + return nil, nil, nil, errors.GrpcInternalErrorf("error converting value for feature %s: %v", featureData.FeatureName, err) } // Explicitly set to nil if status is NOT_FOUND @@ -835,7 +841,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) } } @@ -855,7 +861,7 @@ func KeepOnlyRequestedFeatures[T any]( } qualifiedName := getQualifiedFeatureName(viewName, featureName, fullFeatureNames) if _, ok := vectorsByName[qualifiedName]; !ok { - return nil, fmt.Errorf("requested feature %s can't be retrieved", featureRef) + return nil, errors.GrpcInternalErrorf("requested feature %s can't be retrieved", featureRef) } expectedVectors = append(expectedVectors, vectorsByName[qualifiedName]) usedVectors[qualifiedName] = true @@ -872,7 +878,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) } } @@ -890,7 +896,7 @@ func EntitiesToFeatureVectors(entityColumns map[string]*prototypes.RepeatedValue for entityName, values := range entityColumns { arrowColumn, err := types.ProtoValuesToArrowArray(values.Val, arrowAllocator, numRows) if err != nil { - return nil, err + return nil, errors.GrpcFromError(err) } vectors = append(vectors, &FeatureVector{ Name: entityName, @@ -940,7 +946,7 @@ func ParseFeatureReference(featureRef string) (featureViewName, featureName stri parsedFeatureName := strings.Split(featureRef, ":") if len(parsedFeatureName) == 0 { - e = errors.New("featureReference should be in the format: 'FeatureViewName:FeatureName'") + e = errors.GrpcInvalidArgumentErrorf("featureReference should be in the format: 'FeatureViewName:FeatureName'") } else if len(parsedFeatureName) == 1 { featureName = parsedFeatureName[0] } else { @@ -1012,7 +1018,7 @@ func GroupFeatureRefs(requestedFeatureViews []*FeatureViewAndRefs, } if _, ok := joinKeyValues[joinKeyOrAlias]; !ok { - return nil, fmt.Errorf("key %s is missing in provided entity rows for view %s", joinKey, fv.Base.Name) + return nil, errors.GrpcInvalidArgumentErrorf("key %s is missing in provided entity rows for view %s", joinKey, fv.Base.Name) } joinKeysValuesProjection[joinKey] = joinKeyValues[joinKeyOrAlias] } @@ -1103,7 +1109,7 @@ func GroupSortedFeatureRefs( } if _, ok := joinKeyValues[joinKeyOrAlias]; !ok { - return nil, fmt.Errorf("key %s is missing in provided entity rows", joinKey) + return nil, errors.GrpcInvalidArgumentErrorf("key %s is missing in provided entity rows", joinKey) } joinKeysValuesProjection[joinKey] = joinKeyValues[joinKeyOrAlias] } @@ -1194,7 +1200,7 @@ func getUniqueEntityRows(joinKeysProto []*prototypes.EntityKey) ([]*prototypes.E for index, entityKey := range joinKeysProto { serializedRow, err := proto.Marshal(entityKey) if err != nil { - return nil, nil, err + return nil, nil, errors.GrpcFromError(err) } rowHash := sha256.Sum256(serializedRow) @@ -1242,3 +1248,7 @@ type featureNameCollisionError struct { func (e featureNameCollisionError) Error() string { return fmt.Sprintf("featureNameCollisionError: %s; %t", strings.Join(e.featureRefCollisions, ", "), e.fullFeatureNames) } + +func (e featureNameCollisionError) GRPCStatus() *status.Status { + return status.New(codes.InvalidArgument, e.Error()) +} diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index bac540d0f69..c390adc7a6b 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -4,6 +4,7 @@ package onlineserving import ( "fmt" + "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" "path/filepath" "runtime" @@ -351,7 +352,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) { @@ -389,7 +390,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) { @@ -427,7 +428,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) { @@ -465,7 +466,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) { @@ -549,6 +550,35 @@ func TestValidateSortKeyFilters_ValidFilters(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) @@ -1242,6 +1272,8 @@ func TestValidateFeatureRefs(t *testing.T) { err := ValidateFeatureRefs(requestedFeatures, false) assert.Error(t, err, "Collisions without full feature names should result in an error") + _, errIsStatus := status.FromError(err) + assert.True(t, errIsStatus, "Collision error should be a grpc status error") assert.Contains(t, err.Error(), "featureA", "Error should include the collided feature name") }) @@ -1286,6 +1318,8 @@ func TestValidateFeatureRefs(t *testing.T) { err := ValidateFeatureRefs(requestedFeatures, false) assert.Error(t, err, "Multiple collisions should result in an error") + _, errIsStatus := status.FromError(err) + assert.True(t, errIsStatus, "Collision error should be a grpc status error") assert.Contains(t, err.Error(), "featureA", "Error should include the collided feature name") assert.Contains(t, err.Error(), "featureB", "Error should include the collided feature name") }) @@ -1367,6 +1401,8 @@ func TestValidateSortedFeatureRefs(t *testing.T) { err := ValidateSortedFeatureRefs(sortedViews, false) assert.Error(t, err, "Collisions without full feature names should result in an error") + _, errIsStatus := status.FromError(err) + assert.True(t, errIsStatus, "Collision error should be a grpc status error") assert.Contains(t, err.Error(), "featureA", "Error should include the collided feature name") }) @@ -1419,6 +1455,8 @@ func TestValidateSortedFeatureRefs(t *testing.T) { err := ValidateSortedFeatureRefs(sortedViews, false) assert.Error(t, err, "Multiple collisions should result in an error") + _, errIsStatus := status.FromError(err) + assert.True(t, errIsStatus, "Collision error should be a grpc status error") assert.Contains(t, err.Error(), "featureA", "Error should include the collided feature name") assert.Contains(t, err.Error(), "featureB", "Error should include the collided feature name") }) diff --git a/go/internal/feast/registry/registry.go b/go/internal/feast/registry/registry.go index 37a19bee88a..52043b61dde 100644 --- a/go/internal/feast/registry/registry.go +++ b/go/internal/feast/registry/registry.go @@ -1,8 +1,8 @@ package registry import ( - "errors" "fmt" + "github.com/feast-dev/feast/go/internal/feast/errors" "net/url" "reflect" "sync" @@ -263,14 +263,14 @@ func (r *Registry) GetEntity(project string, entityName string) (*model.Entity, return entity, nil } - return nil, fmt.Errorf("no cached entity %s found for project %s", entityName, project) + return nil, errors.GrpcNotFoundErrorf("no cached entity %s found for project %s", entityName, project) } func (r *Registry) GetEntityFromRegistry(entityName string, project string) (*model.Entity, error) { entityProto, err := r.registryStore.(*HttpRegistryStore).getEntity(entityName, true) if err != nil { log.Error().Err(err).Msgf("no entity %s found in project %s", entityName, project) - return nil, fmt.Errorf("no entity %s found in project %s", entityName, project) + return nil, errors.GrpcNotFoundErrorf("no entity %s found in project %s", entityName, project) } return model.NewEntityFromProto(entityProto), nil @@ -292,7 +292,7 @@ func (r *Registry) GetFeatureViewFromRegistry(featureViewName string, project st featureViewProto, err := r.registryStore.(*HttpRegistryStore).getFeatureView(featureViewName, true) if err != nil { log.Error().Err(err).Msgf("no feature view %s found in project %s", featureViewName, project) - return nil, fmt.Errorf("no feature view %s found in project %s", featureViewName, project) + return nil, errors.GrpcNotFoundErrorf("no feature view %s found in project %s", featureViewName, project) } return model.NewFeatureViewFromProto(featureViewProto), nil @@ -307,14 +307,14 @@ func (r *Registry) GetSortedFeatureView(project string, sortedFeatureViewName st return cachedSortedFeatureView, nil } - return nil, fmt.Errorf("no cached sorted feature view %s found for project %s", sortedFeatureViewName, project) + return nil, errors.GrpcNotFoundErrorf("no cached sorted feature view %s found for project %s", sortedFeatureViewName, project) } func (r *Registry) GetSortedFeatureViewFromRegistry(sortedFeatureViewName string, project string) (*model.SortedFeatureView, error) { sortedFeatureViewProto, err := r.registryStore.(*HttpRegistryStore).getSortedFeatureView(sortedFeatureViewName, true) if err != nil { log.Error().Err(err).Msgf("no sorted feature view %s found in project %s", sortedFeatureViewName, project) - return nil, fmt.Errorf("no sorted feature view %s found in project %s", sortedFeatureViewName, project) + return nil, errors.GrpcNotFoundErrorf("no sorted feature view %s found in project %s", sortedFeatureViewName, project) } return model.NewSortedFeatureViewFromProto(sortedFeatureViewProto), nil @@ -329,14 +329,14 @@ func (r *Registry) GetFeatureService(project string, featureServiceName string) return cachedFeatureService, nil } - return nil, fmt.Errorf("no cached feature service %s found for project %s", featureServiceName, project) + return nil, errors.GrpcNotFoundErrorf("no cached feature service %s found for project %s", featureServiceName, project) } func (r *Registry) GetFeatureServiceFromRegistry(featureServiceName string, project string) (*model.FeatureService, error) { featureServiceProto, err := r.registryStore.(*HttpRegistryStore).getFeatureService(featureServiceName, true) if err != nil { log.Error().Err(err).Msgf("no feature service %s found in project %s", featureServiceName, project) - return nil, fmt.Errorf("no feature service %s found in project %s", featureServiceName, project) + return nil, errors.GrpcNotFoundErrorf("no feature service %s found in project %s", featureServiceName, project) } return model.NewFeatureServiceFromProto(featureServiceProto), nil @@ -358,7 +358,7 @@ func (r *Registry) GetOnDemandFeatureViewFromRegistry(onDemandFeatureViewName st onDemandFeatureViewProto, err := r.registryStore.(*HttpRegistryStore).getOnDemandFeatureView(onDemandFeatureViewName, true) if err != nil { log.Error().Err(err).Msgf("no on demand feature view %s found in project %s", onDemandFeatureViewName, project) - return nil, fmt.Errorf("no on demand feature view %s found in project %s", onDemandFeatureViewName, project) + return nil, errors.GrpcNotFoundErrorf("no on demand feature view %s found in project %s", onDemandFeatureViewName, project) } return model.NewOnDemandFeatureViewFromProto(onDemandFeatureViewProto), nil @@ -372,7 +372,7 @@ func getRegistryStoreFromScheme(registryPath string, registryConfig *RegistryCon if registryStoreType, ok := REGISTRY_STORE_CLASS_FOR_SCHEME[uri.Scheme]; ok { return getRegistryStoreFromType(registryStoreType, registryConfig, repoPath, project) } - return nil, fmt.Errorf("registry path %s has unsupported scheme %s. Supported schemes are file, s3 and gs", registryPath, uri.Scheme) + return nil, errors.GrpcNotFoundErrorf("registry path %s has unsupported scheme %s. Supported schemes are file, s3 and gs", registryPath, uri.Scheme) } func getRegistryStoreFromType(registryStoreType string, registryConfig *RegistryConfig, repoPath string, project string) (RegistryStore, error) { @@ -382,5 +382,5 @@ func getRegistryStoreFromType(registryStoreType string, registryConfig *Registry case "HttpRegistryStore": return NewHttpRegistryStore(registryConfig, project) } - return nil, errors.New("only FileRegistryStore or HttpRegistryStore as a RegistryStore is supported at this moment") + return nil, errors.GrpcInternalErrorf("only FileRegistryStore or HttpRegistryStore as a RegistryStore is supported at this moment") } diff --git a/go/internal/feast/registry/registry_test.go b/go/internal/feast/registry/registry_test.go index 2ac636f2de0..3bce4996b66 100644 --- a/go/internal/feast/registry/registry_test.go +++ b/go/internal/feast/registry/registry_test.go @@ -96,7 +96,7 @@ func TestRegistry_GetFeatureService_Error(t *testing.T) { // Call GetFeatureService with an invalid feature service name result, err := registry.GetFeatureService(PROJECT, "invalid_feature_service") assert.Error(t, err, "Expected an error") - assert.Equal(t, "no feature service invalid_feature_service found in project test_project", err.Error(), "Expected a specific error message") + assert.Equal(t, "rpc error: code = NotFound desc = no feature service invalid_feature_service found in project test_project", err.Error(), "Expected a specific error message") assert.Nil(t, result, "Expected a nil feature service") } @@ -144,7 +144,7 @@ func TestRegistry_GetEntity_Error(t *testing.T) { // Call GetEntity with an invalid entity name result, err := registry.GetEntity(PROJECT, "invalid_entity") assert.Error(t, err, "Expected an error") - assert.Equal(t, "no entity invalid_entity found in project test_project", err.Error(), "Expected a specific error message") + assert.Equal(t, "rpc error: code = NotFound desc = no entity invalid_entity found in project test_project", err.Error(), "Expected a specific error message") assert.Nil(t, result, "Expected a nil entity") } @@ -192,7 +192,7 @@ func TestRegistry_GetFeatureView_Error(t *testing.T) { // Call GetFeatureView with an invalid feature view name result, err := registry.GetFeatureView(PROJECT, "invalid_feature_view") assert.Error(t, err, "Expected an error") - assert.Equal(t, "no feature view invalid_feature_view found in project test_project", err.Error(), "Expected a specific error message") + assert.Equal(t, "rpc error: code = NotFound desc = no feature view invalid_feature_view found in project test_project", err.Error(), "Expected a specific error message") assert.Nil(t, result, "Expected a nil feature view") } @@ -242,7 +242,7 @@ func TestRegistry_GetSortedFeatureView_Error(t *testing.T) { // Call GetSortedFeatureView with an invalid sorted feature view name result, err := registry.GetSortedFeatureView(PROJECT, "invalid_sorted_feature_view") assert.Error(t, err, "Expected an error") - assert.Equal(t, "no sorted feature view invalid_sorted_feature_view found in project test_project", err.Error(), "Expected a specific error message") + assert.Equal(t, "rpc error: code = NotFound desc = no sorted feature view invalid_sorted_feature_view found in project test_project", err.Error(), "Expected a specific error message") assert.Nil(t, result, "Expected a nil sorted feature view") } @@ -290,7 +290,7 @@ func TestRegistry_GetOnDemandFeatureView_Error(t *testing.T) { // Call GetOnDemandFeatureView with an invalid on-demand feature view name result, err := registry.GetOnDemandFeatureView(PROJECT, "invalid_on_demand_feature_view") assert.Error(t, err, "Expected an error") - assert.Equal(t, "no on demand feature view invalid_on_demand_feature_view found in project test_project", err.Error(), "Expected a specific error message") + assert.Equal(t, "rpc error: code = NotFound desc = no on demand feature view invalid_on_demand_feature_view found in project test_project", err.Error(), "Expected a specific error message") assert.Nil(t, result, "Expected a nil on-demand feature view") } diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index 3a556fc71ab..4defac389f7 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "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" "github.com/feast-dev/feast/go/protos/feast/serving" prototypes "github.com/feast-dev/feast/go/protos/feast/types" @@ -66,7 +67,7 @@ func (s *grpcServingServiceServer) GetOnlineFeatures(ctx context.Context, reques if err != nil { logSpanContext.Error().Err(err).Msg("Error getting online features") - return nil, err + return nil, errors.GrpcFromError(err) } resp := &serving.GetOnlineFeaturesResponse{ @@ -85,7 +86,7 @@ func (s *grpcServingServiceServer) GetOnlineFeatures(ctx context.Context, reques values, err := types.ArrowValuesToProtoValues(vector.Values) if err != nil { logSpanContext.Error().Err(err).Msg("Error converting Arrow values to proto values") - return nil, err + return nil, errors.GrpcFromError(err) } if _, ok := request.Entities[vector.Name]; ok { entityValuesMap[vector.Name] = values @@ -148,7 +149,7 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r if err != nil { logSpanContext.Error().Err(err).Msg("Error getting online features range") - return nil, err + return nil, errors.GrpcFromError(err) } entities := request.GetEntities() @@ -161,7 +162,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{ @@ -172,8 +173,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 63e3ad630a5..98ccd1f13c3 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "net/http" "os" "runtime" @@ -677,7 +679,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/feast/server/http_server_test.go b/go/internal/feast/server/http_server_test.go index b5d140fc8b4..6cdeeace77b 100644 --- a/go/internal/feast/server/http_server_test.go +++ b/go/internal/feast/server/http_server_test.go @@ -135,6 +135,36 @@ func TestUnmarshalRangeRequestJSON(t *testing.T) { assert.Equal(t, int32(10), request.Limit) } +func TestUnmarshalRangeRequestJSON_withEmptySortKeyFilters(t *testing.T) { + jsonData := `{ + "features": [ + "batch_mat_sorted_fv:feature_1", + "batch_mat_sorted_fv:feature_2", + "batch_mat_sorted_fv:feature_3" + ], + "entities": { + "entity_key": [ + "entity_key_4" + ] + }, + "sort_key_filters": [], + "reverse_sort_order": false, + "limit": 10, + "full_feature_names": true +}` + var request getOnlineFeaturesRangeRequest + decoder := json.NewDecoder(strings.NewReader(jsonData)) + err := decoder.Decode(&request) + assert.NoError(t, err, "Error unmarshalling JSON") + + sortKeyFiltersProto, err := getSortKeyFiltersProto(request.SortKeyFilters) + assert.NoError(t, err, "Error converting to proto") + + assert.Equal(t, 0, len(sortKeyFiltersProto)) + + assert.Equal(t, int32(10), request.Limit) +} + func TestUnmarshalRangeRequestJSON_InvalidSortKeyFilter(t *testing.T) { jsonData := `{ "features": [ diff --git a/go/internal/feast/transformation/transformation.go b/go/internal/feast/transformation/transformation.go index 7aa3df76689..9b176f72f74 100644 --- a/go/internal/feast/transformation/transformation.go +++ b/go/internal/feast/transformation/transformation.go @@ -2,7 +2,7 @@ package transformation import ( "context" - "fmt" + "github.com/feast-dev/feast/go/internal/feast/errors" "runtime" "strings" @@ -112,7 +112,7 @@ func EnsureRequestedDataExist(requestedOnDemandFeatureViews []*model.OnDemandFea } if len(missingFeatures) > 0 { - return fmt.Errorf("requestDataNotFoundInEntityRowsException: %s", strings.Join(missingFeatures, ", ")) + return errors.GrpcInvalidArgumentErrorf("requestDataNotFoundInEntityRowsException: %s", strings.Join(missingFeatures, ", ")) } return nil } 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 } diff --git a/java/serving-client/src/main/java/dev/feast/FeastClient.java b/java/serving-client/src/main/java/dev/feast/FeastClient.java index c238ed4a01a..b39ebb2e956 100644 --- a/java/serving-client/src/main/java/dev/feast/FeastClient.java +++ b/java/serving-client/src/main/java/dev/feast/FeastClient.java @@ -17,6 +17,7 @@ package dev.feast; import com.google.common.collect.Lists; +import dev.feast.exception.FeastException; import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.FieldStatus; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; @@ -30,6 +31,7 @@ import feast.proto.types.ValueProto; import io.grpc.CallCredentials; import io.grpc.ManagedChannel; +import io.grpc.StatusRuntimeException; import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; import io.opentracing.contrib.grpc.TracingClientInterceptor; @@ -303,7 +305,12 @@ private List fetchOnlineFeatures( ServingServiceGrpc.ServingServiceBlockingStub timedStub = requestTimeout != 0 ? stub.withDeadlineAfter(requestTimeout, TimeUnit.MILLISECONDS) : stub; - GetOnlineFeaturesResponse response = timedStub.getOnlineFeatures(getOnlineFeaturesRequest); + GetOnlineFeaturesResponse response; + try { + response = timedStub.getOnlineFeatures(getOnlineFeaturesRequest); + } catch (StatusRuntimeException e) { + throw FeastException.fromStatusException(e); + } List results = Lists.newArrayList(); if (response.getResultsCount() == 0) { @@ -444,7 +451,12 @@ public List getOnlineFeaturesRange( ServingServiceGrpc.ServingServiceBlockingStub timedStub = requestTimeout != 0 ? stub.withDeadlineAfter(requestTimeout, TimeUnit.MILLISECONDS) : stub; - GetOnlineFeaturesRangeResponse response = timedStub.getOnlineFeaturesRange(request); + GetOnlineFeaturesRangeResponse response; + try { + response = timedStub.getOnlineFeaturesRange(request); + } catch (StatusRuntimeException e) { + throw FeastException.fromStatusException(e); + } List results = Lists.newArrayList(); diff --git a/java/serving-client/src/main/java/dev/feast/exception/FeastBadRequestException.java b/java/serving-client/src/main/java/dev/feast/exception/FeastBadRequestException.java new file mode 100644 index 00000000000..5a914db70e3 --- /dev/null +++ b/java/serving-client/src/main/java/dev/feast/exception/FeastBadRequestException.java @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2025 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.feast.exception; + +public class FeastBadRequestException extends FeastException { + public FeastBadRequestException(String message) { + super(message); + } + + public FeastBadRequestException(String message, Throwable cause) { + super(message, cause); + } + + public FeastBadRequestException(Throwable cause) { + super(cause); + } +} diff --git a/java/serving-client/src/main/java/dev/feast/exception/FeastException.java b/java/serving-client/src/main/java/dev/feast/exception/FeastException.java new file mode 100644 index 00000000000..757ee5c8677 --- /dev/null +++ b/java/serving-client/src/main/java/dev/feast/exception/FeastException.java @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2025 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.feast.exception; + +import io.grpc.StatusRuntimeException; + +public class FeastException extends RuntimeException { + public FeastException(String message) { + super(message); + } + + public FeastException(String message, Throwable cause) { + super(message, cause); + } + + public FeastException(Throwable cause) { + super(cause); + } + + public static FeastException fromStatusException(StatusRuntimeException statusRuntimeException) { + switch (statusRuntimeException.getStatus().getCode()) { + case NOT_FOUND: + return new FeastNotFoundException( + statusRuntimeException.getMessage(), statusRuntimeException); + case INVALID_ARGUMENT: + return new FeastBadRequestException( + statusRuntimeException.getMessage(), statusRuntimeException); + case INTERNAL: + return new FeastInternalException( + statusRuntimeException.getMessage(), statusRuntimeException); + default: + return new FeastException(statusRuntimeException.getMessage(), statusRuntimeException); + } + } +} diff --git a/java/serving-client/src/main/java/dev/feast/exception/FeastInternalException.java b/java/serving-client/src/main/java/dev/feast/exception/FeastInternalException.java new file mode 100644 index 00000000000..72a499d2575 --- /dev/null +++ b/java/serving-client/src/main/java/dev/feast/exception/FeastInternalException.java @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2025 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.feast.exception; + +public class FeastInternalException extends FeastException { + public FeastInternalException(String message) { + super(message); + } + + public FeastInternalException(String message, Throwable cause) { + super(message, cause); + } + + public FeastInternalException(Throwable cause) { + super(cause); + } +} diff --git a/java/serving-client/src/main/java/dev/feast/exception/FeastNotFoundException.java b/java/serving-client/src/main/java/dev/feast/exception/FeastNotFoundException.java new file mode 100644 index 00000000000..59cfae5544b --- /dev/null +++ b/java/serving-client/src/main/java/dev/feast/exception/FeastNotFoundException.java @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2025 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.feast.exception; + +public class FeastNotFoundException extends FeastException { + public FeastNotFoundException(String message) { + super(message); + } + + public FeastNotFoundException(String message, Throwable cause) { + super(message, cause); + } + + public FeastNotFoundException(Throwable cause) { + super(cause); + } +} diff --git a/java/serving-client/src/test/java/dev/feast/FeastClientTest.java b/java/serving-client/src/test/java/dev/feast/FeastClientTest.java index 4fb5eb7959f..88e3e890dea 100644 --- a/java/serving-client/src/test/java/dev/feast/FeastClientTest.java +++ b/java/serving-client/src/test/java/dev/feast/FeastClientTest.java @@ -17,10 +17,15 @@ package dev.feast; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.Mockito.mock; +import dev.feast.exception.FeastBadRequestException; +import dev.feast.exception.FeastException; +import dev.feast.exception.FeastInternalException; +import dev.feast.exception.FeastNotFoundException; import feast.proto.serving.ServingAPIProto; import feast.proto.serving.ServingAPIProto.FieldStatus; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRangeRequest; @@ -63,6 +68,35 @@ public class FeastClientTest { public void getOnlineFeatures( GetOnlineFeaturesRequest request, StreamObserver responseObserver) { + if (request.equals( + FeastClientTest.getFakeOnlineFeaturesForError("should:internal"))) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Error getting online features") + .asRuntimeException()); + return; + } else if (request.equals( + FeastClientTest.getFakeOnlineFeaturesForError("should:bad_request"))) { + responseObserver.onError( + Status.INVALID_ARGUMENT + .withDescription("Error getting online features") + .asRuntimeException()); + return; + } else if (request.equals( + FeastClientTest.getFakeOnlineFeaturesForError("should:not_found"))) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("Error getting online features") + .asRuntimeException()); + return; + } else if (request.equals( + FeastClientTest.getFakeOnlineFeaturesForError("should:unknown"))) { + responseObserver.onError( + Status.UNKNOWN + .withDescription("Error getting online features") + .asRuntimeException()); + return; + } if (!request.equals(FeastClientTest.getFakeOnlineFeaturesRefRequest()) && !request.equals(FeastClientTest.getFakeOnlineFeaturesServiceRequest()) && !request.equals( @@ -79,6 +113,35 @@ public void getOnlineFeatures( public void getOnlineFeaturesRange( GetOnlineFeaturesRangeRequest request, StreamObserver responseObserver) { + if (request.equals( + FeastClientTest.getFakeOnlineFeaturesRangeRequestError("should:internal"))) { + responseObserver.onError( + Status.INTERNAL + .withDescription("Error getting online features range") + .asRuntimeException()); + } else if (request.equals( + FeastClientTest.getFakeOnlineFeaturesRangeRequestError( + "should:bad_request"))) { + responseObserver.onError( + Status.INVALID_ARGUMENT + .withDescription("Error getting online features range") + .asRuntimeException()); + return; + } else if (request.equals( + FeastClientTest.getFakeOnlineFeaturesRangeRequestError("should:not_found"))) { + responseObserver.onError( + Status.NOT_FOUND + .withDescription("Error getting online features range") + .asRuntimeException()); + return; + } else if (request.equals( + FeastClientTest.getFakeOnlineFeaturesRangeRequestError("should:unknown"))) { + responseObserver.onError( + Status.UNKNOWN + .withDescription("Error getting online features range") + .asRuntimeException()); + return; + } if (!request.equals(FeastClientTest.getFakeOnlineFeaturesRangeRequest())) { responseObserver.onError(Status.FAILED_PRECONDITION.asRuntimeException()); return; @@ -126,11 +189,107 @@ public void shouldGetOnlineFeaturesWithoutStatus() { shouldGetOnlineFeaturesWithoutStatus(this.client); } + @Test + public void shouldThrowExceptionForGetOnlineFeaturesInternalError() { + FeastInternalException exception = + assertThrows( + FeastInternalException.class, + () -> + this.client.getOnlineFeatures( + getFakeOnlineFeaturesForError("should:internal"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("INTERNAL: Error getting online features", exception.getMessage()); + } + + @Test + public void shouldThrowExceptionForGetOnlineFeaturesInvalidArgumentError() { + FeastBadRequestException exception = + assertThrows( + FeastBadRequestException.class, + () -> + this.client.getOnlineFeatures( + getFakeOnlineFeaturesForError("should:bad_request"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("INVALID_ARGUMENT: Error getting online features", exception.getMessage()); + } + + @Test + public void shouldThrowExceptionForGetOnlineFeaturesNotFoundError() { + FeastNotFoundException exception = + assertThrows( + FeastNotFoundException.class, + () -> + this.client.getOnlineFeatures( + getFakeOnlineFeaturesForError("should:not_found"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("NOT_FOUND: Error getting online features", exception.getMessage()); + } + + @Test + public void shouldThrowExceptionForGetOnlineFeaturesUnknownError() { + FeastException exception = + assertThrows( + FeastException.class, + () -> + this.client.getOnlineFeatures( + getFakeOnlineFeaturesForError("should:unknown"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("UNKNOWN: Error getting online features", exception.getMessage()); + } + @Test public void shouldGetOnlineFeaturesRange() { shouldGetOnlineFeaturesRangeWithClient(this.client); } + @Test + public void shouldThrowExceptionForGetOnlineFeaturesRangeError() { + FeastInternalException exception = + assertThrows( + FeastInternalException.class, + () -> + this.client.getOnlineFeaturesRange( + getFakeOnlineFeaturesRangeRequestError("should:internal"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("INTERNAL: Error getting online features range", exception.getMessage()); + } + + @Test + public void shouldThrowExceptionForGetOnlineFeaturesRangeInvalidArgumentError() { + FeastBadRequestException exception = + assertThrows( + FeastBadRequestException.class, + () -> + this.client.getOnlineFeaturesRange( + getFakeOnlineFeaturesRangeRequestError("should:bad_request"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("INVALID_ARGUMENT: Error getting online features range", exception.getMessage()); + } + + @Test + public void shouldThrowExceptionForGetOnlineFeaturesRangeNotFoundError() { + FeastNotFoundException exception = + assertThrows( + FeastNotFoundException.class, + () -> + this.client.getOnlineFeaturesRange( + getFakeOnlineFeaturesRangeRequestError("should:not_found"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("NOT_FOUND: Error getting online features range", exception.getMessage()); + } + + @Test + public void shouldThrowExceptionForGetOnlineFeaturesRangeUnknownError() { + FeastException exception = + assertThrows( + FeastException.class, + () -> + this.client.getOnlineFeaturesRange( + getFakeOnlineFeaturesRangeRequestError("should:unknown"), + Collections.singletonList(Row.create().set("driver_id", 1)))); + assertEquals("UNKNOWN: Error getting online features range", exception.getMessage()); + } + private void shouldGetOnlineFeaturesFeatureRef(FeastClient client) { List rows = client.getOnlineFeatures( @@ -268,6 +427,15 @@ private static GetOnlineFeaturesRequest getFakeOnlineFeaturesServiceRequest() { .build(); } + private static GetOnlineFeaturesRequest getFakeOnlineFeaturesForError(String featureName) { + // setup mock serving service stub + return GetOnlineFeaturesRequest.newBuilder() + .setFeatures(ServingAPIProto.FeatureList.newBuilder().addVal(featureName).build()) + .putEntities("driver_id", ValueProto.RepeatedValue.newBuilder().addVal(intValue(1)).build()) + .setIncludeMetadata(false) + .build(); + } + private static GetOnlineFeaturesResponse getFakeOnlineFeaturesResponse() { return GetOnlineFeaturesResponse.newBuilder() .addResults( @@ -321,6 +489,14 @@ private static GetOnlineFeaturesRangeRequest getFakeOnlineFeaturesRangeRequest() .build(); } + private static GetOnlineFeaturesRangeRequest getFakeOnlineFeaturesRangeRequestError( + String featureName) { + return GetOnlineFeaturesRangeRequest.newBuilder() + .setFeatures(ServingAPIProto.FeatureList.newBuilder().addVal(featureName).build()) + .putEntities("driver_id", ValueProto.RepeatedValue.newBuilder().addVal(intValue(1)).build()) + .build(); + } + private static GetOnlineFeaturesRangeResponse getFakeOnlineFeaturesRangeResponse() { return GetOnlineFeaturesRangeResponse.newBuilder() .addResults( From 57e88a5bd2ae8942566e69e1db438ea021e6fc59 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 14 Jul 2025 11:37:02 -0700 Subject: [PATCH 17/25] 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 | 11 ++- .../feast/onlineserving/serving_test.go | 30 +++++++ go/types/typeconversion.go | 27 ++++++ 4 files changed, 140 insertions(+), 11 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 0d61dd80253..2d673274dcf 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -544,16 +544,25 @@ func ValidateSortKeyFilterOrder(filters []*serving.SortKeyFilter, sortedViews [] for _, sortedView := range sortedViews { if len(sortedView.View.SortKeys) > 1 { orderedFilters := make([]*serving.SortKeyFilter, 0) + var lastFilter string for _, sortKey := range sortedView.View.SortKeys { orderedFilters = append(orderedFilters, filtersByName[sortKey.FieldName]) + if f, ok := filtersByName[sortKey.FieldName]; ok { + lastFilter = f.SortKeyName + } } - for i, filter := range orderedFilters[:len(orderedFilters)-1] { + for i, filter := range orderedFilters { if filter == nil { 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 c390adc7a6b..a00a72152c6 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -548,6 +548,36 @@ func TestValidateSortKeyFilters_ValidFilters(t *testing.T) { err = ValidateSortKeyFilters(validFilters, sortedViews) assert.NoError(t, err, "Valid filters should not produce an error") + + validFilters = []*serving.SortKeyFilter{ + { + SortKeyName: "timestamp", + Query: &serving.SortKeyFilter_Equals{ + Equals: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 1640995200}}, + }, + }, + } + + err = ValidateSortKeyFilters(validFilters, sortedViews) + assert.NoError(t, err, "Valid filters should not produce an error") + + validFilters = []*serving.SortKeyFilter{ + { + SortKeyName: "timestamp", + Query: &serving.SortKeyFilter_Equals{ + Equals: &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: 1640995200}}, + }, + }, + { + SortKeyName: "name", + Query: &serving.SortKeyFilter_Equals{ + Equals: &types.Value{Val: &types.Value_StringVal{StringVal: "John"}}, + }, + }, + } + + err = ValidateSortKeyFilters(validFilters, sortedViews) + assert.NoError(t, err, "Valid filters should not produce an error") } func TestValidateSortKeyFilters_EmptyFilters(t *testing.T) { diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index ca17cb6c669..37a4ef6d2b6 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -802,6 +802,8 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty } case types.ValueType_INT64: switch value.Val.(type) { + case *types.Value_Int32Val: + return &types.Value{Val: &types.Value_Int64Val{Int64Val: int64(value.GetInt32Val())}}, nil case *types.Value_Int64Val: return value, nil } @@ -817,6 +819,8 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty } case types.ValueType_DOUBLE: switch value.Val.(type) { + case *types.Value_FloatVal: + return &types.Value{Val: &types.Value_DoubleVal{DoubleVal: float64(value.GetFloatVal())}}, nil case *types.Value_DoubleVal: return value, nil } @@ -824,6 +828,8 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty switch value.Val.(type) { case *types.Value_UnixTimestampVal: return value, nil + case *types.Value_Int32Val: + return &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: int64(value.GetInt32Val())}}, nil case *types.Value_Int64Val: return &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: value.GetInt64Val()}}, nil } @@ -866,6 +872,13 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty } case types.ValueType_INT64_LIST: switch value.Val.(type) { + case *types.Value_Int32ListVal: + int32List := value.GetInt32ListVal().GetVal() + int64List := make([]int64, len(int32List)) + for i, v := range int32List { + int64List[i] = int64(v) + } + return &types.Value{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: int64List}}}, nil case *types.Value_Int64ListVal: return value, nil } @@ -886,6 +899,13 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty } case types.ValueType_DOUBLE_LIST: switch value.Val.(type) { + case *types.Value_FloatListVal: + floatList := value.GetFloatListVal().GetVal() + doubleList := make([]float64, len(floatList)) + for i, v := range floatList { + doubleList[i] = float64(v) + } + return &types.Value{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: doubleList}}}, nil case *types.Value_DoubleListVal: return value, nil } @@ -893,6 +913,13 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty switch value.Val.(type) { case *types.Value_UnixTimestampListVal: return value, nil + case *types.Value_Int32ListVal: + int32List := value.GetInt32ListVal().GetVal() + unixTimestampList := make([]int64, len(int32List)) + for i, v := range int32List { + unixTimestampList[i] = int64(v) + } + return &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: unixTimestampList}}}, nil case *types.Value_Int64ListVal: return &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: value.GetInt64ListVal().GetVal()}}}, nil } From e196c6a4bdd5a452717d08e007a18fb6d3a0877c 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 18/25] 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 | 4 + .../scylladb/scylladb_integration_test.go | 2 +- go/internal/feast/onlineserving/serving.go | 80 +++---- .../feast/onlineserving/serving_test.go | 2 + go/internal/feast/server/http_server.go | 45 ++-- go/internal/feast/server/http_server_test.go | 5 +- go/types/typeconversion.go | 215 ++++++++++-------- go/types/typeconversion_test.go | 195 ++++++---------- 8 files changed, 258 insertions(+), 290 deletions(-) diff --git a/go/internal/feast/featurestore_test.go b/go/internal/feast/featurestore_test.go index 705476f2851..04b30f3eafa 100644 --- a/go/internal/feast/featurestore_test.go +++ b/go/internal/feast/featurestore_test.go @@ -183,6 +183,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureView: "driver_stats", FeatureName: "conv_rate", Values: []interface{}{0.85, 0.87, 0.89}, + Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, EventTimestamps: []timestamp.Timestamp{ {Seconds: now.Unix() - 86400*3}, {Seconds: now.Unix() - 86400*2}, @@ -193,6 +194,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureView: "driver_stats", FeatureName: "acc_rate", Values: []interface{}{0.91, 0.92, 0.94}, + Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, EventTimestamps: []timestamp.Timestamp{ {Seconds: now.Unix() - 86400*3}, {Seconds: now.Unix() - 86400*2}, @@ -205,6 +207,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureView: "driver_stats", FeatureName: "conv_rate", Values: []interface{}{0.78, 0.80}, + Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, EventTimestamps: []timestamp.Timestamp{ {Seconds: now.Unix() - 86400*3}, {Seconds: now.Unix() - 86400*1}, @@ -214,6 +217,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { FeatureView: "driver_stats", FeatureName: "acc_rate", Values: []interface{}{0.85, 0.88}, + Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, 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 2d673274dcf..155826f19b9 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -729,13 +729,17 @@ func TransposeRangeFeatureRowsIntoColumns( } for _, rowIndex := range outputIndexes { - rangeValuesByRow[rowIndex] = &prototypes.RepeatedValue{Val: rangeValues} + if rangeValues == nil { + rangeValuesByRow[rowIndex] = nil + } else { + rangeValuesByRow[rowIndex] = &prototypes.RepeatedValue{Val: rangeValues} + } currentVector.RangeStatuses[rowIndex] = rangeStatuses currentVector.RangeTimestamps[rowIndex] = rangeTimestamps } } - arrowRangeValues, err := types.RepeatedProtoValuesToArrowArray(rangeValuesByRow, arrowAllocator, numRows) + arrowRangeValues, err := types.RepeatedProtoValuesToArrowArray(rangeValuesByRow, arrowAllocator) if err != nil { return nil, errors.GrpcFromError(err) } @@ -765,7 +769,6 @@ func processFeatureRowData( make([]*timestamppb.Timestamp, 0), nil } - featureData := featureData2D[rowEntityIndex][featureIndex] featureViewName := featureData.FeatureView @@ -774,51 +777,48 @@ func processFeatureRowData( return nil, nil, nil, errors.GrpcNotFoundErrorf("feature view '%s' not found in the provided sorted feature views", featureViewName) } - numValues := len(featureData.Values) - rangeValues := make([]*prototypes.Value, numValues) - rangeStatuses := make([]serving.FieldStatus, numValues) - rangeTimestamps := make([]*timestamppb.Timestamp, numValues) + if featureData.Values == nil { + rangeStatuses := make([]serving.FieldStatus, 1) + rangeStatuses[0] = serving.FieldStatus_NOT_FOUND + rangeTimestamps := make([]*timestamppb.Timestamp, 1) + rangeTimestamps[0] = ×tamppb.Timestamp{} + return nil, rangeStatuses, rangeTimestamps, nil + } else { + numValues := len(featureData.Values) + rangeValues := make([]*prototypes.Value, numValues) + 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] - // Explicitly set to nil if status is NOT_FOUND - if i < len(featureData.Statuses) && - (featureData.Statuses[i] == serving.FieldStatus_NOT_FOUND || - featureData.Statuses[i] == serving.FieldStatus_NULL_VALUE) { - rangeValues[i] = nil - } else { + if val == nil { + rangeValues[i] = nil + rangeStatuses[i] = featureData.Statuses[i] + rangeTimestamps[i] = eventTimestamp + continue + } + + protoVal, err := types.InterfaceToProtoValue(val) + if err != nil { + return nil, nil, nil, errors.GrpcInternalErrorf("error converting to ProtoValue for feature %s: %v", featureData.FeatureName, err) + } rangeValues[i] = protoVal - } - eventTimestamp := getEventTimestamp(featureData.EventTimestamps, i) + if eventTimestamp.GetSeconds() > 0 && checkOutsideTtl(eventTimestamp, timestamppb.Now(), sfv.FeatureView.Ttl) { + fieldStatus = serving.FieldStatus_OUTSIDE_MAX_AGE + } - status := serving.FieldStatus_PRESENT - if i < len(featureData.Statuses) { - status = featureData.Statuses[i] - } else if eventTimestamp.GetSeconds() > 0 && checkOutsideTtl(eventTimestamp, timestamppb.Now(), sfv.FeatureView.Ttl) { - status = serving.FieldStatus_OUTSIDE_MAX_AGE + rangeStatuses[i] = fieldStatus + rangeTimestamps[i] = eventTimestamp } - - rangeStatuses[i] = status - rangeTimestamps[i] = eventTimestamp + return rangeValues, rangeStatuses, rangeTimestamps, nil } - - return rangeValues, rangeStatuses, rangeTimestamps, nil } func getEventTimestamp(timestamps []timestamp.Timestamp, index int) *timestamppb.Timestamp { @@ -935,7 +935,7 @@ func EntitiesToRangeFeatureVectors( rangeTimestamps[idx] = []*timestamppb.Timestamp{timestamppb.Now()} } - arrowRangeValues, err := types.RepeatedProtoValuesToArrowArray(entityRangeValues, arrowAllocator, numRows) + arrowRangeValues, err := types.RepeatedProtoValuesToArrowArray(entityRangeValues, arrowAllocator) if err != nil { return nil, err } diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index a00a72152c6..e260b469167 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -1201,6 +1201,7 @@ func TestTransposeRangeFeatureRowsIntoColumns(t *testing.T) { FeatureView: "testView", FeatureName: "f1", Values: []interface{}{42.5, 43.2}, + Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT, serving.FieldStatus_PRESENT}, EventTimestamps: []timestamp.Timestamp{ {Seconds: nowTime.Unix()}, {Seconds: yesterdayTime.Unix()}, @@ -1212,6 +1213,7 @@ func TestTransposeRangeFeatureRowsIntoColumns(t *testing.T) { FeatureView: "testView", FeatureName: "f1", Values: []interface{}{99.9}, + Statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, 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 98ccd1f13c3..c157b0129ae 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 @@ -186,39 +187,23 @@ func processFeatureVectors( result["values"] = simplifiedValues if includeMetadata { - if len(vector.RangeStatuses) > 0 { - statusValues := make([][]string, len(vector.RangeStatuses)) - for j, entityStatuses := range vector.RangeStatuses { - statusValues[j] = make([]string, len(entityStatuses)) - for k, stat := range entityStatuses { - statusValues[j][k] = stat.String() - } + statusValues := make([][]string, len(vector.RangeStatuses)) + for j, entityStatuses := range vector.RangeStatuses { + statusValues[j] = make([]string, len(entityStatuses)) + for k, stat := range entityStatuses { + statusValues[j][k] = stat.String() } - result["statuses"] = statusValues - } else { - result["statuses"] = [][]string{} } + result["statuses"] = statusValues - if len(vector.RangeTimestamps) > 0 { - timestampValues := make([][]interface{}, len(vector.RangeTimestamps)) - for j, entityTimestamps := range vector.RangeTimestamps { - timestampValues[j] = make([]interface{}, len(entityTimestamps)) - for k, ts := range entityTimestamps { - if j < len(vector.RangeStatuses) && k < len(vector.RangeStatuses[j]) { - statusCode := vector.RangeStatuses[j][k] - if statusCode == serving.FieldStatus_NOT_FOUND || - statusCode == serving.FieldStatus_NULL_VALUE { - timestampValues[j][k] = nil - continue - } - } - timestampValues[j][k] = ts.AsTime().Format(time.RFC3339) - } + timestampValues := make([][]interface{}, len(vector.RangeTimestamps)) + for j, entityTimestamps := range vector.RangeTimestamps { + timestampValues[j] = make([]interface{}, len(entityTimestamps)) + for k, ts := range entityTimestamps { + timestampValues[j][k] = ts.AsTime().Format(time.RFC3339) } - result["event_timestamps"] = timestampValues - } else { - result["event_timestamps"] = [][]interface{}{} } + result["event_timestamps"] = timestampValues } results = append(results, result) diff --git a/go/internal/feast/server/http_server_test.go b/go/internal/feast/server/http_server_test.go index 6cdeeace77b..a3d353cfd13 100644 --- a/go/internal/feast/server/http_server_test.go +++ b/go/internal/feast/server/http_server_test.go @@ -10,6 +10,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "strings" "testing" + "time" "github.com/apache/arrow/go/v17/arrow" "github.com/apache/arrow/go/v17/arrow/array" @@ -364,7 +365,7 @@ func TestProcessFeatureVectors_TimestampHandling(t *testing.T) { assert.NoError(t, err, "Error processing feature vectors") assert.Equal(t, []string{"feature_3"}, featureNames) timestamps := results[0]["event_timestamps"].([][]interface{}) - assert.Nil(t, timestamps[0][0]) + assert.Equal(t, "1970-01-01T00:00:00Z", timestamps[0][0]) assert.Equal(t, "1970-01-01T00:00:00Z", timestamps[1][0]) } @@ -418,5 +419,5 @@ func TestProcessFeatureVectors_NullValueReturnsNull(t *testing.T) { assert.Nil(t, entityFeatureValues[0]) timestamps := results[0]["event_timestamps"].([][]interface{}) - assert.Nil(t, timestamps[0][0]) + assert.Equal(t, time.Unix(1234567890, 0).UTC().Format(time.RFC3339), timestamps[0][0], "Expected timestamp to be zero for null value") } diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index 37a4ef6d2b6..bc8bd020234 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -348,92 +348,76 @@ func ProtoValuesToArrowArray(protoValues []*types.Value, arrowAllocator memory.A } func ArrowValuesToRepeatedProtoValues(arr arrow.Array) ([]*types.RepeatedValue, error) { + if arr.Len() == 0 && arr.DataType() == arrow.Null { + return []*types.RepeatedValue{nil}, nil + } repeatedValues := make([]*types.RepeatedValue, 0, arr.Len()) - if listArr, ok := arr.(*array.List); ok { - listValues := listArr.ListValues() - offsets := listArr.Offsets()[1:] - pos := 0 - for i := 0; i < listArr.Len(); i++ { - if listArr.IsNull(i) { - repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: make([]*types.Value, 0)}) - continue - } - - values := make([]*types.Value, 0, int(offsets[i])-pos) + listArray := arr.(*array.List) + listValues := listArray.ListValues() - 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 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]) } - - return repeatedValues, nil - } - - protoValues, err := ArrowValuesToProtoValues(arr) - if err != nil { - return nil, fmt.Errorf("error converting values to proto Values: %v", err) - } - - for _, val := range protoValues { - repeatedValues = append(repeatedValues, &types.RepeatedValue{Val: []*types.Value{val}}) } - return repeatedValues, nil } -func RepeatedProtoValuesToArrowArray(repeatedValues []*types.RepeatedValue, allocator memory.Allocator, numRows int) (arrow.Array, error) { - if len(repeatedValues) == 0 { - return array.NewNull(numRows), nil - } - +func RepeatedProtoValuesToArrowArray(repeatedValues []*types.RepeatedValue, allocator memory.Allocator) (arrow.Array, error) { var valueType arrow.DataType var protoValue *types.Value + var err error + // Find the first non-nil proto value in repeatedValues for _, rv := range repeatedValues { if rv != nil && len(rv.Val) > 0 { for _, val := range rv.Val { @@ -448,37 +432,61 @@ func RepeatedProtoValuesToArrowArray(repeatedValues []*types.RepeatedValue, allo } } - if protoValue == nil { - return array.NewNull(numRows), nil - } + if protoValue != nil { + // Determine the value type from the first non-nil proto value + valueType, err = ProtoTypeToArrowType(protoValue) + if err != nil { + return nil, err + } - var err error - valueType, err = ProtoTypeToArrowType(protoValue) - if err != nil { - return nil, err - } + listBuilder := array.NewListBuilder(allocator, valueType) + defer listBuilder.Release() + valueBuilder := listBuilder.ValueBuilder() - listBuilder := array.NewListBuilder(allocator, valueType) - defer listBuilder.Release() - valueBuilder := listBuilder.ValueBuilder() + for _, repeatedValue := range repeatedValues { + if repeatedValue == nil { + listBuilder.AppendNull() + continue + } - for _, repeatedValue := range repeatedValues { - listBuilder.Append(true) + if len(repeatedValue.Val) == 0 && repeatedValue.Val == nil { + return nil, fmt.Errorf("represent it as an empty array instead of nil") + } + listBuilder.Append(true) - if repeatedValue == nil || len(repeatedValue.Val) == 0 { - continue - } - 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 - for i := len(repeatedValues); i < numRows; i++ { - listBuilder.Append(true) - } + } else { + if len(repeatedValues) == 0 { + return array.NewNull(0), nil + } else { + nullListBuilder := array.NewListBuilder(allocator, arrow.Null) + defer nullListBuilder.Release() + nullValueBuilder := nullListBuilder.ValueBuilder() + for _, repeatedVal := range repeatedValues { + + if repeatedVal == nil { + nullListBuilder.AppendNull() + continue + } + + if len(repeatedVal.Val) == 0 && repeatedVal.Val == nil { + return nil, fmt.Errorf("represent it as an empty array instead of nil") + } - return listBuilder.NewArray(), nil + nullListBuilder.Append(true) + for _, _ = range repeatedVal.Val { + nullValueBuilder.AppendNull() + } + } + return nullListBuilder.NewArray(), nil + } + } } func InterfaceToProtoValue(val interface{}) (*types.Value, error) { @@ -728,18 +736,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 { @@ -754,6 +783,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 c985636d4db..d791f849fa3 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -49,6 +49,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}}}}, @@ -88,9 +89,12 @@ var ( var ( REPEATED_PROTO_VALUES = []*types.RepeatedValue{ - {Val: []*types.Value{}}, + nil, + {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{{}, {}}}, + {Val: []*types.Value{{Val: &types.Value_Int32Val{}}}}, {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}}}, {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, {Val: &types.Value_Int32Val{Int32Val: 20}}}}, {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, nil_or_null_val}}, @@ -130,13 +134,16 @@ var ( var ( MULTIPLE_REPEATED_PROTO_VALUES = [][]*types.RepeatedValue{ { - {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}}}, + // 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 }, { - {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}}}, - {Val: []*types.Value{nil_or_null_val}}, + {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, {Val: &types.Value_Int32Val{Int32Val: 20}}}}, {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 30}}}}, + {Val: []*types.Value{}}, }, { {Val: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 100}}}}, @@ -159,7 +166,7 @@ var ( }, { {Val: []*types.Value{{Val: &types.Value_BoolVal{BoolVal: true}}}}, - {Val: []*types.Value{nil_or_null_val}}, + {Val: []*types.Value{}}, {Val: []*types.Value{{Val: &types.Value_BoolVal{BoolVal: false}}}}, }, { @@ -173,10 +180,9 @@ var ( { {Val: []*types.Value{ {Val: &types.Value_Int32Val{Int32Val: 10}}, - nil_or_null_val, {Val: &types.Value_Int32Val{Int32Val: 30}}, }}, - {Val: []*types.Value{nil_or_null_val}}, + {Val: []*types.Value{}}, {Val: []*types.Value{ {Val: &types.Value_Int32Val{Int32Val: 40}}, {Val: &types.Value_Int32Val{Int32Val: 50}}, @@ -185,34 +191,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{}}, }, } ) @@ -240,11 +269,25 @@ func protoValuesEquals(t *testing.T, a, b []*types.Value) { } } +func protoRepeatedValueEquals(t *testing.T, a *types.RepeatedValue, b *types.RepeatedValue, index int) { + assert.Truef(t, proto.Equal(a, b), + "Values are not equal for testcase[%d]. Diff %v != %v", index, a, b) +} + +func protoRepeatedValuesEquals(t *testing.T, a, b []*types.RepeatedValue, index int) { + assert.Equal(t, len(a), len(b)) + + for idx, left := range a { + assert.Truef(t, proto.Equal(left, b[idx]), + "Arrays are not equal for testcase[%d]. Diff[%d] %v != %v", index, idx, left, b[idx]) + } +} + func TestRepeatedValueRoundTrip(t *testing.T) { pool := memory.NewGoAllocator() for i, repeatedValue := range REPEATED_PROTO_VALUES { - arrowArray, err := RepeatedProtoValuesToArrowArray([]*types.RepeatedValue{repeatedValue}, pool, 1) + arrowArray, err := RepeatedProtoValuesToArrowArray([]*types.RepeatedValue{repeatedValue}, pool) assert.Nil(t, err, "Error creating Arrow array for case %d", i) result, err := ArrowValuesToRepeatedProtoValues(arrowArray) @@ -252,45 +295,7 @@ func TestRepeatedValueRoundTrip(t *testing.T) { assert.Equal(t, 1, len(result), "Should have 1 result for case %d", i) - if len(repeatedValue.Val) == 0 { - if len(result[0].Val) > 1 { - t.Errorf("Case %d: Expected empty value or single null, got %d values", - i, len(result[0].Val)) - } else if len(result[0].Val) == 1 && result[0].Val[0] != nil && result[0].Val[0].Val != nil { - t.Errorf("Case %d: Expected null value, got a non-null value", i) - } - continue - } - - for j := 0; j < len(repeatedValue.Val); j++ { - if repeatedValue.Val[j] != nil && repeatedValue.Val[j].Val != nil { - if j >= len(result[0].Val) { - continue - } - - switch v := repeatedValue.Val[j].Val.(type) { - case *types.Value_FloatVal: - if math.IsNaN(float64(v.FloatVal)) { - assert.True(t, math.IsNaN(float64(result[0].Val[j].GetFloatVal())), - "Float NaN not preserved at index %d in case %d", j, i) - } else { - assert.Equal(t, v.FloatVal, result[0].Val[j].GetFloatVal(), - "Float value mismatch at index %d in case %d", j, i) - } - case *types.Value_DoubleVal: - if math.IsNaN(v.DoubleVal) { - assert.True(t, math.IsNaN(result[0].Val[j].GetDoubleVal()), - "Double NaN not preserved at index %d in case %d", j, i) - } else { - assert.Equal(t, v.DoubleVal, result[0].Val[j].GetDoubleVal(), - "Double value mismatch at index %d in case %d", j, i) - } - default: - assert.True(t, proto.Equal(repeatedValue.Val[j], result[0].Val[j]), - "Value mismatch at index %d in case %d", j, i) - } - } - } + protoRepeatedValueEquals(t, repeatedValue, result[0], i) } } @@ -298,7 +303,7 @@ func TestMultipleRepeatedValueRoundTrip(t *testing.T) { pool := memory.NewGoAllocator() for i, batch := range MULTIPLE_REPEATED_PROTO_VALUES { - arrowArray, err := RepeatedProtoValuesToArrowArray(batch, pool, len(batch)) + arrowArray, err := RepeatedProtoValuesToArrowArray(batch, pool) assert.Nil(t, err, "Error creating Arrow array for batch %d", i) results, err := ArrowValuesToRepeatedProtoValues(arrowArray) @@ -307,50 +312,7 @@ func TestMultipleRepeatedValueRoundTrip(t *testing.T) { assert.Equal(t, len(batch), len(results), "Row count mismatch for batch %d", i) - for j := 0; j < len(batch); j++ { - original := batch[j] - result := results[j] - - if len(original.Val) == 0 { - if len(result.Val) > 1 { - t.Errorf("Batch %d, row %d: Expected empty value or single null, got %d values", - i, j, len(result.Val)) - } else if len(result.Val) == 1 && result.Val[0] != nil && result.Val[0].Val != nil { - t.Errorf("Batch %d, row %d: Expected null value, got a non-null value", i, j) - } - continue - } - - for k := 0; k < len(original.Val); k++ { - if original.Val[k] != nil && original.Val[k].Val != nil { - if k >= len(result.Val) { - continue - } - - switch v := original.Val[k].Val.(type) { - case *types.Value_FloatVal: - if math.IsNaN(float64(v.FloatVal)) { - assert.True(t, math.IsNaN(float64(result.Val[k].GetFloatVal())), - "Float NaN not preserved in batch %d, row %d, index %d", i, j, k) - } else { - assert.Equal(t, v.FloatVal, result.Val[k].GetFloatVal(), - "Float value mismatch in batch %d, row %d, index %d", i, j, k) - } - case *types.Value_DoubleVal: - if math.IsNaN(v.DoubleVal) { - assert.True(t, math.IsNaN(result.Val[k].GetDoubleVal()), - "Double NaN not preserved in batch %d, row %d, index %d", i, j, k) - } else { - assert.Equal(t, v.DoubleVal, result.Val[k].GetDoubleVal(), - "Double value mismatch in batch %d, row %d, index %d", i, j, k) - } - default: - assert.True(t, proto.Equal(original.Val[k], result.Val[k]), - "Value mismatch in batch %d, row %d, index %d", i, j, k) - } - } - } - } + protoRepeatedValuesEquals(t, batch, results, i) } } @@ -358,7 +320,6 @@ func TestEmptyAndNullRepeatedValues(t *testing.T) { pool := memory.NewGoAllocator() testCases := [][]*types.RepeatedValue{ - {}, {{Val: []*types.Value{}}}, {{Val: []*types.Value{}}, {Val: []*types.Value{}}}, {{Val: []*types.Value{nil_or_null_val}}}, @@ -367,7 +328,7 @@ func TestEmptyAndNullRepeatedValues(t *testing.T) { } for i, testCase := range testCases { - arrowArray, err := RepeatedProtoValuesToArrowArray(testCase, pool, len(testCase)) + arrowArray, err := RepeatedProtoValuesToArrowArray(testCase, pool) assert.Nil(t, err, "Error creating Arrow array for case %d", i) result, err := ArrowValuesToRepeatedProtoValues(arrowArray) @@ -393,38 +354,6 @@ func TestEmptyAndNullRepeatedValues(t *testing.T) { } } -func TestProtoValuesToRepeatedConversion(t *testing.T) { - pool := memory.NewGoAllocator() - - testCases := [][]*types.Value{ - {{Val: &types.Value_Int32Val{Int32Val: 10}}, {Val: &types.Value_Int32Val{Int32Val: 20}}}, - {{Val: &types.Value_StringVal{StringVal: "test"}}}, - {nil_or_null_val, {Val: &types.Value_BoolVal{BoolVal: true}}}, - } - - for i, protoValues := range testCases { - arrowArray, err := ProtoValuesToArrowArray(protoValues, pool, len(protoValues)) - assert.Nil(t, err, "Error creating Arrow array for case %d", i) - - result, err := ArrowValuesToRepeatedProtoValues(arrowArray) - assert.Nil(t, err, "Error converting to RepeatedProtoValues for case %d", i) - assert.Equal(t, len(protoValues), len(result), - "Result count mismatch for case %d", i) - - for j := 0; j < len(protoValues); j++ { - if protoValues[j] != nil && protoValues[j].Val != nil { - assert.Equal(t, 1, len(result[j].Val), - "Expected single value in RepeatedValue for case %d, row %d", i, j) - - if len(result[j].Val) > 0 { - assert.True(t, proto.Equal(protoValues[j], result[j].Val[0]), - "Value mismatch in case %d, row %d", i, j) - } - } - } - } -} - func TestInterfaceToProtoValue(t *testing.T) { testTime := time.Now() testCases := []struct { @@ -484,6 +413,8 @@ func TestValueTypeToGoType(t *testing.T) { {Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: []int64{timestamp.Unix(), timestamp.Unix() + 3600}}}}, {Val: &types.Value_NullVal{NullVal: types.Null_NULL}}, nil, + {}, + {Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: []int32{}}}}, } expectedTypes := []interface{}{ @@ -505,6 +436,10 @@ func TestValueTypeToGoType(t *testing.T) { []time.Time{timestamp, timestamp.Add(3600 * time.Second)}, nil, nil, + nil, + nil, + nil, + nil, } for i, testCase := range testCases { @@ -518,6 +453,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{}{ @@ -526,6 +463,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 0e5e7042e7343019da0c5f7ae49868c16a7a4013 Mon Sep 17 00:00:00 2001 From: piket Date: Mon, 14 Jul 2025 15:27:22 -0700 Subject: [PATCH 19/25] fix: When given a string for a bytes entity/sort key filter, attempt to convert as base64 string (#285) --- go/types/typeconversion.go | 14 ++++++++++++-- go/types/typeconversion_test.go | 6 ++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index bc8bd020234..11b4a1f63f8 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -1,6 +1,7 @@ package types import ( + "encoding/base64" "fmt" "google.golang.org/protobuf/types/known/timestamppb" "math" @@ -797,6 +798,15 @@ func valueTypeToGoTypeTimestampAsString(value *types.Value, timestampAsString bo } } +func transformStringToBytes(str string) []byte { + bytes, decodeErr := base64.StdEncoding.DecodeString(str) + if decodeErr != nil { + // If base64 decoding fails, try as a byte string + bytes = []byte(str) + } + return bytes +} + func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*types.Value, error) { if valueType != types.ValueType_NULL { if value == nil || value.Val == nil { @@ -821,7 +831,7 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty case *types.Value_BytesVal: return value, nil case *types.Value_StringVal: - return &types.Value{Val: &types.Value_BytesVal{BytesVal: []byte(value.GetStringVal())}}, nil + return &types.Value{Val: &types.Value_BytesVal{BytesVal: transformStringToBytes(value.GetStringVal())}}, nil } case types.ValueType_INT32: switch value.Val.(type) { @@ -884,7 +894,7 @@ func ConvertToValueType(value *types.Value, valueType types.ValueType_Enum) (*ty stringList := value.GetStringListVal().GetVal() bytesList := make([][]byte, len(stringList)) for i, str := range stringList { - bytesList[i] = []byte(str) + bytesList[i] = transformStringToBytes(str) } return &types.Value{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: bytesList}}}, nil } diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index d791f849fa3..9e5187c9b24 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -500,7 +500,8 @@ func TestConvertToValueType_Bytes(t *testing.T) { }{ {input: &types.Value{Val: &types.Value_BytesVal{BytesVal: []byte{1, 2, 3}}}, expected: []byte{1, 2, 3}}, {input: &types.Value{Val: &types.Value_BytesVal{BytesVal: nil}}, expected: []byte(nil)}, - {input: &types.Value{Val: &types.Value_StringVal{StringVal: "test"}}, expected: []byte("test")}, + {input: &types.Value{Val: &types.Value_StringVal{StringVal: "\u0001\u0002\u0003"}}, expected: []byte{1, 2, 3}}, + {input: &types.Value{Val: &types.Value_StringVal{StringVal: "dGVzdA=="}}, expected: []byte("test")}, } for _, tc := range testCases { @@ -632,7 +633,8 @@ func TestConvertToValueType_BytesList(t *testing.T) { }{ {input: &types.Value{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{{1, 2}, {3, 4}}}}}, expected: [][]byte{{1, 2}, {3, 4}}}, {input: &types.Value{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: [][]byte{}}}}, expected: [][]byte{}}, - {input: &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"a", "b", "c"}}}}, expected: [][]byte{[]byte("a"), []byte("b"), []byte("c")}}, + {input: &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"\u0001\u0002", "\u0003\u0004"}}}}, expected: [][]byte{{1, 2}, {3, 4}}}, + {input: &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{"YQ==", "Yg==", "Yw=="}}}}, expected: [][]byte{[]byte("a"), []byte("b"), []byte("c")}}, {input: &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: []string{}}}}, expected: [][]byte{}}, } From e139a7ab024c669ed96270ca4470f7a6a00953b5 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 20/25] 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 | 266 ++++++++++-------- go/types/typeconversion_test.go | 18 +- 3 files changed, 152 insertions(+), 136 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 11b4a1f63f8..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 } @@ -348,64 +303,154 @@ func ProtoValuesToArrowArray(protoValues []*types.Value, arrowAllocator memory.A } } +// Handles conversion for nested list case for Arrow Array List Builders +func arrowNestedListToRepeatedValues(arrayListVals *array.List, start, end int) ([]*types.Value, error) { + repeatedValue := make([]*types.Value, 0, end-start) + for j := start; j < end; j++ { + valStart, valEnd := arrayListVals.ValueOffsets(j) + if !arrayListVals.IsValid(j) { + repeatedValue = append(repeatedValue, &types.Value{}) + continue + } + listVals := arrayListVals.ListValues() + protoVal, err := arrowListValuesToProtoValue(listVals, valStart, valEnd) + if err != nil { + return nil, err + } + repeatedValue = append(repeatedValue, protoVal) + } + return repeatedValue, nil +} + +// Handles conversion for primitive type case for Arrow Array List Builders +func arrowPrimitiveListToRepeatedValues(arrayListValues arrow.Array, start int, end int) ([]*types.Value, error) { + values := make([]*types.Value, 0, end-start) + for j := start; j < end; j++ { + var protoVal *types.Value + if arrayListValues.IsNull(j) { + protoVal = &types.Value{} + } else { + var err error + protoVal, err = arrowPrimitiveValueToProtoValue(arrayListValues, j) + if err != nil { + return nil, err + } + } + values = append(values, protoVal) + } + return values, nil +} + +// Converts Arrow list values to proto Value for nested lists +func arrowListValuesToProtoValue(listVals arrow.Array, valStart int64, valEnd int64) (*types.Value, error) { + switch listVals.DataType() { + case arrow.PrimitiveTypes.Int32: + vals := make([]int32, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = listVals.(*array.Int32).Value(k + int(valStart)) + } + return &types.Value{Val: &types.Value_Int32ListVal{Int32ListVal: &types.Int32List{Val: vals}}}, nil + case arrow.PrimitiveTypes.Int64: + vals := make([]int64, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = listVals.(*array.Int64).Value(k + int(valStart)) + } + return &types.Value{Val: &types.Value_Int64ListVal{Int64ListVal: &types.Int64List{Val: vals}}}, nil + case arrow.PrimitiveTypes.Float32: + vals := make([]float32, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = listVals.(*array.Float32).Value(k + int(valStart)) + } + return &types.Value{Val: &types.Value_FloatListVal{FloatListVal: &types.FloatList{Val: vals}}}, nil + case arrow.PrimitiveTypes.Float64: + vals := make([]float64, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = listVals.(*array.Float64).Value(k + int(valStart)) + } + return &types.Value{Val: &types.Value_DoubleListVal{DoubleListVal: &types.DoubleList{Val: vals}}}, nil + case arrow.BinaryTypes.Binary: + vals := make([][]byte, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = listVals.(*array.Binary).Value(k + int(valStart)) + } + return &types.Value{Val: &types.Value_BytesListVal{BytesListVal: &types.BytesList{Val: vals}}}, nil + case arrow.BinaryTypes.String: + vals := make([]string, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = listVals.(*array.String).Value(k + int(valStart)) + } + return &types.Value{Val: &types.Value_StringListVal{StringListVal: &types.StringList{Val: vals}}}, nil + case arrow.FixedWidthTypes.Boolean: + vals := make([]bool, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = listVals.(*array.Boolean).Value(k + int(valStart)) + } + return &types.Value{Val: &types.Value_BoolListVal{BoolListVal: &types.BoolList{Val: vals}}}, nil + case arrow.FixedWidthTypes.Timestamp_s: + vals := make([]int64, valEnd-valStart) + for k := 0; k < int(valEnd-valStart); k++ { + vals[k] = int64(listVals.(*array.Timestamp).Value(k + int(valStart))) + } + return &types.Value{Val: &types.Value_UnixTimestampListVal{UnixTimestampListVal: &types.Int64List{Val: vals}}}, nil + default: + return nil, fmt.Errorf("unsupported data type in list: %s", listVals.DataType()) + } +} + +// Converts Arrow primitive value to proto Value +func arrowPrimitiveValueToProtoValue(arr arrow.Array, idx int) (*types.Value, error) { + switch arr.DataType() { + case arrow.PrimitiveTypes.Int32: + return &types.Value{Val: &types.Value_Int32Val{Int32Val: arr.(*array.Int32).Value(idx)}}, nil + case arrow.PrimitiveTypes.Int64: + return &types.Value{Val: &types.Value_Int64Val{Int64Val: arr.(*array.Int64).Value(idx)}}, nil + case arrow.PrimitiveTypes.Float32: + return &types.Value{Val: &types.Value_FloatVal{FloatVal: arr.(*array.Float32).Value(idx)}}, nil + case arrow.PrimitiveTypes.Float64: + return &types.Value{Val: &types.Value_DoubleVal{DoubleVal: arr.(*array.Float64).Value(idx)}}, nil + case arrow.BinaryTypes.Binary: + return &types.Value{Val: &types.Value_BytesVal{BytesVal: arr.(*array.Binary).Value(idx)}}, nil + case arrow.BinaryTypes.String: + return &types.Value{Val: &types.Value_StringVal{StringVal: arr.(*array.String).Value(idx)}}, nil + case arrow.FixedWidthTypes.Boolean: + return &types.Value{Val: &types.Value_BoolVal{BoolVal: arr.(*array.Boolean).Value(idx)}}, nil + case arrow.FixedWidthTypes.Timestamp_s: + return &types.Value{Val: &types.Value_UnixTimestampVal{UnixTimestampVal: int64(arr.(*array.Timestamp).Value(idx))}}, nil + case arrow.Null: + return &types.Value{}, nil + default: + return nil, fmt.Errorf("unsupported data type in list: %s", arr.DataType()) + } +} + func ArrowValuesToRepeatedProtoValues(arr arrow.Array) ([]*types.RepeatedValue, error) { if arr.Len() == 0 && arr.DataType() == arrow.Null { return []*types.RepeatedValue{nil}, nil } 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}) } @@ -737,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 { @@ -784,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 9e5187c9b24..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{{}, {}}}, @@ -100,6 +101,7 @@ var ( {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, nil_or_null_val}}, {Val: []*types.Value{nil_or_null_val, {Val: &types.Value_Int32Val{Int32Val: 20}}}}, {Val: []*types.Value{{Val: &types.Value_Int32Val{Int32Val: 10}}, nil_or_null_val, {Val: &types.Value_Int32Val{Int32Val: 30}}}}, + {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{}}}, {}}}, {Val: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 10}}}}, {Val: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 10}}, {Val: &types.Value_Int64Val{Int64Val: 20}}}}, {Val: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 10}}, nil_or_null_val}}, @@ -134,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 @@ -197,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}}}}}}, @@ -437,14 +435,12 @@ func TestValueTypeToGoType(t *testing.T) { nil, nil, nil, - nil, - nil, - nil, + []int32{}, } for i, testCase := range testCases { actual := ValueTypeToGoType(testCase) - assert.Equal(t, expectedTypes[i], actual) + assert.Equal(t, expectedTypes[i], actual, "Expected type mismatch for test case %d", i) } } From 011840706bf174b12311954bad1cbde536b72908 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 21/25] =?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 --- Makefile | 10 ++- go/internal/feast/server/grpc_server.go | 16 ++++ go/internal/feast/server/grpc_server_test.go | 30 +++++++ go/internal/feast/server/http_server.go | 22 +++++ go/internal/feast/server/hybrid_server.go | 12 ++- go/internal/feast/server/server_commons.go | 4 + go/internal/feast/version/version_info.go | 31 +++++++ go/main.go | 93 ++++++++++++++++---- protos/feast/serving/ServingService.proto | 12 +++ 9 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 go/internal/feast/version/version_info.go diff --git a/Makefile b/Makefile index e9d98c5c411..f6f1a11b05c 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,10 @@ endif TRINO_VERSION ?= 376 PYTHON_VERSION = ${shell python --version | grep -Eo '[0-9]\.[0-9]+'} +COMMIT = $(shell git rev-parse HEAD) +BUILD_TIME = $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +FEATURE_SERVER_VERSION = dev-$(shell git rev-parse --abbrev-ref HEAD)-$(shell date -u +%Y%m%d%H%M%S) + # General format: format-python format-java format-go @@ -435,7 +439,11 @@ install-feast-ci-locally: pip install -e ".[ci]" build-go: compile-protos-go - go build -o feast ./go/main.go + go build -o feast \ + -ldflags "-X github.com/feast-dev/feast/go/internal/feast/version.Version=$(FEATURE_SERVER_VERSION) \ + -X github.com/feast-dev/feast/go/internal/feast/version.CommitHash=$(COMMIT) \ + -X github.com/feast-dev/feast/go/internal/feast/version.BuildTime=$(BUILD_TIME)" \ + ./go/main.go 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 diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index 4defac389f7..11e2c498613 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -6,6 +6,7 @@ import ( "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" + "github.com/feast-dev/feast/go/internal/feast/version" "github.com/feast-dev/feast/go/protos/feast/serving" prototypes "github.com/feast-dev/feast/go/protos/feast/types" "github.com/feast-dev/feast/go/types" @@ -39,6 +40,21 @@ func (s *grpcServingServiceServer) GetFeastServingInfo(ctx context.Context, requ }, nil } +// GetVersionInfo Returns GO Binary Version Information +func (s *grpcServingServiceServer) GetVersionInfo(ctx context.Context, request *serving.GetVersionInfoRequest) (*serving.GetVersionInfoResponse, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "gerVersionInfo", tracer.ResourceName("ServingService/GetVersionInfo")) + defer span.Finish() + + versionInfo := version.GetVersionInfo() + return &serving.GetVersionInfoResponse{ + Version: versionInfo.Version, + BuildTime: versionInfo.BuildTime, + CommitHash: versionInfo.CommitHash, + GoVersion: versionInfo.GoVersion, + ServerType: versionInfo.ServerType, + }, nil +} + // GetOnlineFeatures Returns an object containing the response to GetOnlineFeatures. // Metadata contains feature names that corresponds to the number of rows in response.Results. // Results contains values including the value of the feature, the event timestamp, and feature status in a columnar format. diff --git a/go/internal/feast/server/grpc_server_test.go b/go/internal/feast/server/grpc_server_test.go index ac8a9fab069..2a06ec53358 100644 --- a/go/internal/feast/server/grpc_server_test.go +++ b/go/internal/feast/server/grpc_server_test.go @@ -17,6 +17,7 @@ import ( "github.com/apache/arrow/go/v17/arrow/memory" "github.com/apache/arrow/go/v17/parquet/file" "github.com/apache/arrow/go/v17/parquet/pqarrow" + "github.com/feast-dev/feast/go/internal/feast/version" "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" @@ -208,3 +209,32 @@ func GetExpectedLogRows(featureNames []string, results []*serving.GetOnlineFeatu } return featureValueLogRows, featureStatusLogRows, eventTimestampLogRows } + +func TestGetVersionInfoReturnsCorrectVersionInfo(t *testing.T) { + ctx := context.Background() + server := &grpcServingServiceServer{} + resp, err := server.GetVersionInfo(ctx, &serving.GetVersionInfoRequest{}) + require.Nil(t, err) + require.NotNil(t, resp) + expected := version.GetVersionInfo() + assert.Equal(t, expected.Version, resp.Version) + assert.Equal(t, expected.BuildTime, resp.BuildTime) + assert.Equal(t, expected.CommitHash, resp.CommitHash) + assert.Equal(t, expected.GoVersion, resp.GoVersion) + assert.Equal(t, expected.ServerType, resp.ServerType) +} + +func TestGetVersionInfoHandlesNilContext(t *testing.T) { + server := &grpcServingServiceServer{} + resp, err := server.GetVersionInfo(nil, &serving.GetVersionInfoRequest{}) + require.Nil(t, err) + require.NotNil(t, resp) +} + +func TestGetVersionInfoHandlesNilRequest(t *testing.T) { + ctx := context.Background() + server := &grpcServingServiceServer{} + resp, err := server.GetVersionInfo(ctx, nil) + require.Nil(t, err) + require.NotNil(t, resp) +} diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index c157b0129ae..f72200f7a3f 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/hybrid_server.go b/go/internal/feast/server/hybrid_server.go index e80866d42db..dac40f26c7b 100644 --- a/go/internal/feast/server/hybrid_server.go +++ b/go/internal/feast/server/hybrid_server.go @@ -27,18 +27,22 @@ func combinedHealthCheck(port int) http.HandlerFunc { defer cancel() target := fmt.Sprintf("localhost:%d", port) - conn, err := grpc.DialContext( - ctx, + conn, err := grpc.NewClient( target, grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), ) if err != nil { http.Error(w, fmt.Sprintf("gRPC server connectivity check failed: %v", err), http.StatusServiceUnavailable) return } - defer conn.Close() + defer func(conn *grpc.ClientConn) { + err := conn.Close() + if err != nil { + http.Error(w, fmt.Sprintf("failed to close gRPC connection: %v", err), http.StatusInternalServerError) + return + } + }(conn) hc := healthpb.NewHealthClient(conn) resp, err := hc.Check(ctx, &healthpb.HealthCheckRequest{Service: ""}) diff --git a/go/internal/feast/server/server_commons.go b/go/internal/feast/server/server_commons.go index 03040f070cd..25f4290645e 100644 --- a/go/internal/feast/server/server_commons.go +++ b/go/internal/feast/server/server_commons.go @@ -31,6 +31,10 @@ func CommonHttpHandlers(s *httpServer, healthCheckHandler http.HandlerFunc) []Ha path: "/get-online-features-range", handlerFunc: recoverMiddleware(http.HandlerFunc(s.getOnlineFeaturesRange)), }, + { + path: "/version", + handlerFunc: recoverMiddleware(http.HandlerFunc(s.getVersion)), + }, { path: "/metrics", handlerFunc: promhttp.Handler(), diff --git a/go/internal/feast/version/version_info.go b/go/internal/feast/version/version_info.go new file mode 100644 index 00000000000..a9e1a51f120 --- /dev/null +++ b/go/internal/feast/version/version_info.go @@ -0,0 +1,31 @@ +package version + +import ( + "runtime" +) + +var ( + Version = "dev" + BuildTime = "unknown" + CommitHash = "none" + GoVersion = runtime.Version() + ServerType = "none" +) + +type Info struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + CommitHash string `json:"commit_hash"` + GoVersion string `json:"go_version"` + ServerType string `json:"server_type"` +} + +func GetVersionInfo() *Info { + return &Info{ + Version: Version, + BuildTime: BuildTime, + CommitHash: CommitHash, + GoVersion: GoVersion, + ServerType: ServerType, + } +} diff --git a/go/main.go b/go/main.go index 26b9c5dfb87..bd88971586a 100644 --- a/go/main.go +++ b/go/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "github.com/DataDog/datadog-go/v5/statsd" "net" "net/http" "os" @@ -14,6 +15,7 @@ import ( "github.com/feast-dev/feast/go/internal/feast/registry" "github.com/feast-dev/feast/go/internal/feast/server" "github.com/feast-dev/feast/go/internal/feast/server/logging" + "github.com/feast-dev/feast/go/internal/feast/version" "github.com/rs/zerolog/log" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -47,21 +49,41 @@ func main() { host := "" port := 8080 grpcPort := 6566 - server := RealServerStarter{} + serverStarter := RealServerStarter{} + printVersion := false // Current Directory repoPath, err := os.Getwd() if err != nil { log.Error().Stack().Err(err).Msg("Failed to get current directory") } - flag.StringVar(&serverType, "type", serverType, "Specify the server type (http, grpc, or hybrid)") + flag.StringVar(&serverType, "type", serverType, "Specify the serverStarter type (http, grpc, or hybrid)") flag.StringVar(&repoPath, "chdir", repoPath, "Repository path where feature store yaml file is stored") - flag.StringVar(&host, "host", host, "Specify a host for the server") - flag.IntVar(&port, "port", port, "Specify a port for the server") - flag.IntVar(&grpcPort, "grpcPort", grpcPort, "Specify a grpc port for the server") + flag.StringVar(&host, "host", host, "Specify a host for the serverStarter") + flag.IntVar(&port, "port", port, "Specify a port for the serverStarter") + flag.IntVar(&grpcPort, "grpcPort", grpcPort, "Specify a grpc port for the serverStarter") + flag.BoolVar(&printVersion, "version", printVersion, "Print the version information and exit") flag.Parse() + version.ServerType = serverType + + versionInfo := version.GetVersionInfo() + + if printVersion && flag.NFlag() == 1 && flag.NArg() == 0 { + fmt.Printf("Feature Server Version: %s\nBuild Time: %s\nCommit Hash: %s\nGo Version: %s\n", + versionInfo.Version, versionInfo.BuildTime, versionInfo.CommitHash, versionInfo.GoVersion) + os.Exit(0) + } + + log.Info().Msgf("Feature Server Version: %s", versionInfo.Version) + log.Info().Msgf("Build Time: %s", versionInfo.BuildTime) + log.Info().Msgf("Commit Hash: %s", versionInfo.CommitHash) + log.Info().Msgf("Go Version: %s", versionInfo.GoVersion) + log.Info().Msgf("Server Type: %s", versionInfo.ServerType) + + go publishVersionInfoToDatadog(versionInfo) + repoConfig, err := registry.NewRepoConfigFromFile(repoPath) if err != nil { log.Fatal().Stack().Err(err).Msg("Failed to convert to RepoConfig") @@ -86,18 +108,59 @@ func main() { // implemented in Golang specific to OfflineStoreSink. Python Feature Server doesn't support this. switch serverType { case "http": - err = server.StartHttpServer(fs, host, port, loggingService) + err = serverStarter.StartHttpServer(fs, host, port, loggingService) case "grpc": - err = server.StartGrpcServer(fs, host, port, loggingService) + err = serverStarter.StartGrpcServer(fs, host, port, loggingService) case "hybrid": // hybrid starts both gRPC(on gRPC port) & http(on port) - err = server.StartHybridServer(fs, host, port, grpcPort, loggingService) + err = serverStarter.StartHybridServer(fs, host, port, grpcPort, loggingService) default: - fmt.Println("Unknown server type. Please specify 'http', 'grpc', or 'hybrid'.") + fmt.Println("Unknown serverStarter type. Please specify 'http', 'grpc', or 'hybrid'.") } if err != nil { - log.Fatal().Stack().Err(err).Msg("Failed to start server") + log.Fatal().Stack().Err(err).Msg("Failed to start serverStarter") + } + +} + +func datadogTracingEnabled() bool { + return strings.ToLower(os.Getenv("ENABLE_DATADOG_TRACING")) == "true" +} + +func publishVersionInfoToDatadog(info *version.Info) { + if datadogTracingEnabled() { + if statsdHost, ok := os.LookupEnv("DD_AGENT_HOST"); ok { + var client, err = statsd.New(fmt.Sprintf("%s:8125", statsdHost)) + if err != nil { + log.Error().Err(err).Msg("Failed to connect to statsd") + return + } + defer func(client *statsd.Client) { + var err error + err = client.Flush() + if err != nil { + log.Error().Err(err).Msg("Failed to flush heartbeat to statsd client") + } + err = client.Close() + if err != nil { + log.Error().Err(err).Msg("Failed to close statsd client") + } + }(client) + tags := []string{ + "feast_version:" + info.Version, + "build_time:" + info.BuildTime, + "commit_hash:" + info.CommitHash, + "go_version:" + info.GoVersion, + "server_type:" + info.ServerType, + } + err = client.Gauge("featureserver.heartbeat", 1, tags, 1) + if err != nil { + log.Error().Err(err).Msg("Failed to publish feature server heartbeat info to datadog") + } + } else { + log.Info().Msg("DD_AGENT_HOST environment variable is not set, skipping publishing version info to Datadog") + } } } @@ -125,7 +188,7 @@ func constructLoggingService(fs *feast.FeatureStore, writeLoggedFeaturesCallback // StartGrpcServerWithLogging creates a gRPC server with enabled feature logging func StartGrpcServer(fs *feast.FeatureStore, host string, port int, loggingService *logging.LoggingService) error { - if strings.ToLower(os.Getenv("ENABLE_DATADOG_TRACING")) == "true" { + if datadogTracingEnabled() { tracer.Start(tracer.WithRuntimeMetrics()) defer tracer.Stop() } @@ -192,9 +255,9 @@ func StartHttpServer(fs *feast.FeatureStore, host string, port int, loggingServi // StartHybridServer creates a gRPC Server and HTTP server // Handlers for these are defined in hybrid_server.go -// Stops both servers if a stop signal is recieved. +// Stops both servers if a stop signal is received. func StartHybridServer(fs *feast.FeatureStore, host string, httpPort int, grpcPort int, loggingService *logging.LoggingService) error { - if strings.ToLower(os.Getenv("ENABLE_DATADOG_TRACING")) == "true" { + if datadogTracingEnabled() { tracer.Start(tracer.WithRuntimeMetrics()) defer tracer.Stop() } @@ -208,10 +271,6 @@ func StartHybridServer(fs *feast.FeatureStore, host string, httpPort int, grpcPo grpcSer := ser.RegisterServices() - if err != nil { - return err - } - httpSer := server.NewHttpServer(fs, loggingService) log.Info().Msgf("Starting a HTTP server on host %s, port %d", host, httpPort) diff --git a/protos/feast/serving/ServingService.proto b/protos/feast/serving/ServingService.proto index 662720a5ed2..ebadeb6f7ff 100644 --- a/protos/feast/serving/ServingService.proto +++ b/protos/feast/serving/ServingService.proto @@ -28,6 +28,8 @@ option go_package = "github.com/feast-dev/feast/go/protos/feast/serving"; service ServingService { // Get information about this Feast serving. rpc GetFeastServingInfo (GetFeastServingInfoRequest) returns (GetFeastServingInfoResponse); + // Get Version information of this Feature Server + rpc GetVersionInfo (GetVersionInfoRequest) returns (GetVersionInfoResponse); // Get online features synchronously. rpc GetOnlineFeatures (GetOnlineFeaturesRequest) returns (GetOnlineFeaturesResponse); // Get online features synchronously with range queries. @@ -41,6 +43,16 @@ message GetFeastServingInfoResponse { string version = 1; } +message GetVersionInfoRequest {} + +message GetVersionInfoResponse { + string version = 1; + string build_time=2; + string commit_hash=3; + string go_version=4; + string server_type=5; +} + message FeatureReferenceV2 { // Name of the Feature View to retrieve the feature from. string feature_view_name = 1; From 5e7060c8e4562db65316e5974854dd79888360bb Mon Sep 17 00:00:00 2001 From: piket Date: Fri, 18 Jul 2025 10:41:04 -0700 Subject: [PATCH 22/25] fix: Add http int tests and fix http error codes (#287) --- go/internal/feast/featurestore.go | 6 +- .../scylladb/http/http_integration_test.go | 473 ++++++++++++++++++ .../valid_duplicate_features_response.json | 97 ++++ .../scylladb/http/valid_equals_response.json | 365 ++++++++++++++ .../http/valid_nonexistent_key_response.json | 277 ++++++++++ .../scylladb/http/valid_response.json | 75 +++ go/internal/feast/server/http_server.go | 79 ++- go/internal/feast/server/hybrid_server.go | 2 +- go/internal/feast/server/server_commons.go | 22 +- go/internal/feast/server/server_test_utils.go | 36 +- 10 files changed, 1372 insertions(+), 60 deletions(-) create mode 100644 go/internal/feast/integration_tests/scylladb/http/http_integration_test.go create mode 100644 go/internal/feast/integration_tests/scylladb/http/valid_duplicate_features_response.json create mode 100644 go/internal/feast/integration_tests/scylladb/http/valid_equals_response.json create mode 100644 go/internal/feast/integration_tests/scylladb/http/valid_nonexistent_key_response.json create mode 100644 go/internal/feast/integration_tests/scylladb/http/valid_response.json diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 3bd7e413bca..8bbb6a92c0a 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 new file mode 100644 index 00000000000..88b57f71d4e --- /dev/null +++ b/go/internal/feast/integration_tests/scylladb/http/http_integration_test.go @@ -0,0 +1,473 @@ +//go:build integration + +package http + +import ( + "bytes" + "fmt" + "github.com/feast-dev/feast/go/internal/feast/server" + "github.com/feast-dev/feast/go/internal/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +var httpServer *server.HttpServer +var getOnlineFeaturesRangeHandler http.HandlerFunc + +func TestMain(m *testing.M) { + err := test.SetupInitializedRepo("../") + if err != nil { + fmt.Printf("Failed to set up test environment: %v\n", err) + os.Exit(1) + } + httpServer = server.GetHttpServer("../", "") + + // GetOnlineFeaturesRange Handler should be the second handler in the list returned by DefaultHttpHandlers + for _, handler := range server.DefaultHttpHandlers(httpServer) { + if handler.Path == "/get-online-features-range" { + getOnlineFeaturesRangeHandler = handler.HandlerFunc.(http.HandlerFunc) + break + } + } + + // Run the tests + exitCode := m.Run() + + // Clean up the test environment + test.CleanUpInitializedRepo("../") + + // Exit with the appropriate code + if exitCode != 0 { + fmt.Printf("CassandraOnlineStore Int Tests failed with exit code %d\n", exitCode) + } + os.Exit(exitCode) +} + +func loadResponse(fileName string) ([]byte, error) { + filePath, err := filepath.Abs("./" + fileName) + if err != nil { + return nil, err + } + return os.ReadFile(filePath) +} + +func TestGetOnlineFeaturesRange_Http(t *testing.T) { + requestJson := []byte(`{ + "features": [ + "all_dtypes_sorted:int_val", + "all_dtypes_sorted:long_val", + "all_dtypes_sorted:float_val", + "all_dtypes_sorted:double_val", + "all_dtypes_sorted:byte_val", + "all_dtypes_sorted:string_val", + "all_dtypes_sorted:timestamp_val", + "all_dtypes_sorted:boolean_val", + "all_dtypes_sorted:null_int_val", + "all_dtypes_sorted:null_long_val", + "all_dtypes_sorted:null_float_val", + "all_dtypes_sorted:null_double_val", + "all_dtypes_sorted:null_byte_val", + "all_dtypes_sorted:null_string_val", + "all_dtypes_sorted:null_timestamp_val", + "all_dtypes_sorted:null_boolean_val", + "all_dtypes_sorted:null_array_int_val", + "all_dtypes_sorted:null_array_long_val", + "all_dtypes_sorted:null_array_float_val", + "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_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": { + "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, 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_withOnlyEqualsFilter(t *testing.T) { + requestJson := []byte(`{ + "features": [ + "all_dtypes_sorted:int_val", + "all_dtypes_sorted:long_val", + "all_dtypes_sorted:float_val", + "all_dtypes_sorted:double_val", + "all_dtypes_sorted:byte_val", + "all_dtypes_sorted:string_val", + "all_dtypes_sorted:timestamp_val", + "all_dtypes_sorted:boolean_val", + "all_dtypes_sorted:null_int_val", + "all_dtypes_sorted:null_long_val", + "all_dtypes_sorted:null_float_val", + "all_dtypes_sorted:null_double_val", + "all_dtypes_sorted:null_byte_val", + "all_dtypes_sorted:null_string_val", + "all_dtypes_sorted:null_timestamp_val", + "all_dtypes_sorted:null_boolean_val", + "all_dtypes_sorted:null_array_int_val", + "all_dtypes_sorted:null_array_long_val", + "all_dtypes_sorted:null_array_float_val", + "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_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": { + "index_id": [2] + }, + "sort_key_filters": [ + { + "sort_key_name": "event_timestamp", + "equals": 1744769171 + } + ], + "limit": 10 + }`) + + request := httptest.NewRequest(http.MethodPost, "/get-online-features-range", bytes.NewBuffer(requestJson)) + 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_equals_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_forNonExistentEntityKey(t *testing.T) { + requestJson := []byte(`{ + "features": [ + "all_dtypes_sorted:int_val", + "all_dtypes_sorted:long_val", + "all_dtypes_sorted:float_val", + "all_dtypes_sorted:double_val", + "all_dtypes_sorted:byte_val", + "all_dtypes_sorted:string_val", + "all_dtypes_sorted:timestamp_val", + "all_dtypes_sorted:boolean_val", + "all_dtypes_sorted:null_int_val", + "all_dtypes_sorted:null_long_val", + "all_dtypes_sorted:null_float_val", + "all_dtypes_sorted:null_double_val", + "all_dtypes_sorted:null_byte_val", + "all_dtypes_sorted:null_string_val", + "all_dtypes_sorted:null_timestamp_val", + "all_dtypes_sorted:null_boolean_val", + "all_dtypes_sorted:null_array_int_val", + "all_dtypes_sorted:null_array_long_val", + "all_dtypes_sorted:null_array_float_val", + "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_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": { + "index_id": [-1] + }, + "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, responseRecorder.Code, http.StatusOK, "Expected HTTP status code 200 OK response body is: %s", responseRecorder.Body.String()) + expectedResponse, err := loadResponse("valid_nonexistent_key_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_includesDuplicatedRequestedFeatures(t *testing.T) { + requestJson := []byte(`{ + "features": [ + "all_dtypes_sorted:int_val", + "all_dtypes_sorted: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, responseRecorder.Code, http.StatusOK, "Expected HTTP status code 200 OK response body is: %s", responseRecorder.Body.String()) + expectedResponse, err := loadResponse("valid_duplicate_features_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_withEmptySortKeyFilter(t *testing.T) { + requestJson := []byte(`{ + "features": [ + "all_dtypes_sorted:int_val", + "all_dtypes_sorted:long_val", + "all_dtypes_sorted:float_val", + "all_dtypes_sorted:double_val", + "all_dtypes_sorted:byte_val", + "all_dtypes_sorted:string_val", + "all_dtypes_sorted:timestamp_val", + "all_dtypes_sorted:boolean_val", + "all_dtypes_sorted:null_int_val", + "all_dtypes_sorted:null_long_val", + "all_dtypes_sorted:null_float_val", + "all_dtypes_sorted:null_double_val", + "all_dtypes_sorted:null_byte_val", + "all_dtypes_sorted:null_string_val", + "all_dtypes_sorted:null_timestamp_val", + "all_dtypes_sorted:null_boolean_val", + "all_dtypes_sorted:null_array_int_val", + "all_dtypes_sorted:null_array_long_val", + "all_dtypes_sorted:null_array_float_val", + "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_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": { + "index_id": [1, 2, 3] + }, + "sort_key_filters": [], + "limit": 10 + }`) + + request := httptest.NewRequest(http.MethodPost, "/get-online-features-range", bytes.NewBuffer(requestJson)) + 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_withFeatureService(t *testing.T) { + requestJson := []byte(`{ + "feature_service": "test_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, 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_withInvalidFeatureView(t *testing.T) { + requestJson := []byte(`{ + "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, 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) { + testCases := []struct { + request string + expectedStatus int + expectedError string + }{ + { + request: `{ + "features": [ + "all_dtypes_sorted:int_val" + ], + "entities": { + "index_id": [1, 2, 3] + }, + "sort_key_filters": [ + { + "sort_key_name": "event_timestamp", + "range": { + "range_start": "invalid_value" + } + } + ], + "limit": 10 + }`, + expectedStatus: http.StatusBadRequest, + expectedError: `{"error":"error converting sort key filter range start for event_timestamp: unsupported value type for conversion: UNIX_TIMESTAMP for actual value type: *types.Value_StringVal","status_code":400}`, + }, + { + request: `{ + "features": [ + "all_dtypes_sorted:int_val" + ], + "entities": { + "index_id": [1, 2, 3] + }, + "sort_key_filters": [ + { + "sort_key_name": "event_timestamp", + "range": { + "range_end": 10.45 + } + } + ], + "limit": 10 + }`, + expectedStatus: http.StatusBadRequest, + expectedError: `{"error":"error converting sort key filter range end for event_timestamp: unsupported value type for conversion: UNIX_TIMESTAMP for actual value type: *types.Value_DoubleVal","status_code":400}`, + }, + { + request: `{ + "features": [ + "all_dtypes_sorted:int_val" + ], + "entities": { + "index_id": [1, 2, 3] + }, + "sort_key_filters": [ + { + "sort_key_name": "event_timestamp", + "equals": "invalid_value" + } + ], + "limit": 10 + }`, + expectedStatus: http.StatusBadRequest, + expectedError: `{"error":"error converting sort key filter equals for event_timestamp: unsupported value type for conversion: UNIX_TIMESTAMP for actual value type: *types.Value_StringVal","status_code":400}`, + }, + { + request: `{ + "features": [ + "all_dtypes_sorted:int_val" + ], + "entities": { + "index_id": [1, 2, 3] + }, + "sort_key_filters": [ + { + "sort_key_name": "event_timestamp", + "equals": {} + } + ], + "limit": 10 + }`, + expectedStatus: http.StatusBadRequest, + expectedError: `{"error":"error parsing equals filter: could not parse JSON value: {}","status_code":400}`, + }, + } + + for _, tc := range testCases { + request := httptest.NewRequest(http.MethodPost, "/get-online-features-range", bytes.NewBuffer([]byte(tc.request))) + responseRecorder := httptest.NewRecorder() + + getOnlineFeaturesRangeHandler.ServeHTTP(responseRecorder, request) + assert.Equal(t, tc.expectedStatus, responseRecorder.Code) + assert.Equal(t, tc.expectedError, responseRecorder.Body.String(), "Response body does not match expected error message") + } +} diff --git a/go/internal/feast/integration_tests/scylladb/http/valid_duplicate_features_response.json b/go/internal/feast/integration_tests/scylladb/http/valid_duplicate_features_response.json new file mode 100644 index 00000000000..ae8d4921870 --- /dev/null +++ b/go/internal/feast/integration_tests/scylladb/http/valid_duplicate_features_response.json @@ -0,0 +1,97 @@ +{ + "entities": { + "index_id": [ + 1, + 2, + 3 + ] + }, + "metadata": { + "feature_names": [ + "int_val", + "int_val" + ] + }, + "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 + ] + ] + }, + { + "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 + ] + ] + } + ] +} 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 new file mode 100644 index 00000000000..686d4b45dac --- /dev/null +++ b/go/internal/feast/integration_tests/scylladb/http/valid_equals_response.json @@ -0,0 +1,365 @@ +{ + "entities": { + "index_id": [ + 2 + ] + }, + "metadata": { + "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": [ + [ + 730 + ] + ] + }, + { + "values": [ + [ + 71713 + ] + ] + }, + { + "values": [ + [ + 87.20588 + ] + ] + }, + { + "values": [ + [ + 75.04222910365395 + ] + ] + }, + { + "values": [ + [ + "Yihe58MvS3U1Dg==" + ] + ] + }, + { + "values": [ + [ + "htLvp" + ] + ] + }, + { + "values": [ + [ + "2024-04-20 02:06:12Z" + ] + ] + }, + { + "values": [ + [ + true + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + [ + 641, + 229, + 465, + 968, + 325, + 543, + 806, + 587, + 700, + 641 + ] + ] + ] + }, + { + "values": [ + [ + [ + 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 + ] + ] + ] + }, + { + "values": [ + [ + [ + 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" + ] + ] + ] + }, + { + "values": [ + [ + [ + 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==" + ] + ] + ] + }, + { + "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" + ] + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + "2025-04-16 02:06:11Z" + ] + ] + } + ] +} 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 new file mode 100644 index 00000000000..296832af8d7 --- /dev/null +++ b/go/internal/feast/integration_tests/scylladb/http/valid_nonexistent_key_response.json @@ -0,0 +1,277 @@ +{ + "entities": { + "index_id": [ + -1 + ] + }, + "metadata": { + "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": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + }, + { + "values": [ + [ + null + ] + ] + } + ] +} diff --git a/go/internal/feast/integration_tests/scylladb/http/valid_response.json b/go/internal/feast/integration_tests/scylladb/http/valid_response.json new file mode 100644 index 00000000000..cac5be1634b --- /dev/null +++ b/go/internal/feast/integration_tests/scylladb/http/valid_response.json @@ -0,0 +1,75 @@ +{ + "entities" : { + "index_id" : [ 1, 2, 3 ] + }, + "metadata" : { + "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 ] ] + }, { + "values" : [ [ 71712, 71711, 71710, 71709, 71708, 71707, 71706, 71705, 71704, 71703 ], [ 71713, 71712, 71711, 71710, 71709, 71708, 71707, 71706, 71705, 71704 ], [ 71713, 71712, 71711, 71710, 71709, 71708, 71707, 71706, 71705, 71704 ] ] + }, { + "values" : [ [ 86.70588, 86.20588, 85.70588, 85.20588, 84.70588, 84.20588, 83.70588, 83.20588, 82.70588, 82.20588 ], [ 87.20588, 86.70588, 86.20588, 85.70588, 85.20588, 84.70588, 84.20588, 83.70588, 83.20588, 82.70588 ], [ 87.20588, 86.70588, 86.20588, 85.70588, 85.20588, 84.70588, 84.20588, 83.70588, 83.20588, 82.70588 ] ] + }, { + "values" : [ [ 74.54222910365395, 74.04222910365395, 73.54222910365395, 73.04222910365395, 72.54222910365395, 72.04222910365395, 71.54222910365395, 71.04222910365395, 70.54222910365395, 70.04222910365395 ], [ 75.04222910365395, 74.54222910365395, 74.04222910365395, 73.54222910365395, 73.04222910365395, 72.54222910365395, 72.04222910365395, 71.54222910365395, 71.04222910365395, 70.54222910365395 ], [ 75.04222910365395, 74.54222910365395, 74.04222910365395, 73.54222910365395, 73.04222910365395, 72.54222910365395, 72.04222910365395, 71.54222910365395, 71.04222910365395, 70.54222910365395 ] ] + }, { + "values" : [ [ "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==" ], [ "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==" ], [ "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==", "Yihe58MvS3U1Dg==" ] ] + }, { + "values" : [ [ "90w72", "tJkwD", "nlWZO", "IUDaG", "SrRpu", "u1eJq", "MVbPt", "lKcNe", "A8xrU", "UB1ub" ], [ "htLvp", "VNn6E", "BKwAa", "W3Jn0", "j67y3", "rUo7P", "FORGt", "SB9y0", "Kvrvc", "kTOBr" ], [ "RBGgW", "OOdmJ", "dDzyt", "BJAOp", "nmETw", "JYjR7", "JY32o", "O0YxR", "fJIUT", "25u3z" ] ] + }, { + "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" : [ [ 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" : [ [ 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" : [ [ 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" : [ [ 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" : [ [ 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" : [ [ 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" : [ [ 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" : [ [ 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 ] ] + }, { + "values" : [ [ "2025-04-16 02:06:10Z", "2025-04-16 02:06:09Z", "2025-04-16 02:06:08Z", "2025-04-16 02:06:07Z", "2025-04-16 02:06:06Z", "2025-04-16 02:06:05Z", "2025-04-16 02:06:04Z", "2025-04-16 02:06:03Z", "2025-04-16 02:06:02Z", "2025-04-16 02:06:01Z" ], [ "2025-04-16 02:06:11Z", "2025-04-16 02:06:10Z", "2025-04-16 02:06:09Z", "2025-04-16 02:06:08Z", "2025-04-16 02:06:07Z", "2025-04-16 02:06:06Z", "2025-04-16 02:06:05Z", "2025-04-16 02:06:04Z", "2025-04-16 02:06:03Z", "2025-04-16 02:06:02Z" ], [ "2025-04-16 02:06:11Z", "2025-04-16 02:06:10Z", "2025-04-16 02:06:09Z", "2025-04-16 02:06:08Z", "2025-04-16 02:06:07Z", "2025-04-16 02:06:06Z", "2025-04-16 02:06:05Z", "2025-04-16 02:06:04Z", "2025-04-16 02:06:03Z", "2025-04-16 02:06:02Z" ] ] + } ] +} \ No newline at end of file diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index f72200f7a3f..b1f7d0a9497 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -26,18 +26,18 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) -type httpServer struct { +type HttpServer struct { fs *feast.FeatureStore loggingService *logging.LoggingService server *http.Server } -// This represents mapping between a path and an http Handler. +// This represents mapping between a Path and an http Handler. // Note a handler can be created out of any func with type signature // func(w http.ResponseWriter, r *http.Request) via HandleFunc() type Handler struct { - path string - handlerFunc http.Handler + Path string + HandlerFunc http.Handler } // Some Feast types aren't supported during JSON conversion @@ -319,8 +319,8 @@ type getOnlineFeaturesRequest struct { RequestContext map[string]repeatedValue `json:"request_context"` } -func NewHttpServer(fs *feast.FeatureStore, loggingService *logging.LoggingService) *httpServer { - return &httpServer{fs: fs, loggingService: loggingService} +func NewHttpServer(fs *feast.FeatureStore, loggingService *logging.LoggingService) *HttpServer { + return &HttpServer{fs: fs, loggingService: loggingService} } func parseIncludeMetadata(r *http.Request) (bool, error) { @@ -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 @@ -536,7 +536,7 @@ func getSortKeyFiltersProto(filters []sortKeyFilter) ([]*serving.SortKeyFilter, return sortKeyFiltersProto, nil } -func (s *httpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Request) { +func (s *HttpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Request) { var err error span, ctx := tracer.StartSpanFromContext(r.Context(), "getOnlineFeaturesRange", tracer.ResourceName("/get-online-features-range")) @@ -565,11 +565,14 @@ func (s *httpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque return } - // TODO: Implement support for feature services with range queries var featureService *model.FeatureService if request.FeatureService != nil { - writeJSONError(w, fmt.Errorf("feature services are not supported for range queries"), http.StatusBadRequest) - return + featureService, err = s.fs.GetFeatureService(*request.FeatureService) + if err != nil { + logSpanContext.Error().Err(err).Msg("Error getting feature service from registry") + writeJSONError(w, fmt.Errorf("Error getting feature service from registry: %+v", err), http.StatusNotFound) + return + } } entitiesProto := make(map[string]*prototypes.RepeatedValue) @@ -587,7 +590,7 @@ func (s *httpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque sortKeyFiltersProto, err := getSortKeyFiltersProto(request.SortKeyFilters) if err != nil { logSpanContext.Error().Err(err).Msg("Error converting sort key filter to protobuf") - writeJSONError(w, fmt.Errorf("error converting sort key filter to protobuf: %w", err), http.StatusInternalServerError) + writeJSONError(w, err, http.StatusBadRequest) return } @@ -610,7 +613,7 @@ func (s *httpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque if err != nil { logSpanContext.Error().Err(err).Msg("Error getting range feature vectors") - writeJSONError(w, fmt.Errorf("error getting range feature vectors: %w", err), http.StatusInternalServerError) + writeJSONError(w, err, http.StatusInternalServerError) return } @@ -667,8 +670,22 @@ func logStackTrace() { } func writeJSONError(w http.ResponseWriter, err error, statusCode int) { + errorString := fmt.Sprintf("%+v", err) + if statusErr, ok := status.FromError(err); ok { + switch statusErr.Code() { + case codes.InvalidArgument: + errorString = statusErr.Message() + statusCode = http.StatusBadRequest + case codes.NotFound: + errorString = statusErr.Message() + statusCode = http.StatusNotFound + default: + errorString = statusErr.Message() + statusCode = http.StatusInternalServerError + } + } errMap := map[string]interface{}{ - "error": fmt.Sprintf("%+v", err), + "error": errorString, "status_code": statusCode, } errJSON, _ := json.Marshal(errMap) @@ -686,36 +703,14 @@ 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) }) } -func (s *httpServer) Serve(host string, port int, handlers []Handler) error { +func (s *HttpServer) Serve(host string, port int, handlers []Handler) error { if strings.ToLower(os.Getenv("ENABLE_DATADOG_TRACING")) == "true" { tracer.Start(tracer.WithRuntimeMetrics()) defer tracer.Stop() @@ -723,7 +718,7 @@ func (s *httpServer) Serve(host string, port int, handlers []Handler) error { mux := httptrace.NewServeMux() for _, handler := range handlers { - mux.Handle(handler.path, handler.handlerFunc) + mux.Handle(handler.Path, handler.HandlerFunc) } s.server = &http.Server{Addr: fmt.Sprintf("%s:%d", host, port), Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 15 * time.Second} @@ -736,7 +731,7 @@ func (s *httpServer) Serve(host string, port int, handlers []Handler) error { return err } -func DefaultHttpHandlers(s *httpServer) []Handler { +func DefaultHttpHandlers(s *HttpServer) []Handler { return CommonHttpHandlers(s, healthCheckHandler) } @@ -745,7 +740,7 @@ func healthCheckHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Healthy") } -func (s *httpServer) Stop() error { +func (s *HttpServer) Stop() error { if s.server != nil { return s.server.Shutdown(context.Background()) } diff --git a/go/internal/feast/server/hybrid_server.go b/go/internal/feast/server/hybrid_server.go index dac40f26c7b..9cc3a35eaa9 100644 --- a/go/internal/feast/server/hybrid_server.go +++ b/go/internal/feast/server/hybrid_server.go @@ -15,7 +15,7 @@ import ( var defaultCheckTimeout = 2 * time.Second // Register default HTTP handlers specific to the hybrid server configuration. -func DefaultHybridHandlers(s *httpServer, port int) []Handler { +func DefaultHybridHandlers(s *HttpServer, port int) []Handler { return CommonHttpHandlers(s, combinedHealthCheck(port)) } diff --git a/go/internal/feast/server/server_commons.go b/go/internal/feast/server/server_commons.go index 25f4290645e..babb408bb1a 100644 --- a/go/internal/feast/server/server_commons.go +++ b/go/internal/feast/server/server_commons.go @@ -21,27 +21,27 @@ func LogWithSpanContext(span tracer.Span) zerolog.Logger { return logger } -func CommonHttpHandlers(s *httpServer, healthCheckHandler http.HandlerFunc) []Handler { +func CommonHttpHandlers(s *HttpServer, healthCheckHandler http.HandlerFunc) []Handler { return []Handler{ { - path: "/get-online-features", - handlerFunc: recoverMiddleware(http.HandlerFunc(s.getOnlineFeatures)), + Path: "/get-online-features", + HandlerFunc: recoverMiddleware(http.HandlerFunc(s.getOnlineFeatures)), }, { - path: "/get-online-features-range", - handlerFunc: recoverMiddleware(http.HandlerFunc(s.getOnlineFeaturesRange)), + Path: "/get-online-features-range", + 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", + HandlerFunc: promhttp.Handler(), }, { - path: "/health", - handlerFunc: healthCheckHandler, + Path: "/health", + HandlerFunc: healthCheckHandler, }, } } diff --git a/go/internal/feast/server/server_test_utils.go b/go/internal/feast/server/server_test_utils.go index 872fce4f960..762da7a9dad 100644 --- a/go/internal/feast/server/server_test_utils.go +++ b/go/internal/feast/server/server_test_utils.go @@ -68,16 +68,46 @@ func GetClient(ctx context.Context, basePath string, logPath string) (serving.Se return client, closer } -// Return absolute path to the test_repo directory regardless of the working directory +// Return absolute Path to the test_repo directory regardless of the working directory func getRepoPath(basePath string) string { - // Get the file path of this source file, regardless of the working directory + // Get the file Path of this source file, regardless of the working directory if basePath == "" { _, filename, _, ok := runtime.Caller(0) if !ok { - panic("couldn't find file path of the test file") + panic("couldn't find file Path of the test file") } return filepath.Join(filename, "..", "..", "feature_repo") } else { return filepath.Join(basePath, "feature_repo") } } + +func GetHttpServer(basePath string, logPath string) *HttpServer { + repoPath := getRepoPath(basePath) + config, err := registry.NewRepoConfigFromFile(repoPath) + if err != nil { + panic(err) + } + fs, err := feast.NewFeatureStore(config, nil) + if err != nil { + panic(err) + } + + var logSink logging.LogSink + if logPath != "" { + logSink, err = logging.NewFileLogSink(logPath) + if err != nil { + panic(err) + } + } + loggingService, err := logging.NewLoggingService(fs, logSink, logging.LoggingOptions{ + WriteInterval: 10 * time.Millisecond, + FlushInterval: logging.DefaultOptions.FlushInterval, + EmitTimeout: logging.DefaultOptions.EmitTimeout, + ChannelCapacity: logging.DefaultOptions.ChannelCapacity, + }) + if err != nil { + panic(err) + } + return NewHttpServer(fs, loggingService) +} From 573aef15653d79a1f2f3698056f5df8471da6f28 Mon Sep 17 00:00:00 2001 From: Manisha Sudhir <30449541+Manisha4@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:34:33 -0700 Subject: [PATCH 23/25] fix: Update Feature View Not Catching SortedFeatureView/FeatureView Errors (#291) * adding a check to see if HTTPStatusError is being raised within _handle_exception and if it is, to return the same. * addressing feedback --- sdk/python/feast/infra/registry/http.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdk/python/feast/infra/registry/http.py b/sdk/python/feast/infra/registry/http.py index 6273fa88179..bee18721ed4 100644 --- a/sdk/python/feast/infra/registry/http.py +++ b/sdk/python/feast/infra/registry/http.py @@ -133,6 +133,13 @@ def teardown(self): def _handle_exception(self, exception: Exception): logger.exception("Request failed with exception: %s", repr(exception)) + # If it's already an HTTPStatusError, re-raise it as is + if isinstance(exception, HTTPStatusError): + raise HTTPStatusError( + "Request failed with exception: " + repr(exception), + request=exception.request, + response=exception.response, + ) from exception raise httpx.HTTPError( "Request failed with exception: " + repr(exception) ) from exception From cf522e1041e26fa79bbffec2f73f46a65df070cf Mon Sep 17 00:00:00 2001 From: omirandadev <136642003+omirandadev@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:22:56 -0500 Subject: [PATCH 24/25] =?UTF-8?q?feat:=20Only=20call=20relevant=20registry?= =?UTF-8?q?=20endpoint=20when=20getting=20feature=20views=E2=80=A6=20(#289?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Only call relevant registry endpoint when getting feature views by feature refs or service * update reference to old method signatures * fix integration tests * dedupe feature refs like methods used to * deleting test that tries to reject fvs passed to rq, no longer doing this check * small changes based on PR feedback * fix test * maintain order of fvs and features * define sorted feature service for integration tests * reuse regular fv reponse json for sorted fv integration test * add sort key to expected features in integration test * reorder feature schema to match order in expected response * add unit tests for GetSortedFeatureView methods * add util methods needed to construct fs with ordering * changes based on pr feedback * fix response json order * reorder request jsoons * reuse common feature variables * fix other json objects * refactor old code to use list instead of map * add odfvs to fvsToUse more explicitly * refactor again --------- Co-authored-by: omiranda --- go/internal/feast/featurestore.go | 46 ++- .../scylladb/feature_repo/example_repo.py | 7 +- .../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 +-- .../scylladb/scylladb_integration_test.go | 56 ++-- go/internal/feast/onlineserving/serving.go | 282 ++++++++++++------ .../feast/onlineserving/serving_test.go | 179 ++++++++--- 9 files changed, 607 insertions(+), 386 deletions(-) diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 8bbb6a92c0a..2a652ab8d4a 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -186,26 +186,20 @@ 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, requestedSortedFeatureViews, requestedOnDemandFeatureViews, err = + requestedFeatureViews, requestedOnDemandFeatureViews, err = onlineserving.GetFeatureViewsToUseByService(featureService, fs.registry, fs.config.Project) + if err != nil { + return nil, err + } } else { - requestedFeatureViews, requestedSortedFeatureViews, requestedOnDemandFeatureViews, err = + requestedFeatureViews, requestedOnDemandFeatureViews, err = onlineserving.GetFeatureViewsToUseByFeatureRefs(featureRefs, fs.registry, fs.config.Project) - } - 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 + if err != nil { + return nil, err } - return nil, errors.GrpcInvalidArgumentErrorf("GetOnlineFeatures does not support sorted feature views %v", sfvNames) } if len(requestedFeatureViews) == 0 { @@ -325,24 +319,20 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( var err error var requestedSortedFeatureViews []*onlineserving.SortedFeatureViewAndRefs - var requestedFeatureViews []*onlineserving.FeatureViewAndRefs + if featureService != nil { - requestedFeatureViews, requestedSortedFeatureViews, _, err = - onlineserving.GetFeatureViewsToUseByService(featureService, fs.registry, fs.config.Project) - } else { - requestedFeatureViews, requestedSortedFeatureViews, _, err = - onlineserving.GetFeatureViewsToUseByFeatureRefs(featureRefs, fs.registry, fs.config.Project) - } - if err != nil { - return nil, err - } + requestedSortedFeatureViews, err = + onlineserving.GetSortedFeatureViewsToUseByService(featureService, 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 + } else { + requestedSortedFeatureViews, err = onlineserving.GetSortedFeatureViewsToUseByFeatureRefs( + featureRefs, fs.registry, fs.config.Project) + if err != nil { + return nil, err } - 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/feature_repo/example_repo.py b/go/internal/feast/integration_tests/scylladb/feature_repo/example_repo.py index 7729cd5bb27..06a9c1331bd 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,5 +142,10 @@ mlpfs_test_all_datatypes_service = FeatureService( name="test_service", - features=[mlpfs_test_all_datatypes_view, mlpfs_test_all_datatypes_sorted_view], + features=[mlpfs_test_all_datatypes_view], +) + +mlpfs_test_all_datatypes_sorted_service = FeatureService( + name="test_sorted_service", + features=[mlpfs_test_all_datatypes_sorted_view], ) 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 88b57f71d4e..8179ba32440 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,6 +67,14 @@ 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", @@ -81,16 +89,8 @@ 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_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:null_array_boolean_val", "all_dtypes_sorted:event_timestamp" ], "entities": { @@ -128,6 +128,14 @@ 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", @@ -142,16 +150,8 @@ 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_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:null_array_boolean_val", "all_dtypes_sorted:event_timestamp" ], "entities": { @@ -187,6 +187,14 @@ 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", @@ -201,16 +209,8 @@ 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_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:null_array_boolean_val", "all_dtypes_sorted:event_timestamp" ], "entities": { @@ -278,6 +278,14 @@ 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", @@ -292,16 +300,8 @@ 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_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:null_array_boolean_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_service", + "feature_service": "test_sorted_service", "entities": { "index_id": [1, 2, 3] }, @@ -342,36 +342,63 @@ func TestGetOnlineFeaturesRange_Http_withFeatureService(t *testing.T) { responseRecorder := httptest.NewRecorder() getOnlineFeaturesRangeHandler.ServeHTTP(responseRecorder, request) - 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") + 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_withInvalidFeatureView(t *testing.T) { +func TestGetOnlineFeaturesRange_Http_withInvalidFeatureService(t *testing.T) { requestJson := []byte(`{ - "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 - }`) + "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") +} + +func TestGetOnlineFeaturesRange_Http_withInvalidSortedFeatureView(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 + }`) request := httptest.NewRequest(http.MethodPost, "/get-online-features-range", bytes.NewBuffer(requestJson)) responseRecorder := httptest.NewRecorder() getOnlineFeaturesRangeHandler.ServeHTTP(responseRecorder, request) - 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") + 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") } 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 686d4b45dac..5f837738990 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,6 +14,14 @@ "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", @@ -28,16 +36,8 @@ "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", + "null_array_boolean_val", "event_timestamp" ] }, @@ -101,56 +101,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 + [ + "v3CHIwQTKOsPlg==", + "9NpAUZtBRhQQdg==", + "mFIS2ujOt3nMNw==", + "cLpMChXX44TEqQ==", + "/QYIT7SunvM/BQ==", + "Ia6pzqLJsN+i/g==", + "LtVpAouLocySHw==", + "1SqOKGMZEXm/BQ==", + "xL95J5LcB6QmFw==", + "RpbGjz9Lo64HMA==" + ] ] ] }, { "values": [ [ - null + [ + "9QECZ", + "GLPQJ", + "RWCS4", + "4J2ZQ", + "S2ZJG", + "TLS1U", + "J3ZNM", + "CPILQ", + "9QPKL", + "ZJELB" + ] ] ] }, { "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" + ] ] ] }, { "values": [ [ - null + [ + false, + true, + false, + true, + false, + true, + false, + false, + true, + false + ] ] ] }, @@ -206,144 +294,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": [ [ - [ - "9QECZ", - "GLPQJ", - "RWCS4", - "4J2ZQ", - "S2ZJG", - "TLS1U", - "J3ZNM", - "CPILQ", - "9QPKL", - "ZJELB" - ] + null ] ] }, { "values": [ [ - [ - false, - true, - false, - true, - false, - true, - false, - false, - true, - false - ] + null ] ] }, { "values": [ [ - [ - "v3CHIwQTKOsPlg==", - "9NpAUZtBRhQQdg==", - "mFIS2ujOt3nMNw==", - "cLpMChXX44TEqQ==", - "/QYIT7SunvM/BQ==", - "Ia6pzqLJsN+i/g==", - "LtVpAouLocySHw==", - "1SqOKGMZEXm/BQ==", - "xL95J5LcB6QmFw==", - "RpbGjz9Lo64HMA==" - ] + 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 ] ] }, 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 296832af8d7..fb31952e8c9 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,6 +14,14 @@ "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", @@ -28,16 +36,8 @@ "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", + "null_array_boolean_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 cac5be1634b..55db0470247 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", "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" ] + "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" ] }, "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 ] ] @@ -22,19 +22,21 @@ }, { "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" : [ [ 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" : [ [ 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" : [ [ [ 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" : [ [ 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" : [ [ [ 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" : [ [ 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" : [ [ [ 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" : [ [ 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" : [ [ [ "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" : [ [ 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" : [ [ [ "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 ] ] + "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 ] ] }, { @@ -52,21 +54,19 @@ }, { "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" : [ [ 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" : [ [ [ 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" : [ [ 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" : [ [ [ 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" : [ [ 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" : [ [ [ "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" : [ [ 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" : [ [ [ 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 ] ] }, { - "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" : [ [ 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" : [ [ [ "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 ] ] }, { "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/integration_tests/scylladb/scylladb_integration_test.go b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go index 42ebb3ddc10..d11e1d7d127 100644 --- a/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go +++ b/go/internal/feast/integration_tests/scylladb/scylladb_integration_test.go @@ -17,6 +17,11 @@ 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 @@ -62,11 +67,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { }, } - 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"} + featureNames := getAllSortedFeatureNames() var featureNamesWithFeatureView []string @@ -108,11 +109,7 @@ func TestGetOnlineFeaturesRange_withOnlyEqualsFilter(t *testing.T) { }, } - 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"} + featureNames := getAllSortedFeatureNames() var featureNamesWithFeatureView []string @@ -171,11 +168,7 @@ func TestGetOnlineFeaturesRange_forNonExistentEntityKey(t *testing.T) { }, } - 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"} + featureNames := getAllRegularFeatureNames() var featureNamesWithFeatureView []string @@ -274,11 +267,7 @@ func TestGetOnlineFeaturesRange_withEmptySortKeyFilter(t *testing.T) { }, } - 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"} + featureNames := getAllRegularFeatureNames() var featureNamesWithFeatureView []string @@ -314,7 +303,7 @@ func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { request := &serving.GetOnlineFeaturesRangeRequest{ Kind: &serving.GetOnlineFeaturesRangeRequest_FeatureService{ - FeatureService: "test_service", + FeatureService: "test_sorted_service", }, Entities: entities, SortKeyFilters: []*serving.SortKeyFilter{ @@ -329,9 +318,11 @@ func TestGetOnlineFeaturesRange_withFeatureService(t *testing.T) { }, Limit: 10, } - _, 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 = InvalidArgument desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") + response, err := client.GetOnlineFeaturesRange(ctx, request) + assert.NoError(t, err) + + featureNames := getAllSortedFeatureNames() + assertResponseData(t, response, featureNames, 3, false) } func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { @@ -345,11 +336,7 @@ func TestGetOnlineFeaturesRange_withFeatureViewThrowsError(t *testing.T) { }, } - 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"} + featureNames := getAllRegularFeatureNames() var featureNamesWithFeatureView []string @@ -378,7 +365,8 @@ 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 = InvalidArgument desc = GetOnlineFeaturesRange does not support standard feature views [all_dtypes]", err.Error(), "Expected error message for unsupported feature view") + assert.Contains(t, err.Error(), "sorted feature view all_dtypes doesn't exist", + "Expected error message for non-existent sorted feature view") } func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeResponse, featureNames []string, entitiesRequested int, includeMetadata bool) { @@ -420,3 +408,11 @@ func assertResponseData(t *testing.T, response *serving.GetOnlineFeaturesRangeRe } } } + +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/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 155826f19b9..835691474da 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -62,6 +62,11 @@ type SortedFeatureViewAndRefs struct { FeatureRefs []string } +type ViewFeatures struct { + ViewName string + Features []string +} + /* We group all features from a single request by entities they attached to. Thus, we will be able to call online retrieval per entity and not per each feature View. @@ -92,20 +97,20 @@ existed in the registry func GetFeatureViewsToUseByService( featureService *model.FeatureService, registry *registry.Registry, - projectName string) ([]*FeatureViewAndRefs, []*SortedFeatureViewAndRefs, []*model.OnDemandFeatureView, error) { + projectName string) ([]*FeatureViewAndRefs, []*model.OnDemandFeatureView, error) { viewNameToViewAndRefs := make(map[string]*FeatureViewAndRefs) - viewNameToSortedViewAndRefs := make(map[string]*SortedFeatureViewAndRefs) odFvsToUse := make([]*model.OnDemandFeatureView, 0) for _, featureProjection := range featureService.Projections { // Create copies of FeatureView that may contains the same *FeatureView but // each differentiated by a *FeatureViewProjection featureViewName := featureProjection.Name + // TODO: Call the registry using GetAnyFeatureView instead of GetFeatureView and GetOnDemandFeatureView if fv, fvErr := registry.GetFeatureView(projectName, featureViewName); fvErr == nil { base, err := fv.Base.WithProjection(featureProjection) if err != nil { - return nil, nil, nil, err + return nil, nil, err } if _, ok := viewNameToViewAndRefs[featureProjection.NameToUse()]; !ok { view := fv.NewFeatureViewFromBase(base) @@ -122,29 +127,10 @@ func GetFeatureViewsToUseByService( feature.Name) } - } else if sortedFv, sortedFvErr := registry.GetSortedFeatureView(projectName, featureViewName); sortedFvErr == nil { - base, err := sortedFv.Base.WithProjection(featureProjection) - if err != nil { - return nil, nil, nil, err - } - if _, ok := viewNameToSortedViewAndRefs[featureProjection.NameToUse()]; !ok { - view := sortedFv.NewSortedFeatureViewFromBase(base) - view.EntityColumns = sortedFv.EntityColumns - viewNameToSortedViewAndRefs[featureProjection.NameToUse()] = &SortedFeatureViewAndRefs{ - View: view, - FeatureRefs: []string{}, - } - } - - for _, feature := range featureProjection.Features { - viewNameToSortedViewAndRefs[featureProjection.NameToUse()].FeatureRefs = - addStringIfNotContains(viewNameToSortedViewAndRefs[featureProjection.NameToUse()].FeatureRefs, - feature.Name) - } } else if odFv, odFvErr := registry.GetOnDemandFeatureView(projectName, featureViewName); odFvErr == nil { projectedOdFv, err := odFv.NewWithProjection(featureProjection) if err != nil { - return nil, nil, nil, err + return nil, nil, err } odFvsToUse = append(odFvsToUse, projectedOdFv) err = extractOdFvDependencies( @@ -153,11 +139,11 @@ func GetFeatureViewsToUseByService( projectName, viewNameToViewAndRefs) if err != nil { - return nil, nil, nil, err + return nil, nil, err } } else { - 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"+ + 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"+ "%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) } @@ -167,12 +153,45 @@ func GetFeatureViewsToUseByService( for _, viewAndRef := range viewNameToViewAndRefs { fvsToUse = append(fvsToUse, viewAndRef) } - sortedFvsToUse := make([]*SortedFeatureViewAndRefs, 0) - for _, viewAndRef := range viewNameToSortedViewAndRefs { - sortedFvsToUse = append(sortedFvsToUse, viewAndRef) + + return fvsToUse, odFvsToUse, nil +} + +func GetSortedFeatureViewsToUseByService( + featureService *model.FeatureService, + registry *registry.Registry, + projectName string) ([]*SortedFeatureViewAndRefs, error) { + + sfvsToUse := make([]*SortedFeatureViewAndRefs, 0) + + for _, featureProjection := range featureService.Projections { + featureViewName := featureProjection.Name + if sfv, sfvErr := registry.GetSortedFeatureView(projectName, featureViewName); sfvErr == nil { + base, err := sfv.Base.WithProjection(featureProjection) + if err != nil { + return nil, err + } + view := sfv.NewSortedFeatureViewFromBase(base) + view.EntityColumns = sfv.EntityColumns + + featureRefs := make([]string, 0) + for _, feature := range featureProjection.Features { + featureRefs = addStringIfNotContains(featureRefs, feature.Name) + } + + sfvsToUse = append(sfvsToUse, &SortedFeatureViewAndRefs{ + View: view, + FeatureRefs: featureRefs, + }) + } else { + log.Error().Err(sfvErr).Msgf("Sorted feature view %s not found", featureViewName) + return nil, errors.GrpcInvalidArgumentErrorf("the provided feature service %s contains a reference to a sorted feature View"+ + "%s which doesn't exist, please make sure that you have created the sorted feature View"+ + "%s and that you have registered it by running \"apply\"", featureService.Name, featureViewName, featureViewName) + } } - return fvsToUse, sortedFvsToUse, odFvsToUse, nil + return sfvsToUse, nil } func addFeaturesToValidationMap( @@ -198,78 +217,60 @@ existed in the registry func GetFeatureViewsToUseByFeatureRefs( features []string, registry *registry.Registry, - projectName string) ([]*FeatureViewAndRefs, []*SortedFeatureViewAndRefs, []*model.OnDemandFeatureView, error) { + projectName string) ([]*FeatureViewAndRefs, []*model.OnDemandFeatureView, error) { + + viewFeatures, err := buildDeduplicatedFeatureNamesMap(features) + if err != nil { + return nil, nil, err + } + viewNameToViewAndRefs := make(map[string]*FeatureViewAndRefs) - viewNameToSortedViewAndRefs := make(map[string]*SortedFeatureViewAndRefs) odFvToFeatures := make(map[string][]string) odFvToProjectWithFeatures := make(map[string]*model.OnDemandFeatureView) - 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 { + for _, vf := range viewFeatures { + featureViewName := vf.ViewName + requestedFeatureNames := vf.Features - 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}, - } - } + if fv, fvErr := registry.GetFeatureView(projectName, featureViewName); fvErr == nil { + err := validateFeatures( + featureViewName, + requestedFeatureNames, + fv.Base.Features) + if err != nil { + return nil, nil, err } - } 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 { - if _, ok := odFvToFeatures[odfv.Base.Name]; !ok { - odFvToFeatures[odfv.Base.Name] = []string{featureName} - } else { - odFvToFeatures[odfv.Base.Name] = append( - odFvToFeatures[odfv.Base.Name], featureName) + viewNameToViewAndRefs[fv.Base.Name] = &FeatureViewAndRefs{ + View: fv, + FeatureRefs: requestedFeatureNames, + } + } 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) } - } else { - 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, errors.GrpcInvalidArgumentErrorf("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) if err != nil { - return nil, nil, nil, err + return nil, nil, err } err = extractOdFvDependencies( @@ -278,21 +279,64 @@ func GetFeatureViewsToUseByFeatureRefs( projectName, viewNameToViewAndRefs) if err != nil { - return nil, nil, nil, err + return nil, nil, err } odFvsToUse = append(odFvsToUse, projectedOdFv) } fvsToUse := make([]*FeatureViewAndRefs, 0) - for _, viewAndRefs := range viewNameToViewAndRefs { - fvsToUse = append(fvsToUse, viewAndRefs) + + for _, viewAndRef := range viewNameToViewAndRefs { + fvsToUse = append(fvsToUse, viewAndRef) } + + return fvsToUse, odFvsToUse, nil +} + +/* +Return + + (1) requested sorted feature views and features grouped per View + +existed in the registry +*/ +func GetSortedFeatureViewsToUseByFeatureRefs( + features []string, + registry *registry.Registry, + projectName string) ([]*SortedFeatureViewAndRefs, error) { + + viewFeatures, err := buildDeduplicatedFeatureNamesMap(features) + if err != nil { + return nil, err + } + sortedFvsToUse := make([]*SortedFeatureViewAndRefs, 0) - for _, viewAndRefs := range viewNameToSortedViewAndRefs { - sortedFvsToUse = append(sortedFvsToUse, viewAndRefs) + + for _, vf := range viewFeatures { + featureViewName := vf.ViewName + featureNames := vf.Features + + sortedFv, err := registry.GetSortedFeatureView(projectName, featureViewName) + if err != nil { + return nil, errors.GrpcInvalidArgumentErrorf("sorted feature view %s doesn't exist, please make sure that you have created the"+ + " sorted feature view %s and that you have registered it by running \"apply\"", featureViewName, featureViewName) + } + + err = validateFeatures( + featureViewName, + featureNames, + sortedFv.Base.Features) + if err != nil { + return nil, err + } + + sortedFvsToUse = append(sortedFvsToUse, &SortedFeatureViewAndRefs{ + View: sortedFv, + FeatureRefs: featureNames, + }) } - return fvsToUse, sortedFvsToUse, odFvsToUse, nil + return sortedFvsToUse, nil } func extractOdFvDependencies( @@ -429,6 +473,32 @@ func ValidateEntityValues(joinKeyValues map[string]*prototypes.RepeatedValue, return numRows, nil } +func validateFeatures( + featureViewName string, + requestedFeatures []string, + featureViewFeatures []*model.Field) error { + + if len(requestedFeatures) == 0 { + return errors.GrpcInvalidArgumentErrorf( + "no features requested for feature view %s, please specify at least one feature", featureViewName) + } + + validFeaturesMap := make(map[string]bool) + for _, field := range featureViewFeatures { + validFeaturesMap[field.Name] = true + } + + for _, featureName := range requestedFeatures { + if !validFeaturesMap[featureName] { + return errors.GrpcInvalidArgumentErrorf( + "feature %s does not exist in feature view %s", + featureName, featureViewName) + } + } + + return nil +} + func ValidateFeatureRefs(requestedFeatures []*FeatureViewAndRefs, fullFeatureNames bool) error { uniqueFeatureRefs := make(map[string]bool) collidedFeatureRefs := make([]string, 0) @@ -834,6 +904,36 @@ func getEventTimestamp(timestamps []timestamp.Timestamp, index int) *timestamppb 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, diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index e260b469167..be32e1d3b9b 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -243,19 +243,17 @@ func TestUnpackFeatureService(t *testing.T) { "viewA": {featASpec, featBSpec}, "viewB": {featCSpec}, "odfv": {onDemandFeature2}, - "viewS": {featSSpec}, }) testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) - fvs, sortedFvs, odfvs, err := GetFeatureViewsToUseByService(fs, testRegistry, projectName) + fvs, odfvs, err := GetFeatureViewsToUseByService(fs, testRegistry, projectName) - assertCorrectUnpacking(t, fvs, sortedFvs, odfvs, err) + assertCorrectUnpacking(t, fvs, odfvs, err) } -func assertCorrectUnpacking(t *testing.T, fvs []*FeatureViewAndRefs, sortedFvs []*SortedFeatureViewAndRefs, odfvs []*model.OnDemandFeatureView, err error) { +func assertCorrectUnpacking(t *testing.T, fvs []*FeatureViewAndRefs, odfvs []*model.OnDemandFeatureView, err error) { assert.Nil(t, err) assert.Len(t, fvs, 3) - assert.Len(t, sortedFvs, 1) assert.Len(t, odfvs, 1) fvsByName := make(map[string]*FeatureViewAndRefs) @@ -273,9 +271,6 @@ func assertCorrectUnpacking(t *testing.T, fvs []*FeatureViewAndRefs, sortedFvs [ // only requested features projected assert.Len(t, odfvs[0].Base.Projection.Features, 1) assert.Equal(t, "featG", odfvs[0].Base.Projection.Features[0].Name) - - // sorted feature views and features as declared in service - assert.Equal(t, []string{"featS"}, sortedFvs[0].FeatureRefs) } func TestUnpackFeatureViewsByReferences(t *testing.T) { @@ -304,17 +299,16 @@ func TestUnpackFeatureViewsByReferences(t *testing.T) { onDemandFeature1, onDemandFeature2) testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) - fvs, sortedFvs, odfvs, err := GetFeatureViewsToUseByFeatureRefs( + fvs, odfvs, err := GetFeatureViewsToUseByFeatureRefs( []string{ "viewA:featA", "viewA:featB", "viewB:featC", "odfv:featG", - "viewS:featS", }, testRegistry, projectName) - assertCorrectUnpacking(t, fvs, sortedFvs, odfvs, err) + assertCorrectUnpacking(t, fvs, odfvs, err) } func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidFeatures(t *testing.T) { @@ -347,11 +341,10 @@ func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidFeatures(t *testin "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) + _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) 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") } @@ -385,15 +378,14 @@ func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidOnDemandFeatures(t "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) + _, _, invalidFeaturesErr := GetFeatureViewsToUseByService(fs, testRegistry, projectName) 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) { +func TestGetSortedFeatureViewsToUseByService(t *testing.T) { projectName := "test_project" testRegistry, err := createRegistry(projectName) assert.NoError(t, err) @@ -403,32 +395,66 @@ func TestGetFeatureViewsToUseByService_returnsErrorWithInvalidSortedFeatures(t * 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) + sortKeyB := test.CreateSortKeyProto("timestamp", core.SortOrder_ASC, types.ValueType_UNIX_TIMESTAMP) 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) + sortedViewA := test.CreateSortedFeatureViewProto("sortedViewA", entities, []*core.SortKey{sortKeyA}, featASpec, featBSpec) + sortedViewB := test.CreateSortedFeatureViewProto("sortedViewB", entities, []*core.SortKey{sortKeyB}, featCSpec, featDSpec) + sortedViewC := test.CreateSortedFeatureViewProto("sortedViewC", entities, []*core.SortKey{sortKeyA}, featESpec) + + fs := test.CreateFeatureService("sorted_service", map[string][]*core.FeatureSpecV2{ + "sortedViewA": {featASpec, featBSpec}, + "sortedViewB": {featCSpec}, + "sortedViewC": {featESpec}, + }) + + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{}, []*core.SortedFeatureView{sortedViewA, sortedViewB, sortedViewC}, []*core.OnDemandFeatureView{}) + + sfvs, err := GetSortedFeatureViewsToUseByService(fs, testRegistry, projectName) + + assert.Nil(t, err) + assert.Len(t, sfvs, 3) + + sfvsByName := make(map[string]*SortedFeatureViewAndRefs) + for _, sfv := range sfvs { + sfvsByName[sfv.View.Base.Name] = sfv + } + + assert.Equal(t, []string{"featA", "featB"}, sfvsByName["sortedViewA"].FeatureRefs) + assert.Equal(t, []string{"featC"}, sfvsByName["sortedViewB"].FeatureRefs) + assert.Equal(t, []string{"featE"}, sfvsByName["sortedViewC"].FeatureRefs) + assert.Equal(t, "featS", sfvsByName["sortedViewA"].View.SortKeys[0].FieldName) + assert.Equal(t, core.SortOrder_DESC, *sfvsByName["sortedViewA"].View.SortKeys[0].Order.Order.Enum()) + assert.Equal(t, "timestamp", sfvsByName["sortedViewB"].View.SortKeys[0].FieldName) + assert.Equal(t, core.SortOrder_ASC, *sfvsByName["sortedViewB"].View.SortKeys[0].Order.Order.Enum()) +} + +func TestGetSortedFeatureViewsToUseByService_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) 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}, + + sortKeyA := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) + entities := []*core.Entity{test.CreateEntityProto("entity", types.ValueType_INT32, "entity")} + sortedViewA := test.CreateSortedFeatureViewProto("sortedViewA", entities, []*core.SortKey{sortKeyA}, featASpec, featBSpec) + + fs := test.CreateFeatureService("invalid_sorted_service", map[string][]*core.FeatureSpecV2{ + "sortedViewA": {featASpec, featBSpec, 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, "rpc error: code = InvalidArgument desc = the projection for viewS cannot be applied because it contains featInvalid which the FeatureView doesn't have") + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{}, []*core.SortedFeatureView{sortedViewA}, []*core.OnDemandFeatureView{}) + + _, invalidFeaturesErr := GetSortedFeatureViewsToUseByService(fs, testRegistry, projectName) + assert.Error(t, invalidFeaturesErr) + assert.Contains(t, invalidFeaturesErr.Error(), "rpc error: code = InvalidArgument desc") + assert.Contains(t, invalidFeaturesErr.Error(), "featInvalid which the FeatureView doesn't have") } func TestGetFeatureViewsToUseByFeatureRefs_returnsErrorWithInvalidFeatures(t *testing.T) { @@ -457,16 +483,93 @@ func TestGetFeatureViewsToUseByFeatureRefs_returnsErrorWithInvalidFeatures(t *te onDemandFeature1, onDemandFeature2) testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{viewA, viewB, viewC}, []*core.SortedFeatureView{viewS}, []*core.OnDemandFeatureView{onDemandView}) - _, _, _, fvErr := GetFeatureViewsToUseByFeatureRefs( + _, _, fvErr := GetFeatureViewsToUseByFeatureRefs( []string{ "viewA:featA", "viewA:featB", "viewB:featInvalid", "odfv:odFeatInvalid", - "viewS:sortedFeatInvalid", }, testRegistry, projectName) - assert.EqualError(t, fvErr, "rpc error: code = InvalidArgument desc = requested features are not valid: viewB:featInvalid, odfv:odFeatInvalid, viewS:sortedFeatInvalid") + assert.Error(t, fvErr) + assert.Contains(t, fvErr.Error(), "rpc error: code = InvalidArgument desc") + // Fail only on the first invalid feature + assert.Contains(t, fvErr.Error(), "featInvalid does not exist in feature view viewB") +} + +func TestGetSortedFeatureViewsToUseByFeatureRefs(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) + + sortKeyA := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) + sortKeyB := test.CreateSortKeyProto("price", core.SortOrder_ASC, types.ValueType_DOUBLE) + + entities := []*core.Entity{test.CreateEntityProto("entity", types.ValueType_INT32, "entity")} + + sortedViewA := test.CreateSortedFeatureViewProto("sortedViewA", entities, []*core.SortKey{sortKeyA}, featASpec, featBSpec) + sortedViewB := test.CreateSortedFeatureViewProto("sortedViewB", entities, []*core.SortKey{sortKeyB}, featCSpec, featDSpec) + sortedViewC := test.CreateSortedFeatureViewProto("sortedViewC", entities, []*core.SortKey{sortKeyA}, featESpec) + + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{}, []*core.SortedFeatureView{sortedViewA, sortedViewB, sortedViewC}, []*core.OnDemandFeatureView{}) + + sfvs, err := GetSortedFeatureViewsToUseByFeatureRefs( + []string{ + "sortedViewA:featA", + "sortedViewA:featB", + "sortedViewB:featC", + "sortedViewC:featE", + }, + testRegistry, projectName) + + assert.Nil(t, err) + assert.Len(t, sfvs, 3) + + sfvsByName := make(map[string]*SortedFeatureViewAndRefs) + for _, sfv := range sfvs { + sfvsByName[sfv.View.Base.Name] = sfv + } + + assert.Equal(t, []string{"featA", "featB"}, sfvsByName["sortedViewA"].FeatureRefs) + assert.Equal(t, []string{"featC"}, sfvsByName["sortedViewB"].FeatureRefs) + assert.Equal(t, []string{"featE"}, sfvsByName["sortedViewC"].FeatureRefs) + assert.Equal(t, "timestamp", sfvsByName["sortedViewA"].View.SortKeys[0].FieldName) + assert.Equal(t, "price", sfvsByName["sortedViewB"].View.SortKeys[0].FieldName) + assert.Equal(t, "timestamp", sfvsByName["sortedViewC"].View.SortKeys[0].FieldName) +} + +func TestGetSortedFeatureViewsToUseByFeatureRefs_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) + + sortKeyA := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) + entities := []*core.Entity{test.CreateEntityProto("entity", types.ValueType_INT32, "entity")} + + sortedViewA := test.CreateSortedFeatureViewProto("sortedViewA", entities, []*core.SortKey{sortKeyA}, featASpec, featBSpec) + + testRegistry.SetModels([]*core.FeatureService{}, []*core.Entity{}, []*core.FeatureView{}, []*core.SortedFeatureView{sortedViewA}, []*core.OnDemandFeatureView{}) + + _, sfvErr := GetSortedFeatureViewsToUseByFeatureRefs( + []string{ + "sortedViewA:featA", + "sortedViewA:featB", + "sortedViewA:featInvalid", + }, + testRegistry, projectName) + + assert.Error(t, sfvErr) + assert.Contains(t, sfvErr.Error(), "rpc error: code = InvalidArgument desc") + assert.Contains(t, sfvErr.Error(), "featInvalid does not exist in feature view sortedViewA") } func TestValidateSortKeyFilters_ValidFilters(t *testing.T) { From 8d05d8b447cb035c337476c8c5a524610237ccf1 Mon Sep 17 00:00:00 2001 From: kpulipati29 Date: Wed, 13 Aug 2025 15:54:55 -0500 Subject: [PATCH 25/25] feat: Streaming ingestion latency improvements (#292) * feat: Streaming ingestion latency improvements * feat: remove blank lines * refactor process method * feat: leaving 1 core is enough for multiprocessing --- .../infra/contrib/spark_kafka_processor.py | 8 ++++ .../feast/infra/passthrough_provider.py | 39 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/infra/contrib/spark_kafka_processor.py b/sdk/python/feast/infra/contrib/spark_kafka_processor.py index 549cae61d33..7bc44cf1692 100644 --- a/sdk/python/feast/infra/contrib/spark_kafka_processor.py +++ b/sdk/python/feast/infra/contrib/spark_kafka_processor.py @@ -1,3 +1,4 @@ +import os from types import MethodType from typing import List, Optional, Set, Union, no_type_check @@ -253,6 +254,13 @@ def _write_stream_data(self, df: StreamTable, to: PushMode) -> StreamingQuery: def batch_write(row: DataFrame, batch_id: int): rows: pd.DataFrame = row.toPandas() + num_driver_cores = self.spark.sparkContext.getConf().get( + "spark.driver.cores" + ) + if num_driver_cores is not None: + # This environment variable is used in passthrough provider to determine the number of processes to spawn + os.environ["SPARK_DRIVER_CORES"] = num_driver_cores + # Extract the latest feature values for each unique entity row (i.e. the join keys). # Also add a 'created' column. if isinstance(self.sfv, StreamFeatureView): diff --git a/sdk/python/feast/infra/passthrough_provider.py b/sdk/python/feast/infra/passthrough_provider.py index ea5da62d75a..3b6d4b083dc 100644 --- a/sdk/python/feast/infra/passthrough_provider.py +++ b/sdk/python/feast/infra/passthrough_provider.py @@ -1,5 +1,7 @@ import logging +import os from datetime import datetime, timedelta +from multiprocessing import Pool from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union import pandas as pd @@ -292,8 +294,43 @@ def ingest_df( entity.name: entity.dtype.to_value_type() for entity in feature_view.entity_columns } - rows_to_write = _convert_arrow_to_proto(table, feature_view, join_keys) + num_spark_driver_cores = int(os.environ.get("SPARK_DRIVER_CORES", 1)) + + if num_spark_driver_cores > 2: + # Leaving one core for operating system and other background processes + num_processes = num_spark_driver_cores - 1 + + if table.num_rows < num_processes: + num_processes = table.num_rows + + # Input table is split into smaller chunks and processed in parallel + chunks = self.split_table(num_processes, table) + chunks_to_parallelize = [ + (chunk, feature_view, join_keys) for chunk in chunks + ] + + with Pool(processes=num_processes) as pool: + pool.starmap(self.process, chunks_to_parallelize) + else: + self.process(table, feature_view, join_keys) + + def split_table(self, num_processes, table): + num_table_rows = table.num_rows + size = num_table_rows // num_processes # base size of each chunk + remainder = num_table_rows % num_processes # extra rows to distribute + + chunks = [] + offset = 0 + for i in range(num_processes): + # Distribute the remainder one per split until exhausted + length = size + (1 if i < remainder else 0) + chunks.append(table.slice(offset, length)) + offset += length + return chunks + + def process(self, table, feature_view: FeatureView, join_keys): + rows_to_write = _convert_arrow_to_proto(table, feature_view, join_keys) self.online_write_batch( self.repo_config, feature_view, rows_to_write, progress=None )