diff --git a/go.mod b/go.mod index 7d9e679ea80..affdd9ef082 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 github.com/ghodss/yaml v1.0.0 - github.com/gocql/gocql v1.6.0 + github.com/gocql/gocql v1.7.0 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 github.com/mattn/go-sqlite3 v1.14.32 @@ -102,6 +102,7 @@ require ( github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect + github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/asmfmt v1.3.2 // indirect @@ -156,7 +157,4 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) - -replace github.com/gocql/gocql => github.com/scylladb/gocql v1.15.2 diff --git a/go.sum b/go.sum index 833ae8b47df..b5c0ad4b09c 100644 --- a/go.sum +++ b/go.sum @@ -103,7 +103,9 @@ github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= @@ -145,6 +147,8 @@ github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+d github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus= +github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -153,12 +157,11 @@ github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/ github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -172,6 +175,7 @@ github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -181,7 +185,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= @@ -262,8 +265,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/scylladb/gocql v1.15.2 h1:Vv7iaIyTMMjMtux1INQMi0waH8j8O/ppKS6JcM1vh14= -github.com/scylladb/gocql v1.15.2/go.mod h1:+rInt+HjERaMEYC4N8LocQQEAdREhYKU4QPkE00K5dA= github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3mcNEL9NBPB0iuVjyxvq3LZc= github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw= github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= @@ -282,8 +283,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= @@ -387,7 +386,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -406,8 +404,6 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -417,10 +413,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -462,6 +456,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/go/embedded/online_features.go b/go/embedded/online_features.go index e8272ecd237..af0490b4a66 100644 --- a/go/embedded/online_features.go +++ b/go/embedded/online_features.go @@ -188,7 +188,8 @@ func (s *OnlineFeatureService) GetOnlineFeatures( featureService, entitiesProto, requestDataProto, - fullFeatureNames) + fullFeatureNames, + serving.UseDefaultsMode_USE_DEFAULTS_OFF) if err != nil { return err diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index 8e7e119ea16..48cce515172 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -193,7 +193,8 @@ func (fs *FeatureStore) GetOnlineFeatures( featureService *model.FeatureService, joinKeyToEntityValues map[string]*prototypes.RepeatedValue, requestData map[string]*prototypes.RepeatedValue, - fullFeatureNames bool) ([]*onlineserving.FeatureVector, error) { + fullFeatureNames bool, + useDefaults serving.UseDefaultsMode) ([]*onlineserving.FeatureVector, error) { var err error var requestedFeatureViews []*onlineserving.FeatureViewAndRefs var requestedOnDemandFeatureViews []*model.OnDemandFeatureView @@ -294,6 +295,7 @@ func (fs *FeatureStore) GetOnlineFeatures( arrowMemory, numRows, transformationRequired, + useDefaults, ) if err != nil { return err @@ -336,6 +338,7 @@ func (fs *FeatureStore) GetOnlineFeatures( arrowMemory, numRows, transformationRequired, + useDefaults, ) if err != nil { return err @@ -395,7 +398,8 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( reverseSortOrder bool, limit int32, requestData map[string]*prototypes.RepeatedValue, - fullFeatureNames bool) ([]*onlineserving.RangeFeatureVector, error) { + fullFeatureNames bool, + useDefaults serving.UseDefaultsMode) ([]*onlineserving.RangeFeatureVector, error) { if requestData == nil { requestData = make(map[string]*prototypes.RepeatedValue) @@ -509,6 +513,7 @@ func (fs *FeatureStore) GetOnlineFeaturesRange( arrowMemory, numRows, false, + useDefaults, ) if err != nil { return nil, err diff --git a/go/internal/feast/featurestore_test.go b/go/internal/feast/featurestore_test.go index 7bfb9724b63..3160d4633cd 100644 --- a/go/internal/feast/featurestore_test.go +++ b/go/internal/feast/featurestore_test.go @@ -245,7 +245,7 @@ func testRedisSimpleFeatures(t *testing.T, fs *FeatureStore) { ctx := context.Background() mr := fs.onlineStore.(*MockRedis) mr.On("OnlineRead", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(results, nil) - response, err := fs.GetOnlineFeatures(ctx, featureNames, nil, entities, map[string]*types.RepeatedValue{}, true) + response, err := fs.GetOnlineFeatures(ctx, featureNames, nil, entities, map[string]*types.RepeatedValue{}, true, serving.UseDefaultsMode_USE_DEFAULTS_OFF) require.NoError(t, err) assert.Len(t, response, 4) // 3 Features + 1 entity = 4 columns (feature vectors) in response } @@ -267,7 +267,7 @@ func testRedisODFVNoTransformationService(t *testing.T, fs *FeatureStore) { ctx := context.Background() mr := fs.onlineStore.(*MockRedis) mr.On("OnlineRead", ctx, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) - response, err := fs.GetOnlineFeatures(ctx, featureNames, nil, entities, requestData, true) + response, err := fs.GetOnlineFeatures(ctx, featureNames, nil, entities, requestData, true, serving.UseDefaultsMode_USE_DEFAULTS_OFF) assert.Nil(t, response) assert.ErrorAs(t, err, &FeastTransformationServiceNotConfigured{}) @@ -482,6 +482,7 @@ func TestGetOnlineFeaturesRange(t *testing.T) { 0, nil, true, + serving.UseDefaultsMode_USE_DEFAULTS_OFF, ) assert.NoError(t, err) @@ -538,7 +539,8 @@ func testGetOnlineFeaturesRange( reverseSortOrder bool, limit int32, requestData map[string]*types.RepeatedValue, - fullFeatureNames bool) ([]*onlineserving.RangeFeatureVector, error) { + fullFeatureNames bool, + useDefaults serving.UseDefaultsMode) ([]*onlineserving.RangeFeatureVector, error) { sortedFeatureViews := make([]*onlineserving.SortedFeatureViewAndRefs, 0) for _, view := range sortedViews { @@ -608,6 +610,7 @@ func testGetOnlineFeaturesRange( arrowAllocator, numRows, false, + useDefaults, ) if err != nil { return nil, err diff --git a/go/internal/feast/model/field.go b/go/internal/feast/model/field.go index 4f72d346866..0adf43f7a12 100644 --- a/go/internal/feast/model/field.go +++ b/go/internal/feast/model/field.go @@ -6,13 +6,15 @@ import ( ) type Field struct { - Name string - Dtype types.ValueType_Enum + Name string + Dtype types.ValueType_Enum + DefaultValue *types.Value } func NewFieldFromProto(proto *core.FeatureSpecV2) *Field { return &Field{ - Name: proto.Name, - Dtype: proto.ValueType, + Name: proto.Name, + Dtype: proto.ValueType, + DefaultValue: proto.DefaultValue, } } diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index 3ced5134bf0..7d0de36c58d 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -751,7 +751,8 @@ func TransposeFeatureRowsIntoColumns(featureData2D [][]onlinestore.FeatureData, requestedFeatureViews []*FeatureViewAndRefs, arrowAllocator memory.Allocator, numRows int, - useArrow bool) ([]*FeatureVector, error) { + useArrow bool, + useDefaults serving.UseDefaultsMode) ([]*FeatureVector, error) { numFeatures := len(groupRef.AliasedFeatureNames) fvs := make(map[string]*model.FeatureView) @@ -759,6 +760,16 @@ func TransposeFeatureRowsIntoColumns(featureData2D [][]onlinestore.FeatureData, fvs[viewAndRefs.View.Base.Name] = viewAndRefs.View } + // Build feature name -> default value lookup for defaulting + featureDefaults := make(map[string]*prototypes.Value) + for _, viewAndRefs := range requestedFeatureViews { + for _, field := range viewAndRefs.View.Base.Features { + if field.DefaultValue != nil { + featureDefaults[field.Name] = field.DefaultValue + } + } + } + var featureData *onlinestore.FeatureData var fv *model.FeatureView var featureViewName string @@ -801,6 +812,47 @@ func TransposeFeatureRowsIntoColumns(featureData2D [][]onlinestore.FeatureData, status = serving.FieldStatus_PRESENT } } + + // Apply defaults for NOT_FOUND and NULL_VALUE statuses + if useDefaults == serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE { + if status == serving.FieldStatus_NOT_FOUND || status == serving.FieldStatus_NULL_VALUE { + featureName := groupRef.FeatureNames[featureIndex] + if defaultVal, ok := featureDefaults[featureName]; ok { + // Create new Value to avoid mutating shared default + value = &prototypes.Value{Val: defaultVal.Val} + status = serving.FieldStatus_PRESENT + featureViewName := groupRef.FeatureViewNames[featureIndex] + log.Debug(). + Str("feature_view", featureViewName). + Str("feature_name", featureName). + Str("mode", "FLEXIBLE"). + Msg("Applied default value to feature") + } + } + } else if useDefaults == serving.UseDefaultsMode_USE_DEFAULTS_STRICT { + // STRICT mode: first validate all NULL/NOT_FOUND have defaults, then apply + if status == serving.FieldStatus_NOT_FOUND || status == serving.FieldStatus_NULL_VALUE { + featureName := groupRef.FeatureNames[featureIndex] + if _, ok := featureDefaults[featureName]; !ok { + // No default defined - return error + featureViewName := groupRef.FeatureViewNames[featureIndex] + return nil, errors.GrpcInvalidArgumentErrorf( + "feature '%s' in feature view '%s' has NULL/NOT_FOUND value but no default defined (use_defaults=STRICT)", + featureName, featureViewName) + } + // Default exists, apply it + defaultVal := featureDefaults[featureName] + value = &prototypes.Value{Val: defaultVal.Val} + status = serving.FieldStatus_PRESENT + featureViewName := groupRef.FeatureViewNames[featureIndex] + log.Debug(). + Str("feature_view", featureViewName). + Str("feature_name", featureName). + Str("mode", "STRICT"). + Msg("Applied default value to feature") + } + } + for _, rowIndex := range outputIndexes { protoValues[rowIndex] = value currentVector.Statuses[rowIndex] = status @@ -828,7 +880,8 @@ func TransposeRangeFeatureRowsIntoColumns( sortedViews []*SortedFeatureViewAndRefs, arrowAllocator memory.Allocator, numRows int, - useArrow bool) ([]*RangeFeatureVector, error) { + useArrow bool, + useDefaults serving.UseDefaultsMode) ([]*RangeFeatureVector, error) { numFeatures := len(groupRef.AliasedFeatureNames) sfvs := make(map[string]*model.SortedFeatureView) @@ -836,6 +889,16 @@ func TransposeRangeFeatureRowsIntoColumns( sfvs[viewAndRefs.View.Base.Name] = viewAndRefs.View } + // Build feature name -> default value lookup for range defaulting + featureDefaults := make(map[string]*prototypes.Value) + for _, viewAndRefs := range sortedViews { + for _, field := range viewAndRefs.View.Base.Features { + if field.DefaultValue != nil { + featureDefaults[field.Name] = field.DefaultValue + } + } + } + vectors := make([]*RangeFeatureVector, numFeatures) for featureIndex := 0; featureIndex < numFeatures; featureIndex++ { @@ -849,7 +912,7 @@ func TransposeRangeFeatureRowsIntoColumns( for rowEntityIndex, outputIndexes := range groupRef.Indices { rangeValues, rangeStatuses, rangeTimestamps, err := processFeatureRowData( - featureData2D, rowEntityIndex, featureIndex, sfvs) + featureData2D, rowEntityIndex, featureIndex, sfvs, useDefaults, featureDefaults, groupRef.FeatureNames[featureIndex]) if err != nil { return nil, err } @@ -891,7 +954,10 @@ func processFeatureRowData( featureData2D [][]onlinestore.RangeFeatureData, rowEntityIndex int, featureIndex int, - sfvs map[string]*model.SortedFeatureView) ([]*prototypes.Value, []serving.FieldStatus, []*timestamppb.Timestamp, error) { + sfvs map[string]*model.SortedFeatureView, + useDefaults serving.UseDefaultsMode, + featureDefaults map[string]*prototypes.Value, + featureName string) ([]*prototypes.Value, []serving.FieldStatus, []*timestamppb.Timestamp, error) { if featureData2D[rowEntityIndex] == nil || len(featureData2D[rowEntityIndex]) <= featureIndex { return make([]*prototypes.Value, 0), @@ -908,6 +974,43 @@ func processFeatureRowData( } if featureData.Values == nil { + // Apply defaults for entity-not-found case + if useDefaults == serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE { + if defaultVal, ok := featureDefaults[featureName]; ok { + rangeValues := make([]*prototypes.Value, 1) + rangeValues[0] = &prototypes.Value{Val: defaultVal.Val} + rangeStatuses := make([]serving.FieldStatus, 1) + rangeStatuses[0] = serving.FieldStatus_PRESENT + rangeTimestamps := make([]*timestamppb.Timestamp, 1) + rangeTimestamps[0] = ×tamppb.Timestamp{} + log.Debug(). + Str("feature_view", featureViewName). + Str("feature_name", featureName). + Str("mode", "FLEXIBLE"). + Msg("Applied default value to feature (entity not found)") + return rangeValues, rangeStatuses, rangeTimestamps, nil + } + } else if useDefaults == serving.UseDefaultsMode_USE_DEFAULTS_STRICT { + // STRICT mode: entity-not-found requires default + if defaultVal, ok := featureDefaults[featureName]; ok { + rangeValues := make([]*prototypes.Value, 1) + rangeValues[0] = &prototypes.Value{Val: defaultVal.Val} + rangeStatuses := make([]serving.FieldStatus, 1) + rangeStatuses[0] = serving.FieldStatus_PRESENT + rangeTimestamps := make([]*timestamppb.Timestamp, 1) + rangeTimestamps[0] = ×tamppb.Timestamp{} + log.Debug(). + Str("feature_view", featureViewName). + Str("feature_name", featureName). + Str("mode", "STRICT"). + Msg("Applied default value to feature (entity not found)") + return rangeValues, rangeStatuses, rangeTimestamps, nil + } + // No default - return error + return nil, nil, nil, errors.GrpcInvalidArgumentErrorf( + "feature '%s' has NULL/NOT_FOUND value but no default defined (use_defaults=STRICT)", + featureName) + } rangeStatuses := make([]serving.FieldStatus, 1) rangeStatuses[0] = serving.FieldStatus_NOT_FOUND rangeTimestamps := make([]*timestamppb.Timestamp, 1) @@ -928,6 +1031,41 @@ func processFeatureRowData( fieldStatus := featureData.Statuses[i] if val == nil { + // Apply defaults for nil values (NOT_FOUND or NULL_VALUE) + if useDefaults == serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE { + if (fieldStatus == serving.FieldStatus_NOT_FOUND || fieldStatus == serving.FieldStatus_NULL_VALUE) { + if defaultVal, ok := featureDefaults[featureName]; ok { + rangeValues[i] = &prototypes.Value{Val: defaultVal.Val} + rangeStatuses[i] = serving.FieldStatus_PRESENT + rangeTimestamps[i] = eventTimestamp + log.Debug(). + Str("feature_view", featureViewName). + Str("feature_name", featureName). + Str("mode", "FLEXIBLE"). + Msg("Applied default value to feature") + continue + } + } + } else if useDefaults == serving.UseDefaultsMode_USE_DEFAULTS_STRICT { + // STRICT mode: NULL/NOT_FOUND requires default + if (fieldStatus == serving.FieldStatus_NOT_FOUND || fieldStatus == serving.FieldStatus_NULL_VALUE) { + if defaultVal, ok := featureDefaults[featureName]; ok { + rangeValues[i] = &prototypes.Value{Val: defaultVal.Val} + rangeStatuses[i] = serving.FieldStatus_PRESENT + rangeTimestamps[i] = eventTimestamp + log.Debug(). + Str("feature_view", featureViewName). + Str("feature_name", featureName). + Str("mode", "STRICT"). + Msg("Applied default value to feature") + continue + } + // No default - return error + return nil, nil, nil, errors.GrpcInvalidArgumentErrorf( + "feature '%s' has NULL/NOT_FOUND value but no default defined (use_defaults=STRICT)", + featureName) + } + } rangeValues[i] = nil rangeStatuses[i] = featureData.Statuses[i] rangeTimestamps[i] = eventTimestamp diff --git a/go/internal/feast/onlineserving/serving_test.go b/go/internal/feast/onlineserving/serving_test.go index 863f47829cc..184bdb51bd5 100644 --- a/go/internal/feast/onlineserving/serving_test.go +++ b/go/internal/feast/onlineserving/serving_test.go @@ -1788,7 +1788,7 @@ func testTransposeFeatureRowsIntoColumns(t *testing.T, useArrow bool) *FeatureVe }, } - vectors, err := TransposeFeatureRowsIntoColumns(featureData, groupRef, featureViews, arrowAllocator, numRows, useArrow) + vectors, err := TransposeFeatureRowsIntoColumns(featureData, groupRef, featureViews, arrowAllocator, numRows, useArrow, serving.UseDefaultsMode_USE_DEFAULTS_OFF) assert.NoError(t, err) assert.Len(t, vectors, 1) @@ -1863,7 +1863,7 @@ func testTransposeRangeFeatureRowsIntoColumns(t *testing.T, useArrow bool) *Rang }, } - vectors, err := TransposeRangeFeatureRowsIntoColumns(featureData, groupRef, sortedViews, arrowAllocator, numRows, useArrow) + vectors, err := TransposeRangeFeatureRowsIntoColumns(featureData, groupRef, sortedViews, arrowAllocator, numRows, useArrow, serving.UseDefaultsMode_USE_DEFAULTS_OFF) assert.NoError(t, err) assert.Len(t, vectors, 1) @@ -1881,6 +1881,320 @@ func testTransposeRangeFeatureRowsIntoColumns(t *testing.T, useArrow bool) *Rang return vector } +func TestApplyRangeDefaults(t *testing.T) { + arrowAllocator := memory.NewGoAllocator() + + // Test cases for range value defaulting + testCases := []struct { + name string + useDefaults serving.UseDefaultsMode + hasDefault bool + defaultValue *types.Value + values []interface{} + statuses []serving.FieldStatus + expectedValues []interface{} + expectedStatuses []serving.FieldStatus + expectError bool + errorContains string + entityNotFound bool // if true, simulate entity-not-found with nil Values + }{ + { + name: "OFF mode with NOT_FOUND value", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_OFF, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + expectedValues: []interface{}{nil}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + }, + { + name: "OFF mode with NULL_VALUE value", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_OFF, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NULL_VALUE}, + expectedValues: []interface{}{nil}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_NULL_VALUE}, + }, + { + name: "FLEXIBLE mode with NOT_FOUND and default exists", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + expectedValues: []interface{}{42.0}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + }, + { + name: "FLEXIBLE mode with NULL_VALUE and default exists", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NULL_VALUE}, + expectedValues: []interface{}{42.0}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + }, + { + name: "FLEXIBLE mode with NOT_FOUND and no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: false, + defaultValue: nil, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + expectedValues: []interface{}{nil}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + }, + { + name: "FLEXIBLE mode with NULL_VALUE and no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: false, + defaultValue: nil, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NULL_VALUE}, + expectedValues: []interface{}{nil}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_NULL_VALUE}, + }, + { + name: "FLEXIBLE mode with PRESENT value", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{99.9}, + statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + expectedValues: []interface{}{99.9}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + }, + { + name: "FLEXIBLE mode with OUTSIDE_MAX_AGE", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{99.9}, + statuses: []serving.FieldStatus{serving.FieldStatus_OUTSIDE_MAX_AGE}, + expectedValues: []interface{}{99.9}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_OUTSIDE_MAX_AGE}, + }, + { + name: "UNSPECIFIED mode behaves like OFF", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_UNSPECIFIED, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + expectedValues: []interface{}{nil}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + }, + // STRICT mode test cases + { + name: "STRICT mode with NOT_FOUND and default exists", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + expectedValues: []interface{}{42.0}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + }, + { + name: "STRICT mode with NULL_VALUE and default exists", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NULL_VALUE}, + expectedValues: []interface{}{42.0}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + }, + { + name: "STRICT mode with NOT_FOUND and no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: false, + defaultValue: nil, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NOT_FOUND}, + expectError: true, + errorContains: "no default defined", + }, + { + name: "STRICT mode with NULL_VALUE and no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: false, + defaultValue: nil, + values: []interface{}{nil}, + statuses: []serving.FieldStatus{serving.FieldStatus_NULL_VALUE}, + expectError: true, + errorContains: "no default defined", + }, + { + name: "STRICT mode with PRESENT value", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{99.9}, + statuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + expectedValues: []interface{}{99.9}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + }, + { + name: "STRICT mode with OUTSIDE_MAX_AGE", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + values: []interface{}{99.9}, + statuses: []serving.FieldStatus{serving.FieldStatus_OUTSIDE_MAX_AGE}, + expectedValues: []interface{}{99.9}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_OUTSIDE_MAX_AGE}, + }, + { + name: "STRICT mode entity-not-found with default exists", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 42.0}}, + entityNotFound: true, + expectedValues: []interface{}{42.0}, + expectedStatuses: []serving.FieldStatus{serving.FieldStatus_PRESENT}, + }, + { + name: "STRICT mode entity-not-found with no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: false, + defaultValue: nil, + entityNotFound: true, + expectError: true, + errorContains: "no default defined", + }, + } + + for _, tc := range testCases { + for _, useArrow := range []bool{true, false} { + testName := tc.name + if useArrow { + testName += " (Arrow)" + } else { + testName += " (Proto)" + } + + t.Run(testName, func(t *testing.T) { + numRows := 1 + + // Create sorted feature view with or without default + sortKey1 := test.CreateSortKeyProto("timestamp", core.SortOrder_DESC, types.ValueType_UNIX_TIMESTAMP) + entity1 := test.CreateEntityProto("driver", types.ValueType_INT64, "driver") + + var feature *core.FeatureSpecV2 + if tc.hasDefault { + feature = test.CreateFeatureWithDefault("f1", types.ValueType_DOUBLE, tc.defaultValue) + } else { + feature = test.CreateFeature("f1", types.ValueType_DOUBLE) + } + + sfv := test.CreateSortedFeatureViewModel("testView", []*core.Entity{entity1}, []*core.SortKey{sortKey1}, feature) + + sortedViews := []*SortedFeatureViewAndRefs{ + {View: sfv, FeatureRefs: []string{"f1"}}, + } + + groupRef := &model.GroupedRangeFeatureRefs{ + FeatureNames: []string{"f1"}, + FeatureViewNames: []string{"testView"}, + AliasedFeatureNames: []string{"testView__f1"}, + Indices: [][]int{{0}}, + } + + nowTime := time.Now() + + var featureData [][]onlinestore.RangeFeatureData + if tc.entityNotFound { + // Simulate entity-not-found by setting Values to nil + featureData = [][]onlinestore.RangeFeatureData{ + { + { + FeatureView: "testView", + FeatureName: "f1", + Values: nil, + Statuses: nil, + EventTimestamps: nil, + }, + }, + } + } else { + featureData = [][]onlinestore.RangeFeatureData{ + { + { + FeatureView: "testView", + FeatureName: "f1", + Values: tc.values, + Statuses: tc.statuses, + EventTimestamps: []timestamppb.Timestamp{{Seconds: nowTime.Unix()}}, + }, + }, + } + } + + // Call TransposeRangeFeatureRowsIntoColumns with useDefaults parameter + vectors, err := TransposeRangeFeatureRowsIntoColumns(featureData, groupRef, sortedViews, arrowAllocator, numRows, useArrow, tc.useDefaults) + + // Handle error expectations + if tc.expectError { + assert.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + return + } + + assert.NoError(t, err) + assert.Len(t, vectors, 1) + + vector := vectors[0] + assert.Equal(t, "testView__f1", vector.Name) + + // Verify status + assert.Len(t, vector.RangeStatuses, numRows) + assert.Len(t, vector.RangeStatuses[0], 1) + assert.Equal(t, tc.expectedStatuses[0], vector.RangeStatuses[0][0]) + + // Verify value + protoValues, err := vector.GetProtoValues() + assert.NoError(t, err) + assert.Len(t, protoValues, numRows) + + // For range values, we always get a RepeatedValue (not nil) + // unless there are no values at all (entity not found case) + assert.NotNil(t, protoValues[0]) + assert.Len(t, protoValues[0].Val, 1) + + if tc.expectedValues[0] == nil { + // For NOT_FOUND/NULL_VALUE without default, value inside RepeatedValue is nil or empty + // Arrow and Proto may handle this differently - Arrow may return empty Value, Proto returns nil + if protoValues[0].Val[0] != nil { + // Arrow case: empty Value with nil Val + assert.Nil(t, protoValues[0].Val[0].Val) + } + // Proto case: nil Value (already checked by if statement) + } else { + // For values with defaults or present values + assert.NotNil(t, protoValues[0].Val[0]) + expectedDouble := tc.expectedValues[0].(float64) + actualDouble := protoValues[0].Val[0].GetDoubleVal() + assert.Equal(t, expectedDouble, actualDouble) + } + + // Clean up Arrow arrays if using Arrow + if useArrow && vector.RangeValues != nil { + if arr, ok := vector.RangeValues.(arrow.Array); ok { + arr.Release() + } + } + }) + } + } +} + func TestValidateFeatureRefs(t *testing.T) { t.Run("NoCollisions", func(t *testing.T) { viewA := &model.FeatureView{ @@ -2176,7 +2490,7 @@ func BenchmarkTransposeFeatureRowsIntoColumnsWithArrowConversion(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := TransposeFeatureRowsIntoColumns(featureData2D, groupRef, requestedFeatureViews, arrowAllocator, numRows, true) + _, err := TransposeFeatureRowsIntoColumns(featureData2D, groupRef, requestedFeatureViews, arrowAllocator, numRows, true, serving.UseDefaultsMode_USE_DEFAULTS_OFF) if err != nil { b.Fatalf("Error during TransposeFeatureRowsIntoColumns: %v", err) } @@ -2188,7 +2502,7 @@ func BenchmarkTransposeFeatureRowsIntoColumnsWithoutArrowConversion(b *testing.B b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := TransposeFeatureRowsIntoColumns(featureData2D, groupRef, requestedFeatureViews, arrowAllocator, numRows, false) + _, err := TransposeFeatureRowsIntoColumns(featureData2D, groupRef, requestedFeatureViews, arrowAllocator, numRows, false, serving.UseDefaultsMode_USE_DEFAULTS_OFF) if err != nil { b.Fatalf("Error during TransposeFeatureRowsIntoColumns: %v", err) } @@ -2200,7 +2514,7 @@ func BenchmarkFullLoopArrowConversion(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - vectors, err := TransposeFeatureRowsIntoColumns(featureData2D, groupRef, requestedFeatureViews, arrowAllocator, numRows, true) + vectors, err := TransposeFeatureRowsIntoColumns(featureData2D, groupRef, requestedFeatureViews, arrowAllocator, numRows, true, serving.UseDefaultsMode_USE_DEFAULTS_OFF) if err != nil { b.Fatalf("Error during TransposeFeatureRowsIntoColumns: %v", err) } @@ -2363,3 +2677,309 @@ func TestBatchGroupedFeatureRef_VariableBatchSizes(t *testing.T) { } }) } + +func TestApplyDefaults(t *testing.T) { + testCases := []struct { + name string + useDefaults serving.UseDefaultsMode + hasDefault bool + defaultValue *types.Value + featureDataNil bool // if true, simulate NOT_FOUND via nil row + featureValue *types.Value // if nil and featureDataNil=false, use NullVal + expiredTimestamp bool // if true, use old timestamp to trigger OUTSIDE_MAX_AGE + initialStatus serving.FieldStatus + expectValue *types.Value + expectStatus serving.FieldStatus + expectError bool + errorContains string + }{ + { + name: "OFF + NOT_FOUND + has default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_OFF, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureDataNil: true, + expectValue: nil, + expectStatus: serving.FieldStatus_NOT_FOUND, + }, + { + name: "OFF + NULL_VALUE + has default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_OFF, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureValue: &types.Value{Val: &types.Value_NullVal{}}, + expectValue: nil, + expectStatus: serving.FieldStatus_NOT_FOUND, + }, + { + name: "FLEXIBLE + NOT_FOUND + has default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureDataNil: true, + expectValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + expectStatus: serving.FieldStatus_PRESENT, + }, + { + name: "FLEXIBLE + NULL_VALUE + has default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureValue: &types.Value{Val: &types.Value_NullVal{}}, + expectValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + expectStatus: serving.FieldStatus_PRESENT, + }, + { + name: "FLEXIBLE + NOT_FOUND + no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: false, + featureDataNil: true, + expectValue: nil, + expectStatus: serving.FieldStatus_NOT_FOUND, + }, + { + name: "FLEXIBLE + NULL_VALUE + no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: false, + featureValue: &types.Value{Val: &types.Value_NullVal{}}, + expectValue: nil, + expectStatus: serving.FieldStatus_NOT_FOUND, + }, + { + name: "FLEXIBLE + PRESENT value", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 99.9}}, + expectValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 99.9}}, + expectStatus: serving.FieldStatus_PRESENT, + }, + { + name: "FLEXIBLE + OUTSIDE_MAX_AGE", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 1.0}}, + expiredTimestamp: true, + expectValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 1.0}}, + expectStatus: serving.FieldStatus_OUTSIDE_MAX_AGE, + }, + { + name: "UNSPECIFIED + NOT_FOUND + has default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_UNSPECIFIED, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureDataNil: true, + expectValue: nil, + expectStatus: serving.FieldStatus_NOT_FOUND, + }, + // STRICT mode test cases + { + name: "STRICT + NOT_FOUND + has default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureDataNil: true, + expectValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + expectStatus: serving.FieldStatus_PRESENT, + }, + { + name: "STRICT + NULL_VALUE + has default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureValue: &types.Value{Val: &types.Value_NullVal{}}, + expectValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + expectStatus: serving.FieldStatus_PRESENT, + }, + { + name: "STRICT + NOT_FOUND + no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: false, + featureDataNil: true, + expectError: true, + errorContains: "no default defined", + }, + { + name: "STRICT + NULL_VALUE + no default", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: false, + featureValue: &types.Value{Val: &types.Value_NullVal{}}, + expectError: true, + errorContains: "no default defined", + }, + { + name: "STRICT + PRESENT value", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 99.9}}, + expectValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 99.9}}, + expectStatus: serving.FieldStatus_PRESENT, + }, + { + name: "STRICT + OUTSIDE_MAX_AGE", + useDefaults: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + hasDefault: true, + defaultValue: &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}}, + featureValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 1.0}}, + expiredTimestamp: true, + expectValue: &types.Value{Val: &types.Value_DoubleVal{DoubleVal: 1.0}}, + expectStatus: serving.FieldStatus_OUTSIDE_MAX_AGE, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Test with both Arrow and non-Arrow + for _, useArrow := range []bool{true, false} { + testName := fmt.Sprintf("useArrow=%v", useArrow) + t.Run(testName, func(t *testing.T) { + arrowAllocator := memory.NewGoAllocator() + numRows := 1 + + // Create entity + entity1 := test.CreateEntityProto("driver", types.ValueType_INT64, "driver") + + // Create feature with or without default + var feature *core.FeatureSpecV2 + if tc.hasDefault { + feature = test.CreateFeatureWithDefault("f1", types.ValueType_INT64, tc.defaultValue) + } else { + feature = test.CreateFeature("f1", types.ValueType_INT64) + } + + fv := test.CreateFeatureViewModel("testView", []*core.Entity{entity1}, feature) + // Set a TTL for OUTSIDE_MAX_AGE test + fv.Ttl = &durationpb.Duration{Seconds: 3600} // 1 hour TTL + + featureViews := []*FeatureViewAndRefs{ + {View: fv, FeatureRefs: []string{"f1"}}, + } + + entityKeys := []*types.EntityKey{ + { + JoinKeys: []string{"driver"}, + EntityValues: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 1}}}, + }, + } + + groupRef := &GroupedFeaturesPerEntitySet{ + FeatureNames: []string{"f1"}, + FeatureViewNames: []string{"testView"}, + AliasedFeatureNames: []string{"testView__f1"}, + EntityKeys: entityKeys, + Indices: [][]int{{0}}, + } + + nowTime := time.Now() + // Use old timestamp for OUTSIDE_MAX_AGE test (2 hours old, TTL is 1 hour) + timestampToUse := nowTime + if tc.expiredTimestamp { + timestampToUse = nowTime.Add(-2 * time.Hour) + } + var featureData [][]onlinestore.FeatureData + + if tc.featureDataNil { + // Simulate NOT_FOUND by nil row + featureData = [][]onlinestore.FeatureData{nil} + } else if tc.featureValue == nil || tc.featureValue.Val == nil { + // Create feature data with NULL value + featureData = [][]onlinestore.FeatureData{ + { + { + Reference: serving.FeatureReferenceV2{ + FeatureViewName: "testView", + FeatureName: "f1", + }, + Timestamp: timestamppb.Timestamp{Seconds: timestampToUse.Unix()}, + Value: types.Value{Val: &types.Value_NullVal{}}, + }, + }, + } + } else { + // Create feature data with the specified value + featureData = [][]onlinestore.FeatureData{ + { + { + Reference: serving.FeatureReferenceV2{ + FeatureViewName: "testView", + FeatureName: "f1", + }, + Timestamp: timestamppb.Timestamp{Seconds: timestampToUse.Unix()}, + Value: types.Value{Val: tc.featureValue.Val}, + }, + }, + } + } + + // Call TransposeFeatureRowsIntoColumns with useDefaults parameter + vectors, err := TransposeFeatureRowsIntoColumns(featureData, groupRef, featureViews, arrowAllocator, numRows, useArrow, tc.useDefaults) + + // Handle error expectations + if tc.expectError { + assert.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + return + } + + assert.NoError(t, err) + assert.Len(t, vectors, 1) + vector := vectors[0] + + // Get proto values for comparison + protoValues, err := vector.GetProtoValues() + assert.NoError(t, err) + + if useArrow { + vector.Values.(arrow.Array).Release() + } + + // Check status + assert.Equal(t, tc.expectStatus, vector.Statuses[0], "Status mismatch") + + // Check value + if tc.expectValue == nil { + // Arrow conversion creates empty Value objects for nil, non-Arrow returns nil + if useArrow { + assert.NotNil(t, protoValues[0], "Arrow should create non-nil Value") + assert.Nil(t, protoValues[0].Val, "Arrow Value.Val should be nil") + } else { + assert.Nil(t, protoValues[0], "Non-Arrow should return nil value") + } + } else { + assert.NotNil(t, protoValues[0], "Expected non-nil value") + assert.True(t, proto.Equal(tc.expectValue, protoValues[0]), "Value mismatch") + } + }) + } + }) + } +} + +func TestFieldDefaultValueLoadedFromProto(t *testing.T) { + // Create a FeatureSpecV2 proto with a default value + defaultVal := &types.Value{Val: &types.Value_Int64Val{Int64Val: 42}} + featureProto := test.CreateFeatureWithDefault("feature_with_default", types.ValueType_INT64, defaultVal) + + // Create a FeatureView from proto (this is how Feature Server loads metadata) + entity := test.CreateEntityProto("driver", types.ValueType_INT64, "driver") + fvProto := test.CreateFeatureViewProto("test_fv", []*core.Entity{entity}, featureProto) + fv := model.NewFeatureViewFromProto(fvProto) + + // Verify the default value is loaded into the in-memory model + require.Len(t, fv.Base.Features, 1) + field := fv.Base.Features[0] + assert.Equal(t, "feature_with_default", field.Name) + require.NotNil(t, field.DefaultValue, "DefaultValue must be loaded from proto") + assert.Equal(t, int64(42), field.DefaultValue.GetInt64Val()) + + // Also verify a feature WITHOUT default has nil DefaultValue + featureNoDefault := test.CreateFeature("feature_no_default", types.ValueType_INT64) + fvProto2 := test.CreateFeatureViewProto("test_fv2", []*core.Entity{entity}, featureNoDefault) + fv2 := model.NewFeatureViewFromProto(fvProto2) + require.Len(t, fv2.Base.Features, 1) + assert.Nil(t, fv2.Base.Features[0].DefaultValue, "Feature without proto default must have nil DefaultValue") +} diff --git a/go/internal/feast/onlinestore/cassandraonlinestore.go b/go/internal/feast/onlinestore/cassandraonlinestore.go index 45c4618bff9..a8c86feafc2 100644 --- a/go/internal/feast/onlinestore/cassandraonlinestore.go +++ b/go/internal/feast/onlinestore/cassandraonlinestore.go @@ -130,6 +130,15 @@ func extractCassandraConfig(onlineStoreConfig map[string]any) (*CassandraConfig, if err != nil { return nil, err } + + // parse user_name as fallback + if username == "" { + username, err = parseStringField(onlineStoreConfig, "user_name", "") + if err != nil { + return nil, err + } + } + cassandraConfig.username = username // parse password @@ -256,7 +265,7 @@ func NewCassandraOnlineStore(project string, config *registry.RepoConfig, online } createdSession, err := gocqltrace.CreateTracedSession(store.clusterConfigs, gocqltrace.WithService(cassandraTraceServiceName)) if err != nil { - return nil, fmt.Errorf("unable to connect to the ScyllaDB database") + return nil, fmt.Errorf("unable to connect to the Cassandra database") } store.session = createdSession diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index 5077683b68e..16d445b6b62 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -79,7 +79,8 @@ func (s *grpcServingServiceServer) GetOnlineFeatures(ctx context.Context, reques featuresOrService.FeatureService, request.GetEntities(), request.GetRequestContext(), - request.GetFullFeatureNames()) + request.GetFullFeatureNames(), + request.GetUseDefaults()) if err != nil { logSpanContext.Error().Err(err).Msg("Error getting online features") @@ -161,6 +162,7 @@ func (s *grpcServingServiceServer) GetOnlineFeaturesRange(ctx context.Context, r request.GetLimit(), request.GetRequestContext(), request.GetFullFeatureNames(), + request.GetUseDefaults(), ) if err != nil { diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 68e71e0aabe..22eea41872d 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -318,6 +318,23 @@ type getOnlineFeaturesRequest struct { Entities map[string]repeatedValue `json:"entities"` FullFeatureNames bool `json:"full_feature_names"` RequestContext map[string]repeatedValue `json:"request_context"` + UseDefaults *string `json:"use_defaults"` +} + +func parseUseDefaultsMode(mode *string) (serving.UseDefaultsMode, error) { + if mode == nil { + return serving.UseDefaultsMode_USE_DEFAULTS_UNSPECIFIED, nil + } + switch strings.ToUpper(*mode) { + case "OFF": + return serving.UseDefaultsMode_USE_DEFAULTS_OFF, nil + case "FLEXIBLE": + return serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, nil + case "STRICT": + return serving.UseDefaultsMode_USE_DEFAULTS_STRICT, nil + default: + return serving.UseDefaultsMode_USE_DEFAULTS_UNSPECIFIED, fmt.Errorf("invalid use_defaults mode: %s (valid values: OFF, FLEXIBLE, STRICT)", *mode) + } } func NewHttpServer(fs *feast.FeatureStore, loggingService *logging.LoggingService) *HttpServer { @@ -404,13 +421,23 @@ func (s *HttpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { requestContextProto[key] = value.ToProto() } + + useDefaultsMode, err := parseUseDefaultsMode(request.UseDefaults) + if err != nil { + logSpanContext.Error().Err(err).Msg("Invalid use_defaults mode") + writeJSONError(w, err, http.StatusBadRequest) + return + } + featureVectors, err = s.fs.GetOnlineFeatures( ctx, request.Features, featureService, entitiesProto, requestContextProto, - request.FullFeatureNames) + request.FullFeatureNames, + useDefaultsMode) + defer func() { if featureVectors != nil { @@ -520,6 +547,7 @@ type getOnlineFeaturesRangeRequest struct { Limit int32 `json:"limit"` FullFeatureNames bool `json:"full_feature_names"` RequestContext map[string]repeatedValue `json:"request_context"` + UseDefaults *string `json:"use_defaults"` } type sortKeyFilter struct { @@ -605,6 +633,14 @@ func (s *HttpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque return } + + useDefaultsMode, err := parseUseDefaultsMode(request.UseDefaults) + if err != nil { + logSpanContext.Error().Err(err).Msg("Invalid use_defaults mode") + writeJSONError(w, err, http.StatusBadRequest) + return + } + rangeFeatureVectors, err := s.fs.GetOnlineFeaturesRange( ctx, request.Features, @@ -614,7 +650,9 @@ func (s *HttpServer) getOnlineFeaturesRange(w http.ResponseWriter, r *http.Reque request.ReverseSortOrder, request.Limit, requestContextProto, - request.FullFeatureNames) + request.FullFeatureNames, + useDefaultsMode) + defer func() { if rangeFeatureVectors != nil { diff --git a/go/internal/feast/server/http_server_test.go b/go/internal/feast/server/http_server_test.go index 71cb6047ae6..1a58565907d 100644 --- a/go/internal/feast/server/http_server_test.go +++ b/go/internal/feast/server/http_server_test.go @@ -421,3 +421,60 @@ func TestProcessFeatureVectors_NullValueReturnsNull(t *testing.T) { timestamps := results[0]["event_timestamps"].([][]interface{}) assert.Equal(t, time.Unix(1234567890, 0).UTC().Format(time.RFC3339), timestamps[0][0], "Expected timestamp to be zero for null value") } + +func TestParseUseDefaultsMode(t *testing.T) { + tests := []struct { + name string + input *string + expected serving.UseDefaultsMode + expectError bool + }{ + { + name: "nil defaults to UNSPECIFIED", + input: nil, + expected: serving.UseDefaultsMode_USE_DEFAULTS_UNSPECIFIED, + expectError: false, + }, + { + name: "OFF uppercase", + input: stringPtr("OFF"), + expected: serving.UseDefaultsMode_USE_DEFAULTS_OFF, + expectError: false, + }, + { + name: "flexible lowercase", + input: stringPtr("flexible"), + expected: serving.UseDefaultsMode_USE_DEFAULTS_FLEXIBLE, + expectError: false, + }, + { + name: "STRICT mixed case", + input: stringPtr("Strict"), + expected: serving.UseDefaultsMode_USE_DEFAULTS_STRICT, + expectError: false, + }, + { + name: "invalid string returns error", + input: stringPtr("INVALID"), + expected: serving.UseDefaultsMode_USE_DEFAULTS_UNSPECIFIED, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseUseDefaultsMode(tt.input) + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid use_defaults mode") + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func stringPtr(s string) *string { + return &s +} diff --git a/go/internal/test/go_test_utils.go b/go/internal/test/go_test_utils.go index 34f34e780b2..8ce3bdedad8 100644 --- a/go/internal/test/go_test_utils.go +++ b/go/internal/test/go_test_utils.go @@ -109,6 +109,14 @@ func CreateFeature(name string, valueType types.ValueType_Enum) *core.FeatureSpe } } +func CreateFeatureWithDefault(name string, valueType types.ValueType_Enum, defaultValue *types.Value) *core.FeatureSpecV2 { + return &core.FeatureSpecV2{ + Name: name, + ValueType: valueType, + DefaultValue: defaultValue, + } +} + func CreateFeatureViewProto(name string, entities []*core.Entity, features ...*core.FeatureSpecV2) *core.FeatureView { entityNames, entityColumns := getEntityNamesAndColumns(entities) viewProto := core.FeatureView{ diff --git a/protos/feast/core/Feature.proto b/protos/feast/core/Feature.proto index 9f7708c65e7..472df8f25b8 100644 --- a/protos/feast/core/Feature.proto +++ b/protos/feast/core/Feature.proto @@ -45,4 +45,7 @@ message FeatureSpecV2 { // Field indicating the vector length int32 vector_length = 7; + + // Default value to be used for the feature when its value is missing/expired. + feast.types.Value default_value = 8; } diff --git a/protos/feast/serving/ServingService.proto b/protos/feast/serving/ServingService.proto index ebadeb6f7ff..36af202d23a 100644 --- a/protos/feast/serving/ServingService.proto +++ b/protos/feast/serving/ServingService.proto @@ -108,6 +108,16 @@ message GetOnlineFeaturesRequest { // Whether to include the timestamp/status metadata in the response bool include_metadata = 10; + + // Mode for handling features with default values when feature value is missing + UseDefaultsMode use_defaults = 11; +} + +enum UseDefaultsMode { + USE_DEFAULTS_UNSPECIFIED = 0; // Field not set - use server default behavior (currently OFF) + USE_DEFAULTS_OFF = 1; // Explicitly disable default replacement + USE_DEFAULTS_FLEXIBLE = 2; // Ignore if default missing + USE_DEFAULTS_STRICT = 3; // Fail if default is missing } message GetOnlineFeaturesResponse { @@ -200,6 +210,10 @@ message GetOnlineFeaturesRangeRequest { // Whether to include the timestamp and status metadata in the response bool include_metadata = 9; + + // Mode for handling features with default values when feature value is missing + UseDefaultsMode use_defaults = 11; + } message GetOnlineFeaturesRangeResponse { diff --git a/sdk/python/feast/expediagroup/pydantic_models/field_model.py b/sdk/python/feast/expediagroup/pydantic_models/field_model.py index b30c5862755..22540707888 100644 --- a/sdk/python/feast/expediagroup/pydantic_models/field_model.py +++ b/sdk/python/feast/expediagroup/pydantic_models/field_model.py @@ -1,9 +1,11 @@ -from typing import Dict, Optional, Union +from typing import Any, Dict, Optional, Union -from pydantic import BaseModel +from google.protobuf.json_format import MessageToDict, ParseDict +from pydantic import BaseModel, ConfigDict, field_validator from typing_extensions import Self from feast.field import Field +from feast.protos.feast.types import Value_pb2 as ValueProto from feast.types import Array, PrimitiveFeastType @@ -19,6 +21,37 @@ class FieldModel(BaseModel): vector_index: bool = False vector_length: int = 0 vector_search_metric: Optional[str] = None + default_value: Optional[ValueProto.Value] = None + + model_config = ConfigDict( + arbitrary_types_allowed=True, + json_schema_serialization_defaults_required=False, + ) + + def model_dump(self, **kwargs) -> Dict[str, Any]: + """Override model_dump to handle proto Value serialization.""" + data = super().model_dump(**kwargs) + if self.default_value is not None: + data["default_value"] = MessageToDict( + self.default_value, preserving_proto_field_name=False + ) + return data + + @field_validator("default_value", mode="before") + @classmethod + def validate_default_value(cls, v: Any) -> Optional[ValueProto.Value]: + """ + Validate default_value: accepts proto Value object or dict. + When receiving dict (from JSON), convert to proto Value using ParseDict. + Note: ParseDict handles base64-encoded bytes automatically for bytesVal fields. + """ + if v is None: + return None + if isinstance(v, ValueProto.Value): + return v + if isinstance(v, dict): + return ParseDict(v, ValueProto.Value()) + return v def to_field(self) -> Field: """ @@ -35,6 +68,7 @@ def to_field(self) -> Field: vector_index=self.vector_index, vector_length=self.vector_length, vector_search_metric=self.vector_search_metric, + default_value=self.default_value, ) @classmethod @@ -56,4 +90,5 @@ def from_field( vector_index=field.vector_index, vector_length=field.vector_length, vector_search_metric=field.vector_search_metric, + default_value=field.default_value, ) diff --git a/sdk/python/feast/feature.py b/sdk/python/feast/feature.py index db629d677a8..15904474df6 100644 --- a/sdk/python/feast/feature.py +++ b/sdk/python/feast/feature.py @@ -15,6 +15,7 @@ from typing import Dict, Optional from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 as FeatureSpecProto +from feast.protos.feast.types import Value_pb2 as ValueProto from feast.protos.feast.types.Value_pb2 import ValueType as ValueTypeProto from feast.value_type import ValueType @@ -35,6 +36,7 @@ def __init__( dtype: ValueType, description: str = "", labels: Optional[Dict[str, str]] = None, + default_value: Optional[ValueProto.Value] = None, ): """Creates a Feature object.""" self._name = name @@ -48,6 +50,7 @@ def __init__( self._labels = dict() else: self._labels = labels + self._default_value = default_value def __eq__(self, other): if self.name != other.name or self.dtype != other.dtype: @@ -64,6 +67,7 @@ def __repr__(self): f" dtype={self._dtype!r},\n" f" description={self._description!r},\n" f" labels={self._labels!r}\n" + f" default_value={self._default_value!r}\n" f")" ) @@ -99,6 +103,13 @@ def labels(self) -> Dict[str, str]: """ return self._labels + @property + def default_value(self) -> Optional[ValueProto.Value]: + """ + Gets the default value of this feature. + """ + return self._default_value + def to_proto(self) -> FeatureSpecProto: """ Converts Feature object to its Protocol Buffer representation. @@ -108,12 +119,15 @@ def to_proto(self) -> FeatureSpecProto: """ value_type = ValueTypeProto.Enum.Value(self.dtype.name) - return FeatureSpecProto( + proto = FeatureSpecProto( name=self.name, value_type=value_type, description=self.description, tags=self.labels, ) + if self.default_value is not None: + proto.default_value.CopyFrom(self.default_value) # type: ignore[attr-defined] + return proto @classmethod def from_proto(cls, feature_proto: FeatureSpecProto): @@ -124,11 +138,16 @@ def from_proto(cls, feature_proto: FeatureSpecProto): Returns: Feature object """ + default_value = getattr(feature_proto, "default_value", None) + if default_value is not None and not default_value.WhichOneof("val"): + # Empty Value proto, treat as None + default_value = None feature = cls( name=feature_proto.name, dtype=ValueType(feature_proto.value_type), description=feature_proto.description, labels=dict(feature_proto.tags), + default_value=default_value, ) return feature diff --git a/sdk/python/feast/field.py b/sdk/python/feast/field.py index d03a5ccdaac..41d74876d61 100644 --- a/sdk/python/feast/field.py +++ b/sdk/python/feast/field.py @@ -12,13 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Optional +from typing import Any, Dict, Optional from pydantic import BaseModel, ConfigDict, field_validator from typeguard import check_type, typechecked from feast.feature import Feature from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 as FieldProto +from feast.protos.feast.types import Value_pb2 as ValueProto from feast.types import FeastType, from_string, from_value_type from feast.value_type import ValueType @@ -47,6 +48,61 @@ class Field(BaseModel): vector_index: bool = False vector_length: int = 0 vector_search_metric: Optional[str] = "" + default_value: Optional[ValueProto.Value] = None + + @field_validator("default_value") + @classmethod + def validate_default_value_type( + cls, v: Optional[ValueProto.Value], values: Any + ) -> Optional[ValueProto.Value]: + """ + Validate that default_value type matches the field's dtype. + """ + if v is None: + return v + + # Get dtype from the model data + dtype = values.data.get("dtype") + if dtype is None: + # dtype will be validated by its own validator, skip for now + return v + + # Validate type compatibility + value_type = dtype.to_value_type() + val_case = v.WhichOneof("val") + + if val_case is None: + # Empty Value proto + return v + + # Map proto value types to ValueType enums + type_mapping: Dict[str, ValueType] = { + "int32_val": ValueType.INT32, + "int64_val": ValueType.INT64, + "double_val": ValueType.DOUBLE, + "float_val": ValueType.FLOAT, + "string_val": ValueType.STRING, + "bytes_val": ValueType.BYTES, + "bool_val": ValueType.BOOL, + "unix_timestamp_val": ValueType.UNIX_TIMESTAMP, + "int32_list_val": ValueType.INT32_LIST, + "int64_list_val": ValueType.INT64_LIST, + "double_list_val": ValueType.DOUBLE_LIST, + "float_list_val": ValueType.FLOAT_LIST, + "string_list_val": ValueType.STRING_LIST, + "bytes_list_val": ValueType.BYTES_LIST, + "bool_list_val": ValueType.BOOL_LIST, + "unix_timestamp_list_val": ValueType.UNIX_TIMESTAMP_LIST, + } + + expected_type = type_mapping.get(val_case) + if expected_type != value_type: + raise ValueError( + f"default_value type '{val_case}' does not match field dtype '{dtype}' " + f"(expected ValueType.{value_type.name})" + ) + + return v @field_validator("dtype", mode="before") def dtype_is_feasttype_or_string_feasttype(cls, v): @@ -82,6 +138,18 @@ def __eq__(self, other): # or self.vector_search_metric != other.vector_search_metric ): return False + + # Compare default_value - handle None and proto Value comparison + if self.default_value is None and other.default_value is None: + pass # Both None, equal + elif self.default_value is None or other.default_value is None: + return False # One is None, other is not + elif ( + self.default_value.SerializeToString() + != other.default_value.SerializeToString() + ): + return False # Both are Values but different + return True def __hash__(self): @@ -100,6 +168,7 @@ def __repr__(self): f" vector_index={self.vector_index!r}\n" f" vector_length={self.vector_length!r}\n" f" vector_search_metric={self.vector_search_metric!r}\n" + f" default_value={self.default_value!r}\n" f")" ) @@ -110,7 +179,7 @@ def to_proto(self) -> FieldProto: """Converts a Field object to its protobuf representation.""" value_type = self.dtype.to_value_type() vector_search_metric = self.vector_search_metric or "" - return FieldProto( + proto = FieldProto( name=self.name, value_type=value_type.value, description=self.description, @@ -119,6 +188,11 @@ def to_proto(self) -> FieldProto: vector_length=self.vector_length, vector_search_metric=vector_search_metric, ) + # Add default_value if present (using type: ignore until proto is regenerated) + if self.default_value is not None: + proto.default_value.CopyFrom(self.default_value) # type: ignore[attr-defined] + + return proto @classmethod def from_proto(cls, field_proto: FieldProto): @@ -132,6 +206,11 @@ def from_proto(cls, field_proto: FieldProto): vector_search_metric = getattr(field_proto, "vector_search_metric", "") vector_index = getattr(field_proto, "vector_index", False) vector_length = getattr(field_proto, "vector_length", 0) + # Extract default_value if present + default_value = getattr(field_proto, "default_value", None) + if default_value is not None and not default_value.WhichOneof("val"): + # Empty Value proto, treat as None + default_value = None return cls( name=field_proto.name, dtype=from_value_type(value_type=value_type), @@ -140,6 +219,7 @@ def from_proto(cls, field_proto: FieldProto): vector_index=vector_index, vector_length=vector_length, vector_search_metric=vector_search_metric, + default_value=default_value, ) @classmethod @@ -155,4 +235,5 @@ def from_feature(cls, feature: Feature): dtype=from_value_type(feature.dtype), description=feature.description, tags=feature.labels, + default_value=feature.default_value, ) diff --git a/sdk/python/feast/infra/online_stores/cassandra_online_store/cassandra_online_store.py b/sdk/python/feast/infra/online_stores/cassandra_online_store/cassandra_online_store.py index 9755c2a0744..ef38ba8ef68 100644 --- a/sdk/python/feast/infra/online_stores/cassandra_online_store/cassandra_online_store.py +++ b/sdk/python/feast/infra/online_stores/cassandra_online_store/cassandra_online_store.py @@ -515,7 +515,7 @@ def on_failure(exc, concurrent_queue): else: feature_value = getattr(valProto, str(feast_value_type)) else: - # For all other features, use the serialized value + # For all other features, use the serialized value. feature_value = valProto.SerializeToString() # type:ignore feature_values += (feature_value,) diff --git a/sdk/python/feast/infra/registry/http.py b/sdk/python/feast/infra/registry/http.py index 3b4470ae301..a07e3aeeed0 100644 --- a/sdk/python/feast/infra/registry/http.py +++ b/sdk/python/feast/infra/registry/http.py @@ -959,6 +959,7 @@ def list_project_metadata( # type: ignore[return] try: url = f"{self.base_url}/projects/{project}" response_data = self._send_request("GET", url) + logger.info(f"ProjectMetadata response data: {response_data}") return [ ProjectMetadataModel.model_validate(response_data).to_project_metadata() ] diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 2b3f93f79b5..df9a3a98989 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -444,7 +444,7 @@ def apply_project_metadata( self, project: str, commit: bool ) -> ProjectMetadataModel: self._maybe_init_project_metadata(project) - return self._get_project_metadata_model(project) + return self.get_project_metadata_model(project) def _get_entity(self, name: str, project: str) -> Entity: return self._get_object( @@ -1531,12 +1531,13 @@ def get_project_metadata(self, project: str, key: str) -> Optional[str]: return row._mapping["metadata_value"] return None - def _get_project_metadata_model( + def get_project_metadata_model( self, project: str, allow_cache: bool = False, ) -> ProjectMetadataModel: """ + Expedia specific function used in eg-feature-store-registry to get project metadata model. Returns given project metdata. No supporting function in SQL Registry so implemented this here rather than using _get_last_updated_metadata and list_project_metadata. """ diff --git a/sdk/python/feast/protos/feast/core/Feature_pb2.py b/sdk/python/feast/protos/feast/core/Feature_pb2.py index a02bb7ff403..c7abf76d66e 100644 --- a/sdk/python/feast/protos/feast/core/Feature_pb2.py +++ b/sdk/python/feast/protos/feast/core/Feature_pb2.py @@ -15,7 +15,7 @@ from feast.protos.feast.types import Value_pb2 as feast_dot_types_dot_Value__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x66\x65\x61st/core/Feature.proto\x12\nfeast.core\x1a\x17\x66\x65\x61st/types/Value.proto\"\x8e\x02\n\rFeatureSpecV2\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\nvalue_type\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum\x12\x31\n\x04tags\x18\x03 \x03(\x0b\x32#.feast.core.FeatureSpecV2.TagsEntry\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\x14\n\x0cvector_index\x18\x05 \x01(\x08\x12\x1c\n\x14vector_search_metric\x18\x06 \x01(\t\x12\x15\n\rvector_length\x18\x07 \x01(\x05\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42Q\n\x10\x66\x65\x61st.proto.coreB\x0c\x46\x65\x61tureProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x66\x65\x61st/core/Feature.proto\x12\nfeast.core\x1a\x17\x66\x65\x61st/types/Value.proto\"\xb9\x02\n\rFeatureSpecV2\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\nvalue_type\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum\x12\x31\n\x04tags\x18\x03 \x03(\x0b\x32#.feast.core.FeatureSpecV2.TagsEntry\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\x14\n\x0cvector_index\x18\x05 \x01(\x08\x12\x1c\n\x14vector_search_metric\x18\x06 \x01(\t\x12\x15\n\rvector_length\x18\x07 \x01(\x05\x12)\n\rdefault_value\x18\x08 \x01(\x0b\x32\x12.feast.types.Value\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42Q\n\x10\x66\x65\x61st.proto.coreB\x0c\x46\x65\x61tureProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -26,7 +26,7 @@ _globals['_FEATURESPECV2_TAGSENTRY']._options = None _globals['_FEATURESPECV2_TAGSENTRY']._serialized_options = b'8\001' _globals['_FEATURESPECV2']._serialized_start=66 - _globals['_FEATURESPECV2']._serialized_end=336 - _globals['_FEATURESPECV2_TAGSENTRY']._serialized_start=293 - _globals['_FEATURESPECV2_TAGSENTRY']._serialized_end=336 + _globals['_FEATURESPECV2']._serialized_end=379 + _globals['_FEATURESPECV2_TAGSENTRY']._serialized_start=336 + _globals['_FEATURESPECV2_TAGSENTRY']._serialized_end=379 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/Feature_pb2.pyi b/sdk/python/feast/protos/feast/core/Feature_pb2.pyi index aa56630424f..734d5bc275e 100644 --- a/sdk/python/feast/protos/feast/core/Feature_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/Feature_pb2.pyi @@ -56,6 +56,7 @@ class FeatureSpecV2(google.protobuf.message.Message): VECTOR_INDEX_FIELD_NUMBER: builtins.int VECTOR_SEARCH_METRIC_FIELD_NUMBER: builtins.int VECTOR_LENGTH_FIELD_NUMBER: builtins.int + DEFAULT_VALUE_FIELD_NUMBER: builtins.int name: builtins.str """Name of the feature. Not updatable.""" value_type: feast.types.Value_pb2.ValueType.Enum.ValueType @@ -71,6 +72,9 @@ class FeatureSpecV2(google.protobuf.message.Message): """Metric used for vector similarity search.""" vector_length: builtins.int """Field indicating the vector length""" + @property + def default_value(self) -> feast.types.Value_pb2.Value: + """Default value to be used for the feature when its value is missing/expired.""" def __init__( self, *, @@ -81,7 +85,9 @@ class FeatureSpecV2(google.protobuf.message.Message): vector_index: builtins.bool = ..., vector_search_metric: builtins.str = ..., vector_length: builtins.int = ..., + default_value: feast.types.Value_pb2.Value | None = ..., ) -> None: ... - def ClearField(self, field_name: typing_extensions.Literal["description", b"description", "name", b"name", "tags", b"tags", "value_type", b"value_type", "vector_index", b"vector_index", "vector_length", b"vector_length", "vector_search_metric", b"vector_search_metric"]) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["default_value", b"default_value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["default_value", b"default_value", "description", b"description", "name", b"name", "tags", b"tags", "value_type", b"value_type", "vector_index", b"vector_index", "vector_length", b"vector_length", "vector_search_metric", b"vector_search_metric"]) -> None: ... global___FeatureSpecV2 = FeatureSpecV2 diff --git a/sdk/python/feast/protos/feast/serving/ServingService_pb2.py b/sdk/python/feast/protos/feast/serving/ServingService_pb2.py index 12c00e856b2..85e467ca447 100644 --- a/sdk/python/feast/protos/feast/serving/ServingService_pb2.py +++ b/sdk/python/feast/protos/feast/serving/ServingService_pb2.py @@ -16,7 +16,7 @@ from feast.protos.feast.types import Value_pb2 as feast_dot_types_dot_Value__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"feast/serving/ServingService.proto\x12\rfeast.serving\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17\x66\x65\x61st/types/Value.proto\"\x1c\n\x1aGetFeastServingInfoRequest\".\n\x1bGetFeastServingInfoResponse\x12\x0f\n\x07version\x18\x01 \x01(\t\"\x17\n\x15GetVersionInfoRequest\"{\n\x16GetVersionInfoResponse\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x12\n\nbuild_time\x18\x02 \x01(\t\x12\x13\n\x0b\x63ommit_hash\x18\x03 \x01(\t\x12\x12\n\ngo_version\x18\x04 \x01(\t\x12\x13\n\x0bserver_type\x18\x05 \x01(\t\"E\n\x12\x46\x65\x61tureReferenceV2\x12\x19\n\x11\x66\x65\x61ture_view_name\x18\x01 \x01(\t\x12\x14\n\x0c\x66\x65\x61ture_name\x18\x02 \x01(\t\"\xfd\x02\n\x1aGetOnlineFeaturesRequestV2\x12\x33\n\x08\x66\x65\x61tures\x18\x04 \x03(\x0b\x32!.feast.serving.FeatureReferenceV2\x12H\n\x0b\x65ntity_rows\x18\x02 \x03(\x0b\x32\x33.feast.serving.GetOnlineFeaturesRequestV2.EntityRow\x12\x0f\n\x07project\x18\x05 \x01(\t\x1a\xce\x01\n\tEntityRow\x12-\n\ttimestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12O\n\x06\x66ields\x18\x02 \x03(\x0b\x32?.feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry\x1a\x41\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value:\x02\x38\x01\"\x1a\n\x0b\x46\x65\x61tureList\x12\x0b\n\x03val\x18\x01 \x03(\t\"\xe2\x03\n\x18GetOnlineFeaturesRequest\x12\x19\n\x0f\x66\x65\x61ture_service\x18\x01 \x01(\tH\x00\x12.\n\x08\x66\x65\x61tures\x18\x02 \x01(\x0b\x32\x1a.feast.serving.FeatureListH\x00\x12G\n\x08\x65ntities\x18\x03 \x03(\x0b\x32\x35.feast.serving.GetOnlineFeaturesRequest.EntitiesEntry\x12\x1a\n\x12\x66ull_feature_names\x18\x04 \x01(\x08\x12T\n\x0frequest_context\x18\x05 \x03(\x0b\x32;.feast.serving.GetOnlineFeaturesRequest.RequestContextEntry\x12\x18\n\x10include_metadata\x18\n \x01(\x08\x1aK\n\rEntitiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x1aQ\n\x13RequestContextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x42\x06\n\x04kind\"\xd2\x02\n\x19GetOnlineFeaturesResponse\x12\x42\n\x08metadata\x18\x01 \x01(\x0b\x32\x30.feast.serving.GetOnlineFeaturesResponseMetadata\x12G\n\x07results\x18\x02 \x03(\x0b\x32\x36.feast.serving.GetOnlineFeaturesResponse.FeatureVector\x12\x0e\n\x06status\x18\x03 \x01(\x08\x1a\x97\x01\n\rFeatureVector\x12\"\n\x06values\x18\x01 \x03(\x0b\x32\x12.feast.types.Value\x12,\n\x08statuses\x18\x02 \x03(\x0e\x32\x1a.feast.serving.FieldStatus\x12\x34\n\x10\x65vent_timestamps\x18\x03 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"V\n!GetOnlineFeaturesResponseMetadata\x12\x31\n\rfeature_names\x18\x01 \x01(\x0b\x32\x1a.feast.serving.FeatureList\"A\n\x13RepeatedFieldStatus\x12*\n\x06status\x18\x01 \x03(\x0e\x32\x1a.feast.serving.FieldStatus\"\x9e\x02\n\rSortKeyFilter\x12\x15\n\rsort_key_name\x18\x01 \x01(\t\x12\x38\n\x05range\x18\x02 \x01(\x0b\x32\'.feast.serving.SortKeyFilter.RangeQueryH\x00\x12$\n\x06\x65quals\x18\x03 \x01(\x0b\x32\x12.feast.types.ValueH\x00\x1a\x8c\x01\n\nRangeQuery\x12\'\n\x0brange_start\x18\x02 \x01(\x0b\x32\x12.feast.types.Value\x12%\n\trange_end\x18\x03 \x01(\x0b\x32\x12.feast.types.Value\x12\x17\n\x0fstart_inclusive\x18\x04 \x01(\x08\x12\x15\n\rend_inclusive\x18\x05 \x01(\x08\x42\x07\n\x05query\"\xd4\x04\n\x1dGetOnlineFeaturesRangeRequest\x12\x19\n\x0f\x66\x65\x61ture_service\x18\x01 \x01(\tH\x00\x12.\n\x08\x66\x65\x61tures\x18\x02 \x01(\x0b\x32\x1a.feast.serving.FeatureListH\x00\x12L\n\x08\x65ntities\x18\x03 \x03(\x0b\x32:.feast.serving.GetOnlineFeaturesRangeRequest.EntitiesEntry\x12\x1a\n\x12\x66ull_feature_names\x18\x04 \x01(\x08\x12\x36\n\x10sort_key_filters\x18\x05 \x03(\x0b\x32\x1c.feast.serving.SortKeyFilter\x12\x1a\n\x12reverse_sort_order\x18\x06 \x01(\x08\x12\r\n\x05limit\x18\x07 \x01(\x05\x12Y\n\x0frequest_context\x18\x08 \x03(\x0b\x32@.feast.serving.GetOnlineFeaturesRangeRequest.RequestContextEntry\x12\x18\n\x10include_metadata\x18\t \x01(\x08\x1aK\n\rEntitiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x1aQ\n\x13RequestContextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x42\x06\n\x04kind\"\x82\x04\n\x1eGetOnlineFeaturesRangeResponse\x12\x42\n\x08metadata\x18\x01 \x01(\x0b\x32\x30.feast.serving.GetOnlineFeaturesResponseMetadata\x12M\n\x08\x65ntities\x18\x02 \x03(\x0b\x32;.feast.serving.GetOnlineFeaturesRangeResponse.EntitiesEntry\x12Q\n\x07results\x18\x03 \x03(\x0b\x32@.feast.serving.GetOnlineFeaturesRangeResponse.RangeFeatureVector\x1aK\n\rEntitiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x1a\xac\x01\n\x12RangeFeatureVector\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.feast.types.RepeatedValue\x12\x34\n\x08statuses\x18\x02 \x03(\x0b\x32\".feast.serving.RepeatedFieldStatus\x12\x34\n\x10\x65vent_timestamps\x18\x03 \x03(\x0b\x32\x1a.feast.types.RepeatedValue*[\n\x0b\x46ieldStatus\x12\x0b\n\x07INVALID\x10\x00\x12\x0b\n\x07PRESENT\x10\x01\x12\x0e\n\nNULL_VALUE\x10\x02\x12\r\n\tNOT_FOUND\x10\x03\x12\x13\n\x0fOUTSIDE_MAX_AGE\x10\x04\x32\xbc\x03\n\x0eServingService\x12l\n\x13GetFeastServingInfo\x12).feast.serving.GetFeastServingInfoRequest\x1a*.feast.serving.GetFeastServingInfoResponse\x12]\n\x0eGetVersionInfo\x12$.feast.serving.GetVersionInfoRequest\x1a%.feast.serving.GetVersionInfoResponse\x12\x66\n\x11GetOnlineFeatures\x12\'.feast.serving.GetOnlineFeaturesRequest\x1a(.feast.serving.GetOnlineFeaturesResponse\x12u\n\x16GetOnlineFeaturesRange\x12,.feast.serving.GetOnlineFeaturesRangeRequest\x1a-.feast.serving.GetOnlineFeaturesRangeResponseBZ\n\x13\x66\x65\x61st.proto.servingB\x0fServingAPIProtoZ2github.com/feast-dev/feast/go/protos/feast/servingb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"feast/serving/ServingService.proto\x12\rfeast.serving\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17\x66\x65\x61st/types/Value.proto\"\x1c\n\x1aGetFeastServingInfoRequest\".\n\x1bGetFeastServingInfoResponse\x12\x0f\n\x07version\x18\x01 \x01(\t\"\x17\n\x15GetVersionInfoRequest\"{\n\x16GetVersionInfoResponse\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x12\n\nbuild_time\x18\x02 \x01(\t\x12\x13\n\x0b\x63ommit_hash\x18\x03 \x01(\t\x12\x12\n\ngo_version\x18\x04 \x01(\t\x12\x13\n\x0bserver_type\x18\x05 \x01(\t\"E\n\x12\x46\x65\x61tureReferenceV2\x12\x19\n\x11\x66\x65\x61ture_view_name\x18\x01 \x01(\t\x12\x14\n\x0c\x66\x65\x61ture_name\x18\x02 \x01(\t\"\xfd\x02\n\x1aGetOnlineFeaturesRequestV2\x12\x33\n\x08\x66\x65\x61tures\x18\x04 \x03(\x0b\x32!.feast.serving.FeatureReferenceV2\x12H\n\x0b\x65ntity_rows\x18\x02 \x03(\x0b\x32\x33.feast.serving.GetOnlineFeaturesRequestV2.EntityRow\x12\x0f\n\x07project\x18\x05 \x01(\t\x1a\xce\x01\n\tEntityRow\x12-\n\ttimestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12O\n\x06\x66ields\x18\x02 \x03(\x0b\x32?.feast.serving.GetOnlineFeaturesRequestV2.EntityRow.FieldsEntry\x1a\x41\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value:\x02\x38\x01\"\x1a\n\x0b\x46\x65\x61tureList\x12\x0b\n\x03val\x18\x01 \x03(\t\"\x98\x04\n\x18GetOnlineFeaturesRequest\x12\x19\n\x0f\x66\x65\x61ture_service\x18\x01 \x01(\tH\x00\x12.\n\x08\x66\x65\x61tures\x18\x02 \x01(\x0b\x32\x1a.feast.serving.FeatureListH\x00\x12G\n\x08\x65ntities\x18\x03 \x03(\x0b\x32\x35.feast.serving.GetOnlineFeaturesRequest.EntitiesEntry\x12\x1a\n\x12\x66ull_feature_names\x18\x04 \x01(\x08\x12T\n\x0frequest_context\x18\x05 \x03(\x0b\x32;.feast.serving.GetOnlineFeaturesRequest.RequestContextEntry\x12\x18\n\x10include_metadata\x18\n \x01(\x08\x12\x34\n\x0cuse_defaults\x18\x0b \x01(\x0e\x32\x1e.feast.serving.UseDefaultsMode\x1aK\n\rEntitiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x1aQ\n\x13RequestContextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x42\x06\n\x04kind\"\xd2\x02\n\x19GetOnlineFeaturesResponse\x12\x42\n\x08metadata\x18\x01 \x01(\x0b\x32\x30.feast.serving.GetOnlineFeaturesResponseMetadata\x12G\n\x07results\x18\x02 \x03(\x0b\x32\x36.feast.serving.GetOnlineFeaturesResponse.FeatureVector\x12\x0e\n\x06status\x18\x03 \x01(\x08\x1a\x97\x01\n\rFeatureVector\x12\"\n\x06values\x18\x01 \x03(\x0b\x32\x12.feast.types.Value\x12,\n\x08statuses\x18\x02 \x03(\x0e\x32\x1a.feast.serving.FieldStatus\x12\x34\n\x10\x65vent_timestamps\x18\x03 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"V\n!GetOnlineFeaturesResponseMetadata\x12\x31\n\rfeature_names\x18\x01 \x01(\x0b\x32\x1a.feast.serving.FeatureList\"A\n\x13RepeatedFieldStatus\x12*\n\x06status\x18\x01 \x03(\x0e\x32\x1a.feast.serving.FieldStatus\"\x9e\x02\n\rSortKeyFilter\x12\x15\n\rsort_key_name\x18\x01 \x01(\t\x12\x38\n\x05range\x18\x02 \x01(\x0b\x32\'.feast.serving.SortKeyFilter.RangeQueryH\x00\x12$\n\x06\x65quals\x18\x03 \x01(\x0b\x32\x12.feast.types.ValueH\x00\x1a\x8c\x01\n\nRangeQuery\x12\'\n\x0brange_start\x18\x02 \x01(\x0b\x32\x12.feast.types.Value\x12%\n\trange_end\x18\x03 \x01(\x0b\x32\x12.feast.types.Value\x12\x17\n\x0fstart_inclusive\x18\x04 \x01(\x08\x12\x15\n\rend_inclusive\x18\x05 \x01(\x08\x42\x07\n\x05query\"\x8a\x05\n\x1dGetOnlineFeaturesRangeRequest\x12\x19\n\x0f\x66\x65\x61ture_service\x18\x01 \x01(\tH\x00\x12.\n\x08\x66\x65\x61tures\x18\x02 \x01(\x0b\x32\x1a.feast.serving.FeatureListH\x00\x12L\n\x08\x65ntities\x18\x03 \x03(\x0b\x32:.feast.serving.GetOnlineFeaturesRangeRequest.EntitiesEntry\x12\x1a\n\x12\x66ull_feature_names\x18\x04 \x01(\x08\x12\x36\n\x10sort_key_filters\x18\x05 \x03(\x0b\x32\x1c.feast.serving.SortKeyFilter\x12\x1a\n\x12reverse_sort_order\x18\x06 \x01(\x08\x12\r\n\x05limit\x18\x07 \x01(\x05\x12Y\n\x0frequest_context\x18\x08 \x03(\x0b\x32@.feast.serving.GetOnlineFeaturesRangeRequest.RequestContextEntry\x12\x18\n\x10include_metadata\x18\t \x01(\x08\x12\x34\n\x0cuse_defaults\x18\x0b \x01(\x0e\x32\x1e.feast.serving.UseDefaultsMode\x1aK\n\rEntitiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x1aQ\n\x13RequestContextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x42\x06\n\x04kind\"\x82\x04\n\x1eGetOnlineFeaturesRangeResponse\x12\x42\n\x08metadata\x18\x01 \x01(\x0b\x32\x30.feast.serving.GetOnlineFeaturesResponseMetadata\x12M\n\x08\x65ntities\x18\x02 \x03(\x0b\x32;.feast.serving.GetOnlineFeaturesRangeResponse.EntitiesEntry\x12Q\n\x07results\x18\x03 \x03(\x0b\x32@.feast.serving.GetOnlineFeaturesRangeResponse.RangeFeatureVector\x1aK\n\rEntitiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.types.RepeatedValue:\x02\x38\x01\x1a\xac\x01\n\x12RangeFeatureVector\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.feast.types.RepeatedValue\x12\x34\n\x08statuses\x18\x02 \x03(\x0b\x32\".feast.serving.RepeatedFieldStatus\x12\x34\n\x10\x65vent_timestamps\x18\x03 \x03(\x0b\x32\x1a.feast.types.RepeatedValue*y\n\x0fUseDefaultsMode\x12\x1c\n\x18USE_DEFAULTS_UNSPECIFIED\x10\x00\x12\x14\n\x10USE_DEFAULTS_OFF\x10\x01\x12\x19\n\x15USE_DEFAULTS_FLEXIBLE\x10\x02\x12\x17\n\x13USE_DEFAULTS_STRICT\x10\x03*[\n\x0b\x46ieldStatus\x12\x0b\n\x07INVALID\x10\x00\x12\x0b\n\x07PRESENT\x10\x01\x12\x0e\n\nNULL_VALUE\x10\x02\x12\r\n\tNOT_FOUND\x10\x03\x12\x13\n\x0fOUTSIDE_MAX_AGE\x10\x04\x32\xbc\x03\n\x0eServingService\x12l\n\x13GetFeastServingInfo\x12).feast.serving.GetFeastServingInfoRequest\x1a*.feast.serving.GetFeastServingInfoResponse\x12]\n\x0eGetVersionInfo\x12$.feast.serving.GetVersionInfoRequest\x1a%.feast.serving.GetVersionInfoResponse\x12\x66\n\x11GetOnlineFeatures\x12\'.feast.serving.GetOnlineFeaturesRequest\x1a(.feast.serving.GetOnlineFeaturesResponse\x12u\n\x16GetOnlineFeaturesRange\x12,.feast.serving.GetOnlineFeaturesRangeRequest\x1a-.feast.serving.GetOnlineFeaturesRangeResponseBZ\n\x13\x66\x65\x61st.proto.servingB\x0fServingAPIProtoZ2github.com/feast-dev/feast/go/protos/feast/servingb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -36,8 +36,10 @@ _globals['_GETONLINEFEATURESRANGEREQUEST_REQUESTCONTEXTENTRY']._serialized_options = b'8\001' _globals['_GETONLINEFEATURESRANGERESPONSE_ENTITIESENTRY']._options = None _globals['_GETONLINEFEATURESRANGERESPONSE_ENTITIESENTRY']._serialized_options = b'8\001' - _globals['_FIELDSTATUS']._serialized_start=3208 - _globals['_FIELDSTATUS']._serialized_end=3299 + _globals['_USEDEFAULTSMODE']._serialized_start=3316 + _globals['_USEDEFAULTSMODE']._serialized_end=3437 + _globals['_FIELDSTATUS']._serialized_start=3439 + _globals['_FIELDSTATUS']._serialized_end=3530 _globals['_GETFEASTSERVINGINFOREQUEST']._serialized_start=111 _globals['_GETFEASTSERVINGINFOREQUEST']._serialized_end=139 _globals['_GETFEASTSERVINGINFORESPONSE']._serialized_start=141 @@ -57,35 +59,35 @@ _globals['_FEATURELIST']._serialized_start=794 _globals['_FEATURELIST']._serialized_end=820 _globals['_GETONLINEFEATURESREQUEST']._serialized_start=823 - _globals['_GETONLINEFEATURESREQUEST']._serialized_end=1305 - _globals['_GETONLINEFEATURESREQUEST_ENTITIESENTRY']._serialized_start=1139 - _globals['_GETONLINEFEATURESREQUEST_ENTITIESENTRY']._serialized_end=1214 - _globals['_GETONLINEFEATURESREQUEST_REQUESTCONTEXTENTRY']._serialized_start=1216 - _globals['_GETONLINEFEATURESREQUEST_REQUESTCONTEXTENTRY']._serialized_end=1297 - _globals['_GETONLINEFEATURESRESPONSE']._serialized_start=1308 - _globals['_GETONLINEFEATURESRESPONSE']._serialized_end=1646 - _globals['_GETONLINEFEATURESRESPONSE_FEATUREVECTOR']._serialized_start=1495 - _globals['_GETONLINEFEATURESRESPONSE_FEATUREVECTOR']._serialized_end=1646 - _globals['_GETONLINEFEATURESRESPONSEMETADATA']._serialized_start=1648 - _globals['_GETONLINEFEATURESRESPONSEMETADATA']._serialized_end=1734 - _globals['_REPEATEDFIELDSTATUS']._serialized_start=1736 - _globals['_REPEATEDFIELDSTATUS']._serialized_end=1801 - _globals['_SORTKEYFILTER']._serialized_start=1804 - _globals['_SORTKEYFILTER']._serialized_end=2090 - _globals['_SORTKEYFILTER_RANGEQUERY']._serialized_start=1941 - _globals['_SORTKEYFILTER_RANGEQUERY']._serialized_end=2081 - _globals['_GETONLINEFEATURESRANGEREQUEST']._serialized_start=2093 - _globals['_GETONLINEFEATURESRANGEREQUEST']._serialized_end=2689 - _globals['_GETONLINEFEATURESRANGEREQUEST_ENTITIESENTRY']._serialized_start=1139 - _globals['_GETONLINEFEATURESRANGEREQUEST_ENTITIESENTRY']._serialized_end=1214 - _globals['_GETONLINEFEATURESRANGEREQUEST_REQUESTCONTEXTENTRY']._serialized_start=1216 - _globals['_GETONLINEFEATURESRANGEREQUEST_REQUESTCONTEXTENTRY']._serialized_end=1297 - _globals['_GETONLINEFEATURESRANGERESPONSE']._serialized_start=2692 - _globals['_GETONLINEFEATURESRANGERESPONSE']._serialized_end=3206 - _globals['_GETONLINEFEATURESRANGERESPONSE_ENTITIESENTRY']._serialized_start=1139 - _globals['_GETONLINEFEATURESRANGERESPONSE_ENTITIESENTRY']._serialized_end=1214 - _globals['_GETONLINEFEATURESRANGERESPONSE_RANGEFEATUREVECTOR']._serialized_start=3034 - _globals['_GETONLINEFEATURESRANGERESPONSE_RANGEFEATUREVECTOR']._serialized_end=3206 - _globals['_SERVINGSERVICE']._serialized_start=3302 - _globals['_SERVINGSERVICE']._serialized_end=3746 + _globals['_GETONLINEFEATURESREQUEST']._serialized_end=1359 + _globals['_GETONLINEFEATURESREQUEST_ENTITIESENTRY']._serialized_start=1193 + _globals['_GETONLINEFEATURESREQUEST_ENTITIESENTRY']._serialized_end=1268 + _globals['_GETONLINEFEATURESREQUEST_REQUESTCONTEXTENTRY']._serialized_start=1270 + _globals['_GETONLINEFEATURESREQUEST_REQUESTCONTEXTENTRY']._serialized_end=1351 + _globals['_GETONLINEFEATURESRESPONSE']._serialized_start=1362 + _globals['_GETONLINEFEATURESRESPONSE']._serialized_end=1700 + _globals['_GETONLINEFEATURESRESPONSE_FEATUREVECTOR']._serialized_start=1549 + _globals['_GETONLINEFEATURESRESPONSE_FEATUREVECTOR']._serialized_end=1700 + _globals['_GETONLINEFEATURESRESPONSEMETADATA']._serialized_start=1702 + _globals['_GETONLINEFEATURESRESPONSEMETADATA']._serialized_end=1788 + _globals['_REPEATEDFIELDSTATUS']._serialized_start=1790 + _globals['_REPEATEDFIELDSTATUS']._serialized_end=1855 + _globals['_SORTKEYFILTER']._serialized_start=1858 + _globals['_SORTKEYFILTER']._serialized_end=2144 + _globals['_SORTKEYFILTER_RANGEQUERY']._serialized_start=1995 + _globals['_SORTKEYFILTER_RANGEQUERY']._serialized_end=2135 + _globals['_GETONLINEFEATURESRANGEREQUEST']._serialized_start=2147 + _globals['_GETONLINEFEATURESRANGEREQUEST']._serialized_end=2797 + _globals['_GETONLINEFEATURESRANGEREQUEST_ENTITIESENTRY']._serialized_start=1193 + _globals['_GETONLINEFEATURESRANGEREQUEST_ENTITIESENTRY']._serialized_end=1268 + _globals['_GETONLINEFEATURESRANGEREQUEST_REQUESTCONTEXTENTRY']._serialized_start=1270 + _globals['_GETONLINEFEATURESRANGEREQUEST_REQUESTCONTEXTENTRY']._serialized_end=1351 + _globals['_GETONLINEFEATURESRANGERESPONSE']._serialized_start=2800 + _globals['_GETONLINEFEATURESRANGERESPONSE']._serialized_end=3314 + _globals['_GETONLINEFEATURESRANGERESPONSE_ENTITIESENTRY']._serialized_start=1193 + _globals['_GETONLINEFEATURESRANGERESPONSE_ENTITIESENTRY']._serialized_end=1268 + _globals['_GETONLINEFEATURESRANGERESPONSE_RANGEFEATUREVECTOR']._serialized_start=3142 + _globals['_GETONLINEFEATURESRANGERESPONSE_RANGEFEATUREVECTOR']._serialized_end=3314 + _globals['_SERVINGSERVICE']._serialized_start=3533 + _globals['_SERVINGSERVICE']._serialized_end=3977 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/serving/ServingService_pb2.pyi b/sdk/python/feast/protos/feast/serving/ServingService_pb2.pyi index 24c452620a7..f43d4333dce 100644 --- a/sdk/python/feast/protos/feast/serving/ServingService_pb2.pyi +++ b/sdk/python/feast/protos/feast/serving/ServingService_pb2.pyi @@ -34,6 +34,33 @@ else: DESCRIPTOR: google.protobuf.descriptor.FileDescriptor +class _UseDefaultsMode: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _UseDefaultsModeEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[_UseDefaultsMode.ValueType], builtins.type): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + USE_DEFAULTS_UNSPECIFIED: _UseDefaultsMode.ValueType # 0 + """Field not set - use server default behavior (currently OFF)""" + USE_DEFAULTS_OFF: _UseDefaultsMode.ValueType # 1 + """Explicitly disable default replacement""" + USE_DEFAULTS_FLEXIBLE: _UseDefaultsMode.ValueType # 2 + """Ignore if default missing""" + USE_DEFAULTS_STRICT: _UseDefaultsMode.ValueType # 3 + """Fail if default is missing""" + +class UseDefaultsMode(_UseDefaultsMode, metaclass=_UseDefaultsModeEnumTypeWrapper): ... + +USE_DEFAULTS_UNSPECIFIED: UseDefaultsMode.ValueType # 0 +"""Field not set - use server default behavior (currently OFF)""" +USE_DEFAULTS_OFF: UseDefaultsMode.ValueType # 1 +"""Explicitly disable default replacement""" +USE_DEFAULTS_FLEXIBLE: UseDefaultsMode.ValueType # 2 +"""Ignore if default missing""" +USE_DEFAULTS_STRICT: UseDefaultsMode.ValueType # 3 +"""Fail if default is missing""" +global___UseDefaultsMode = UseDefaultsMode + class _FieldStatus: ValueType = typing.NewType("ValueType", builtins.int) V: typing_extensions.TypeAlias = ValueType @@ -289,6 +316,7 @@ class GetOnlineFeaturesRequest(google.protobuf.message.Message): FULL_FEATURE_NAMES_FIELD_NUMBER: builtins.int REQUEST_CONTEXT_FIELD_NUMBER: builtins.int INCLUDE_METADATA_FIELD_NUMBER: builtins.int + USE_DEFAULTS_FIELD_NUMBER: builtins.int feature_service: builtins.str @property def features(self) -> global___FeatureList: ... @@ -306,6 +334,8 @@ class GetOnlineFeaturesRequest(google.protobuf.message.Message): """ include_metadata: builtins.bool """Whether to include the timestamp/status metadata in the response""" + use_defaults: global___UseDefaultsMode.ValueType + """Mode for handling features with default values when feature value is missing""" def __init__( self, *, @@ -315,9 +345,10 @@ class GetOnlineFeaturesRequest(google.protobuf.message.Message): full_feature_names: builtins.bool = ..., request_context: collections.abc.Mapping[builtins.str, feast.types.Value_pb2.RepeatedValue] | None = ..., include_metadata: builtins.bool = ..., + use_defaults: global___UseDefaultsMode.ValueType = ..., ) -> None: ... def HasField(self, field_name: typing_extensions.Literal["feature_service", b"feature_service", "features", b"features", "kind", b"kind"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["entities", b"entities", "feature_service", b"feature_service", "features", b"features", "full_feature_names", b"full_feature_names", "include_metadata", b"include_metadata", "kind", b"kind", "request_context", b"request_context"]) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["entities", b"entities", "feature_service", b"feature_service", "features", b"features", "full_feature_names", b"full_feature_names", "include_metadata", b"include_metadata", "kind", b"kind", "request_context", b"request_context", "use_defaults", b"use_defaults"]) -> None: ... def WhichOneof(self, oneof_group: typing_extensions.Literal["kind", b"kind"]) -> typing_extensions.Literal["feature_service", "features"] | None: ... global___GetOnlineFeaturesRequest = GetOnlineFeaturesRequest @@ -499,6 +530,7 @@ class GetOnlineFeaturesRangeRequest(google.protobuf.message.Message): LIMIT_FIELD_NUMBER: builtins.int REQUEST_CONTEXT_FIELD_NUMBER: builtins.int INCLUDE_METADATA_FIELD_NUMBER: builtins.int + USE_DEFAULTS_FIELD_NUMBER: builtins.int feature_service: builtins.str @property def features(self) -> global___FeatureList: ... @@ -520,6 +552,8 @@ class GetOnlineFeaturesRangeRequest(google.protobuf.message.Message): """ include_metadata: builtins.bool """Whether to include the timestamp and status metadata in the response""" + use_defaults: global___UseDefaultsMode.ValueType + """Mode for handling features with default values when feature value is missing""" def __init__( self, *, @@ -532,9 +566,10 @@ class GetOnlineFeaturesRangeRequest(google.protobuf.message.Message): limit: builtins.int = ..., request_context: collections.abc.Mapping[builtins.str, feast.types.Value_pb2.RepeatedValue] | None = ..., include_metadata: builtins.bool = ..., + use_defaults: global___UseDefaultsMode.ValueType = ..., ) -> None: ... def HasField(self, field_name: typing_extensions.Literal["feature_service", b"feature_service", "features", b"features", "kind", b"kind"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["entities", b"entities", "feature_service", b"feature_service", "features", b"features", "full_feature_names", b"full_feature_names", "include_metadata", b"include_metadata", "kind", b"kind", "limit", b"limit", "request_context", b"request_context", "reverse_sort_order", b"reverse_sort_order", "sort_key_filters", b"sort_key_filters"]) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["entities", b"entities", "feature_service", b"feature_service", "features", b"features", "full_feature_names", b"full_feature_names", "include_metadata", b"include_metadata", "kind", b"kind", "limit", b"limit", "request_context", b"request_context", "reverse_sort_order", b"reverse_sort_order", "sort_key_filters", b"sort_key_filters", "use_defaults", b"use_defaults"]) -> None: ... def WhichOneof(self, oneof_group: typing_extensions.Literal["kind", b"kind"]) -> typing_extensions.Literal["feature_service", "features"] | None: ... global___GetOnlineFeaturesRangeRequest = GetOnlineFeaturesRangeRequest diff --git a/sdk/python/tests/unit/expediagroup/__init__.py b/sdk/python/tests/unit/expediagroup/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/tests/unit/expediagroup/test_field_model_default_value.py b/sdk/python/tests/unit/expediagroup/test_field_model_default_value.py new file mode 100644 index 00000000000..f43fcf2f32b --- /dev/null +++ b/sdk/python/tests/unit/expediagroup/test_field_model_default_value.py @@ -0,0 +1,179 @@ +""" +Unit tests for FieldModel default_value JSON serialization. + +Tests cover: +- Serialization of proto Value to JSON dict +- Deserialization from JSON dict to proto Value +- Roundtrip (serialize -> deserialize) +- Field bridge methods (to_field/from_field) +- Full roundtrip (Field -> FieldModel -> JSON -> FieldModel -> Field) +""" + +from feast.expediagroup.pydantic_models.field_model import FieldModel +from feast.field import Field +from feast.protos.feast.types.Value_pb2 import Value +from feast.types import Bool, Float64, Int64, String + + +def test_field_model_serialize_int64_default(): + """FieldModel with Int64 default_value serializes to dict with int64Val.""" + fm = FieldModel(name="age", dtype=Int64, default_value=Value(int64_val=42)) + d = fm.model_dump() + + assert d["default_value"] is not None + # Proto JSON format represents int64 as string to preserve precision + assert d["default_value"] == {"int64Val": "42"} + + +def test_field_model_serialize_string_default(): + """FieldModel with String default_value serializes to dict with stringVal.""" + fm = FieldModel(name="country", dtype=String, default_value=Value(string_val="US")) + d = fm.model_dump() + + assert d["default_value"] is not None + assert d["default_value"] == {"stringVal": "US"} + + +def test_field_model_serialize_double_default(): + """FieldModel with Float64 default_value serializes to dict with doubleVal.""" + fm = FieldModel(name="rating", dtype=Float64, default_value=Value(double_val=2.718)) + d = fm.model_dump() + + assert d["default_value"] is not None + assert d["default_value"] == {"doubleVal": 2.718} + + +def test_field_model_serialize_bool_default(): + """FieldModel with Bool default_value serializes to dict with boolVal.""" + fm = FieldModel(name="is_active", dtype=Bool, default_value=Value(bool_val=True)) + d = fm.model_dump() + + assert d["default_value"] is not None + assert d["default_value"] == {"boolVal": True} + + +def test_field_model_serialize_none_default(): + """FieldModel without default_value serializes default_value as None.""" + fm = FieldModel(name="optional_field", dtype=String) + d = fm.model_dump() + + assert d["default_value"] is None + + +def test_field_model_deserialize_from_dict(): + """FieldModel.model_validate() with dict default_value creates proper proto Value.""" + data = { + "name": "country", + "dtype": 2, # String type enum value + "default_value": {"stringVal": "CA"}, + } + fm = FieldModel.model_validate(data) + + assert fm.default_value is not None + assert isinstance(fm.default_value, Value) + assert fm.default_value.string_val == "CA" + + +def test_field_model_deserialize_from_proto(): + """FieldModel.model_validate() with proto Value passes through unchanged.""" + proto_value = Value(int64_val=100) + data = { + "name": "count", + "dtype": 4, # Int64 type enum value + "default_value": proto_value, + } + fm = FieldModel.model_validate(data) + + assert fm.default_value is not None + assert isinstance(fm.default_value, Value) + assert fm.default_value.int64_val == 100 + # Verify it's the same object (not a copy) + assert fm.default_value is proto_value + + +def test_field_model_roundtrip_json(): + """Serialize to JSON string, deserialize back, verify proto values match.""" + # Create original with double value + fm1 = FieldModel( + name="score", dtype=Float64, default_value=Value(double_val=3.14159) + ) + + # Serialize to JSON string + json_str = fm1.model_dump_json() + + # Deserialize from JSON string + fm2 = FieldModel.model_validate_json(json_str) + + # Verify proto values match + assert fm2.default_value is not None + assert fm2.default_value.double_val == 3.14159 + + +def test_field_model_to_field_preserves_default(): + """FieldModel.to_field() returns Field with matching default_value.""" + proto_value = Value(string_val="default") + fm = FieldModel( + name="status", + dtype=String, + description="Status field", + default_value=proto_value, + ) + + field = fm.to_field() + + assert isinstance(field, Field) + assert field.name == "status" + assert field.dtype == String + assert field.default_value is not None + assert field.default_value.string_val == "default" + # Verify it's the same proto object + assert field.default_value is proto_value + + +def test_field_model_from_field_preserves_default(): + """FieldModel.from_field(Field(...)) captures default_value.""" + proto_value = Value(bool_val=False) + field = Field( + name="enabled", dtype=Bool, description="Enable flag", default_value=proto_value + ) + + fm = FieldModel.from_field(field) + + assert fm.name == "enabled" + assert fm.dtype == Bool + assert fm.default_value is not None + assert fm.default_value.bool_val is False + # Verify it's the same proto object + assert fm.default_value is proto_value + + +def test_field_model_full_roundtrip(): + """Field -> FieldModel -> JSON string -> FieldModel -> to_field() -> compare.""" + # Start with a Field + original_field = Field( + name="price", + dtype=Float64, + description="Item price", + tags={"unit": "USD"}, + default_value=Value(double_val=9.99), + ) + + # Convert to FieldModel + fm1 = FieldModel.from_field(original_field) + + # Serialize to JSON string + json_str = fm1.model_dump_json() + + # Deserialize from JSON string + fm2 = FieldModel.model_validate_json(json_str) + + # Convert back to Field + result_field = fm2.to_field() + + # Compare all attributes + assert result_field.name == original_field.name + assert result_field.dtype == original_field.dtype + assert result_field.description == original_field.description + assert result_field.tags == original_field.tags + assert result_field.default_value is not None + assert result_field.default_value.double_val == 9.99 diff --git a/sdk/python/tests/unit/expediagroup/test_remote_registry_default_value.py b/sdk/python/tests/unit/expediagroup/test_remote_registry_default_value.py new file mode 100644 index 00000000000..537c7493458 --- /dev/null +++ b/sdk/python/tests/unit/expediagroup/test_remote_registry_default_value.py @@ -0,0 +1,240 @@ +""" +Tests for Remote Registry default_value proto roundtrip. + +Verifies that FeatureView.to_proto() / from_proto() preserves Field.default_value, +which is the core serialization path used by Remote Registry (feast serve_registry). + +Copyright 2026 Expedia Group +""" + +from feast.data_source import RequestSource +from feast.feature_view import FeatureView +from feast.field import Field +from feast.protos.feast.types.Value_pb2 import Value +from feast.sorted_feature_view import SortedFeatureView +from feast.types import Bool, Float64, Int64, String + + +def test_feature_view_proto_roundtrip_with_defaults(): + """ + Verify FeatureView with Field default_value survives proto serialization/deserialization. + + This simulates the Remote Registry gRPC path: + Server: FeatureView -> to_proto() -> bytes over wire + Client: bytes -> from_proto() -> FeatureView + """ + # Create FeatureView with fields that have default values + fields = [ + Field(name="country_code", dtype=String, default_value=Value(string_val="US")), + Field(name="latitude", dtype=Float64, default_value=Value(double_val=0.0)), + Field(name="is_active", dtype=Bool, default_value=Value(bool_val=True)), + ] + + # Use RequestSource as a minimal source for testing + source = RequestSource( + name="test_source", + schema=fields, + ) + + fv = FeatureView( + name="test_fv_with_defaults", + source=source, + schema=fields, + ) + + # Serialize to proto + fv_proto = fv.to_proto() + + # Verify proto has default_value set (Phase 1 implementation) + assert len(fv_proto.spec.features) == 3 + assert fv_proto.spec.features[0].default_value.string_val == "US" + assert fv_proto.spec.features[1].default_value.double_val == 0.0 + assert fv_proto.spec.features[2].default_value.bool_val is True + + # Simulate wire transmission by serializing to bytes + proto_bytes = fv_proto.SerializeToString() + + # Deserialize from proto (simulates client receiving from Remote Registry) + from feast.protos.feast.core.FeatureView_pb2 import FeatureView as FeatureViewProto + + fv_proto_received = FeatureViewProto() + fv_proto_received.ParseFromString(proto_bytes) + + # Convert back to FeatureView object + fv_reconstructed = FeatureView.from_proto(fv_proto_received) + + # Verify default_value preserved (order may change) + assert len(fv_reconstructed.schema) == 3 + fields_by_name = {f.name: f for f in fv_reconstructed.schema} + assert fields_by_name["country_code"].default_value.string_val == "US" + assert fields_by_name["latitude"].default_value.double_val == 0.0 + assert fields_by_name["is_active"].default_value.bool_val is True + + +def test_feature_view_proto_roundtrip_without_defaults(): + """ + Verify FeatureView without default_value works correctly (backwards compatibility). + """ + fields = [ + Field(name="country_code", dtype=String), + Field(name="latitude", dtype=Float64), + ] + + source = RequestSource( + name="test_source", + schema=fields, + ) + + fv = FeatureView( + name="test_fv_without_defaults", + source=source, + schema=fields, + ) + + # Round-trip through proto + fv_proto = fv.to_proto() + proto_bytes = fv_proto.SerializeToString() + + from feast.protos.feast.core.FeatureView_pb2 import FeatureView as FeatureViewProto + + fv_proto_received = FeatureViewProto() + fv_proto_received.ParseFromString(proto_bytes) + + fv_reconstructed = FeatureView.from_proto(fv_proto_received) + + # Verify default_value is None (not set) + assert fv_reconstructed.schema[0].default_value is None + assert fv_reconstructed.schema[1].default_value is None + + +def test_feature_view_proto_roundtrip_mixed_defaults(): + """ + Verify FeatureView with some fields having defaults and some not. + """ + fields = [ + Field(name="country_code", dtype=String, default_value=Value(string_val="US")), + Field(name="latitude", dtype=Float64), # No default + Field(name="property_id", dtype=Int64), # No default + Field(name="is_active", dtype=Bool, default_value=Value(bool_val=False)), + ] + + source = RequestSource( + name="test_source", + schema=fields, + ) + + fv = FeatureView( + name="test_fv_mixed", + source=source, + schema=fields, + ) + + # Round-trip through proto + fv_proto = fv.to_proto() + proto_bytes = fv_proto.SerializeToString() + + from feast.protos.feast.core.FeatureView_pb2 import FeatureView as FeatureViewProto + + fv_proto_received = FeatureViewProto() + fv_proto_received.ParseFromString(proto_bytes) + + fv_reconstructed = FeatureView.from_proto(fv_proto_received) + + # Verify only fields with defaults have default_value set (order may change) + fields_by_name = {f.name: f for f in fv_reconstructed.schema} + assert fields_by_name["country_code"].default_value.string_val == "US" + assert fields_by_name["latitude"].default_value is None + assert fields_by_name["property_id"].default_value is None + assert fields_by_name["is_active"].default_value.bool_val is False + + +def test_sorted_feature_view_proto_roundtrip_with_defaults(): + """ + Verify SortedFeatureView with default_value survives proto roundtrip. + Confirms that both sort_keys AND default_value are preserved. + """ + from feast.data_source import RequestSource + from feast.sort_key import SortKey + from feast.value_type import ValueType + + fields = [ + Field(name="property_id", dtype=Int64), + Field(name="score", dtype=Float64, default_value=Value(double_val=0.0)), + Field(name="country_code", dtype=String, default_value=Value(string_val="US")), + ] + + source = RequestSource( + name="test_source", + schema=fields, + ) + + sfv = SortedFeatureView( + name="test_sorted_fv", + source=source, + schema=fields, + sort_keys=[ + SortKey(name="score", value_type=ValueType.DOUBLE) + ], # Sort by score field + ) + + # Round-trip through proto + sfv_proto = sfv.to_proto() + proto_bytes = sfv_proto.SerializeToString() + + from feast.protos.feast.core.SortedFeatureView_pb2 import ( + SortedFeatureView as SortedFeatureViewProto, + ) + + sfv_proto_received = SortedFeatureViewProto() + sfv_proto_received.ParseFromString(proto_bytes) + + sfv_reconstructed = SortedFeatureView.from_proto(sfv_proto_received) + + # Verify sort_keys preserved + assert len(sfv_reconstructed.sort_keys) == 1 + assert sfv_reconstructed.sort_keys[0].name == "score" + + # Verify default_value preserved (order may change) + fields_by_name = {f.name: f for f in sfv_reconstructed.schema} + assert fields_by_name["property_id"].default_value is None + assert fields_by_name["score"].default_value.double_val == 0.0 + assert fields_by_name["country_code"].default_value.string_val == "US" + + +def test_feature_view_proto_bytes_identity(): + """ + Verify proto wire format contains default_value. + + This tests the actual gRPC wire format by inspecting the proto + before converting back to Python objects. + """ + fields = [ + Field(name="age", dtype=Int64, default_value=Value(int64_val=18)), + ] + + source = RequestSource( + name="test_source", + schema=fields, + ) + + fv = FeatureView( + name="test_fv", + source=source, + schema=fields, + ) + + # Serialize to proto + fv_proto = fv.to_proto() + + # Serialize to bytes (what goes over gRPC wire) + proto_bytes = fv_proto.SerializeToString() + + # Deserialize back to proto (NOT to FeatureView yet) + from feast.protos.feast.core.FeatureView_pb2 import FeatureView as FeatureViewProto + + fv_proto_from_wire = FeatureViewProto() + fv_proto_from_wire.ParseFromString(proto_bytes) + + # Inspect proto directly - verify default_value is in the wire format + assert fv_proto_from_wire.spec.features[0].HasField("default_value") + assert fv_proto_from_wire.spec.features[0].default_value.int64_val == 18 diff --git a/sdk/python/tests/unit/test_feature.py b/sdk/python/tests/unit/test_feature.py index ca0dce44457..cc3184835d0 100644 --- a/sdk/python/tests/unit/test_feature.py +++ b/sdk/python/tests/unit/test_feature.py @@ -1,5 +1,9 @@ +import pytest +from pydantic_core import ValidationError + from feast.field import Feature, Field -from feast.types import Float32 +from feast.protos.feast.types import Value_pb2 as ValueProto +from feast.types import Array, Bool, Bytes, Float32, Float64, Int32, Int64, String from feast.value_type import ValueType @@ -9,7 +13,6 @@ def test_feature_serialization_with_description(): name="avg_daily_trips", dtype=ValueType.FLOAT, description=expected_description ) serialized_feature = feature.to_proto() - assert serialized_feature.description == expected_description @@ -21,12 +24,209 @@ def test_field_serialization_with_description(): feature = Feature( name="avg_daily_trips", dtype=ValueType.FLOAT, description=expected_description ) - serialized_field = field.to_proto() field_from_feature = Field.from_feature(feature) - assert serialized_field.description == expected_description assert field_from_feature.description == expected_description - field = Field.from_proto(serialized_field) assert field.description == expected_description + + +def test_field_with_default_value_to_proto(): + default_val = ValueProto.Value(int32_val=42) + field = Field(name="age", dtype=Int32, default_value=default_val) + proto = field.to_proto() + assert proto.name == "age" + assert proto.HasField("default_value") + assert proto.default_value.int32_val == 42 + + +def test_field_without_default_value_to_proto(): + field = Field(name="age", dtype=Int32) + proto = field.to_proto() + assert proto.name == "age" + assert not proto.HasField("default_value") + + +def test_field_from_proto_with_default_value(): + from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 + + default_val = ValueProto.Value(string_val="unknown") + proto = FeatureSpecV2( + name="country", + value_type=2, # STRING + default_value=default_val, + ) + field = Field.from_proto(proto) + assert field.name == "country" + assert field.default_value is not None + assert field.default_value.string_val == "unknown" + + +def test_field_from_proto_without_default_value(): + from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 + + proto = FeatureSpecV2(name="country", value_type=2) + field = Field.from_proto(proto) + assert field.name == "country" + assert field.default_value is None + + +def test_field_roundtrip_with_default_value(): + default_val = ValueProto.Value(int64_val=9999) + original_field = Field(name="user_id", dtype=Int64, default_value=default_val) + proto = original_field.to_proto() + restored_field = Field.from_proto(proto) + assert restored_field.name == original_field.name + assert restored_field.dtype == original_field.dtype + assert restored_field.default_value.int64_val == 9999 + + +def test_field_default_value_type_validation(): + with pytest.raises(ValidationError, match="does not match field dtype"): + default_val = ValueProto.Value(string_val="not_an_int") + Field(name="age", dtype=Int32, default_value=default_val) + + +def test_field_with_list_default_value(): + default_val = ValueProto.Value(int32_list_val=ValueProto.Int32List(val=[1, 2, 3])) + field = Field(name="scores", dtype=Array(Int32), default_value=default_val) + assert list(field.default_value.int32_list_val.val) == [1, 2, 3] + + +def test_feature_with_default_value_to_proto(): + default_val = ValueProto.Value(int32_val=0) + feature = Feature(name="count", dtype=ValueType.INT32, default_value=default_val) + proto = feature.to_proto() + assert proto.name == "count" + assert proto.HasField("default_value") + assert proto.default_value.int32_val == 0 + + +def test_feature_without_default_value_to_proto(): + feature = Feature(name="count", dtype=ValueType.INT32) + proto = feature.to_proto() + assert proto.name == "count" + assert not proto.HasField("default_value") + + +def test_feature_from_proto_with_default_value(): + from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 + + default_val = ValueProto.Value(bool_val=False) + proto = FeatureSpecV2( + name="is_active", + value_type=7, # BOOL + default_value=default_val, + ) + feature = Feature.from_proto(proto) + assert feature.name == "is_active" + assert feature.default_value is not None + assert feature.default_value.bool_val is False + + +def test_feature_from_proto_without_default_value(): + from feast.protos.feast.core.Feature_pb2 import FeatureSpecV2 + + proto = FeatureSpecV2(name="is_active", value_type=7) + feature = Feature.from_proto(proto) + assert feature.name == "is_active" + assert feature.default_value is None + + +def test_feature_roundtrip_with_default_value(): + default_val = ValueProto.Value(string_val="US") + original_feature = Feature( + name="country", dtype=ValueType.STRING, default_value=default_val + ) + proto = original_feature.to_proto() + restored_feature = Feature.from_proto(proto) + assert restored_feature.name == original_feature.name + assert restored_feature.dtype == original_feature.dtype + assert restored_feature.default_value.string_val == "US" + + +def test_backward_compatibility_field_from_feature(): + default_val = ValueProto.Value(int32_val=18) + feature = Feature(name="age", dtype=ValueType.INT32, default_value=default_val) + field = Field.from_feature(feature) + assert field.name == "age" + assert field.default_value is not None + assert field.default_value.int32_val == 18 + + +def test_field_default_value_edge_cases(): + # Zero value + field1 = Field( + name="count", dtype=Int32, default_value=ValueProto.Value(int32_val=0) + ) + assert field1.default_value.int32_val == 0 + # Empty string + field2 = Field( + name="name", dtype=String, default_value=ValueProto.Value(string_val="") + ) + assert field2.default_value.string_val == "" + # False boolean + field3 = Field( + name="flag", dtype=Bool, default_value=ValueProto.Value(bool_val=False) + ) + assert field3.default_value.bool_val is False + # Negative number + field4 = Field( + name="error", dtype=Int64, default_value=ValueProto.Value(int64_val=-1) + ) + assert field4.default_value.int64_val == -1 + + +def test_field_with_float32_default_value(): + default_val = ValueProto.Value(float_val=3.14) + field = Field(name="temperature", dtype=Float32, default_value=default_val) + proto = field.to_proto() + assert proto.name == "temperature" + assert proto.HasField("default_value") + assert abs(proto.default_value.float_val - 3.14) < 0.001 + + +def test_field_with_float64_default_value(): + default_val = ValueProto.Value(double_val=2.718281828) + field = Field(name="precision_value", dtype=Float64, default_value=default_val) + proto = field.to_proto() + assert proto.name == "precision_value" + assert proto.HasField("default_value") + assert abs(proto.default_value.double_val - 2.718281828) < 0.0000001 + + +def test_field_with_bytes_default_value(): + default_val = ValueProto.Value(bytes_val=b"default_bytes") + field = Field(name="binary_data", dtype=Bytes, default_value=default_val) + proto = field.to_proto() + assert proto.name == "binary_data" + assert proto.HasField("default_value") + assert proto.default_value.bytes_val == b"default_bytes" + + +def test_field_with_string_list_default_value(): + default_val = ValueProto.Value( + string_list_val=ValueProto.StringList(val=["a", "b", "c"]) + ) + field = Field(name="tags", dtype=Array(String), default_value=default_val) + assert list(field.default_value.string_list_val.val) == ["a", "b", "c"] + + +def test_field_with_float_list_default_value(): + default_val = ValueProto.Value( + float_list_val=ValueProto.FloatList(val=[1.1, 2.2, 3.3]) + ) + field = Field(name="probabilities", dtype=Array(Float32), default_value=default_val) + assert len(field.default_value.float_list_val.val) == 3 + assert abs(field.default_value.float_list_val.val[0] - 1.1) < 0.01 + assert abs(field.default_value.float_list_val.val[1] - 2.2) < 0.01 + assert abs(field.default_value.float_list_val.val[2] - 3.3) < 0.01 + + +def test_field_with_bool_list_default_value(): + default_val = ValueProto.Value( + bool_list_val=ValueProto.BoolList(val=[True, False, True]) + ) + field = Field(name="flags", dtype=Array(Bool), default_value=default_val) + assert list(field.default_value.bool_list_val.val) == [True, False, True]