Skip to content

Commit 787845a

Browse files
authored
feat: Version-pinning for FeatureService (online + offline) (#6718)
* fix: Pin FeatureView version in FeatureService projections A FeatureService built from a version-pinned FeatureView (e.g. FeatureView(version="v2")) silently served the promoted version instead of the pinned one, defeating the guarantee a FeatureService is meant to provide. Two bugs caused this: 1. FeatureService.__init__ appended the source view's projection without translating its `version` string into `projection.version_tag` (the field name_to_use() checks to render "fv@v2"). 2. utils._get_feature_views_to_use hard-coded the version as None for the FeatureService branch, so retrieval always fell through to the promoted snapshot. Stamp version_tag from the source view's version in __init__, and read projection.version_tag (instead of None) during retrieval. The default version ("latest") leaves version_tag as None, so every existing unversioned FeatureService is unaffected. Covers both online and offline retrieval, which share _get_feature_views_to_use. Signed-off-by: h-sarwat <[email protected]> * feat: Support string feature references in FeatureService FeatureService.features now accepts string refs using the same '<feature_view>[@<version>][:<feature>]' syntax as get_historical_features/get_online_features, e.g. "driver_stats@v2" or "driver_stats@v2:trips_today". This lets a service pin a historical FeatureView version without importing or reconstructing the underlying object. String entries are stashed at construction (no registry is available then) and resolved once, in FeatureStore.apply/plan, via a new FeatureService.resolve_pending_refs. A pinned ref always resolves from the registry snapshot for that version; an unversioned ref prefers the apply batch, then the promoted version. utils._parse_feature_ref is refactored onto a new _parse_feature_or_view_ref that makes the ':<feature>' suffix optional (whole-view refs); _parse_feature_ref is unchanged for existing callers. Signed-off-by: h-sarwat <[email protected]> * fix: Resolve version-pinned OnDemandFeatureViews in offline retrieval Offline stores re-fetch OnDemandFeatureViews from the registry independently of the version-aware online path, using an unversioned registry.list_on_demand_feature_views. A version-pinned ODFV (e.g. from a FeatureService pinning a specific ODFV version) therefore either raised "Could not find feature view from reference odfv@v1:feat" or silently served the promoted version during get_historical_features. Add a shared utils._get_requested_on_demand_feature_views that resolves each ref version-aware — pinned refs via get_feature_view_by_version (stamping projection.version_tag so name_to_use() matches downstream), unversioned refs via the promoted list as before. Route the three unversioned call sites through it: OnDemandFeatureView.get_requested_odfvs, offline_utils.get_feature_view_query_context, and dask.py's inline duplicate. This covers every offline backend, which all funnel through one of those. Unversioned refs are unchanged. Signed-off-by: h-sarwat <[email protected]> * docs: Document FeatureService version pinning Document pinning a historical feature view version inside a FeatureService, via both string feature refs ("driver_stats@v2:trips_today") and version-pinned FeatureView objects, on the alpha feature-view-versioning page. Clarify that enable_online_feature_view_versioning gates both online and offline versioned resolution (they share one code path), both in the page and in the RegistryConfig field docstring. Update Known Limitations: offline version-qualified retrieval and version-pinned feature services are now supported. Signed-off-by: h-sarwat <[email protected]> * fix: Handle unresolved string-ref services in feature service retrieval Signed-off-by: h-sarwat <[email protected]> * test: Add integration tests for string-ref FeatureService retrieval Signed-off-by: h-sarwat <[email protected]> * fix: Copy projection in object-based FeatureService pinning Signed-off-by: h-sarwat <[email protected]> * fix: Resolve string refs against all ODFVs in same apply Signed-off-by: h-sarwat <[email protected]> * fix: Reject/resolve unresolved string refs in apply and to_proto Signed-off-by: h-sarwat <[email protected]> * test: Add version-pinned string-ref FeatureService retrieval test Signed-off-by: h-sarwat <[email protected]> * fix: Apply inference to stored projection, not source view infer_features mutated feature_grouping.projection, but __init__ now stores a decoupled copy in feature_view_projections (the serialized object). This broke schema-less FeatureViews used as a feature subset in a FeatureService. Mutate the stored projection instead. Signed-off-by: h-sarwat <[email protected]> --------- Signed-off-by: h-sarwat <[email protected]>
1 parent 922db5c commit 787845a

11 files changed

Lines changed: 937 additions & 43 deletions

File tree

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

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ feast apply # Detects change → v2
4242
{% hint style="info" %}
4343
Version history tracking is **always active** — no configuration needed. Every `feast apply` that changes a feature view automatically records a version snapshot.
4444

45-
To enable **versioned online reads** (e.g., `fv@v2:feature`), add `enable_online_feature_view_versioning: true` to your registry config in `feature_store.yaml`:
45+
To enable **versioned reads** (e.g., `fv@v2:feature`), add `enable_online_feature_view_versioning: true` to your registry config in `feature_store.yaml`:
4646

4747
```yaml
4848
registry:
4949
path: data/registry.db
5050
enable_online_feature_view_versioning: true
5151
```
5252
53-
When this flag is off, version-qualified refs (e.g., `fv@v2:feature`) in online reads will raise errors, but version history, version listing, version pinning, and version lookups all work normally.
53+
Despite the `online` in its name, this flag gates **both** online (`get_online_features`) and offline (`get_historical_features`) versioned resolution — they share the same code path. When it is off, version-qualified refs (e.g., `fv@v2:feature`) raise errors, but version history, version listing, version pinning, and version lookups all work normally.
5454
{% endhint %}
5555

5656
## Pinning to a Specific Version
@@ -92,6 +92,50 @@ After reverting with a pin, you can go back to normal auto-incrementing behavior
9292
| `"v0"`, `"v1"`, `"v2"`, ... | Pin to a specific version number |
9393
| `"version0"`, `"version1"`, ... | Equivalent long form (case-insensitive) |
9494

95+
## Pinning a Version in a Feature Service
96+
97+
A `FeatureService` freezes the exact feature definitions a model was trained and
98+
served on, so it can pin a specific historical feature view version. Two ways to
99+
do this:
100+
101+
**1. String feature references (recommended).** A `FeatureService` entry may be a
102+
string using the same `<feature_view>[@<version>][:<feature>]` syntax as
103+
`get_historical_features`/`get_online_features`. No need to import or reconstruct
104+
the historical feature view object — the pin is resolved against the registry at
105+
`feast apply` time:
106+
107+
```python
108+
from feast import FeatureService
109+
110+
pinned_service = FeatureService(
111+
name="model_v1_service",
112+
features=[
113+
"driver_stats@v0:trips_today", # pinned version, single feature
114+
"driver_stats@v1", # pinned version, whole view
115+
"driver_stats", # latest (unpinned), whole view
116+
],
117+
)
118+
```
119+
120+
**2. A version-pinned feature view object.** Passing a `FeatureView(version="v2")`
121+
object (or a slice of it) into `features` carries its version into the service:
122+
123+
```python
124+
driver_stats_v2 = FeatureView(name="driver_stats", version="v2", ...)
125+
pinned_service = FeatureService(
126+
name="model_v1_service",
127+
features=[driver_stats_v2[["trips_today"]]],
128+
)
129+
```
130+
131+
Either way, the resolved projection renders as `driver_stats@v2` and both online
132+
(`get_online_features`) and offline (`get_historical_features`) retrieval return
133+
the pinned snapshot instead of the promoted version. An unversioned entry
134+
(`"driver_stats"` or a `version="latest"` object) behaves exactly as before.
135+
Requires `enable_online_feature_view_versioning: true` (see
136+
[Configuration](#configuration)); a pinned string ref only resolves once that
137+
version exists in the registry's history.
138+
95139
## Staged Publishing (`--no-promote`)
96140

97141
By default, `feast apply` atomically saves a version snapshot **and** promotes it to the active definition. For breaking schema changes, you may want to stage the new version without disrupting unversioned consumers.
@@ -223,7 +267,6 @@ ambiguity, the following characters are reserved and must not appear in feature
223267
## Known Limitations
224268

225269
- **Online store coverage** — Version-qualified reads (`@v<N>`) are SQLite-only today. Other online stores are follow-up work.
226-
- **Offline store versioning** — Versioned historical retrieval is not yet supported.
270+
- **Offline store versioning** — Version-qualified historical retrieval (`get_historical_features`) is supported, including through a version-pinned feature service (see [Pinning a Version in a Feature Service](#pinning-a-version-in-a-feature-service)). It requires `enable_online_feature_view_versioning: true`.
227271
- **Version deletion** — There is no mechanism to prune old versions from the registry.
228272
- **Cross-version joins** — Joining features from different versions of the same feature view in `get_historical_features` is not supported.
229-
- **Feature services** — Feature services always resolve to the active (promoted) version. `--no-promote` versions are not served until promoted.

‎sdk/python/feast/feature_service.py‎

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
from datetime import datetime
23
from typing import TYPE_CHECKING, Dict, List, Optional, Union
34

@@ -24,6 +25,7 @@
2425
from feast.protos.feast.core.FeatureService_pb2 import (
2526
FeatureServiceSpec as FeatureServiceSpecProto,
2627
)
28+
from feast.version_utils import parse_version
2729

2830
if TYPE_CHECKING:
2931
from feast.infra.registry.base_registry import BaseRegistry
@@ -48,7 +50,8 @@ class FeatureService:
4850
"""
4951

5052
name: str
51-
_features: List[Union[FeatureView, OnDemandFeatureView, LabelView]]
53+
_features: List[Union[FeatureView, OnDemandFeatureView, LabelView, str]]
54+
_pending_feature_refs: List[str]
5255
feature_view_projections: List[FeatureViewProjection]
5356
description: str
5457
tags: Dict[str, str]
@@ -63,7 +66,7 @@ def __init__(
6366
self,
6467
*,
6568
name: str,
66-
features: List[Union[FeatureView, OnDemandFeatureView, LabelView]],
69+
features: List[Union[FeatureView, OnDemandFeatureView, LabelView, str]],
6770
tags: Optional[Dict[str, str]] = None,
6871
description: str = "",
6972
owner: str = "",
@@ -75,8 +78,18 @@ def __init__(
7578
7679
Args:
7780
name: The unique name of the feature service.
78-
features: A list containing feature views and feature view
79-
projections, representing the features in the feature service.
81+
features: A list containing feature views, feature view projections,
82+
and/or string feature references, representing the features in
83+
the feature service. A string entry uses the same
84+
'<feature_view>[@<version>][:<feature>]' syntax accepted by
85+
``get_historical_features``/``get_online_features`` — e.g.
86+
"driver_stats" (latest, all features), "driver_stats@v2"
87+
(pinned version, all features), or "driver_stats@v2:trips_today"
88+
(pinned version, single feature). String refs are resolved
89+
against the registry when the feature service is applied
90+
(``FeatureStore.apply``), so a historical version can be pinned
91+
without importing or reconstructing the underlying FeatureView
92+
object.
8093
description (optional): A human-readable description.
8194
tags (optional): A dictionary of key-value pairs to store arbitrary metadata.
8295
owner (optional): The owner of the feature view, typically the email of the
@@ -86,6 +99,7 @@ def __init__(
8699
"""
87100
self.name = name
88101
self._features = features
102+
self._pending_feature_refs = []
89103
self.feature_view_projections = []
90104
self.description = description
91105
self.tags = tags or {}
@@ -95,8 +109,81 @@ def __init__(
95109
self.logging_config = logging_config
96110
self.precompute_online = precompute_online
97111
for feature_grouping in self._features:
98-
if isinstance(feature_grouping, BaseFeatureView):
99-
self.feature_view_projections.append(feature_grouping.projection)
112+
if isinstance(feature_grouping, str):
113+
# No registry at construction time; resolved in resolve_pending_refs.
114+
self._pending_feature_refs.append(feature_grouping)
115+
elif isinstance(feature_grouping, BaseFeatureView):
116+
# copy so we never mutate the source view's own projection.
117+
projection = copy.copy(feature_grouping.projection)
118+
# If the source feature view is version-pinned (e.g.
119+
# FeatureView(version="v2")), stamp that version onto the
120+
# projection so name_to_use() renders "fv@v2" and retrieval
121+
# resolves the pinned snapshot. The default version ("latest")
122+
# leaves version_tag as None, preserving existing behavior for
123+
# every unversioned feature service.
124+
fv_version = getattr(feature_grouping, "version", None)
125+
if projection.version_tag is None and fv_version:
126+
is_latest, version_num = parse_version(fv_version)
127+
if not is_latest:
128+
projection.version_tag = version_num
129+
self.feature_view_projections.append(projection)
130+
131+
def resolve_pending_refs(
132+
self,
133+
project: str,
134+
registry: "BaseRegistry",
135+
fvs_to_update: Optional[Dict[str, Union[FeatureView, BaseFeatureView]]] = None,
136+
) -> None:
137+
"""Resolve string feature refs (see ``__init__``) into projections.
138+
139+
Called automatically by ``FeatureStore.apply``/``plan`` so the pin is
140+
baked into the applied service instead of re-resolved on every read.
141+
142+
A version-pinned ref (``"fv@v2"``) always resolves from the registry's
143+
snapshot for that version, never from ``fvs_to_update`` (which only
144+
holds the batch's "latest" objects). An unversioned ref resolves from
145+
``fvs_to_update`` first, else the promoted version.
146+
147+
Raises:
148+
ValueError: If a ref names a feature not on the resolved view.
149+
"""
150+
if not self._pending_feature_refs:
151+
return
152+
153+
from feast.utils import _parse_feature_or_view_ref
154+
155+
fvs_to_update = fvs_to_update or {}
156+
for ref in self._pending_feature_refs:
157+
fv_name, version_num, feature_name = _parse_feature_or_view_ref(ref)
158+
159+
if version_num is not None:
160+
feature_view = registry.get_feature_view_by_version(
161+
fv_name, project, version_num, allow_cache=False
162+
)
163+
elif fv_name in fvs_to_update:
164+
feature_view = fvs_to_update[fv_name]
165+
else:
166+
feature_view = registry.get_any_feature_view(
167+
fv_name, project, allow_cache=False
168+
)
169+
170+
# copy so we never mutate the source view's own projection.
171+
projection = copy.copy(feature_view.projection)
172+
if version_num is not None:
173+
projection.version_tag = version_num
174+
175+
if feature_name is not None:
176+
matches = [f for f in projection.features if f.name == feature_name]
177+
if not matches:
178+
raise ValueError(
179+
f"Invalid feature reference '{ref}': feature "
180+
f"'{feature_name}' not found on feature view '{fv_name}'."
181+
)
182+
projection.features = matches
183+
184+
self.feature_view_projections.append(projection)
185+
186+
self._pending_feature_refs = []
100187

101188
def infer_features(
102189
self, fvs_to_update: Dict[str, Union[FeatureView, BaseFeatureView]]
@@ -112,9 +199,18 @@ def infer_features(
112199
fvs_to_update: A mapping of feature view names to corresponding feature views that
113200
contains all the feature views necessary to run inference.
114201
"""
202+
# Mutate the stored projection, not feature_grouping.projection (decoupled
203+
# from it by copy in __init__), so inference lands on the serialized object.
204+
# Object groupings are appended in _features order (string refs added last),
205+
# so the k-th BaseFeatureView maps to the k-th stored projection.
206+
projection_index = 0
115207
for feature_grouping in self._features:
208+
if isinstance(feature_grouping, str):
209+
# Already resolved by resolve_pending_refs before inference.
210+
continue
116211
if isinstance(feature_grouping, BaseFeatureView):
117-
projection = feature_grouping.projection
212+
projection = self.feature_view_projections[projection_index]
213+
projection_index += 1
118214

119215
if projection.desired_features:
120216
# The projection wants to select a specific set of inferred features.
@@ -187,6 +283,11 @@ def prepare_for_apply(
187283
"""
188284
from feast.types import Invalid
189285

286+
# Resolve string refs first; otherwise all([]) below treats an
287+
# unresolved service as fully resolved and serializes it empty.
288+
if self._pending_feature_refs:
289+
self.resolve_pending_refs(project, registry)
290+
190291
if self._features and all(p.features for p in self.feature_view_projections):
191292
return self
192293

@@ -213,7 +314,9 @@ def prepare_for_apply(
213314
self.infer_features(fvs_to_update=fvs_to_update)
214315
return self
215316

216-
resolved_features: List[Union[FeatureView, OnDemandFeatureView, LabelView]] = []
317+
resolved_features: List[
318+
Union[FeatureView, OnDemandFeatureView, LabelView, str]
319+
] = []
217320
for projection in self.feature_view_projections:
218321
try:
219322
feature_view = registry.get_any_feature_view(
@@ -355,6 +458,14 @@ def to_proto(self) -> FeatureServiceProto:
355458
Returns:
356459
A FeatureServiceProto protobuf.
357460
"""
461+
# Reject unresolved string refs so we never serialize an empty service.
462+
if self._pending_feature_refs:
463+
raise ValueError(
464+
f"FeatureService '{self.name}' has unresolved feature refs "
465+
f"{self._pending_feature_refs}. Apply it via FeatureStore.apply "
466+
f"(or call resolve_pending_refs) before serializing."
467+
)
468+
358469
meta = FeatureServiceMetaProto()
359470
if self.created_timestamp:
360471
meta.created_timestamp.FromDatetime(self.created_timestamp)

‎sdk/python/feast/feature_store.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,7 +1258,23 @@ def _make_inferences(
12581258
*lvs_to_update,
12591259
]
12601260
}
1261+
# Includes all ODFVs (not just write-enabled) so string refs can
1262+
# resolve any ODFV created in the same apply.
1263+
fvs_to_resolve_map = {
1264+
view.name: view
1265+
for view in [
1266+
*views_to_update,
1267+
*sfvs_to_update,
1268+
*odfvs_to_update,
1269+
*lvs_to_update,
1270+
]
1271+
}
12611272
for feature_service in feature_services_to_update:
1273+
# Resolve string feature refs (e.g. "driver_stats@v2") before
1274+
# inference. No-op for object-only services.
1275+
feature_service.resolve_pending_refs(
1276+
self.project, self.registry, fvs_to_update=fvs_to_resolve_map
1277+
)
12621278
feature_service.infer_features(fvs_to_update=fvs_to_update_map)
12631279

12641280
def _validate_materialize_version(

‎sdk/python/feast/infra/offline_stores/dask.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from feast.saved_dataset import SavedDatasetStorage
5151
from feast.utils import (
5252
_get_requested_feature_views_to_features_dict,
53+
_get_requested_on_demand_feature_views,
5354
compute_non_entity_date_range,
5455
)
5556

@@ -198,7 +199,9 @@ def get_historical_features(
198199
) = _get_requested_feature_views_to_features_dict(
199200
feature_refs,
200201
feature_views,
201-
registry.list_on_demand_feature_views(config.project),
202+
_get_requested_on_demand_feature_views(
203+
feature_refs, config.project, registry
204+
),
202205
)
203206

204207
entity_df_event_timestamp_range = (

‎sdk/python/feast/infra/offline_stores/offline_utils.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
from feast.infra.registry.base_registry import BaseRegistry
2222
from feast.repo_config import RepoConfig
2323
from feast.type_map import feast_value_type_to_pa
24-
from feast.utils import _get_requested_feature_views_to_features_dict, to_naive_utc
24+
from feast.utils import (
25+
_get_requested_feature_views_to_features_dict,
26+
_get_requested_on_demand_feature_views,
27+
to_naive_utc,
28+
)
2529
from feast.value_type import ValueType
2630

2731
DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL = "event_timestamp"
@@ -116,7 +120,9 @@ def get_feature_view_query_context(
116120
feature_views_to_feature_map,
117121
on_demand_feature_views_to_features,
118122
) = _get_requested_feature_views_to_features_dict(
119-
feature_refs, feature_views, registry.list_on_demand_feature_views(project)
123+
feature_refs,
124+
feature_views,
125+
_get_requested_on_demand_feature_views(feature_refs, project, registry),
120126
)
121127

122128
query_context = []

‎sdk/python/feast/on_demand_feature_view.py‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,16 +1345,9 @@ def _get_sample_values_by_type(self) -> dict[ValueType, list[Any]]:
13451345
def get_requested_odfvs(
13461346
feature_refs, project, registry
13471347
) -> list["OnDemandFeatureView"]:
1348-
all_on_demand_feature_views = registry.list_on_demand_feature_views(
1349-
project, allow_cache=True
1350-
)
1351-
requested_on_demand_feature_views: list[OnDemandFeatureView] = []
1352-
for odfv in all_on_demand_feature_views:
1353-
for feature in odfv.features:
1354-
if f"{odfv.name}:{feature.name}" in feature_refs:
1355-
requested_on_demand_feature_views.append(odfv)
1356-
break
1357-
return requested_on_demand_feature_views
1348+
from feast.utils import _get_requested_on_demand_feature_views
1349+
1350+
return _get_requested_on_demand_feature_views(feature_refs, project, registry)
13581351

13591352

13601353
def on_demand_feature_view(

‎sdk/python/feast/repo_config.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,13 @@ class RegistryConfig(FeastBaseModel):
198198
only reset the project but not all the projects"""
199199

200200
enable_online_feature_view_versioning: StrictBool = False
201-
""" bool: Enable versioned online store tables and version-qualified reads
202-
(e.g., 'fv@v2:feature'). When True, each schema version gets its own
203-
online store table and can be queried independently. Version history
204-
tracking in the registry is always active regardless of this setting. """
201+
""" bool: Enable versioned resolution of version-qualified feature references
202+
(e.g., 'fv@v2:feature'). Despite the 'online' in its name, this flag gates
203+
both online and offline (get_historical_features) versioned reads, which
204+
share the same resolution path; when True, each schema version also gets
205+
its own online store table and can be queried independently. Version
206+
history tracking in the registry is always active regardless of this
207+
setting. """
205208

206209
mcp: Optional[McpRegistryConfig] = None
207210
""" McpRegistryConfig: MCP (Model Context Protocol) configuration for the registry REST server. """

0 commit comments

Comments
 (0)