Visitar URL original
fix: Return feature_view_metadata from the REST feature server by Zhuoxi2000 · Pull Request #6927 · feast-dev/feast · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sdk/python/feast/feature_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,14 @@ class FeatureVectorResponse(BaseModel):
event_timestamps: List[str] = []


class FeatureViewMetadataResponse(BaseModel):
name: str = ""
version: int = 0


class OnlineFeaturesMetadataResponse(BaseModel):
feature_names: List[str] = []
feature_view_metadata: List[FeatureViewMetadataResponse] = []

@field_validator("feature_names", mode="before")
@classmethod
Expand Down
10 changes: 10 additions & 0 deletions sdk/python/feast/feature_server_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,14 @@ def _metadata_to_dict(metadata) -> Dict[str, Any]:
result: Dict[str, Any] = {}
if metadata.HasField("feature_names"):
result["feature_names"] = list(metadata.feature_names.val)
if metadata.feature_view_metadata:
fv_metadata = []
for fvm in metadata.feature_view_metadata:
entry: Dict[str, Any] = {}
if fvm.name:
entry["name"] = fvm.name
if fvm.version:
entry["version"] = fvm.version
fv_metadata.append(entry)
result["feature_view_metadata"] = fv_metadata
return result
7 changes: 6 additions & 1 deletion sdk/python/feast/infra/online_stores/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,13 @@ def _build_online_response_from_json(
proto = GetOnlineFeaturesResponse()

metadata = GetOnlineFeaturesResponseMetadata()
feature_names = resp_json.get("metadata", {}).get("feature_names", [])
resp_metadata = resp_json.get("metadata", {})
feature_names = resp_metadata.get("feature_names", [])
metadata.feature_names.val.extend(feature_names)
for fvm in resp_metadata.get("feature_view_metadata", []):
fv_metadata = metadata.feature_view_metadata.add()
fv_metadata.name = fvm.get("name", "")
fv_metadata.version = fvm.get("version", 0)
proto.metadata.CopyFrom(metadata)

for result in resp_json.get("results", []):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,30 @@ def test_build_online_response_status_mapping(self, remote_store):
assert proto.results[2].statuses[0] == FieldStatus.NULL_VALUE
assert proto.results[3].statuses[0] == FieldStatus.OUTSIDE_MAX_AGE

def test_build_online_response_feature_view_metadata(self, remote_store):
"""feature_view_metadata returned by the server must be carried over
to the reconstructed proto, so versioned reads work end-to-end."""
resp_json = {
"metadata": {
"feature_names": ["user_id", "score"],
"feature_view_metadata": [
{"name": "user_stats", "version": 2},
{"name": "user_profile"},
],
},
"results": [
{"values": [101], "statuses": ["PRESENT"]},
{"values": [0.95], "statuses": ["PRESENT"]},
],
}

result = remote_store._build_online_response_from_json(resp_json)
fv_metadata = result.proto.metadata.feature_view_metadata
assert [(m.name, m.version) for m in fv_metadata] == [
("user_stats", 2),
("user_profile", 0),
]

# ── Error handling ────────────────────────────────────────────────

@patch("feast.infra.online_stores.remote.get_remote_online_features")
Expand Down
18 changes: 18 additions & 0 deletions sdk/python/tests/unit/test_feature_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,3 +764,21 @@ def test_gunicorn_post_worker_init_starts_resource_and_freshness_monitoring():

mock_fm.init_worker_monitoring.assert_called_once()
mock_fm.init_worker_freshness_monitoring.assert_called_once_with(mock_store)


def test_get_online_features_returns_feature_view_version_metadata(test_client):
"""``include_feature_view_version_metadata=True`` must surface the
``feature_view_metadata`` block over REST, like it does on the proto."""
push_response = test_client.post("/push", json=push_body())
assert push_response.status_code == 200

request_payload = get_online_features_body()
request_payload["include_feature_view_version_metadata"] = True
response = test_client.post("/get-online-features", json=request_payload)
assert response.status_code == 200

metadata = response.json()["metadata"]
assert "feature_view_metadata" in metadata, metadata
assert [m["name"] for m in metadata["feature_view_metadata"]] == [
"pushed_driver_locations"
]
43 changes: 43 additions & 0 deletions sdk/python/tests/unit/test_feature_server_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,46 @@ def test_all_status_codes_mapped(self):

def test_unknown_status_returns_invalid(self):
assert _STATUS_NAMES.get(999, "INVALID") == "INVALID"


class TestFeatureViewVersionMetadata:
"""``feature_view_metadata`` is part of the response metadata and must
survive ``convert_response_to_dict`` exactly as MessageToDict emits it."""

@pytest.fixture(autouse=True)
def setup_proto_json_patch(self):
proto_json.patch()

def test_feature_view_metadata_matches_message_to_dict(self):
response = GetOnlineFeaturesResponse()
response.metadata.feature_names.val.extend(["trips_today"])
fv_meta = response.metadata.feature_view_metadata.add()
fv_meta.name = "driver_stats"
fv_meta.version = 2
fv = response.results.add()
fv.values.append(Value(int64_val=7))
fv.statuses.append(FieldStatus.PRESENT)

fast_result = convert_response_to_dict(response)
standard_result = MessageToDict(response, preserving_proto_field_name=True)

assert "feature_view_metadata" in standard_result["metadata"]
assert fast_result["metadata"] == standard_result["metadata"]

def test_feature_view_metadata_version_zero_matches_message_to_dict(self):
response = GetOnlineFeaturesResponse()
response.metadata.feature_names.val.extend(["trips_today"])
response.metadata.feature_view_metadata.add(name="driver_stats", version=0)

fast_result = convert_response_to_dict(response)
standard_result = MessageToDict(response, preserving_proto_field_name=True)

assert fast_result["metadata"] == standard_result["metadata"]

def test_feature_view_metadata_omitted_when_not_requested(self):
response = GetOnlineFeaturesResponse()
response.metadata.feature_names.val.extend(["trips_today"])

fast_result = convert_response_to_dict(response)

assert "feature_view_metadata" not in fast_result["metadata"]
Loading