Skip to content

Commit 436404f

Browse files
Prathap-PShizoqua
authored andcommitted
fix: Check duplicate names for feature view across types (feast-dev#5999)
* check duplicate names for feature view across types Signed-off-by: Prathap P <436prathap@gmail.com> * fix: check uniqueness before applying feature view Signed-off-by: Prathap P <436prathap@gmail.com> * use FeatureViewNotFoundException for all types as per Devin's suggestion Signed-off-by: Prathap P <436prathap@gmail.com> * fix tests Signed-off-by: Prathap P <436prathap@gmail.com> * fix linting issues Signed-off-by: Prathap P <436prathap@gmail.com> --------- Signed-off-by: Prathap P <436prathap@gmail.com> Signed-off-by: Shizoqua <hr.lanreshittu@gmail.com>
1 parent 7a8b62d commit 436404f

10 files changed

Lines changed: 432 additions & 32 deletions

File tree

docs/getting-started/quickstart.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,10 @@ driver_stats_source = FileSource(
160160
# three feature column. Here we define a Feature View that will allow us to serve this
161161
# data to our model online.
162162
driver_stats_fv = FeatureView(
163-
# The unique name of this feature view. Two feature views in a single
164-
# project cannot have the same name
163+
# The unique name of this feature view. Two feature views in a single
164+
# project cannot have the same name, and names must be unique across
165+
# all feature view types (regular, stream, on-demand) to avoid conflicts
166+
# during `feast apply`.
165167
name="driver_hourly_stats",
166168
entities=[driver],
167169
ttl=timedelta(days=1),

sdk/python/feast/errors.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -411,10 +411,32 @@ def __init__(self, entity_type: type):
411411

412412
class ConflictingFeatureViewNames(FeastError):
413413
# TODO: print file location of conflicting feature views
414-
def __init__(self, feature_view_name: str):
415-
super().__init__(
416-
f"The feature view name: {feature_view_name} refers to feature views of different types."
417-
)
414+
def __init__(
415+
self,
416+
feature_view_name: str,
417+
existing_type: Optional[str] = None,
418+
new_type: Optional[str] = None,
419+
):
420+
if existing_type and new_type:
421+
if existing_type == new_type:
422+
# Same-type duplicate
423+
super().__init__(
424+
f"Multiple {existing_type}s with name '{feature_view_name}' found. "
425+
f"Feature view names must be case-insensitively unique. "
426+
f"It may be necessary to ignore certain files in your feature "
427+
f"repository by using a .feastignore file."
428+
)
429+
else:
430+
# Cross-type conflict
431+
super().__init__(
432+
f"Feature view name '{feature_view_name}' is already used by a {existing_type}. "
433+
f"Cannot register a {new_type} with the same name. "
434+
f"Feature view names must be unique across FeatureView, StreamFeatureView, and OnDemandFeatureView."
435+
)
436+
else:
437+
super().__init__(
438+
f"The feature view name: {feature_view_name} refers to feature views of different types."
439+
)
418440

419441

420442
class FeastInvalidInfraObjectType(FeastError):

sdk/python/feast/feature_store.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from feast.dqm.errors import ValidationFailed
5454
from feast.entity import Entity
5555
from feast.errors import (
56+
ConflictingFeatureViewNames,
5657
DataFrameSerializationError,
5758
DataSourceRepeatNamesException,
5859
FeatureViewNotFoundException,
@@ -3014,18 +3015,25 @@ def _print_materializing_banner() -> None:
30143015

30153016

30163017
def _validate_feature_views(feature_views: List[BaseFeatureView]):
3017-
"""Verify feature views have case-insensitively unique names"""
3018-
fv_names = set()
3018+
"""Verify feature views have case-insensitively unique names across all types.
3019+
3020+
This validates that no two feature views (of any type: FeatureView,
3021+
StreamFeatureView, OnDemandFeatureView) share the same case-insensitive name.
3022+
This is critical because get_online_features uses get_any_feature_view which
3023+
resolves names in a fixed order, potentially returning the wrong feature view.
3024+
"""
3025+
fv_by_name: Dict[str, BaseFeatureView] = {}
30193026
for fv in feature_views:
30203027
case_insensitive_fv_name = fv.name.lower()
3021-
if case_insensitive_fv_name in fv_names:
3022-
raise ValueError(
3023-
f"More than one feature view with name {case_insensitive_fv_name} found. "
3024-
f"Please ensure that all feature view names are case-insensitively unique. "
3025-
f"It may be necessary to ignore certain files in your feature repository by using a .feastignore file."
3028+
if case_insensitive_fv_name in fv_by_name:
3029+
existing_fv = fv_by_name[case_insensitive_fv_name]
3030+
raise ConflictingFeatureViewNames(
3031+
fv.name,
3032+
existing_type=type(existing_fv).__name__,
3033+
new_type=type(fv).__name__,
30263034
)
30273035
else:
3028-
fv_names.add(case_insensitive_fv_name)
3036+
fv_by_name[case_insensitive_fv_name] = fv
30293037

30303038

30313039
def _validate_data_sources(data_sources: List[DataSource]):

sdk/python/feast/infra/registry/base_registry.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from feast.base_feature_view import BaseFeatureView
2525
from feast.data_source import DataSource
2626
from feast.entity import Entity
27+
from feast.errors import (
28+
ConflictingFeatureViewNames,
29+
FeatureViewNotFoundException,
30+
)
2731
from feast.feature_service import FeatureService
2832
from feast.feature_view import FeatureView
2933
from feast.infra.infra_object import Infra
@@ -263,6 +267,61 @@ def apply_feature_view(
263267
"""
264268
raise NotImplementedError
265269

270+
def _ensure_feature_view_name_is_unique(
271+
self,
272+
feature_view: BaseFeatureView,
273+
project: str,
274+
allow_cache: bool = False,
275+
):
276+
"""
277+
Validates that no feature view name conflict exists across feature view types.
278+
Raises ConflictingFeatureViewNames if a different type already uses the name.
279+
280+
This is a defense-in-depth check for direct apply_feature_view() calls.
281+
The primary validation happens in _validate_all_feature_views() during feast plan/apply.
282+
"""
283+
name = feature_view.name
284+
new_type = type(feature_view).__name__
285+
286+
def _check_conflict(getter, not_found_exc, existing_type: str):
287+
try:
288+
getter(name, project, allow_cache=allow_cache)
289+
raise ConflictingFeatureViewNames(name, existing_type, new_type)
290+
except not_found_exc:
291+
pass
292+
293+
# Check StreamFeatureView before FeatureView since StreamFeatureView is a subclass
294+
# Note: All getters raise FeatureViewNotFoundException (not type-specific exceptions)
295+
if isinstance(feature_view, StreamFeatureView):
296+
_check_conflict(
297+
self.get_feature_view, FeatureViewNotFoundException, "FeatureView"
298+
)
299+
_check_conflict(
300+
self.get_on_demand_feature_view,
301+
FeatureViewNotFoundException,
302+
"OnDemandFeatureView",
303+
)
304+
elif isinstance(feature_view, FeatureView):
305+
_check_conflict(
306+
self.get_stream_feature_view,
307+
FeatureViewNotFoundException,
308+
"StreamFeatureView",
309+
)
310+
_check_conflict(
311+
self.get_on_demand_feature_view,
312+
FeatureViewNotFoundException,
313+
"OnDemandFeatureView",
314+
)
315+
elif isinstance(feature_view, OnDemandFeatureView):
316+
_check_conflict(
317+
self.get_feature_view, FeatureViewNotFoundException, "FeatureView"
318+
)
319+
_check_conflict(
320+
self.get_stream_feature_view,
321+
FeatureViewNotFoundException,
322+
"StreamFeatureView",
323+
)
324+
266325
@abstractmethod
267326
def delete_feature_view(self, name: str, project: str, commit: bool = True):
268327
"""

sdk/python/feast/infra/registry/sql.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ def apply_data_source(
578578
def apply_feature_view(
579579
self, feature_view: BaseFeatureView, project: str, commit: bool = True
580580
):
581+
self._ensure_feature_view_name_is_unique(feature_view, project)
581582
fv_table = self._infer_fv_table(feature_view)
582583

583584
return self._apply_object(

sdk/python/tests/integration/registration/test_feature_store.py

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,23 @@
1414
from datetime import timedelta
1515
from tempfile import mkstemp
1616

17+
import pandas as pd
1718
import pytest
1819
from pytest_lazyfixture import lazy_fixture
1920

21+
from feast import FileSource
22+
from feast.data_format import AvroFormat
23+
from feast.data_source import KafkaSource
2024
from feast.entity import Entity
21-
from feast.feature_store import FeatureStore
25+
from feast.errors import ConflictingFeatureViewNames
26+
from feast.feature_store import FeatureStore, _validate_feature_views
2227
from feast.feature_view import FeatureView
28+
from feast.field import Field
2329
from feast.infra.online_stores.sqlite import SqliteOnlineStoreConfig
30+
from feast.on_demand_feature_view import on_demand_feature_view
2431
from feast.repo_config import RepoConfig
25-
from feast.types import Float64, Int64, String
32+
from feast.stream_feature_view import StreamFeatureView
33+
from feast.types import Float32, Float64, Int64, String
2634
from tests.utils.data_source_test_creator import prep_file_source
2735

2836

@@ -75,3 +83,146 @@ def feature_store_with_local_registry():
7583
entity_key_serialization_version=3,
7684
)
7785
)
86+
87+
88+
@pytest.mark.integration
89+
def test_validate_feature_views_cross_type_conflict():
90+
"""
91+
Test that _validate_feature_views() catches cross-type name conflicts.
92+
93+
This is a unit test for the validation that happens during feast plan/apply.
94+
The validation must catch conflicts across FeatureView, StreamFeatureView,
95+
and OnDemandFeatureView to prevent silent data correctness bugs in
96+
get_online_features (which uses fixed-order lookup).
97+
98+
See: https://github.com/feast-dev/feast/issues/5995
99+
"""
100+
# Create a simple entity
101+
entity = Entity(name="driver_entity", join_keys=["test_key"])
102+
103+
# Create a regular FeatureView
104+
file_source = FileSource(name="my_file_source", path="test.parquet")
105+
feature_view = FeatureView(
106+
name="my_feature_view",
107+
entities=[entity],
108+
schema=[Field(name="feature1", dtype=Float32)],
109+
source=file_source,
110+
)
111+
112+
# Create a StreamFeatureView with the SAME name
113+
stream_source = KafkaSource(
114+
name="kafka",
115+
timestamp_field="event_timestamp",
116+
kafka_bootstrap_servers="",
117+
message_format=AvroFormat(""),
118+
topic="topic",
119+
batch_source=file_source,
120+
watermark_delay_threshold=timedelta(days=1),
121+
)
122+
stream_feature_view = StreamFeatureView(
123+
name="my_feature_view", # Same name as FeatureView!
124+
entities=[entity],
125+
ttl=timedelta(days=30),
126+
schema=[Field(name="feature1", dtype=Float32)],
127+
source=stream_source,
128+
)
129+
130+
# Validate should raise ConflictingFeatureViewNames
131+
with pytest.raises(ConflictingFeatureViewNames) as exc_info:
132+
_validate_feature_views([feature_view, stream_feature_view])
133+
134+
# Verify error message contains type information
135+
error_message = str(exc_info.value)
136+
assert "my_feature_view" in error_message
137+
assert "FeatureView" in error_message
138+
assert "StreamFeatureView" in error_message
139+
140+
141+
def test_validate_feature_views_same_type_conflict():
142+
"""
143+
Test that _validate_feature_views() also catches same-type name conflicts
144+
with a proper error message indicating duplicate FeatureViews.
145+
"""
146+
# Create a simple entity
147+
entity = Entity(name="driver_entity", join_keys=["test_key"])
148+
149+
# Create two FeatureViews with the same name
150+
file_source = FileSource(name="my_file_source", path="test.parquet")
151+
fv1 = FeatureView(
152+
name="duplicate_fv",
153+
entities=[entity],
154+
schema=[Field(name="feature1", dtype=Float32)],
155+
source=file_source,
156+
)
157+
fv2 = FeatureView(
158+
name="duplicate_fv", # Same name!
159+
entities=[entity],
160+
schema=[Field(name="feature2", dtype=Float32)],
161+
source=file_source,
162+
)
163+
164+
# Validate should raise ConflictingFeatureViewNames
165+
with pytest.raises(ConflictingFeatureViewNames) as exc_info:
166+
_validate_feature_views([fv1, fv2])
167+
168+
# Verify error message indicates same-type duplicate
169+
error_message = str(exc_info.value)
170+
assert "duplicate_fv" in error_message
171+
assert "Multiple FeatureViews" in error_message
172+
assert "case-insensitively unique" in error_message
173+
174+
175+
def test_validate_feature_views_case_insensitive():
176+
"""
177+
Test that _validate_feature_views() catches case-insensitive conflicts.
178+
"""
179+
entity = Entity(name="driver_entity", join_keys=["test_key"])
180+
file_source = FileSource(name="my_file_source", path="test.parquet")
181+
182+
fv1 = FeatureView(
183+
name="MyFeatureView",
184+
entities=[entity],
185+
schema=[Field(name="feature1", dtype=Float32)],
186+
source=file_source,
187+
)
188+
fv2 = FeatureView(
189+
name="myfeatureview", # Same name, different case!
190+
entities=[entity],
191+
schema=[Field(name="feature2", dtype=Float32)],
192+
source=file_source,
193+
)
194+
195+
# Validate should raise ConflictingFeatureViewNames (case-insensitive)
196+
with pytest.raises(ConflictingFeatureViewNames):
197+
_validate_feature_views([fv1, fv2])
198+
199+
200+
def test_validate_feature_views_odfv_conflict():
201+
"""
202+
Test that _validate_feature_views() catches OnDemandFeatureView name conflicts.
203+
"""
204+
entity = Entity(name="driver_entity", join_keys=["test_key"])
205+
file_source = FileSource(name="my_file_source", path="test.parquet")
206+
207+
fv = FeatureView(
208+
name="shared_name",
209+
entities=[entity],
210+
schema=[Field(name="feature1", dtype=Float32)],
211+
source=file_source,
212+
)
213+
214+
@on_demand_feature_view(
215+
sources=[fv],
216+
schema=[Field(name="output", dtype=Float32)],
217+
)
218+
def shared_name(inputs: pd.DataFrame) -> pd.DataFrame:
219+
return pd.DataFrame({"output": inputs["feature1"] * 2})
220+
221+
# Validate should raise ConflictingFeatureViewNames
222+
with pytest.raises(ConflictingFeatureViewNames) as exc_info:
223+
_validate_feature_views([fv, shared_name])
224+
225+
error_message = str(exc_info.value)
226+
assert "shared_name" in error_message
227+
assert "FeatureView" in error_message
228+
assert "OnDemandFeatureView" in error_message

0 commit comments

Comments
 (0)