Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,10 @@ driver_stats_source = FileSource(
# three feature column. Here we define a Feature View that will allow us to serve this
# data to our model online.
driver_stats_fv = FeatureView(
# The unique name of this feature view. Two feature views in a single
# project cannot have the same name
# The unique name of this feature view. Two feature views in a single
# project cannot have the same name, and names must be unique across
# all feature view types (regular, stream, on-demand) to avoid conflicts
# during `feast apply`.
name="driver_hourly_stats",
entities=[driver],
ttl=timedelta(days=1),
Expand Down
30 changes: 26 additions & 4 deletions sdk/python/feast/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,10 +411,32 @@ def __init__(self, entity_type: type):

class ConflictingFeatureViewNames(FeastError):
# TODO: print file location of conflicting feature views
def __init__(self, feature_view_name: str):
super().__init__(
f"The feature view name: {feature_view_name} refers to feature views of different types."
)
def __init__(
self,
feature_view_name: str,
existing_type: Optional[str] = None,
new_type: Optional[str] = None,
):
if existing_type and new_type:
if existing_type == new_type:
# Same-type duplicate
super().__init__(
f"Multiple {existing_type}s with name '{feature_view_name}' found. "
f"Feature view names must be case-insensitively unique. "
f"It may be necessary to ignore certain files in your feature "
f"repository by using a .feastignore file."
)
else:
# Cross-type conflict
super().__init__(
f"Feature view name '{feature_view_name}' is already used by a {existing_type}. "
f"Cannot register a {new_type} with the same name. "
f"Feature view names must be unique across FeatureView, StreamFeatureView, and OnDemandFeatureView."
)
else:
super().__init__(
f"The feature view name: {feature_view_name} refers to feature views of different types."
)


class FeastInvalidInfraObjectType(FeastError):
Expand Down
24 changes: 16 additions & 8 deletions sdk/python/feast/feature_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from feast.dqm.errors import ValidationFailed
from feast.entity import Entity
from feast.errors import (
ConflictingFeatureViewNames,
DataFrameSerializationError,
DataSourceRepeatNamesException,
FeatureViewNotFoundException,
Expand Down Expand Up @@ -3255,18 +3256,25 @@ def _print_materialization_log(


def _validate_feature_views(feature_views: List[BaseFeatureView]):
"""Verify feature views have case-insensitively unique names"""
fv_names = set()
"""Verify feature views have case-insensitively unique names across all types.

This validates that no two feature views (of any type: FeatureView,
StreamFeatureView, OnDemandFeatureView) share the same case-insensitive name.
This is critical because get_online_features uses get_any_feature_view which
resolves names in a fixed order, potentially returning the wrong feature view.
"""
fv_by_name: Dict[str, BaseFeatureView] = {}
for fv in feature_views:
case_insensitive_fv_name = fv.name.lower()
if case_insensitive_fv_name in fv_names:
raise ValueError(
f"More than one feature view with name {case_insensitive_fv_name} found. "
f"Please ensure that all feature view names are case-insensitively unique. "
f"It may be necessary to ignore certain files in your feature repository by using a .feastignore file."
if case_insensitive_fv_name in fv_by_name:
existing_fv = fv_by_name[case_insensitive_fv_name]
raise ConflictingFeatureViewNames(
fv.name,
existing_type=type(existing_fv).__name__,
new_type=type(fv).__name__,
)
else:
fv_names.add(case_insensitive_fv_name)
fv_by_name[case_insensitive_fv_name] = fv


def _validate_data_sources(data_sources: List[DataSource]):
Expand Down
59 changes: 59 additions & 0 deletions sdk/python/feast/infra/registry/base_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
from feast.base_feature_view import BaseFeatureView
from feast.data_source import DataSource
from feast.entity import Entity
from feast.errors import (
ConflictingFeatureViewNames,
FeatureViewNotFoundException,
)
from feast.feature_service import FeatureService
from feast.feature_view import FeatureView
from feast.infra.infra_object import Infra
Expand Down Expand Up @@ -263,6 +267,61 @@ def apply_feature_view(
"""
raise NotImplementedError

def _ensure_feature_view_name_is_unique(
self,
feature_view: BaseFeatureView,
project: str,
allow_cache: bool = False,
):
"""
Validates that no feature view name conflict exists across feature view types.
Raises ConflictingFeatureViewNames if a different type already uses the name.

This is a defense-in-depth check for direct apply_feature_view() calls.
The primary validation happens in _validate_all_feature_views() during feast plan/apply.
"""
name = feature_view.name
new_type = type(feature_view).__name__

def _check_conflict(getter, not_found_exc, existing_type: str):
try:
getter(name, project, allow_cache=allow_cache)
raise ConflictingFeatureViewNames(name, existing_type, new_type)
except not_found_exc:
pass

# Check StreamFeatureView before FeatureView since StreamFeatureView is a subclass
# Note: All getters raise FeatureViewNotFoundException (not type-specific exceptions)
if isinstance(feature_view, StreamFeatureView):
_check_conflict(
self.get_feature_view, FeatureViewNotFoundException, "FeatureView"
)
_check_conflict(
self.get_on_demand_feature_view,
FeatureViewNotFoundException,
"OnDemandFeatureView",
)
elif isinstance(feature_view, FeatureView):
_check_conflict(
self.get_stream_feature_view,
FeatureViewNotFoundException,
"StreamFeatureView",
)
_check_conflict(
self.get_on_demand_feature_view,
FeatureViewNotFoundException,
"OnDemandFeatureView",
)
elif isinstance(feature_view, OnDemandFeatureView):
_check_conflict(
self.get_feature_view, FeatureViewNotFoundException, "FeatureView"
)
_check_conflict(
self.get_stream_feature_view,
FeatureViewNotFoundException,
"StreamFeatureView",
)

@abstractmethod
def delete_feature_view(self, name: str, project: str, commit: bool = True):
"""
Expand Down
1 change: 1 addition & 0 deletions sdk/python/feast/infra/registry/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ def apply_data_source(
def apply_feature_view(
self, feature_view: BaseFeatureView, project: str, commit: bool = True
):
self._ensure_feature_view_name_is_unique(feature_view, project)
fv_table = self._infer_fv_table(feature_view)

return self._apply_object(
Expand Down
155 changes: 153 additions & 2 deletions sdk/python/tests/integration/registration/test_feature_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@
from datetime import timedelta
from tempfile import mkstemp

import pandas as pd
import pytest
from pytest_lazyfixture import lazy_fixture

from feast import FileSource
from feast.data_format import AvroFormat
from feast.data_source import KafkaSource
from feast.entity import Entity
from feast.feature_store import FeatureStore
from feast.errors import ConflictingFeatureViewNames
from feast.feature_store import FeatureStore, _validate_feature_views
from feast.feature_view import FeatureView
from feast.field import Field
from feast.infra.online_stores.sqlite import SqliteOnlineStoreConfig
from feast.on_demand_feature_view import on_demand_feature_view
from feast.repo_config import RepoConfig
from feast.types import Float64, Int64, String
from feast.stream_feature_view import StreamFeatureView
from feast.types import Float32, Float64, Int64, String
from tests.utils.data_source_test_creator import prep_file_source


Expand Down Expand Up @@ -75,3 +83,146 @@ def feature_store_with_local_registry():
entity_key_serialization_version=3,
)
)


@pytest.mark.integration
def test_validate_feature_views_cross_type_conflict():
"""
Test that _validate_feature_views() catches cross-type name conflicts.

This is a unit test for the validation that happens during feast plan/apply.
The validation must catch conflicts across FeatureView, StreamFeatureView,
and OnDemandFeatureView to prevent silent data correctness bugs in
get_online_features (which uses fixed-order lookup).

See: https://github.com/feast-dev/feast/issues/5995
"""
# Create a simple entity
entity = Entity(name="driver_entity", join_keys=["test_key"])

# Create a regular FeatureView
file_source = FileSource(name="my_file_source", path="test.parquet")
feature_view = FeatureView(
name="my_feature_view",
entities=[entity],
schema=[Field(name="feature1", dtype=Float32)],
source=file_source,
)

# Create a StreamFeatureView with the SAME name
stream_source = KafkaSource(
name="kafka",
timestamp_field="event_timestamp",
kafka_bootstrap_servers="",
message_format=AvroFormat(""),
topic="topic",
batch_source=file_source,
watermark_delay_threshold=timedelta(days=1),
)
stream_feature_view = StreamFeatureView(
name="my_feature_view", # Same name as FeatureView!
entities=[entity],
ttl=timedelta(days=30),
schema=[Field(name="feature1", dtype=Float32)],
source=stream_source,
)

# Validate should raise ConflictingFeatureViewNames
with pytest.raises(ConflictingFeatureViewNames) as exc_info:
_validate_feature_views([feature_view, stream_feature_view])

# Verify error message contains type information
error_message = str(exc_info.value)
assert "my_feature_view" in error_message
assert "FeatureView" in error_message
assert "StreamFeatureView" in error_message


def test_validate_feature_views_same_type_conflict():
"""
Test that _validate_feature_views() also catches same-type name conflicts
with a proper error message indicating duplicate FeatureViews.
"""
# Create a simple entity
entity = Entity(name="driver_entity", join_keys=["test_key"])

# Create two FeatureViews with the same name
file_source = FileSource(name="my_file_source", path="test.parquet")
fv1 = FeatureView(
name="duplicate_fv",
entities=[entity],
schema=[Field(name="feature1", dtype=Float32)],
source=file_source,
)
fv2 = FeatureView(
name="duplicate_fv", # Same name!
entities=[entity],
schema=[Field(name="feature2", dtype=Float32)],
source=file_source,
)

# Validate should raise ConflictingFeatureViewNames
with pytest.raises(ConflictingFeatureViewNames) as exc_info:
_validate_feature_views([fv1, fv2])

# Verify error message indicates same-type duplicate
error_message = str(exc_info.value)
assert "duplicate_fv" in error_message
assert "Multiple FeatureViews" in error_message
assert "case-insensitively unique" in error_message


def test_validate_feature_views_case_insensitive():
"""
Test that _validate_feature_views() catches case-insensitive conflicts.
"""
entity = Entity(name="driver_entity", join_keys=["test_key"])
file_source = FileSource(name="my_file_source", path="test.parquet")

fv1 = FeatureView(
name="MyFeatureView",
entities=[entity],
schema=[Field(name="feature1", dtype=Float32)],
source=file_source,
)
fv2 = FeatureView(
name="myfeatureview", # Same name, different case!
entities=[entity],
schema=[Field(name="feature2", dtype=Float32)],
source=file_source,
)

# Validate should raise ConflictingFeatureViewNames (case-insensitive)
with pytest.raises(ConflictingFeatureViewNames):
_validate_feature_views([fv1, fv2])


def test_validate_feature_views_odfv_conflict():
"""
Test that _validate_feature_views() catches OnDemandFeatureView name conflicts.
"""
entity = Entity(name="driver_entity", join_keys=["test_key"])
file_source = FileSource(name="my_file_source", path="test.parquet")

fv = FeatureView(
name="shared_name",
entities=[entity],
schema=[Field(name="feature1", dtype=Float32)],
source=file_source,
)

@on_demand_feature_view(
sources=[fv],
schema=[Field(name="output", dtype=Float32)],
)
def shared_name(inputs: pd.DataFrame) -> pd.DataFrame:
return pd.DataFrame({"output": inputs["feature1"] * 2})

# Validate should raise ConflictingFeatureViewNames
with pytest.raises(ConflictingFeatureViewNames) as exc_info:
_validate_feature_views([fv, shared_name])

error_message = str(exc_info.value)
assert "shared_name" in error_message
assert "FeatureView" in error_message
assert "OnDemandFeatureView" in error_message
Loading
Loading