feat(server): Remote Materialization - #6649
Conversation
When ?async=true is passed to /materialize and /materialize-incremental, the endpoint fires off materialization in a background thread and returns 202 Accepted immediately. Concurrent requests for an already-MATERIALIZING FV are rejected with 409. Without ?async=true, behavior is unchanged. Client-side additions: - remote=True param on store.materialize() / materialize_incremental() to delegate to the feature server (URL/TLS from online_store config) - wait=False support with store.poll_materialization() for status polling - FeatureView state set to MATERIALIZING before 202, reset on failure Server-side additions: - ?async=true on existing /materialize and /materialize-incremental - ?force=true to override stuck MATERIALIZING state - Four module-level helpers for testability: _authorize_materialize_views, _check_already_materializing, _update_fv_state, _parse_materialize_timestamps Registry fixes: - SQL and Snowflake registries now set FV state to AVAILABLE_ONLINE in apply_materialization() (parity with file-based registry) Addresses feast-dev#4526 Signed-off-by: Aniket Paluskar <apaluska@redhat.com>
When ?async=true is passed to /materialize and /materialize-incremental, the endpoint fires off materialization in a background thread and returns 202 Accepted immediately. Concurrent requests for an already-MATERIALIZING FV are rejected with 409. Without ?async=true, behavior is unchanged. Client-side additions: - remote=True param on store.materialize() / materialize_incremental() to delegate to the feature server (URL/TLS from online_store config) - wait=False support with store.poll_materialization() for status polling - FeatureView state set to MATERIALIZING before 202, reset on failure Server-side additions: - ?async=true on existing /materialize and /materialize-incremental - ?force=true to override stuck MATERIALIZING state - Four module-level helpers for testability: _authorize_materialize_views, _check_already_materializing, _update_fv_state, _parse_materialize_timestamps Registry fixes: - SQL and Snowflake registries now set FV state to AVAILABLE_ONLINE in apply_materialization() (parity with file-based registry) Addresses feast-dev#4526 Signed-off-by: Aniket Paluskar <apaluska@redhat.com>
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #6649 +/- ##
==========================================
- Coverage 46.77% 46.66% -0.11%
==========================================
Files 414 414
Lines 50191 50339 +148
Branches 7181 7208 +27
==========================================
+ Hits 23475 23489 +14
- Misses 25077 25207 +130
- Partials 1639 1643 +4
Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
…ore is remote When online_store.type == remote, materialize() and materialize_incremental() POST to the feature server with ?async=true (fire-and-forget) instead of running the engine locally. Optional force=True maps to ?force=true for stuck MATERIALIZING recovery. URL/TLS/auth come from online_store config. Shared _delegate_remote_materialize() builds query params and posts; RemoteComputeEngine remains a follow-up for provider-layer remoting. Signed-off-by: Aniket Paluskar <apaluska@redhat.com>
Signed-off-by: Aniket Paluskar <apaluska@redhat.com>
Remove server-side MATERIALIZING pre-set before 202 which conflicted with store.materialize() state machine (MATERIALIZING → MATERIALIZING rejected). Async now accepts and runs materialize in the background; store owns transitions. ?force=true resets stuck MATERIALIZING FVs to GENERATED so normal materialize can proceed. Signed-off-by: Aniket Paluskar <apaluska@redhat.com>
| force: bool = False, | ||
| ) -> None: | ||
| """Fire-and-forget POST to feature server with ?async=true.""" | ||
| query_params = {"async": "true"} |
There was a problem hiding this comment.
may be allow user to pass param, There's no way for an SDK user to do synchronous materialization now.
There was a problem hiding this comment.
Agreed — added run_async: bool = True on materialize() / materialize_incremental().
- Default
run_async=True: remote topology POSTs with?async=true, returns after 202 run_async=False: POSTs without async and blocks until the server finishes sync materializationforce=Truestill requiresrun_async=True(force only applies on the async path)
franciscojavierarceo
left a comment
There was a problem hiding this comment.
I found two blocking correctness issues:
- The async concurrency guard is check-then-act.
_check_already_materializing()reads registry state, butMATERIALIZINGis not written until the executor task starts. Two concurrentasync=truerequests can therefore both pass the check and enqueue duplicate materializations. Please atomically reserve the feature views before returning 202 (and roll the reservation back if submission fails), or use a registry compare-and-set/lock. MaterializeIncrementalRequest.versionis accepted and sent by the SDK, but both server paths callstore.materialize_incremental()withoutversion=request.version; authorization resolution also ignores it. A versioned request can therefore materialize a different feature-view version than requested. Please threadversionthrough both sync and async paths and cover it in the server tests.
The existing review thread about the SDK no longer exposing synchronous mode also remains unresolved.
jyejare
left a comment
There was a problem hiding this comment.
This PR implements remote materialization support, allowing clients with a remote online store topology to delegate materialization requests to a feature server via async HTTP endpoints. The implementation adds proper conflict detection, state management, and force override capabilities.
|
|
||
| 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) |
There was a problem hiding this comment.
[Critical] Race condition in state tracking initialization
The line previous_states[feature_view.name] = getattr(feature_view, "state", None) was moved before the state transition logic, but this creates a critical bug. If the transition fails and we need to rollback other FVs, the previous_states dict won't have entries for FVs that failed before this one was reached.
Suggested:
| 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) | |
| previous_state = getattr(feature_view, "state", None) | |
| if ( | |
| hasattr(feature_view, "state") | |
| and feature_view.state != FeatureViewState.STATE_UNSPECIFIED | |
| ): | |
| # ... validation logic ... | |
| feature_view.state = FeatureViewState.MATERIALIZING | |
| self.registry.apply_feature_view(feature_view, self.project, commit=True) | |
| previous_states[feature_view.name] = previous_state |
There was a problem hiding this comment.
Thanks for the careful look — walked through both placements with the caller loop in mind; they have the same observable outcome (including failure cases).
Caller (simplified):
for feature_view, fv_start in fv_with_dates:
self._transition_fv_to_materializing(feature_view, regular_fvs, previous_states)
regular_fvs.append(feature_view) # only after a successful returnOn a failed transition, the failing FV is not in already_transitioned / regular_fvs. Rollback only restores FVs that already succeeded.
| # by store.materialize(); server only accepts and runs in background. | ||
| 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}", |
There was a problem hiding this comment.
[Critical] Background task exception handling insufficient
The async materialization runs in a background thread but doesn't handle critical failures properly. If materialization fails, only the FV state is reset to GENERATED, but there's no way for the client to know the operation failed since the API already returned 202. This could lead to silent failures in production.
Suggested:
| # by store.materialize(); server only accepts and runs in background. | |
| 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}", | |
| 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) | |
| # TODO: Consider implementing a status endpoint or webhook callback | |
| # for clients to check materialization status |
There was a problem hiding this comment.
Thanks — agreed that fire-and-forget 202 means clients don’t get the failure on the HTTP response. That’s intentional for this PR: async acceptance + observe completion via FeatureView.state / materialization_intervals (and run_async=False when the client needs the sync HTTP path to block/error).
A dedicated status endpoint / job handle / webhook is out of scope here and planned as a follow-up (along the RemoteComputeEngine / job-tracking direction discussed earlier). I’ve added a TODO in the failure path comment pointing to that follow-up.
| 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( |
There was a problem hiding this comment.
[Warning] Silent exception handling could hide real errors
The _reset_stuck_materializing_to_generated and _check_already_materializing functions silently ignore all exceptions when accessing feature views. This could hide legitimate errors like registry corruption or network issues, making debugging difficult.
Suggested:
| 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( | |
| except (FeatureViewNotFoundException, KeyError): | |
| # Expected when FV doesn't exist | |
| pass | |
| except Exception as e: | |
| logger.warning(f"Unexpected error checking state for {fv_name}: {e}") | |
| pass |
| or normalize_version_string(self.version) | ||
| != normalize_version_string(other.version) | ||
| or self.org != other.org | ||
| or self.state != other.state |
There was a problem hiding this comment.
[Suggestion] State comparison added to eq affects hash consistency
Adding state to the equality comparison means two otherwise identical FeatureViews will be considered different if they're in different states (e.g., GENERATED vs MATERIALIZING). This could break set operations, dict lookups, and caching logic that expects state changes to not affect object identity.
Suggested:
| or self.state != other.state | |
| # Consider if state should be included in equality. If FeatureViews should be | |
| # considered equal regardless of materialization state, remove this line. | |
| # If state is semantically important for equality, ensure __hash__ is also updated | |
| # or make the class unhashable. |
| 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): |
There was a problem hiding this comment.
[Suggestion] Missing version parameter documentation and validation
The version parameter was added to both MaterializeRequest and MaterializeIncrementalRequest but there's no documentation about what values are valid or how it affects the materialization behavior.
Suggested:
| 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): | |
| version: Optional[str] = Field( | |
| None, | |
| description="Optional version to materialize (e.g., 'v2'). Requires feature_views with exactly one entry." | |
| ) |
| @@ -839,27 +971,49 @@ async def materialize(request: MaterializeRequest) -> None: | |||
| ) | |||
|
|
|||
There was a problem hiding this comment.
[Nitpick] Inconsistent parameter passing in sync mode
In the synchronous code path for materialize, the version parameter is passed but the feature_views parameter uses the original request.feature_views instead of the resolved fv_names list.
Suggested:
| await run_in_threadpool( | |
| store.materialize, | |
| start_date, | |
| end_date, | |
| fv_names, # Use resolved names for consistency | |
| disable_event_timestamp=request.disable_event_timestamp, | |
| full_feature_names=request.full_feature_names, | |
| version=request.version, # Don't forget version parameter | |
| ) |
There was a problem hiding this comment.
sync path now passes resolved fv_names and version=request.version (aligned with async).
… threading - Revert FeatureView.state from __eq__ - Add run_async for remote sync vs async HTTP - Reserve MATERIALIZING before 202; idempotent store transitions - Thread version through authorize and all materialize server paths - Narrow silent excepts; document version on request models Signed-off-by: Aniket Paluskar <apaluska@redhat.com>
|
Thanks — both blocking items addressed in d8e7a99:
|
| _update_fv_state(store, fv_names, FeatureViewState.GENERATED) | ||
|
|
||
| loop = asyncio.get_running_loop() | ||
| loop.run_in_executor(None, _run_materialize_incremental) |
There was a problem hiding this comment.
non-blocking but I think it's better if we used dedicated executor, instead of default shared pool, for materialization so that it won't block other operations if multiple executors in progress
| endpoint: str, | ||
| payload: Dict[str, Any], | ||
| force: bool = False, | ||
| run_async: bool = True, |
There was a problem hiding this comment.
should this be by default False ? since it's change in behavior for existing users - the call returns successfully even if the server-side materialization fails later.
There was a problem hiding this comment.
Agree, updating soon
Summary
Supersedes #6590 (clean single-commit history with proper DCO).
Add
?async=truequery parameter support to the existing/materializeand/materialize-incrementalendpoints. When set, materialization runs in a background thread and the endpoint returns 202 Accepted immediately. Concurrent requests for an already-MATERIALIZING FV are rejected with 409. Also adds?force=trueto allow operators to override stuck MATERIALIZING state (e.g. after a crashed SparkApplication pod).Client-side (SDK)
store.materialize(..., remote=True)delegates to the feature server(URL/TLS derived from
online_storeconfig)wait=Falsereturns immediately after triggeringstore.poll_materialization()for status polling via registry stateServer-side
?async=trueon existing/materializeand/materialize-incremental?force=trueto skip the 409 concurrency guardRegistry
apply_materialization()now set FV state toAVAILABLE_ONLINE(parity with file-based registry)Which issue(s) this PR fixes
Addresses #4526
Checks
git commit -s)Testing Strategy
Misc