Skip to content

Commit 280daf6

Browse files
feat: Add version-aware materialization support
Add --version flag to feast materialize/materialize-incremental CLI commands and corresponding Python SDK support. Gate versioned table IDs behind enable_online_feature_view_versioning config flag in SQLite online store. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6878fb0 commit 280daf6

File tree

4 files changed

+160
-56
lines changed

4 files changed

+160
-56
lines changed

docs/rfcs/feature-view-versioning.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,47 @@ Versioning works on all three feature view types:
239239

240240
Version-qualified reads (`@v<N>`) are currently implemented for the **SQLite** online store. Other online stores will raise a clear error. Expanding to additional stores is follow-up work.
241241

242+
### Materialization
243+
244+
Each version's data lives in its own online store table (e.g., `project_fv_v1`, `project_fv_v2`). By default, `feast materialize` and `feast materialize-incremental` populate the **active (latest)** version's table. To populate a specific version's table, pass the `--version` flag along with a single `--views` target:
245+
246+
```bash
247+
# Materialize v1 of driver_stats
248+
feast materialize --views driver_stats --version v1 2024-01-01T00:00:00 2024-01-15T00:00:00
249+
250+
# Incrementally materialize v2 of driver_stats
251+
feast materialize-incremental --views driver_stats --version v2 2024-01-15T00:00:00
252+
```
253+
254+
Python SDK equivalent:
255+
256+
```python
257+
store.materialize(
258+
feature_views=["driver_stats"],
259+
version="v2",
260+
start_date=start,
261+
end_date=end,
262+
)
263+
```
264+
265+
**Requirements:**
266+
- `enable_online_feature_view_versioning: true` must be set in `feature_store.yaml`
267+
- `--version` requires `--views` with exactly one feature view name
268+
- The specified version must exist in the registry (created by a prior `feast apply`)
269+
- Without `--version`, materialization targets the active version's table (existing behavior)
270+
271+
**Multi-version workflow example:**
272+
273+
```bash
274+
# Model A uses v1, Model B uses v2 — populate both tables
275+
feast materialize --views driver_stats --version v1 2024-01-01T00:00:00 2024-02-01T00:00:00
276+
feast materialize --views driver_stats --version v2 2024-01-01T00:00:00 2024-02-01T00:00:00
277+
278+
# Models can now query their respective versions online
279+
# Model A: store.get_online_features(features=["driver_stats@v1:trips_today"], ...)
280+
# Model B: store.get_online_features(features=["driver_stats@v2:trips_today"], ...)
281+
```
282+
242283
## API Surface
243284

244285
### Python SDK
@@ -256,6 +297,10 @@ FeatureView(name="driver_stats", ..., version="v2")
256297
257298
# Version-qualified online read (requires enable_online_feature_view_versioning)
258299
store.get_online_features(features=["driver_stats@v2:trips_today"], ...)
300+
301+
# Materialize a specific version
302+
store.materialize(feature_views=["driver_stats"], version="v2", start_date=start, end_date=end)
303+
store.materialize_incremental(feature_views=["driver_stats"], version="v2", end_date=end)
259304
```
260305

261306
### CLI
@@ -268,6 +313,10 @@ feast feature-views list-versions driver_stats
268313
# VERSION TYPE CREATED VERSION_ID
269314
# v0 feature_view 2024-01-15 10:30:00 a1b2c3d4-...
270315
# v1 feature_view 2024-01-16 14:22:00 e5f6g7h8-...
316+
317+
# Materialize a specific version
318+
feast materialize --views driver_stats --version v2 2024-01-01T00:00:00 2024-02-01T00:00:00
319+
feast materialize-incremental --views driver_stats --version v2 2024-02-01T00:00:00
271320
```
272321

273322
### Configuration

sdk/python/feast/cli/cli.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,13 +351,20 @@ def registry_dump_command(ctx: click.Context):
351351
is_flag=True,
352352
help="Materialize all available data using current datetime as event timestamp (useful when source data lacks event timestamps)",
353353
)
354+
@click.option(
355+
"--version",
356+
"feature_view_version",
357+
default=None,
358+
help="Version to materialize (e.g., 'v2'). Requires --views with exactly one feature view.",
359+
)
354360
@click.pass_context
355361
def materialize_command(
356362
ctx: click.Context,
357363
start_ts: Optional[str],
358364
end_ts: Optional[str],
359365
views: List[str],
360366
disable_event_timestamp: bool,
367+
feature_view_version: Optional[str],
361368
):
362369
"""
363370
Run a (non-incremental) materialization job to ingest data into the online store. Feast
@@ -395,6 +402,7 @@ def materialize_command(
395402
start_date=start_date,
396403
end_date=end_date,
397404
disable_event_timestamp=disable_event_timestamp,
405+
version=feature_view_version,
398406
)
399407

400408

@@ -406,8 +414,19 @@ def materialize_command(
406414
help="Feature views to incrementally materialize",
407415
multiple=True,
408416
)
417+
@click.option(
418+
"--version",
419+
"feature_view_version",
420+
default=None,
421+
help="Version to materialize (e.g., 'v2'). Requires --views with exactly one feature view.",
422+
)
409423
@click.pass_context
410-
def materialize_incremental_command(ctx: click.Context, end_ts: str, views: List[str]):
424+
def materialize_incremental_command(
425+
ctx: click.Context,
426+
end_ts: str,
427+
views: List[str],
428+
feature_view_version: Optional[str],
429+
):
411430
"""
412431
Run an incremental materialization job to ingest new data into the online store. Feast will read
413432
all data from the previously ingested point to END_TS from the offline store and write it to the
@@ -420,6 +439,7 @@ def materialize_incremental_command(ctx: click.Context, end_ts: str, views: List
420439
store.materialize_incremental(
421440
feature_views=None if not views else views,
422441
end_date=utils.make_tzaware(datetime.fromisoformat(end_ts)),
442+
version=feature_view_version,
423443
)
424444

425445

sdk/python/feast/feature_store.py

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
from feast.transformation.pandas_transformation import PandasTransformation
101101
from feast.transformation.python_transformation import PythonTransformation
102102
from feast.utils import _get_feature_view_vector_field_metadata, _utc_now
103+
from feast.version_utils import parse_version
103104

104105
_track_materialization = None # Lazy-loaded on first materialization call
105106
_track_materialization_loaded = False
@@ -772,9 +773,39 @@ def _make_inferences(
772773
for feature_service in feature_services_to_update:
773774
feature_service.infer_features(fvs_to_update=fvs_to_update_map)
774775

776+
def _validate_materialize_version(
777+
self,
778+
version: Optional[str],
779+
feature_views: Optional[List[str]],
780+
) -> Optional[int]:
781+
"""Validate and parse the version parameter for materialize calls.
782+
783+
Returns the parsed version number, or None if no version was specified.
784+
"""
785+
if version is None:
786+
return None
787+
788+
if not feature_views or len(feature_views) != 1:
789+
raise ValueError(
790+
"--version requires --views with exactly one feature view."
791+
)
792+
793+
if not self.config.registry.enable_online_feature_view_versioning:
794+
raise ValueError(
795+
"Version-aware materialization requires "
796+
"'enable_online_feature_view_versioning: true' under 'registry' "
797+
"in feature_store.yaml."
798+
)
799+
800+
is_latest, version_number = parse_version(version)
801+
if is_latest:
802+
return None
803+
return version_number
804+
775805
def _get_feature_views_to_materialize(
776806
self,
777807
feature_views: Optional[List[str]],
808+
version: Optional[int] = None,
778809
) -> List[Union[FeatureView, OnDemandFeatureView]]:
779810
"""
780811
Returns the list of feature views that should be materialized.
@@ -783,6 +814,8 @@ def _get_feature_views_to_materialize(
783814
784815
Args:
785816
feature_views: List of names of feature views to materialize.
817+
version: If set, load this specific version number from the registry
818+
instead of the active definition. Requires exactly one feature view name.
786819
787820
Raises:
788821
FeatureViewNotFoundException: One of the specified feature views could not be found.
@@ -814,15 +847,25 @@ def _get_feature_views_to_materialize(
814847
else:
815848
for name in feature_views:
816849
feature_view: Union[FeatureView, OnDemandFeatureView]
817-
try:
818-
feature_view = self._get_feature_view(name, hide_dummy_entity=False)
819-
except FeatureViewNotFoundException:
850+
if version is not None:
851+
feature_view = cast(
852+
Union[FeatureView, OnDemandFeatureView],
853+
self.registry.get_feature_view_by_version(
854+
name, self.project, version
855+
),
856+
)
857+
else:
820858
try:
821-
feature_view = self._get_stream_feature_view(
859+
feature_view = self._get_feature_view(
822860
name, hide_dummy_entity=False
823861
)
824862
except FeatureViewNotFoundException:
825-
feature_view = self.get_on_demand_feature_view(name)
863+
try:
864+
feature_view = self._get_stream_feature_view(
865+
name, hide_dummy_entity=False
866+
)
867+
except FeatureViewNotFoundException:
868+
feature_view = self.get_on_demand_feature_view(name)
826869

827870
if hasattr(feature_view, "online") and not feature_view.online:
828871
raise ValueError(
@@ -1152,38 +1195,6 @@ def apply(
11521195
for ent in entities_to_update:
11531196
self.registry.apply_entity(ent, project=self.project, commit=False)
11541197

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-
ref_fv: Optional[BaseFeatureView] = fvs_in_batch.get(
1166-
projection.name
1167-
)
1168-
if ref_fv is None:
1169-
try:
1170-
ref_fv = self.registry.get_any_feature_view(
1171-
projection.name, self.project
1172-
)
1173-
except FeatureViewNotFoundException:
1174-
continue
1175-
cur_ver: Optional[int] = getattr(
1176-
ref_fv, "current_version_number", None
1177-
)
1178-
if cur_ver is not None and cur_ver > 0:
1179-
raise ValueError(
1180-
f"Feature service '{feature_service.name}' references feature view "
1181-
f"'{projection.name}' which is at version v{cur_ver}. "
1182-
f"To use versioned feature views in feature services, set "
1183-
f"'enable_online_feature_view_versioning: true' under 'registry' "
1184-
f"in feature_store.yaml."
1185-
)
1186-
11871198
for feature_service in services_to_update:
11881199
self.registry.apply_feature_service(
11891200
feature_service, project=self.project, commit=False
@@ -1678,6 +1689,7 @@ def materialize_incremental(
16781689
end_date: datetime,
16791690
feature_views: Optional[List[str]] = None,
16801691
full_feature_names: bool = False,
1692+
version: Optional[str] = None,
16811693
) -> None:
16821694
"""
16831695
Materialize incremental new data from the offline store into the online store.
@@ -1694,6 +1706,8 @@ def materialize_incremental(
16941706
materialization for the specified feature views.
16951707
full_feature_names (bool): If True, feature names will be prefixed with the corresponding
16961708
feature view name.
1709+
version (str): Optional version to materialize (e.g., 'v2'). Requires feature_views
1710+
with exactly one entry and enable_online_feature_view_versioning to be enabled.
16971711
16981712
Raises:
16991713
Exception: A feature view being materialized does not have a TTL set.
@@ -1709,8 +1723,9 @@ def materialize_incremental(
17091723
<BLANKLINE>
17101724
...
17111725
"""
1726+
parsed_version = self._validate_materialize_version(version, feature_views)
17121727
feature_views_to_materialize = self._get_feature_views_to_materialize(
1713-
feature_views
1728+
feature_views, version=parsed_version
17141729
)
17151730
_print_materialization_log(
17161731
None,
@@ -1831,6 +1846,7 @@ def materialize(
18311846
feature_views: Optional[List[str]] = None,
18321847
disable_event_timestamp: bool = False,
18331848
full_feature_names: bool = False,
1849+
version: Optional[str] = None,
18341850
) -> None:
18351851
"""
18361852
Materialize data from the offline store into the online store.
@@ -1847,6 +1863,8 @@ def materialize(
18471863
disable_event_timestamp (bool): If True, materializes all available data using current datetime as event timestamp instead of source event timestamps
18481864
full_feature_names (bool): If True, feature names will be prefixed with the corresponding
18491865
feature view name.
1866+
version (str): Optional version to materialize (e.g., 'v2'). Requires feature_views
1867+
with exactly one entry and enable_online_feature_view_versioning to be enabled.
18501868
18511869
Examples:
18521870
Materialize all features into the online store over the interval
@@ -1866,8 +1884,9 @@ def materialize(
18661884
f"The given start_date {start_date} is greater than the given end_date {end_date}."
18671885
)
18681886

1887+
parsed_version = self._validate_materialize_version(version, feature_views)
18691888
feature_views_to_materialize = self._get_feature_views_to_materialize(
1870-
feature_views
1889+
feature_views, version=parsed_version
18711890
)
18721891
_print_materialization_log(
18731892
start_date,

0 commit comments

Comments
 (0)