Skip to content

Commit bc986ef

Browse files
fix: Reject reserved chars in FV names and make version parser resilient
Block `@` and `:` in feature view names via ensure_valid() to prevent ambiguous version-qualified reference parsing. Make _parse_feature_ref() fall back gracefully for legacy FV names containing `@` instead of raising, and update Go's ParseFeatureReference to only strip `@` suffixes matching `v<N>`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 760c003 commit bc986ef

File tree

8 files changed

+132
-8
lines changed

8 files changed

+132
-8
lines changed

docs/reference/alpha-feature-view-versioning.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,18 @@ Version history tracking in the registry (listing versions, pinning, `--no-promo
208208

209209
For the complete design, concurrency semantics, and feature service interactions, see the [Feature View Versioning RFC](../rfcs/feature-view-versioning.md).
210210

211+
## Naming Restrictions
212+
213+
Feature references use a structured format: `feature_view_name@v<N>:feature_name`. To avoid
214+
ambiguity, the following characters are reserved and must not appear in feature view or feature names:
215+
216+
- **`@`** — Reserved as the version delimiter (e.g., `driver_stats@v2:trips_today`). `feast apply`
217+
will reject feature views with `@` in their name. If you have existing feature views with `@` in
218+
their names, they will continue to work for unversioned reads, but we recommend renaming them to
219+
avoid ambiguity with the `@v<N>` syntax.
220+
- **`:`** — Reserved as the separator between feature view name and feature name in fully qualified
221+
feature references (e.g., `driver_stats:trips_today`).
222+
211223
## Known Limitations
212224

213225
- **Online store coverage** — Version-qualified reads (`@v<N>`) are SQLite-only today. Other online stores are follow-up work.

docs/rfcs/feature-view-versioning.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,7 @@ Feature services work with versioned feature views when the online versioning fl
437437
- **Offline store versioning.** This RFC covers online reads only. Versioned historical retrieval is out of scope.
438438
- **Version deletion.** There is no mechanism to prune old versions. This could be added later if registries grow large.
439439
- **Cross-version joins.** Joining features from different versions of the same feature view in `get_historical_features` is not supported.
440+
- **Naming restrictions.** Feature view names must not contain `@` or `:` since these characters are reserved for version-qualified references (`fv@v2:feature`). `feast apply` rejects new feature views with these characters. The parser falls back gracefully for legacy feature views that already contain `@` in their names — unrecognized `@` suffixes are treated as part of the name rather than raising errors.
440441

441442
## Open Questions
442443

go/internal/feast/onlineserving/serving.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/sha256"
55
"errors"
66
"fmt"
7+
"regexp"
78
"sort"
89
"strings"
910

@@ -495,7 +496,11 @@ func ParseFeatureReference(featureRef string) (featureViewName, featureName stri
495496

496497
// Strip @v<N> version qualifier from feature view name
497498
if atIdx := strings.Index(featureViewName, "@"); atIdx >= 0 {
498-
featureViewName = featureViewName[:atIdx]
499+
suffix := featureViewName[atIdx+1:]
500+
matched, _ := regexp.MatchString(`^[vV]\d+$`, suffix)
501+
if matched {
502+
featureViewName = featureViewName[:atIdx]
503+
}
499504
}
500505
return
501506
}

sdk/python/feast/base_feature_view.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,18 @@ def ensure_valid(self):
195195
"""
196196
if not self.name:
197197
raise ValueError("Feature view needs a name.")
198+
if "@" in self.name:
199+
raise ValueError(
200+
f"Feature view name '{self.name}' must not contain '@'. "
201+
f"The '@' character is reserved for version-qualified references "
202+
f"(e.g., 'fv@v2:feature')."
203+
)
204+
if ":" in self.name:
205+
raise ValueError(
206+
f"Feature view name '{self.name}' must not contain ':'. "
207+
f"The ':' character is reserved as the separator in fully qualified "
208+
f"feature references (e.g., 'feature_view:feature_name')."
209+
)
198210

199211
def with_name(self, name: str):
200212
"""

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ def apply_feature_view(
627627
commit: bool = True,
628628
no_promote: bool = False,
629629
):
630+
feature_view.ensure_valid()
630631
self._ensure_feature_view_name_is_unique(feature_view, project)
631632
fv_table = self._infer_fv_table(feature_view)
632633
fv_type_str = self._infer_fv_type_string(feature_view)

sdk/python/feast/utils.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,8 @@ def _parse_feature_ref(ref: str) -> Tuple[str, Optional[int], str]:
9494
# Parse version number from formats like "v2", "V2"
9595
match = re.match(r"^[vV](\d+)$", version_str)
9696
if not match:
97-
raise ValueError(
98-
f"Invalid version '{version_str}' in feature reference '{ref}'. "
99-
f"Expected format: 'v<number>' or 'latest'"
100-
)
97+
# Not a recognized version format — treat entire fv_part as the name
98+
return (fv_part, None, feature_name)
10199

102100
return (fv_name, int(match.group(1)), feature_name)
103101

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,3 +1384,35 @@ def test_no_promote_version_accessible_by_explicit_ref(
13841384
assert v0_fv.current_version_number == 0
13851385
feature_names_v0 = {f.name for f in v0_fv.schema}
13861386
assert "explicit_feature" not in feature_names_v0
1387+
1388+
1389+
class TestFeatureViewNameValidation:
1390+
"""Tests that feature view names with reserved characters are rejected on apply."""
1391+
1392+
def test_apply_feature_view_with_at_sign_raises(self, registry, entity):
1393+
"""Applying a feature view with '@' in its name should raise ValueError."""
1394+
fv = FeatureView(
1395+
name="my_weirdly_@_named_fv",
1396+
entities=[entity],
1397+
ttl=timedelta(days=1),
1398+
schema=[
1399+
Field(name="driver_id", dtype=Int64),
1400+
Field(name="trips_today", dtype=Int64),
1401+
],
1402+
)
1403+
with pytest.raises(ValueError, match="must not contain '@'"):
1404+
registry.apply_feature_view(fv, "test_project", commit=True)
1405+
1406+
def test_apply_feature_view_with_colon_raises(self, registry, entity):
1407+
"""Applying a feature view with ':' in its name should raise ValueError."""
1408+
fv = FeatureView(
1409+
name="my:weird:fv",
1410+
entities=[entity],
1411+
ttl=timedelta(days=1),
1412+
schema=[
1413+
Field(name="driver_id", dtype=Int64),
1414+
Field(name="trips_today", dtype=Int64),
1415+
],
1416+
)
1417+
with pytest.raises(ValueError, match="must not contain ':'"):
1418+
registry.apply_feature_view(fv, "test_project", commit=True)

sdk/python/tests/unit/test_feature_view_versioning.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,16 +370,79 @@ def test_invalid_no_colon(self):
370370
with pytest.raises(ValueError, match="Invalid feature reference"):
371371
_parse_feature_ref("driver_stats_trips")
372372

373-
def test_invalid_version_format(self):
374-
with pytest.raises(ValueError, match="Invalid version"):
375-
_parse_feature_ref("driver_stats@abc:trips")
373+
def test_unrecognized_version_falls_back(self):
374+
"""Unrecognized version format falls back to treating full fv_part as the name."""
375+
fv, version, feat = _parse_feature_ref("driver_stats@abc:trips")
376+
assert fv == "driver_stats@abc"
377+
assert version is None
378+
assert feat == "trips"
376379

377380
def test_empty_version(self):
378381
fv, version, feat = _parse_feature_ref("driver_stats@:trips")
379382
assert fv == "driver_stats"
380383
assert version is None
381384
assert feat == "trips"
382385

386+
def test_at_sign_in_fv_name_falls_back_gracefully(self):
387+
"""Legacy FV name with @ falls back to treating whole pre-colon string as name."""
388+
fv, version, feat = _parse_feature_ref("my@weird:feature")
389+
assert fv == "my@weird"
390+
assert version is None
391+
assert feat == "feature"
392+
393+
def test_at_sign_with_valid_version_still_parses(self):
394+
"""A valid @v<N> suffix still parses as a version."""
395+
fv, version, feat = _parse_feature_ref("stats@v2:trips")
396+
assert fv == "stats"
397+
assert version == 2
398+
assert feat == "trips"
399+
400+
401+
class TestEnsureValidRejectsReservedChars:
402+
def test_at_sign_in_name_raises(self):
403+
from datetime import timedelta
404+
405+
from feast.entity import Entity
406+
from feast.feature_view import FeatureView
407+
408+
entity = Entity(name="entity_id", join_keys=["entity_id"])
409+
fv = FeatureView(
410+
name="my@weird_fv",
411+
entities=[entity],
412+
ttl=timedelta(days=1),
413+
)
414+
with pytest.raises(ValueError, match="must not contain '@'"):
415+
fv.ensure_valid()
416+
417+
def test_colon_in_name_raises(self):
418+
from datetime import timedelta
419+
420+
from feast.entity import Entity
421+
from feast.feature_view import FeatureView
422+
423+
entity = Entity(name="entity_id", join_keys=["entity_id"])
424+
fv = FeatureView(
425+
name="my:weird_fv",
426+
entities=[entity],
427+
ttl=timedelta(days=1),
428+
)
429+
with pytest.raises(ValueError, match="must not contain ':'"):
430+
fv.ensure_valid()
431+
432+
def test_valid_name_passes(self):
433+
from datetime import timedelta
434+
435+
from feast.entity import Entity
436+
from feast.feature_view import FeatureView
437+
438+
entity = Entity(name="entity_id", join_keys=["entity_id"])
439+
fv = FeatureView(
440+
name="driver_stats",
441+
entities=[entity],
442+
ttl=timedelta(days=1),
443+
)
444+
fv.ensure_valid() # Should not raise
445+
383446

384447
class TestStripVersionFromRef:
385448
def test_bare_ref(self):

0 commit comments

Comments
 (0)