Skip to content
Open
3 changes: 3 additions & 0 deletions protos/feast/core/Feature.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 14 additions & 0 deletions protos/feast/serving/ServingService.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion sdk/python/feast/expediagroup/pydantic_models/field_model.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from typing import Dict, Optional, Union

from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
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


Expand All @@ -19,6 +20,9 @@ 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)

def to_field(self) -> Field:
"""
Expand All @@ -35,6 +39,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
Expand All @@ -56,4 +61,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,
)
21 changes: 20 additions & 1 deletion sdk/python/feast/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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")"
)

Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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
72 changes: 70 additions & 2 deletions sdk/python/feast/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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], info: 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 = info.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):
Expand Down Expand Up @@ -110,7 +166,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,
Expand All @@ -119,6 +175,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):
Expand All @@ -132,6 +193,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),
Expand All @@ -140,6 +206,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
Expand All @@ -155,4 +222,5 @@ def from_feature(cls, feature: Feature):
dtype=from_value_type(feature.dtype),
description=feature.description,
tags=feature.labels,
default_value=feature.default_value,
)
8 changes: 4 additions & 4 deletions sdk/python/feast/protos/feast/core/Feature_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion sdk/python/feast/protos/feast/core/Feature_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
*,
Expand All @@ -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
Loading
Loading