diff --git a/docs/reference/feature-store-yaml.md b/docs/reference/feature-store-yaml.md index 1aac166bd8b..f5cc1cecb0a 100644 --- a/docs/reference/feature-store-yaml.md +++ b/docs/reference/feature-store-yaml.md @@ -68,6 +68,14 @@ registry: | `path` | string | — | Connection string or file path | | `schema_mode` | string | `auto` | SQL registry only. `auto`: create tables on startup; `verify`: check tables exist, error if missing; `skip`: no DDL or verification. See [SQL Registry docs](registries/sql.md#schema-management-schema_mode). | | `mcp.enabled` | bool | `false` | Enable MCP (Model Context Protocol) on the REST registry server | +| `serve_features_while_materializing` | bool | `false` | Keep serving a feature view's last-materialized values while it is in the `MATERIALIZING` state, instead of rejecting online requests during materialization | + +When `serve_features_while_materializing` is `true`, online serving continues for a +feature view while `feast materialize` runs against a shared registry (which +transitions the feature view to `MATERIALIZING`). This avoids serving interruptions +from routine incremental materialization; feature views serve their +last-materialized values until materialization completes. States that never had +online data (e.g. `CREATED`, `GENERATED`) remain gated. When `registry.mcp.enabled` is `true`, the REST registry server exposes registry metadata (entities, feature views, feature services) as MCP tool endpoints for diff --git a/sdk/python/feast/infra/registry/registry.py b/sdk/python/feast/infra/registry/registry.py index 5737df06881..a16c4538a92 100644 --- a/sdk/python/feast/infra/registry/registry.py +++ b/sdk/python/feast/infra/registry/registry.py @@ -247,6 +247,12 @@ def __init__( else False ) + self.serve_features_while_materializing = ( + registry_config.serve_features_while_materializing + if registry_config is not None + else False + ) + self.cache_mode = ( registry_config.cache_mode if registry_config is not None else "sync" ) diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 4f1b6c174f0..c2b57d06cf1 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -376,6 +376,9 @@ def __init__( self.enable_online_versioning = ( registry_config.enable_online_feature_view_versioning ) + self.serve_features_while_materializing = ( + registry_config.serve_features_while_materializing + ) super().__init__( project=project, cache_ttl_seconds=registry_config.cache_ttl_seconds, diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 54e7b2f5d68..3ffbf279966 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -203,6 +203,15 @@ class RegistryConfig(FeastBaseModel): online store table and can be queried independently. Version history tracking in the registry is always active regardless of this setting. """ + serve_features_while_materializing: StrictBool = False + """ bool: Allow online serving to continue for a feature view while it is in + the ``MATERIALIZING`` state. When ``feature_store.materialize()`` runs against + a shared registry it transitions the feature view to ``MATERIALIZING`` and + commits that state, which otherwise causes concurrent feature servers to + reject requests until materialization completes. When True, feature views in + the ``MATERIALIZING`` state keep serving their last-materialized values. + Truly-unavailable states (e.g. ``CREATED``, ``GENERATED``) remain gated. """ + mcp: Optional[McpRegistryConfig] = None """ McpRegistryConfig: MCP (Model Context Protocol) configuration for the registry REST server. """ diff --git a/sdk/python/feast/utils.py b/sdk/python/feast/utils.py index 4f8d18ad080..aa04380728d 100644 --- a/sdk/python/feast/utils.py +++ b/sdk/python/feast/utils.py @@ -1333,9 +1333,20 @@ def _append_or_merge_source_fv(fv_with_projection: "FeatureView") -> None: if hasattr(fv, "state"): from feast.feature_view import FeatureViewState - if isinstance(fv.state, FeatureViewState) and fv.state not in ( + servable_states = { FeatureViewState.STATE_UNSPECIFIED, FeatureViewState.AVAILABLE_ONLINE, + } + # When 'serve_features_while_materializing' is enabled, keep serving + # the last-materialized values while a feature view is MATERIALIZING, + # so routine incremental materialization against a shared registry does + # not interrupt concurrent feature servers (see issue #6780). + if getattr(registry, "serve_features_while_materializing", False): + servable_states.add(FeatureViewState.MATERIALIZING) + + if ( + isinstance(fv.state, FeatureViewState) + and fv.state not in servable_states ): raise ValueError( f"Feature view '{name}' is in state '{fv.state.name}' " diff --git a/sdk/python/tests/unit/test_feature_view_state.py b/sdk/python/tests/unit/test_feature_view_state.py index 3af91b3b469..8e161a080a4 100644 --- a/sdk/python/tests/unit/test_feature_view_state.py +++ b/sdk/python/tests/unit/test_feature_view_state.py @@ -4,6 +4,7 @@ import pytest +from feast import utils from feast.data_format import AvroFormat, ParquetFormat from feast.data_source import KafkaSource from feast.entity import Entity @@ -431,3 +432,52 @@ def test_materialize_disabled_fv_by_name_raises(self, local_feature_store): end_date=datetime.utcnow(), ) store.teardown() + + +# --------------------------------------------------------------------------- +# Serving lifecycle gate: serve_features_while_materializing +# --------------------------------------------------------------------------- + + +class _StubRegistry: + """Minimal registry stub exposing what _get_feature_views_to_use needs.""" + + def __init__(self, fv, serve_features_while_materializing=False): + self._fv = fv + self.serve_features_while_materializing = serve_features_while_materializing + + def get_any_feature_view(self, name, project, allow_cache=False): + return self._fv + + +class TestServeWhileMaterializingGate: + def _fv_in_state(self, state): + fv = _simple_feature_view() + fv.state = state + return fv + + def test_materializing_blocked_by_default(self): + registry = _StubRegistry(self._fv_in_state(FeatureViewState.MATERIALIZING)) + with pytest.raises(ValueError, match="cannot serve features"): + utils._get_feature_views_to_use(registry, "default", ["test_fv:f1"]) + + def test_materializing_served_when_flag_enabled(self): + registry = _StubRegistry( + self._fv_in_state(FeatureViewState.MATERIALIZING), + serve_features_while_materializing=True, + ) + fvs, _ = utils._get_feature_views_to_use(registry, "default", ["test_fv:f1"]) + assert [fv.name for fv in fvs] == ["test_fv"] + + def test_available_online_served_regardless_of_flag(self): + registry = _StubRegistry(self._fv_in_state(FeatureViewState.AVAILABLE_ONLINE)) + fvs, _ = utils._get_feature_views_to_use(registry, "default", ["test_fv:f1"]) + assert [fv.name for fv in fvs] == ["test_fv"] + + def test_created_still_blocked_even_with_flag(self): + registry = _StubRegistry( + self._fv_in_state(FeatureViewState.CREATED), + serve_features_while_materializing=True, + ) + with pytest.raises(ValueError, match="cannot serve features"): + utils._get_feature_views_to_use(registry, "default", ["test_fv:f1"])