Skip to content

Commit 2a3e544

Browse files
feat: Gate feature services that reference versioned feature views
Fail fast at apply time and retrieval time when a feature service references a versioned FV (current_version_number > 0) and enable_online_feature_view_versioning is off. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c5d4b49 commit 2a3e544

File tree

4 files changed

+225
-3
lines changed

4 files changed

+225
-3
lines changed

docs/rfcs/feature-view-versioning.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,15 @@ If two concurrent applies both try to forward-declare the same version:
315315
- For single-developer or CI/CD workflows, the file registry works fine.
316316
- For multi-client environments with concurrent applies, use the SQL registry for proper conflict detection.
317317

318+
## Feature Services
319+
320+
Feature services currently have limited versioning support:
321+
322+
- **Always resolve to the active (latest) version.** A `FeatureService` references feature views by name without version qualifiers. At both apply time and retrieval time, the service resolves each reference to the currently active feature view definition.
323+
- **No `@v<N>` syntax in feature services.** Version-qualified reads (`driver_stats@v2:trips_today`) require string-based feature references passed directly to `get_online_features()`. Feature services do not support pinning individual feature view references to specific versions.
324+
- **Versioned FVs require the flag.** If any feature view referenced by a feature service has been versioned (`current_version_number > 0`), the `enable_online_feature_view_versioning` flag must be set to `true`. Without it, `feast apply` will reject the feature service with a clear error, and `get_online_features()` will fail at retrieval time.
325+
- **Future work: per-reference version pinning.** A future enhancement could allow feature services to pin individual feature view references to specific versions (e.g., `FeatureService(features=[driver_stats["v2"]])`).
326+
318327
## Limitations & Future Work
319328

320329
- **Online store coverage.** Version-qualified reads are only on SQLite today. Redis, DynamoDB, Bigtable, Postgres, etc. are follow-up work.

sdk/python/feast/feature_store.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,37 @@ def apply(
11511151
self.registry.apply_feature_view(view, project=self.project, commit=False)
11521152
for ent in entities_to_update:
11531153
self.registry.apply_entity(ent, project=self.project, commit=False)
1154+
1155+
# Gate: feature services must not reference versioned FVs when online versioning is off
1156+
if not self.config.registry.enable_online_feature_view_versioning:
1157+
fvs_in_batch = {
1158+
fv.name: fv
1159+
for fv in itertools.chain(
1160+
views_to_update, odfvs_to_update, sfvs_to_update
1161+
)
1162+
}
1163+
for feature_service in services_to_update:
1164+
for projection in feature_service.feature_view_projections:
1165+
fv = fvs_in_batch.get(projection.name)
1166+
if fv is None:
1167+
try:
1168+
fv = self.registry.get_any_feature_view(
1169+
projection.name, self.project
1170+
)
1171+
except FeatureViewNotFoundException:
1172+
continue
1173+
if (
1174+
getattr(fv, "current_version_number", None) is not None
1175+
and fv.current_version_number > 0
1176+
):
1177+
raise ValueError(
1178+
f"Feature service '{feature_service.name}' references feature view "
1179+
f"'{projection.name}' which is at version v{fv.current_version_number}. "
1180+
f"To use versioned feature views in feature services, set "
1181+
f"'enable_online_feature_view_versioning: true' under 'registry' "
1182+
f"in feature_store.yaml."
1183+
)
1184+
11541185
for feature_service in services_to_update:
11551186
self.registry.apply_feature_service(
11561187
feature_service, project=self.project, commit=False

sdk/python/feast/utils.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,10 +1300,10 @@ def _get_feature_views_to_use(
13001300
fvs_to_use, od_fvs_to_use = [], []
13011301
for name, version_num, projection in feature_views:
13021302
if version_num is not None:
1303-
if not getattr(registry, "enable_versioning", False):
1303+
if not getattr(registry, "enable_online_versioning", False):
13041304
raise ValueError(
13051305
f"Version-qualified ref '{name}@v{version_num}' not supported: "
1306-
f"versioning is disabled. Set 'enable_feature_view_versioning: true' "
1306+
f"online versioning is disabled. Set 'enable_online_feature_view_versioning: true' "
13071307
f"under 'registry' in feature_store.yaml."
13081308
)
13091309
# Version-qualified reference: look up the specific version snapshot
@@ -1322,6 +1322,19 @@ def _get_feature_views_to_use(
13221322
fv.projection.version_tag = version_num
13231323
else:
13241324
fv = registry.get_any_feature_view(name, project, allow_cache)
1325+
# Gate: feature services must not resolve to versioned FVs when online versioning is off
1326+
if (
1327+
isinstance(features, FeatureService)
1328+
and getattr(fv, "current_version_number", None) is not None
1329+
and fv.current_version_number > 0
1330+
and not getattr(registry, "enable_online_versioning", False)
1331+
):
1332+
raise ValueError(
1333+
f"Feature service references feature view '{name}' which is at version "
1334+
f"v{fv.current_version_number}, but online versioning is disabled. "
1335+
f"Set 'enable_online_feature_view_versioning: true' under 'registry' "
1336+
f"in feature_store.yaml."
1337+
)
13251338

13261339
if isinstance(fv, OnDemandFeatureView):
13271340
od_fvs_to_use.append(

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

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
"""Integration tests for feature view versioning."""
22

3+
import os
34
import tempfile
45
from datetime import timedelta
56
from pathlib import Path
67

78
import pytest
89

10+
from feast import FeatureStore
911
from feast.entity import Entity
1012
from feast.errors import FeatureViewPinConflict, FeatureViewVersionNotFound
13+
from feast.feature_service import FeatureService
1114
from feast.feature_view import FeatureView
1215
from feast.field import Field
16+
from feast.infra.online_stores.sqlite import SqliteOnlineStoreConfig
1317
from feast.infra.registry.registry import Registry
14-
from feast.repo_config import RegistryConfig
18+
from feast.repo_config import RegistryConfig, RepoConfig
1519
from feast.stream_feature_view import StreamFeatureView
1620
from feast.types import Float32, Int64
1721
from feast.value_type import ValueType
@@ -915,3 +919,168 @@ def test_version_qualified_ref_raises_when_online_versioning_disabled(
915919
allow_cache=False,
916920
hide_dummy_entity=False,
917921
)
922+
923+
924+
class TestFeatureServiceVersioningGates:
925+
"""Tests that feature services are gated when referencing versioned feature views."""
926+
927+
@pytest.fixture
928+
def versioned_fv_and_entity(self):
929+
"""Create a versioned feature view (v1) and its entity."""
930+
entity = Entity(
931+
name="driver_id",
932+
join_keys=["driver_id"],
933+
value_type=ValueType.INT64,
934+
)
935+
# v0 definition
936+
fv_v0 = FeatureView(
937+
name="driver_stats",
938+
entities=[entity],
939+
ttl=timedelta(days=1),
940+
schema=[
941+
Field(name="driver_id", dtype=Int64),
942+
Field(name="trips_today", dtype=Int64),
943+
],
944+
description="v0",
945+
)
946+
# v1 definition (schema change)
947+
fv_v1 = FeatureView(
948+
name="driver_stats",
949+
entities=[entity],
950+
ttl=timedelta(days=1),
951+
schema=[
952+
Field(name="driver_id", dtype=Int64),
953+
Field(name="trips_today", dtype=Int64),
954+
Field(name="avg_rating", dtype=Float32),
955+
],
956+
description="v1",
957+
)
958+
return entity, fv_v0, fv_v1
959+
960+
@pytest.fixture
961+
def unversioned_fv_and_entity(self):
962+
"""Create an unversioned feature view (v0 only) and its entity."""
963+
entity = Entity(
964+
name="driver_id",
965+
join_keys=["driver_id"],
966+
value_type=ValueType.INT64,
967+
)
968+
fv = FeatureView(
969+
name="driver_stats",
970+
entities=[entity],
971+
ttl=timedelta(days=1),
972+
schema=[
973+
Field(name="driver_id", dtype=Int64),
974+
Field(name="trips_today", dtype=Int64),
975+
],
976+
description="only version",
977+
)
978+
return entity, fv
979+
980+
def _make_store(self, tmpdir, enable_versioning=False):
981+
"""Create a FeatureStore with optional online versioning."""
982+
registry_path = os.path.join(tmpdir, "registry.db")
983+
online_path = os.path.join(tmpdir, "online.db")
984+
return FeatureStore(
985+
config=RepoConfig(
986+
registry=RegistryConfig(
987+
path=registry_path,
988+
enable_online_feature_view_versioning=enable_versioning,
989+
),
990+
project="test_project",
991+
provider="local",
992+
online_store=SqliteOnlineStoreConfig(path=online_path),
993+
entity_key_serialization_version=3,
994+
)
995+
)
996+
997+
def test_feature_service_apply_fails_with_versioned_fv_when_flag_off(
998+
self, versioned_fv_and_entity
999+
):
1000+
"""Apply a feature service referencing a versioned FV with flag off -> ValueError."""
1001+
entity, fv_v0, fv_v1 = versioned_fv_and_entity
1002+
1003+
with tempfile.TemporaryDirectory() as tmpdir:
1004+
store = self._make_store(tmpdir, enable_versioning=False)
1005+
1006+
# Apply v0 first, then v1 to create version history
1007+
store.apply([entity, fv_v0])
1008+
store.apply([entity, fv_v1])
1009+
1010+
# Now create a feature service referencing the versioned FV
1011+
fs = FeatureService(
1012+
name="driver_service",
1013+
features=[fv_v1],
1014+
)
1015+
1016+
with pytest.raises(ValueError, match="version v1"):
1017+
store.apply([fs])
1018+
1019+
def test_feature_service_apply_succeeds_with_versioned_fv_when_flag_on(
1020+
self, versioned_fv_and_entity
1021+
):
1022+
"""Apply a feature service referencing a versioned FV with flag on -> succeeds."""
1023+
entity, fv_v0, fv_v1 = versioned_fv_and_entity
1024+
1025+
with tempfile.TemporaryDirectory() as tmpdir:
1026+
store = self._make_store(tmpdir, enable_versioning=True)
1027+
1028+
# Apply v0 first, then v1 to create version history
1029+
store.apply([entity, fv_v0])
1030+
store.apply([entity, fv_v1])
1031+
1032+
# Feature service referencing versioned FV should succeed
1033+
fs = FeatureService(
1034+
name="driver_service",
1035+
features=[fv_v1],
1036+
)
1037+
store.apply([fs]) # Should not raise
1038+
1039+
def test_feature_service_retrieval_fails_with_versioned_fv_when_flag_off(
1040+
self, versioned_fv_and_entity
1041+
):
1042+
"""get_online_features with a feature service referencing a versioned FV, flag off -> ValueError."""
1043+
entity, fv_v0, fv_v1 = versioned_fv_and_entity
1044+
from feast.utils import _get_feature_views_to_use
1045+
1046+
with tempfile.TemporaryDirectory() as tmpdir:
1047+
# First apply with flag on so the feature service can be registered
1048+
store_on = self._make_store(tmpdir, enable_versioning=True)
1049+
store_on.apply([entity, fv_v0])
1050+
store_on.apply([entity, fv_v1])
1051+
fs = FeatureService(
1052+
name="driver_service",
1053+
features=[fv_v1],
1054+
)
1055+
store_on.apply([fs])
1056+
1057+
# Now create a store with the flag off to test retrieval
1058+
store_off = self._make_store(tmpdir, enable_versioning=False)
1059+
registered_fs = store_off.registry.get_feature_service(
1060+
"driver_service", "test_project"
1061+
)
1062+
1063+
with pytest.raises(ValueError, match="online versioning is disabled"):
1064+
_get_feature_views_to_use(
1065+
registry=store_off.registry,
1066+
project="test_project",
1067+
features=registered_fs,
1068+
allow_cache=False,
1069+
hide_dummy_entity=False,
1070+
)
1071+
1072+
def test_feature_service_with_unversioned_fv_succeeds(
1073+
self, unversioned_fv_and_entity
1074+
):
1075+
"""Feature service with v0 FV works fine regardless of flag."""
1076+
entity, fv = unversioned_fv_and_entity
1077+
1078+
with tempfile.TemporaryDirectory() as tmpdir:
1079+
store = self._make_store(tmpdir, enable_versioning=False)
1080+
1081+
# Apply unversioned FV and feature service
1082+
fs = FeatureService(
1083+
name="driver_service",
1084+
features=[fv],
1085+
)
1086+
store.apply([entity, fv, fs]) # Should not raise

0 commit comments

Comments
 (0)