diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index 7412931ce8a..7464ca876ab 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -31,6 +31,7 @@ from fastapi import ( Depends, FastAPI, + Query, Request, Response, WebSocket, @@ -54,6 +55,7 @@ ) from feast.feast_object import FeastObject from feast.feature_server_utils import convert_response_to_dict +from feast.feature_view import FeatureViewState from feast.feature_view_utils import get_feature_view_from_feature_store from feast.filter_models import ComparisonFilter, CompoundFilter from feast.permissions.action import WRITE, AuthzedAction @@ -94,12 +96,14 @@ class MaterializeRequest(BaseModel): feature_views: Optional[List[str]] = None disable_event_timestamp: bool = False full_feature_names: bool = False + version: Optional[str] = None class MaterializeIncrementalRequest(BaseModel): end_ts: str feature_views: Optional[List[str]] = None full_feature_names: bool = False + version: Optional[str] = None class GetOnlineFeaturesRequest(BaseModel): @@ -333,6 +337,96 @@ async def load_static_artifacts(app: FastAPI, store): logger.warning(f"Failed to load static artifacts: {e}") +def _authorize_materialize_views( + store: "feast.FeatureStore", + feature_view_names: Optional[List[str]], +) -> List[str]: + """Resolve + authorize feature views for materialization. + + Returns the resolved list of FV names (all eligible FVs when + feature_view_names is None). + """ + feature_views_to_materialize = store._get_feature_views_to_materialize( + feature_view_names + ) + for fv in feature_views_to_materialize: + assert_permissions( + resource=fv, + actions=[AuthzedAction.WRITE_ONLINE], + ) + return [fv.name for fv in feature_views_to_materialize] + + +def _check_already_materializing( + store: "feast.FeatureStore", + fv_names: List[str], +) -> Optional[JSONResponse]: + """Return a 409 JSONResponse if any requested FV is already MATERIALIZING.""" + conflicting: List[str] = [] + for fv_name in fv_names: + try: + fv = store.registry.get_feature_view( + fv_name, store.project, allow_cache=False + ) + if getattr(fv, "state", None) == FeatureViewState.MATERIALIZING: + conflicting.append(fv_name) + except Exception: + pass + if conflicting: + return JSONResponse( + status_code=409, + content={ + "error": ( + f"Cannot start async materialization — the following feature " + f"views are already in MATERIALIZING state: {conflicting}. " + f"Use ?force=true to override." + ), + "feature_views": conflicting, + }, + ) + return None + + +def _update_fv_state( + store: "feast.FeatureStore", + fv_names: List[str], + state: FeatureViewState, +) -> None: + """Set FV state in the registry for each named feature view.""" + for fv_name in fv_names: + try: + fv = store.registry.get_feature_view( + fv_name, store.project, allow_cache=False + ) + fv.state = state + store.registry.apply_feature_view(fv, store.project) + except Exception: + logger.warning(f"Failed to set state={state} for {fv_name}") + + +def _parse_materialize_timestamps( + request: "MaterializeRequest", +) -> tuple: + """Parse and validate start/end timestamps from a MaterializeRequest.""" + if request.disable_event_timestamp: + now = datetime.now() + return datetime(1970, 1, 1), now + + if not request.start_ts or not request.end_ts: + raise ValueError( + "start_ts and end_ts are required when disable_event_timestamp is False" + ) + try: + start_date = utils.make_tzaware(parser.parse(request.start_ts)) + end_date = utils.make_tzaware(parser.parse(request.end_ts)) + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid timestamp format: {e}") from e + + if start_date >= end_date: + raise ValueError(f"start_ts ({start_date}) must be before end_ts ({end_date})") + return start_date, end_date + + def get_app( store: "feast.FeatureStore", registry_ttl_sec: int = DEFAULT_FEATURE_SERVER_REGISTRY_TTL, @@ -798,36 +892,47 @@ async def chat_ui(): return Response(content=content, media_type="text/html") @app.post("/materialize", dependencies=[Depends(inject_user_details)]) - async def materialize(request: MaterializeRequest) -> None: + async def materialize( + request: MaterializeRequest, + async_mode: bool = Query(False, alias="async"), + force: bool = Query(False), + ): with feast_metrics.track_request_latency("/materialize"): - if request.feature_views: - for feature_view in request.feature_views: - resource = await _get_feast_object(feature_view, True) - assert_permissions( - resource=resource, - actions=[AuthzedAction.WRITE_ONLINE], - ) - else: - feature_views_to_materialize = store._get_feature_views_to_materialize( - None - ) - for fv in feature_views_to_materialize: - assert_permissions( - resource=fv, - actions=[AuthzedAction.WRITE_ONLINE], - ) + fv_names = _authorize_materialize_views(store, request.feature_views) + start_date, end_date = _parse_materialize_timestamps(request) + + if async_mode: + if not force: + conflict = _check_already_materializing(store, fv_names) + if conflict: + return conflict + + _update_fv_state(store, fv_names, FeatureViewState.MATERIALIZING) + + def _run_materialize(): + try: + store.materialize( + start_date, + end_date, + fv_names, + disable_event_timestamp=request.disable_event_timestamp, + full_feature_names=request.full_feature_names, + version=request.version, + ) + except Exception as e: + logger.error( + f"Async materialization failed for {fv_names}: {e}", + exc_info=True, + ) + _update_fv_state(store, fv_names, FeatureViewState.GENERATED) - if request.disable_event_timestamp: - now = datetime.now() - start_date = datetime(1970, 1, 1) - end_date = now - else: - if not request.start_ts or not request.end_ts: - raise ValueError( - "start_ts and end_ts are required when disable_event_timestamp is False" - ) - start_date = utils.make_tzaware(parser.parse(request.start_ts)) - end_date = utils.make_tzaware(parser.parse(request.end_ts)) + loop = asyncio.get_running_loop() + loop.run_in_executor(None, _run_materialize) + + return JSONResponse( + status_code=202, + content={"status": "accepted", "feature_views": fv_names}, + ) await run_in_threadpool( store.materialize, @@ -839,27 +944,48 @@ async def materialize(request: MaterializeRequest) -> None: ) @app.post("/materialize-incremental", dependencies=[Depends(inject_user_details)]) - async def materialize_incremental(request: MaterializeIncrementalRequest) -> None: + async def materialize_incremental( + request: MaterializeIncrementalRequest, + async_mode: bool = Query(False, alias="async"), + force: bool = Query(False), + ): with feast_metrics.track_request_latency("/materialize-incremental"): - if request.feature_views: - for feature_view in request.feature_views: - resource = await _get_feast_object(feature_view, True) - assert_permissions( - resource=resource, - actions=[AuthzedAction.WRITE_ONLINE], - ) - else: - feature_views_to_materialize = store._get_feature_views_to_materialize( - None + fv_names = _authorize_materialize_views(store, request.feature_views) + end_date = utils.make_tzaware(parser.parse(request.end_ts)) + + if async_mode: + if not force: + conflict = _check_already_materializing(store, fv_names) + if conflict: + return conflict + + _update_fv_state(store, fv_names, FeatureViewState.MATERIALIZING) + + def _run_materialize_incremental(): + try: + store.materialize_incremental( + end_date, + fv_names, + full_feature_names=request.full_feature_names, + ) + except Exception as e: + logger.error( + f"Async materialize-incremental failed for {fv_names}: {e}", + exc_info=True, + ) + _update_fv_state(store, fv_names, FeatureViewState.GENERATED) + + loop = asyncio.get_running_loop() + loop.run_in_executor(None, _run_materialize_incremental) + + return JSONResponse( + status_code=202, + content={"status": "accepted", "feature_views": fv_names}, ) - for fv in feature_views_to_materialize: - assert_permissions( - resource=fv, - actions=[AuthzedAction.WRITE_ONLINE], - ) + await run_in_threadpool( store.materialize_incremental, - utils.make_tzaware(parser.parse(request.end_ts)), + end_date, request.feature_views, full_feature_names=request.full_feature_names, ) diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 34a77310bac..e5ad27b7101 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -510,7 +510,7 @@ def _transition_fv_to_materializing( Rolls back all already-transitioned FVs if this one can't transition. """ - previous_state = getattr(feature_view, "state", None) + previous_states[feature_view.name] = getattr(feature_view, "state", None) if ( hasattr(feature_view, "state") and feature_view.state != FeatureViewState.STATE_UNSPECIFIED @@ -523,7 +523,6 @@ def _transition_fv_to_materializing( ) feature_view.state = FeatureViewState.MATERIALIZING self.registry.apply_feature_view(feature_view, self.project, commit=True) - previous_states[feature_view.name] = previous_state def _submit_and_process_materialization_jobs( self, @@ -2427,6 +2426,31 @@ def _materialize_odfv( ) self.write_to_online_store(feature_view.name, df=transformed_df) + def _get_remote_materialize_url(self) -> str: + """Get the feature server URL from online_store.path for remote materialization.""" + online_cfg = self.config.online_store + url = getattr(online_cfg, "path", None) + if not url: + raise ValueError( + "online_store.path must be set to use remote materialization. " + "Configure online_store with type: remote and a valid path." + ) + return url.rstrip("/") + + def _get_remote_http_session(self): + """Get an HTTP session with auth configured for the feature server.""" + import requests + + auth_config = getattr(self.config, "auth_config", None) + if auth_config and getattr(auth_config, "type", "no_auth") != "no_auth": + from feast.permissions.client.http_auth_requests_wrapper import ( + get_http_auth_requests_session, + ) + + return get_http_auth_requests_session(auth_config) + + return requests.Session() + def materialize_incremental( self, end_date: datetime, @@ -2560,8 +2584,6 @@ def tqdm_builder(length): ) else: for feature_view, start_date in regular_fvs_with_dates: - # Transition state to MATERIALIZING before starting. - # Only enforce when the state machine is active (not STATE_UNSPECIFIED). previous_state = getattr(feature_view, "state", None) if ( hasattr(feature_view, "state") @@ -2593,7 +2615,6 @@ def tqdm_builder(length): ) except Exception: fv_success = False - # Roll back state to previous value on failure. if ( hasattr(feature_view, "state") and previous_state is not None @@ -2752,8 +2773,6 @@ def tqdm_builder(length): ) else: for feature_view, fv_start in regular_fvs_with_dates: - # Transition state to MATERIALIZING before starting. - # Only enforce when the state machine is active (not STATE_UNSPECIFIED). previous_state = getattr(feature_view, "state", None) if ( hasattr(feature_view, "state") @@ -2786,7 +2805,6 @@ def tqdm_builder(length): ) except Exception: fv_success = False - # Roll back state to previous value on failure. if ( hasattr(feature_view, "state") and previous_state is not None diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index a5d3c8d9537..0b7e28f555b 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -416,6 +416,7 @@ def __eq__(self, other): or normalize_version_string(self.version) != normalize_version_string(other.version) or self.org != other.org + or self.state != other.state ): return False diff --git a/sdk/python/feast/infra/registry/snowflake.py b/sdk/python/feast/infra/registry/snowflake.py index 0438e997ab1..fc75db20e9c 100644 --- a/sdk/python/feast/infra/registry/snowflake.py +++ b/sdk/python/feast/infra/registry/snowflake.py @@ -1147,6 +1147,10 @@ def apply_materialization( FeatureViewNotFoundException, ) fv.materialization_intervals.append((start_date, end_date)) + if hasattr(fv, "state"): + from feast.feature_view import FeatureViewState + + fv.state = FeatureViewState.AVAILABLE_ONLINE self._apply_object( fv_table_str, project, diff --git a/sdk/python/feast/infra/registry/sql.py b/sdk/python/feast/infra/registry/sql.py index 0a037e519c2..00e5f9c5253 100644 --- a/sdk/python/feast/infra/registry/sql.py +++ b/sdk/python/feast/infra/registry/sql.py @@ -1228,6 +1228,10 @@ def apply_materialization( FeatureViewNotFoundException, ) fv.materialization_intervals.append((start_date, end_date)) + if hasattr(fv, "state"): + from feast.feature_view import FeatureViewState + + fv.state = FeatureViewState.AVAILABLE_ONLINE self._apply_object( table, project, "feature_view_name", fv, "feature_view_proto" )