Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
check duplicate names for feature view across types
Signed-off-by: Prathap P <436prathap@gmail.com>
  • Loading branch information
Prathap-P authored and ntkathole committed Mar 6, 2026
commit 6e256b66ec2d11382f237769dadf949cc69ecb22
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
40 changes: 36 additions & 4 deletions sdk/python/feast/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ def __init__(self, name, project=None):
super().__init__(f"On demand feature view {name} does not exist")


class StreamFeatureViewNotFoundException(FeastObjectNotFoundException):
def __init__(self, name, project=None):
if project:
super().__init__(
f"Stream feature view {name} does not exist in project {project}"
)
else:
super().__init__(f"Stream feature view {name} does not exist")


class RequestDataNotFoundInEntityDfException(FeastObjectNotFoundException):
def __init__(self, feature_name, feature_view_name):
super().__init__(
Expand Down Expand Up @@ -411,10 +421,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. "
Comment thread
Prathap-P marked this conversation as resolved.
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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_validate_feature_views() in feature_store.py uses case-insensitive names (fv.name.lower()), while
_ensure_feature_view_name_is_unique() relies on the registry getters, which are typically case-sensitive. Worth aligning the behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ntkathole Thanks for your comment.
On trying to work on that, I have a found an inconsistency (might be a potential issue).
I'm holding my progress on above issue, till I get clarified on this.

In current Feast master, FeatureStore.apply() behaves as an incremental upsert (not a full registry refresh): it validates only the objects in the current request and writes those objects, while existing unrelated records remain untouched. Because of this, duplicate-name protection is incomplete for previously registered objects—especially when names differ only by case (for example, existing driver and new Driver). The in-request validation checks only the incoming payload, and the SQL registry uniqueness constraints are per table and case-sensitive by default, so this scenario can pass and create ambiguous registry state. Expected behavior (per my understanding) is case-insensitive uniqueness for feature view names, so this appears to be a gap.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Prathap-P I think it's fine if you want to handle it separately and we keep scope of this PR for sql registry duplicate names

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ntkathole Solving the test cases took a lot of time, finally was able to fix it. Do you have any other points to discuss?

Copy link
Copy Markdown
Contributor Author

@Prathap-P Prathap-P Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know Devin is reporting these changes are not implemented in snowflake, but as you said to scope this pr to sql, ignoring it...

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,12 @@
from feast.base_feature_view import BaseFeatureView
from feast.data_source import DataSource
from feast.entity import Entity
from feast.errors import (
ConflictingFeatureViewNames,
FeatureViewNotFoundException,
OnDemandFeatureViewNotFoundException,
StreamFeatureViewNotFoundException,
)
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 +269,59 @@ 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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

if isinstance(feature_view, FeatureView):
_check_conflict(
self.get_stream_feature_view,
StreamFeatureViewNotFoundException,
"StreamFeatureView",
)
_check_conflict(
self.get_on_demand_feature_view,
OnDemandFeatureViewNotFoundException,
"OnDemandFeatureView",
)
elif isinstance(feature_view, StreamFeatureView):
_check_conflict(
self.get_feature_view, FeatureViewNotFoundException, "FeatureView"
)
_check_conflict(
self.get_on_demand_feature_view,
OnDemandFeatureViewNotFoundException,
"OnDemandFeatureView",
)
elif isinstance(feature_view, OnDemandFeatureView):
_check_conflict(
self.get_feature_view, FeatureViewNotFoundException, "FeatureView"
)
_check_conflict(
self.get_stream_feature_view,
StreamFeatureViewNotFoundException,
"StreamFeatureView",
)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated

@abstractmethod
def delete_feature_view(self, name: str, project: str, commit: bool = True):
"""
Expand Down
143 changes: 141 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,21 @@
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.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 +81,136 @@ def feature_store_with_local_registry():
entity_key_serialization_version=3,
)
)


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_feature_view = StreamFeatureView(
name="my_feature_view", # Same name as FeatureView!
entities=[entity],
ttl=timedelta(days=30),
schema=[Field(name="feature1", dtype=Float32)],
source=file_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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.


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