GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — feat: Version-pinning for FeatureService (online + offline) by HSarwat · Pull Request #6718 · feast-dev/feast

feat: Version-pinning for FeatureService (online + offline) #6718

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

Sign up for GitHub

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
ntkathole merged 11 commits into feast-dev:master from HSarwat:feat/feature-service-version-pinning
Sep 16, 2026

Uh oh!

There was an error while loading. Please reload this page.

Merged

feat: Version-pinning for FeatureService (online + offline) #6718

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
d286d64
fix: Pin FeatureView version in FeatureService projections
HSarwat Aug 9, 2026
fb2426d
feat: Support string feature references in FeatureService
HSarwat Aug 9, 2026
01952b0
fix: Resolve version-pinned OnDemandFeatureViews in offline retrieval
HSarwat Aug 9, 2026
ae74e24
docs: Document FeatureService version pinning
HSarwat Aug 9, 2026
532f3e9
fix: Handle unresolved string-ref services in feature service retrieval
HSarwat Aug 16, 2026
a55d139
test: Add integration tests for string-ref FeatureService retrieval
HSarwat Aug 16, 2026
588ce40
fix: Copy projection in object-based FeatureService pinning
HSarwat Aug 19, 2026
11e3b3a
fix: Resolve string refs against all ODFVs in same apply
HSarwat Aug 19, 2026
061a233
fix: Reject/resolve unresolved string refs in apply and to_proto
HSarwat Aug 19, 2026
0f30555
test: Add version-pinned string-ref FeatureService retrieval test
HSarwat Aug 19, 2026
fd9959d
fix: Apply inference to stored projection, not source view
HSarwat Aug 20, 2026
File filter
Filter by extension

Clear filters
Conversations
Failed to load comments.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Jump to
Jump to file
Failed to load files.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Diff view
Diff view

There are no files selected for viewing

51 changes: 47 additions & 4 deletions 51 docs/reference/alpha-feature-view-versioning.md
Show comments
View file
Open in desktop
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.
127 changes: 119 additions & 8 deletions 127 sdk/python/feast/feature_service.py
Show comments
View file
Open in desktop
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,81 @@ 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):
# copy so we never mutate the source view's own projection.
projection = copy.copy(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
Comment thread

@ntkathole ntkathole Aug 18, 2026

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

This mutates the source FeatureView's own projection. FeatureView(version="v2") does not set projection.version_tag itself, so after FeatureService(features=[fv]) the caller's fv.projection.version_tag becomes 2.

Online table naming (compute_versioned_name) reads that field, so a later materialize/get on the same object can unexpectedly hit *_v2. The string-ref path already copy.copys for this reason — please copy here too before stamping version_tag.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@HSarwat HSarwat Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Copied

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
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
Comment thread

@ntkathole ntkathole Aug 18, 2026

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Docstring says apply/plan resolve these refs, but prepare_for_apply does not call resolve_pending_refs, and its early return is if self._features and all(p.features for p in self.feature_view_projections).

all([]) is True, so a service with only string refs and empty projections returns immediately. to_proto() then serializes zero features.

FeatureStore.apply happens to call this first, so the happy path is OK. Direct registry.apply_feature_service(...) or any proto round-trip before apply will persist an empty service with no error.

Please reject (or resolve) unresolved _pending_feature_refs in prepare_for_apply and to_proto().

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@HSarwat HSarwat Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Resolved during prepare_for_apply, raised error for to_proto

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
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 @@ -112,9 +199,18 @@ def infer_features(
fvs_to_update: A mapping of feature view names to corresponding feature views that
contains all the feature views necessary to run inference.
"""
# Mutate the stored projection, not feature_grouping.projection (decoupled
# from it by copy in __init__), so inference lands on the serialized object.
# Object groupings are appended in _features order (string refs added last),
# so the k-th BaseFeatureView maps to the k-th stored projection.
projection_index = 0
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
projection = self.feature_view_projections[projection_index]
projection_index += 1

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

# Resolve string refs first; otherwise all([]) below treats an
# unresolved service as fully resolved and serializes it empty.
if self._pending_feature_refs:
self.resolve_pending_refs(project, registry)

if self._features and all(p.features for p in self.feature_view_projections):
return self

Expand All @@ -213,7 +314,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 Expand Up @@ -355,6 +458,14 @@ def to_proto(self) -> FeatureServiceProto:
Returns:
A FeatureServiceProto protobuf.
"""
# Reject unresolved string refs so we never serialize an empty service.
if self._pending_feature_refs:
raise ValueError(
f"FeatureService '{self.name}' has unresolved feature refs "
f"{self._pending_feature_refs}. Apply it via FeatureStore.apply "
f"(or call resolve_pending_refs) before serializing."
)

meta = FeatureServiceMetaProto()
if self.created_timestamp:
meta.created_timestamp.FromDatetime(self.created_timestamp)
Expand Down
16 changes: 16 additions & 0 deletions 16 sdk/python/feast/feature_store.py
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -1258,7 +1258,23 @@ def _make_inferences(
*lvs_to_update,
]
}
# Includes all ODFVs (not just write-enabled) so string refs can
# resolve any ODFV created in the same apply.
fvs_to_resolve_map = {
view.name: view
for view in [
*views_to_update,
*sfvs_to_update,
*odfvs_to_update,
*lvs_to_update,
]
}
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(
Comment thread

@ntkathole ntkathole Aug 18, 2026

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

fvs_to_update_map only includes odfvs_to_write (write_to_online_store=True). A normal ODFV is absent, and _make_inferences runs before apply_feature_view, so the registry lookup fails too.

The docs recommend string refs, but this fails:

odfv = on_demand_feature_view(...)(src_fv)
svc = FeatureService(name="svc", features=["my_odfv"])
store.apply([src_fv, odfv, svc])  # FeatureViewNotFound

Object form features=[odfv] still works. Please pass odfvs_to_update (not just odfvs_to_write) into resolve_pending_refs.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@HSarwat HSarwat Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Built a second map, fvs_to_resolve_map, that includes the full odfvs_to_update list (all ODFVs in the apply, write-enabled or not), and passed that to resolve_pending_refs. I left infer_features on the original fvs_to_update_map so inference/online-write behavior is untouched.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
self.project, self.registry, fvs_to_update=fvs_to_resolve_map
)
feature_service.infer_features(fvs_to_update=fvs_to_update_map)

def _validate_materialize_version(
Expand Down
5 changes: 4 additions & 1 deletion 5 sdk/python/feast/infra/offline_stores/dask.py
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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 @@ -198,7 +199,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 10 sdk/python/feast/infra/offline_stores/offline_utils.py
Show comments
View file
Open in desktop
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 13 sdk/python/feast/on_demand_feature_view.py
Show comments
View file
Open in desktop
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 11 sdk/python/feast/repo_config.py
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,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
Oops, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

Loading
Oops, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

Add this suggestion to a batch that can be applied as a single commit. This suggestion is invalid because no changes were made to the code. Suggestions cannot be applied while the pull request is closed. Suggestions cannot be applied while viewing a subset of changes. Only one suggestion per line can be applied in a batch. Add this suggestion to a batch that can be applied as a single commit. Applying suggestions on deleted lines is not supported. You must change the existing code in this line in order to create a valid suggestion. Outdated suggestions cannot be applied. This suggestion has been applied or marked resolved. Suggestions cannot be applied from pending reviews. Suggestions cannot be applied on multi-line comments. Suggestions cannot be applied while the pull request is queued to merge. Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.