Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
51 changes: 47 additions & 4 deletions docs/reference/alpha-feature-view-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ feast apply # Detects change → v2
{% hint style="info" %}
Version history tracking is **always active** — no configuration needed. Every `feast apply` that changes a feature view automatically records a version snapshot.

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`:
To enable **versioned reads** (e.g., `fv@v2:feature`), add `enable_online_feature_view_versioning: true` to your registry config in `feature_store.yaml`:

```yaml
registry:
path: data/registry.db
enable_online_feature_view_versioning: true
```

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.
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.
{% endhint %}

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

## Pinning a Version in a Feature Service

A `FeatureService` freezes the exact feature definitions a model was trained and
served on, so it can pin a specific historical feature view version. Two ways to
do this:

**1. String feature references (recommended).** A `FeatureService` entry may be a
string using the same `<feature_view>[@<version>][:<feature>]` syntax as
`get_historical_features`/`get_online_features`. No need to import or reconstruct
the historical feature view object — the pin is resolved against the registry at
`feast apply` time:

```python
from feast import FeatureService

pinned_service = FeatureService(
name="model_v1_service",
features=[
"driver_stats@v0:trips_today", # pinned version, single feature
"driver_stats@v1", # pinned version, whole view
"driver_stats", # latest (unpinned), whole view
],
)
```

**2. A version-pinned feature view object.** Passing a `FeatureView(version="v2")`
object (or a slice of it) into `features` carries its version into the service:

```python
driver_stats_v2 = FeatureView(name="driver_stats", version="v2", ...)
pinned_service = FeatureService(
name="model_v1_service",
features=[driver_stats_v2[["trips_today"]]],
)
```

Either way, the resolved projection renders as `driver_stats@v2` and both online
(`get_online_features`) and offline (`get_historical_features`) retrieval return
the pinned snapshot instead of the promoted version. An unversioned entry
(`"driver_stats"` or a `version="latest"` object) behaves exactly as before.
Requires `enable_online_feature_view_versioning: true` (see
[Configuration](#configuration)); a pinned string ref only resolves once that
version exists in the registry's history.

## Staged Publishing (`--no-promote`)

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.
Expand Down Expand Up @@ -223,7 +267,6 @@ ambiguity, the following characters are reserved and must not appear in feature
## Known Limitations

- **Online store coverage** — Version-qualified reads (`@v<N>`) are SQLite-only today. Other online stores are follow-up work.
- **Offline store versioning** — Versioned historical retrieval is not yet supported.
- **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`.
- **Version deletion** — There is no mechanism to prune old versions from the registry.
- **Cross-version joins** — Joining features from different versions of the same feature view in `get_historical_features` is not supported.
- **Feature services** — Feature services always resolve to the active (promoted) version. `--no-promote` versions are not served until promoted.
105 changes: 98 additions & 7 deletions sdk/python/feast/feature_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
from datetime import datetime
from typing import TYPE_CHECKING, Dict, List, Optional, Union

Expand All @@ -24,6 +25,7 @@
from feast.protos.feast.core.FeatureService_pb2 import (
FeatureServiceSpec as FeatureServiceSpecProto,
)
from feast.version_utils import parse_version

if TYPE_CHECKING:
from feast.infra.registry.base_registry import BaseRegistry
Expand All @@ -48,7 +50,8 @@ class FeatureService:
"""

name: str
_features: List[Union[FeatureView, OnDemandFeatureView, LabelView]]
_features: List[Union[FeatureView, OnDemandFeatureView, LabelView, str]]
_pending_feature_refs: List[str]
feature_view_projections: List[FeatureViewProjection]
description: str
tags: Dict[str, str]
Expand All @@ -63,7 +66,7 @@ def __init__(
self,
*,
name: str,
features: List[Union[FeatureView, OnDemandFeatureView, LabelView]],
features: List[Union[FeatureView, OnDemandFeatureView, LabelView, str]],
tags: Optional[Dict[str, str]] = None,
description: str = "",
owner: str = "",
Expand All @@ -75,8 +78,18 @@ def __init__(

Args:
name: The unique name of the feature service.
features: A list containing feature views and feature view
projections, representing the features in the feature service.
features: A list containing feature views, feature view projections,
and/or string feature references, representing the features in
the feature service. A string entry uses the same
'<feature_view>[@<version>][:<feature>]' syntax accepted by
``get_historical_features``/``get_online_features`` — e.g.
"driver_stats" (latest, all features), "driver_stats@v2"
(pinned version, all features), or "driver_stats@v2:trips_today"
(pinned version, single feature). String refs are resolved
against the registry when the feature service is applied
(``FeatureStore.apply``), so a historical version can be pinned
without importing or reconstructing the underlying FeatureView
object.
description (optional): A human-readable description.
tags (optional): A dictionary of key-value pairs to store arbitrary metadata.
owner (optional): The owner of the feature view, typically the email of the
Expand All @@ -86,6 +99,7 @@ def __init__(
"""
self.name = name
self._features = features
self._pending_feature_refs = []
self.feature_view_projections = []
self.description = description
self.tags = tags or {}
Expand All @@ -95,8 +109,80 @@ def __init__(
self.logging_config = logging_config
self.precompute_online = precompute_online
for feature_grouping in self._features:
if isinstance(feature_grouping, BaseFeatureView):
self.feature_view_projections.append(feature_grouping.projection)
if isinstance(feature_grouping, str):
# No registry at construction time; resolved in resolve_pending_refs.
self._pending_feature_refs.append(feature_grouping)
elif isinstance(feature_grouping, BaseFeatureView):
projection = feature_grouping.projection
# If the source feature view is version-pinned (e.g.
# FeatureView(version="v2")), stamp that version onto the
# projection so name_to_use() renders "fv@v2" and retrieval
# resolves the pinned snapshot. The default version ("latest")
# leaves version_tag as None, preserving existing behavior for
# every unversioned feature service.
fv_version = getattr(feature_grouping, "version", None)
if projection.version_tag is None and fv_version:
is_latest, version_num = parse_version(fv_version)
if not is_latest:
projection.version_tag = version_num
self.feature_view_projections.append(projection)

def resolve_pending_refs(
self,
project: str,
registry: "BaseRegistry",
fvs_to_update: Optional[Dict[str, Union[FeatureView, BaseFeatureView]]] = None,
) -> None:
"""Resolve string feature refs (see ``__init__``) into projections.

Called automatically by ``FeatureStore.apply``/``plan`` so the pin is
baked into the applied service instead of re-resolved on every read.

A version-pinned ref (``"fv@v2"``) always resolves from the registry's
snapshot for that version, never from ``fvs_to_update`` (which only
holds the batch's "latest" objects). An unversioned ref resolves from
``fvs_to_update`` first, else the promoted version.

Raises:
ValueError: If a ref names a feature not on the resolved view.
"""
if not self._pending_feature_refs:
return

from feast.utils import _parse_feature_or_view_ref

fvs_to_update = fvs_to_update or {}
for ref in self._pending_feature_refs:
fv_name, version_num, feature_name = _parse_feature_or_view_ref(ref)

if version_num is not None:
feature_view = registry.get_feature_view_by_version(
fv_name, project, version_num, allow_cache=False
)
elif fv_name in fvs_to_update:
feature_view = fvs_to_update[fv_name]
else:
feature_view = registry.get_any_feature_view(
fv_name, project, allow_cache=False
)

# copy so we never mutate the source view's own projection.
projection = copy.copy(feature_view.projection)
if version_num is not None:
projection.version_tag = version_num

if feature_name is not None:
matches = [f for f in projection.features if f.name == feature_name]
if not matches:
raise ValueError(
f"Invalid feature reference '{ref}': feature "
f"'{feature_name}' not found on feature view '{fv_name}'."
)
projection.features = matches

self.feature_view_projections.append(projection)

self._pending_feature_refs = []

def infer_features(
self, fvs_to_update: Dict[str, Union[FeatureView, BaseFeatureView]]
Expand All @@ -113,6 +199,9 @@ def infer_features(
contains all the feature views necessary to run inference.
"""
for feature_grouping in self._features:
if isinstance(feature_grouping, str):
# Already resolved by resolve_pending_refs before inference.
continue
if isinstance(feature_grouping, BaseFeatureView):
projection = feature_grouping.projection

Expand Down Expand Up @@ -213,7 +302,9 @@ def prepare_for_apply(
self.infer_features(fvs_to_update=fvs_to_update)
return self

resolved_features: List[Union[FeatureView, OnDemandFeatureView, LabelView]] = []
resolved_features: List[
Union[FeatureView, OnDemandFeatureView, LabelView, str]
] = []
for projection in self.feature_view_projections:
try:
feature_view = registry.get_any_feature_view(
Expand Down
5 changes: 5 additions & 0 deletions sdk/python/feast/feature_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,11 @@ def _make_inferences(
]
}
for feature_service in feature_services_to_update:
# Resolve string feature refs (e.g. "driver_stats@v2") before
# inference. No-op for object-only services.
feature_service.resolve_pending_refs(
self.project, self.registry, fvs_to_update=fvs_to_update_map
)
feature_service.infer_features(fvs_to_update=fvs_to_update_map)

def _validate_materialize_version(
Expand Down
5 changes: 4 additions & 1 deletion sdk/python/feast/infra/offline_stores/dask.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from feast.saved_dataset import SavedDatasetStorage
from feast.utils import (
_get_requested_feature_views_to_features_dict,
_get_requested_on_demand_feature_views,
compute_non_entity_date_range,
)

Expand Down Expand Up @@ -197,7 +198,9 @@ def get_historical_features(
) = _get_requested_feature_views_to_features_dict(
feature_refs,
feature_views,
registry.list_on_demand_feature_views(config.project),
_get_requested_on_demand_feature_views(
feature_refs, config.project, registry
),
)

entity_df_event_timestamp_range = (
Expand Down
10 changes: 8 additions & 2 deletions sdk/python/feast/infra/offline_stores/offline_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
from feast.infra.registry.base_registry import BaseRegistry
from feast.repo_config import RepoConfig
from feast.type_map import feast_value_type_to_pa
from feast.utils import _get_requested_feature_views_to_features_dict, to_naive_utc
from feast.utils import (
_get_requested_feature_views_to_features_dict,
_get_requested_on_demand_feature_views,
to_naive_utc,
)
from feast.value_type import ValueType

DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL = "event_timestamp"
Expand Down Expand Up @@ -116,7 +120,9 @@ def get_feature_view_query_context(
feature_views_to_feature_map,
on_demand_feature_views_to_features,
) = _get_requested_feature_views_to_features_dict(
feature_refs, feature_views, registry.list_on_demand_feature_views(project)
feature_refs,
feature_views,
_get_requested_on_demand_feature_views(feature_refs, project, registry),
)

query_context = []
Expand Down
13 changes: 3 additions & 10 deletions sdk/python/feast/on_demand_feature_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -1345,16 +1345,9 @@ def _get_sample_values_by_type(self) -> dict[ValueType, list[Any]]:
def get_requested_odfvs(
feature_refs, project, registry
) -> list["OnDemandFeatureView"]:
all_on_demand_feature_views = registry.list_on_demand_feature_views(
project, allow_cache=True
)
requested_on_demand_feature_views: list[OnDemandFeatureView] = []
for odfv in all_on_demand_feature_views:
for feature in odfv.features:
if f"{odfv.name}:{feature.name}" in feature_refs:
requested_on_demand_feature_views.append(odfv)
break
return requested_on_demand_feature_views
from feast.utils import _get_requested_on_demand_feature_views

return _get_requested_on_demand_feature_views(feature_refs, project, registry)


def on_demand_feature_view(
Expand Down
11 changes: 7 additions & 4 deletions sdk/python/feast/repo_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,13 @@ class RegistryConfig(FeastBaseModel):
only reset the project but not all the projects"""

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

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