From b28ddf9bd362a6636621ac0244b28cd7d71a2cd2 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 25 Feb 2026 19:50:43 +0530 Subject: [PATCH 01/12] perf: Replace MessageToDict with optimized custom dict builder Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server.py | 22 +- sdk/python/feast/feature_server_utils.py | 79 ++++ .../tests/unit/test_feature_server_utils.py | 408 ++++++++++++++++++ 3 files changed, 493 insertions(+), 16 deletions(-) create mode 100644 sdk/python/feast/feature_server_utils.py create mode 100644 sdk/python/tests/unit/test_feature_server_utils.py diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index 43fb8485316..803f63aa63f 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -38,9 +38,8 @@ ) from fastapi.concurrency import run_in_threadpool from fastapi.logger import logger -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, ORJSONResponse from fastapi.staticfiles import StaticFiles -from google.protobuf.json_format import MessageToDict from pydantic import BaseModel, field_validator import feast @@ -52,6 +51,7 @@ FeastError, ) from feast.feast_object import FeastObject +from feast.feature_server_utils import response_to_dict_fast from feast.feature_view_utils import get_feature_view_from_feature_store from feast.permissions.action import WRITE, AuthzedAction from feast.permissions.security_manager import assert_permissions @@ -391,13 +391,8 @@ async def get_online_features(request: GetOnlineFeaturesRequest) -> Any: lambda: store.get_online_features(**read_params) # type: ignore ) - response_dict = await run_in_threadpool( - MessageToDict, - response.proto, - preserving_proto_field_name=True, - float_precision=18, - ) - return response_dict + response_dict = await run_in_threadpool(response_to_dict_fast, response.proto) + return ORJSONResponse(content=response_dict) @app.post( "/retrieve-online-documents", @@ -433,13 +428,8 @@ async def retrieve_online_documents( lambda: store.retrieve_online_documents(**read_params) # type: ignore ) - response_dict = await run_in_threadpool( - MessageToDict, - response.proto, - preserving_proto_field_name=True, - float_precision=18, - ) - return response_dict + response_dict = await run_in_threadpool(response_to_dict_fast, response.proto) + return ORJSONResponse(content=response_dict) @app.post("/push", dependencies=[Depends(inject_user_details)]) async def push(request: PushFeaturesRequest) -> Response: diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py new file mode 100644 index 00000000000..ca76be4fd95 --- /dev/null +++ b/sdk/python/feast/feature_server_utils.py @@ -0,0 +1,79 @@ +"""Fast serialization utilities for Feature Server responses. + +Matches the output format of MessageToDict with proto_json.patch() applied. +Values are serialized as native Python types (not wrapped dicts). +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse +from feast.protos.feast.types.Value_pb2 import Value + +# FieldStatus enum mapping (protos/feast/serving/ServingService.proto) +_STATUS_NAMES: Dict[int, str] = { + 0: "INVALID", + 1: "PRESENT", + 2: "NULL_VALUE", + 3: "NOT_FOUND", + 4: "OUTSIDE_MAX_AGE", +} + + +def response_to_dict_fast(response: GetOnlineFeaturesResponse) -> Dict[str, Any]: + """Convert GetOnlineFeaturesResponse to dict (matches proto_json.patch() format).""" + result: Dict[str, Any] = { + "results": [ + { + "values": [_value_to_native(v) for v in feature_vector.values], + "statuses": [ + _STATUS_NAMES.get(s, "INVALID") for s in feature_vector.statuses + ], + **( + { + "event_timestamps": [ + _timestamp_to_str(ts) + for ts in feature_vector.event_timestamps + ] + } + if feature_vector.event_timestamps + else {} + ), + } + for feature_vector in response.results + ] + } + + if response.HasField("metadata"): + result["metadata"] = _metadata_to_dict(response.metadata) + + return result + + +def _value_to_native(v: Value) -> Optional[Any]: + """Convert a Value proto to native Python type (matches proto_json.patch() format).""" + which = v.WhichOneof("val") + if which is None or which == "null_val": + return None + elif "_list_" in which: + return list(getattr(v, which).val) + else: + return getattr(v, which) + + +def _timestamp_to_str(ts) -> str: + """Convert protobuf Timestamp to RFC 3339 format with Z suffix.""" + if ts.seconds == 0 and ts.nanos == 0: + return "1970-01-01T00:00:00Z" + dt = datetime.fromtimestamp(ts.seconds + ts.nanos / 1e9, tz=timezone.utc) + return dt.strftime("%Y-%m-%dT%H:%M:%S") + ( + ".%06dZ" % (ts.nanos // 1000) if ts.nanos else "Z" + ) + + +def _metadata_to_dict(metadata) -> Dict[str, Any]: + """Convert FeatureResponseMeta to dict (matches proto_json.patch() format).""" + result: Dict[str, Any] = {} + if metadata.HasField("feature_names"): + result["feature_names"] = list(metadata.feature_names.val) + return result diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py new file mode 100644 index 00000000000..66768542300 --- /dev/null +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -0,0 +1,408 @@ +# Copyright 2025 The Feast Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Unit tests for feature_server_utils.py + +Tests the optimized response_to_dict_fast function to ensure it matches +the output format of MessageToDict with proto_json.patch() applied. + +Related issue: https://github.com/feast-dev/feast/issues/6013 +""" + +import time + +import pytest +from google.protobuf.json_format import MessageToDict +from google.protobuf.timestamp_pb2 import Timestamp + +import feast.proto_json as proto_json +from feast.feature_server_utils import ( + _STATUS_NAMES, + _metadata_to_dict, + _timestamp_to_str, + _value_to_native, + response_to_dict_fast, +) +from feast.protos.feast.serving.ServingService_pb2 import ( + FieldStatus, + GetOnlineFeaturesResponse, +) +from feast.protos.feast.types.Value_pb2 import ( + BoolList, + DoubleList, + FloatList, + Int32List, + Int64List, + StringList, + Value, +) + + +class TestValueToNative: + """Tests for _value_to_native function (matches proto_json.patch() format).""" + + def test_null_value(self): + v = Value() + result = _value_to_native(v) + assert result is None + + def test_explicit_null_val(self): + v = Value(null_val=0) + result = _value_to_native(v) + assert result is None + + def test_double_val(self): + v = Value(double_val=3.14159) + result = _value_to_native(v) + assert result == 3.14159 + + def test_float_val(self): + v = Value(float_val=2.5) + result = _value_to_native(v) + assert result == 2.5 + + def test_int64_val(self): + v = Value(int64_val=9223372036854775807) + result = _value_to_native(v) + assert result == 9223372036854775807 + + def test_int32_val(self): + v = Value(int32_val=42) + result = _value_to_native(v) + assert result == 42 + + def test_string_val(self): + v = Value(string_val="hello feast") + result = _value_to_native(v) + assert result == "hello feast" + + def test_bool_val(self): + v = Value(bool_val=True) + result = _value_to_native(v) + assert result is True + + def test_bytes_val(self): + data = b"\x00\x01\x02\x03" + v = Value(bytes_val=data) + result = _value_to_native(v) + assert result == data + + def test_double_list_val(self): + v = Value(double_list_val=DoubleList(val=[1.1, 2.2, 3.3])) + result = _value_to_native(v) + assert result == [1.1, 2.2, 3.3] + + def test_float_list_val(self): + v = Value(float_list_val=FloatList(val=[1.5, 2.5])) + result = _value_to_native(v) + assert result == [1.5, 2.5] + + def test_int64_list_val(self): + v = Value(int64_list_val=Int64List(val=[100, 200, 300])) + result = _value_to_native(v) + assert result == [100, 200, 300] + + def test_int32_list_val(self): + v = Value(int32_list_val=Int32List(val=[1, 2, 3])) + result = _value_to_native(v) + assert result == [1, 2, 3] + + def test_string_list_val(self): + v = Value(string_list_val=StringList(val=["a", "b", "c"])) + result = _value_to_native(v) + assert result == ["a", "b", "c"] + + def test_bool_list_val(self): + v = Value(bool_list_val=BoolList(val=[True, False, True])) + result = _value_to_native(v) + assert result == [True, False, True] + + def test_unix_timestamp_val(self): + v = Value(unix_timestamp_val=1609459200) + result = _value_to_native(v) + assert result == 1609459200 + + +class TestTimestampToStr: + """Tests for _timestamp_to_str function.""" + + def test_zero_timestamp(self): + ts = Timestamp(seconds=0, nanos=0) + result = _timestamp_to_str(ts) + assert result == "1970-01-01T00:00:00Z" + + def test_valid_timestamp(self): + ts = Timestamp(seconds=1609459200, nanos=0) + result = _timestamp_to_str(ts) + assert result == "2021-01-01T00:00:00Z" + + def test_timestamp_with_nanos(self): + ts = Timestamp(seconds=1609459200, nanos=500000000) + result = _timestamp_to_str(ts) + assert "2021-01-01" in result + assert "Z" in result + + +class TestMetadataToDict: + """Tests for _metadata_to_dict function (matches proto_json.patch() format).""" + + def test_empty_metadata(self): + metadata = GetOnlineFeaturesResponse.FieldValues() + result = _metadata_to_dict(metadata) + assert result == {} + + def test_metadata_with_feature_names(self): + metadata = GetOnlineFeaturesResponse.FieldValues() + metadata.feature_names.val.extend(["feature1", "feature2", "feature3"]) + result = _metadata_to_dict(metadata) + assert result == {"feature_names": ["feature1", "feature2", "feature3"]} + + +class TestResponseToDictFast: + """Tests for the main response_to_dict_fast function.""" + + def test_empty_response(self): + response = GetOnlineFeaturesResponse() + result = response_to_dict_fast(response) + assert result == {"results": []} + + def test_single_feature_vector(self): + response = GetOnlineFeaturesResponse() + fv = response.results.add() + fv.values.append(Value(string_val="test")) + fv.statuses.append(FieldStatus.PRESENT) + + result = response_to_dict_fast(response) + + assert len(result["results"]) == 1 + assert result["results"][0]["values"] == ["test"] + assert result["results"][0]["statuses"] == ["PRESENT"] + + def test_multiple_feature_vectors(self): + response = GetOnlineFeaturesResponse() + + fv1 = response.results.add() + fv1.values.append(Value(int64_val=100)) + fv1.statuses.append(FieldStatus.PRESENT) + + fv2 = response.results.add() + fv2.values.append(Value(double_val=3.14)) + fv2.statuses.append(FieldStatus.NOT_FOUND) + + result = response_to_dict_fast(response) + + assert len(result["results"]) == 2 + assert result["results"][0]["values"] == [100] + assert result["results"][0]["statuses"] == ["PRESENT"] + assert result["results"][1]["values"] == [3.14] + assert result["results"][1]["statuses"] == ["NOT_FOUND"] + + def test_response_with_metadata(self): + response = GetOnlineFeaturesResponse() + response.metadata.feature_names.val.extend(["driver_id", "driver_rating"]) + + fv = response.results.add() + fv.values.append(Value(int64_val=123)) + fv.statuses.append(FieldStatus.PRESENT) + + result = response_to_dict_fast(response) + + assert "metadata" in result + assert result["metadata"]["feature_names"] == ["driver_id", "driver_rating"] + + def test_response_with_timestamps(self): + response = GetOnlineFeaturesResponse() + fv = response.results.add() + fv.values.append(Value(string_val="test")) + fv.statuses.append(FieldStatus.PRESENT) + ts = fv.event_timestamps.add() + ts.seconds = 1609459200 + + result = response_to_dict_fast(response) + + assert len(result["results"][0]["event_timestamps"]) == 1 + assert "2021-01-01" in result["results"][0]["event_timestamps"][0] + + def test_all_status_types(self): + response = GetOnlineFeaturesResponse() + fv = response.results.add() + + for status in [ + FieldStatus.INVALID, + FieldStatus.PRESENT, + FieldStatus.NULL_VALUE, + FieldStatus.NOT_FOUND, + FieldStatus.OUTSIDE_MAX_AGE, + ]: + fv.values.append(Value(int32_val=1)) + fv.statuses.append(status) + + result = response_to_dict_fast(response) + + expected_statuses = [ + "INVALID", + "PRESENT", + "NULL_VALUE", + "NOT_FOUND", + "OUTSIDE_MAX_AGE", + ] + assert result["results"][0]["statuses"] == expected_statuses + + def test_null_values_become_none(self): + response = GetOnlineFeaturesResponse() + fv = response.results.add() + fv.values.append(Value()) + fv.values.append(Value(null_val=0)) + fv.statuses.extend([FieldStatus.NULL_VALUE, FieldStatus.NULL_VALUE]) + + result = response_to_dict_fast(response) + + assert result["results"][0]["values"] == [None, None] + + def test_list_values_are_native_lists(self): + response = GetOnlineFeaturesResponse() + fv = response.results.add() + fv.values.append(Value(int64_list_val=Int64List(val=[1, 2, 3]))) + fv.values.append(Value(string_list_val=StringList(val=["a", "b"]))) + fv.statuses.extend([FieldStatus.PRESENT, FieldStatus.PRESENT]) + + result = response_to_dict_fast(response) + + assert result["results"][0]["values"] == [[1, 2, 3], ["a", "b"]] + + +class TestResponseToDictFastConsistency: + """Tests ensuring response_to_dict_fast matches MessageToDict with patch.""" + + @pytest.fixture(autouse=True) + def setup_proto_json_patch(self): + proto_json.patch() + + def _build_complex_response( + self, num_features: int = 10 + ) -> GetOnlineFeaturesResponse: + response = GetOnlineFeaturesResponse() + feature_names = [f"feature_{i}" for i in range(num_features)] + response.metadata.feature_names.val.extend(feature_names) + + for i in range(num_features): + fv = response.results.add() + if i % 4 == 0: + fv.values.append(Value(int64_val=i * 100)) + elif i % 4 == 1: + fv.values.append(Value(double_val=i * 0.1)) + elif i % 4 == 2: + fv.values.append(Value(string_val=f"value_{i}")) + else: + fv.values.append(Value()) + fv.statuses.append(FieldStatus.PRESENT) + + return response + + def test_values_match_patched_message_to_dict(self): + """Ensure value serialization matches proto_json.patch() format.""" + response = self._build_complex_response(8) + + fast_result = response_to_dict_fast(response) + standard_result = MessageToDict(response, preserving_proto_field_name=True) + + assert set(fast_result.keys()) == set(standard_result.keys()) + assert len(fast_result["results"]) == len(standard_result["results"]) + + for i in range(len(fast_result["results"])): + fast_values = fast_result["results"][i]["values"] + standard_values = standard_result["results"][i]["values"] + assert fast_values == standard_values, f"Mismatch at result {i}" + + def test_metadata_matches_patched_format(self): + """Ensure metadata format matches proto_json.patch() format.""" + response = self._build_complex_response(5) + + fast_result = response_to_dict_fast(response) + standard_result = MessageToDict(response, preserving_proto_field_name=True) + + if "metadata" in standard_result: + assert "metadata" in fast_result + assert ( + fast_result["metadata"]["feature_names"] + == standard_result["metadata"]["feature_names"] + ) + + +class TestPerformance: + """Performance tests to validate the optimization claim.""" + + def _build_large_response( + self, num_entities: int = 50, num_features: int = 100 + ) -> GetOnlineFeaturesResponse: + response = GetOnlineFeaturesResponse() + feature_names = [f"feature_{i}" for i in range(num_features)] + response.metadata.feature_names.val.extend(feature_names) + + for i in range(num_features): + fv = response.results.add() + for j in range(num_entities): + if i % 4 == 0: + fv.values.append(Value(int64_val=j * 100 + i)) + elif i % 4 == 1: + fv.values.append(Value(double_val=j * 0.1 + i)) + elif i % 4 == 2: + fv.values.append(Value(string_val=f"entity_{j}_feature_{i}")) + else: + fv.values.append(Value(bool_val=j % 2 == 0)) + fv.statuses.append(FieldStatus.PRESENT) + + return response + + @pytest.mark.slow + def test_faster_than_message_to_dict(self): + """Verify response_to_dict_fast is faster than MessageToDict.""" + proto_json.patch() + response = self._build_large_response(num_entities=50, num_features=100) + iterations = 100 + + for _ in range(10): + response_to_dict_fast(response) + MessageToDict(response, preserving_proto_field_name=True) + + start = time.perf_counter() + for _ in range(iterations): + response_to_dict_fast(response) + fast_time = time.perf_counter() - start + + start = time.perf_counter() + for _ in range(iterations): + MessageToDict(response, preserving_proto_field_name=True) + standard_time = time.perf_counter() - start + + speedup = standard_time / fast_time + print(f"\nPerformance: fast={fast_time:.3f}s, standard={standard_time:.3f}s") + print(f"Speedup: {speedup:.2f}x") + + assert speedup >= 1.5, f"Expected at least 1.5x speedup, got {speedup:.2f}x" + + +class TestStatusNames: + """Tests for the status name mapping.""" + + def test_all_status_codes_mapped(self): + assert 0 in _STATUS_NAMES # INVALID + assert 1 in _STATUS_NAMES # PRESENT + assert 2 in _STATUS_NAMES # NULL_VALUE + assert 3 in _STATUS_NAMES # NOT_FOUND + assert 4 in _STATUS_NAMES # OUTSIDE_MAX_AGE + + def test_unknown_status_returns_invalid(self): + assert _STATUS_NAMES.get(999, "INVALID") == "INVALID" From 40846ad545f5b133b4702a57939fae248af42767 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Mon, 2 Mar 2026 11:59:24 +0530 Subject: [PATCH 02/12] Fix test: Use GetOnlineFeaturesResponseMetadata instead of non-existent FieldValues Signed-off-by: abhijeet-dhumal --- sdk/python/tests/unit/test_feature_server_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index 66768542300..a4840222c8a 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -38,6 +38,7 @@ from feast.protos.feast.serving.ServingService_pb2 import ( FieldStatus, GetOnlineFeaturesResponse, + GetOnlineFeaturesResponseMetadata, ) from feast.protos.feast.types.Value_pb2 import ( BoolList, @@ -159,12 +160,12 @@ class TestMetadataToDict: """Tests for _metadata_to_dict function (matches proto_json.patch() format).""" def test_empty_metadata(self): - metadata = GetOnlineFeaturesResponse.FieldValues() + metadata = GetOnlineFeaturesResponseMetadata() result = _metadata_to_dict(metadata) assert result == {} def test_metadata_with_feature_names(self): - metadata = GetOnlineFeaturesResponse.FieldValues() + metadata = GetOnlineFeaturesResponseMetadata() metadata.feature_names.val.extend(["feature1", "feature2", "feature3"]) result = _metadata_to_dict(metadata) assert result == {"feature_names": ["feature1", "feature2", "feature3"]} From ad4882b51659b0b29c508efcf4ed480f6fcf93c3 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Tue, 10 Mar 2026 14:20:44 +0530 Subject: [PATCH 03/12] fix: Rename to convert_response_to_dict, fix timestamp float-rounding and precision, add base64 encoding for bytes Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server.py | 10 ++- sdk/python/feast/feature_server_utils.py | 29 +++++-- .../tests/unit/test_feature_server_utils.py | 76 +++++++++++++------ 3 files changed, 83 insertions(+), 32 deletions(-) diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index 803f63aa63f..fc68f5f8ebb 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -51,7 +51,7 @@ FeastError, ) from feast.feast_object import FeastObject -from feast.feature_server_utils import response_to_dict_fast +from feast.feature_server_utils import convert_response_to_dict from feast.feature_view_utils import get_feature_view_from_feature_store from feast.permissions.action import WRITE, AuthzedAction from feast.permissions.security_manager import assert_permissions @@ -391,7 +391,9 @@ async def get_online_features(request: GetOnlineFeaturesRequest) -> Any: lambda: store.get_online_features(**read_params) # type: ignore ) - response_dict = await run_in_threadpool(response_to_dict_fast, response.proto) + response_dict = await run_in_threadpool( + convert_response_to_dict, response.proto + ) return ORJSONResponse(content=response_dict) @app.post( @@ -428,7 +430,9 @@ async def retrieve_online_documents( lambda: store.retrieve_online_documents(**read_params) # type: ignore ) - response_dict = await run_in_threadpool(response_to_dict_fast, response.proto) + response_dict = await run_in_threadpool( + convert_response_to_dict, response.proto + ) return ORJSONResponse(content=response_dict) @app.post("/push", dependencies=[Depends(inject_user_details)]) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index ca76be4fd95..849040026a3 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -4,6 +4,7 @@ Values are serialized as native Python types (not wrapped dicts). """ +import base64 from datetime import datetime, timezone from typing import Any, Dict, Optional @@ -20,7 +21,7 @@ } -def response_to_dict_fast(response: GetOnlineFeaturesResponse) -> Dict[str, Any]: +def convert_response_to_dict(response: GetOnlineFeaturesResponse) -> Dict[str, Any]: """Convert GetOnlineFeaturesResponse to dict (matches proto_json.patch() format).""" result: Dict[str, Any] = { "results": [ @@ -55,6 +56,8 @@ def _value_to_native(v: Value) -> Optional[Any]: which = v.WhichOneof("val") if which is None or which == "null_val": return None + elif which == "bytes_val": + return base64.b64encode(v.bytes_val).decode("ascii") elif "_list_" in which: return list(getattr(v, which).val) else: @@ -62,13 +65,27 @@ def _value_to_native(v: Value) -> Optional[Any]: def _timestamp_to_str(ts) -> str: - """Convert protobuf Timestamp to RFC 3339 format with Z suffix.""" + """Convert protobuf Timestamp to RFC 3339 format with Z suffix. + + Uses adaptive precision to match MessageToDict output: + - No fractional seconds when nanos == 0 + - 3 digits (milliseconds) when nanos % 1_000_000 == 0 + - 6 digits (microseconds) when nanos % 1_000 == 0 + - 9 digits (nanoseconds) otherwise + """ if ts.seconds == 0 and ts.nanos == 0: return "1970-01-01T00:00:00Z" - dt = datetime.fromtimestamp(ts.seconds + ts.nanos / 1e9, tz=timezone.utc) - return dt.strftime("%Y-%m-%dT%H:%M:%S") + ( - ".%06dZ" % (ts.nanos // 1000) if ts.nanos else "Z" - ) + dt = datetime.fromtimestamp(ts.seconds, tz=timezone.utc) + base = dt.strftime("%Y-%m-%dT%H:%M:%S") + nanos = ts.nanos + if nanos == 0: + return base + "Z" + elif nanos % 1_000_000 == 0: + return base + ".%03dZ" % (nanos // 1_000_000) + elif nanos % 1_000 == 0: + return base + ".%06dZ" % (nanos // 1_000) + else: + return base + ".%09dZ" % nanos def _metadata_to_dict(metadata) -> Dict[str, Any]: diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index a4840222c8a..5d090290b0b 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -15,12 +15,13 @@ """ Unit tests for feature_server_utils.py -Tests the optimized response_to_dict_fast function to ensure it matches +Tests the optimized convert_response_to_dict function to ensure it matches the output format of MessageToDict with proto_json.patch() applied. Related issue: https://github.com/feast-dev/feast/issues/6013 """ +import base64 import time import pytest @@ -33,7 +34,7 @@ _metadata_to_dict, _timestamp_to_str, _value_to_native, - response_to_dict_fast, + convert_response_to_dict, ) from feast.protos.feast.serving.ServingService_pb2 import ( FieldStatus, @@ -98,7 +99,7 @@ def test_bytes_val(self): data = b"\x00\x01\x02\x03" v = Value(bytes_val=data) result = _value_to_native(v) - assert result == data + assert result == base64.b64encode(data).decode("ascii") def test_double_list_val(self): v = Value(double_list_val=DoubleList(val=[1.1, 2.2, 3.3])) @@ -149,11 +150,25 @@ def test_valid_timestamp(self): result = _timestamp_to_str(ts) assert result == "2021-01-01T00:00:00Z" - def test_timestamp_with_nanos(self): + def test_timestamp_with_millis(self): ts = Timestamp(seconds=1609459200, nanos=500000000) result = _timestamp_to_str(ts) - assert "2021-01-01" in result - assert "Z" in result + assert result == "2021-01-01T00:00:00.500Z" + + def test_timestamp_with_micros(self): + ts = Timestamp(seconds=1609459200, nanos=123456000) + result = _timestamp_to_str(ts) + assert result == "2021-01-01T00:00:00.123456Z" + + def test_timestamp_with_nanos(self): + ts = Timestamp(seconds=1609459200, nanos=123456789) + result = _timestamp_to_str(ts) + assert result == "2021-01-01T00:00:00.123456789Z" + + def test_timestamp_high_nanos_no_float_rounding(self): + ts = Timestamp(seconds=1609459200, nanos=999999999) + result = _timestamp_to_str(ts) + assert result == "2021-01-01T00:00:00.999999999Z" class TestMetadataToDict: @@ -171,12 +186,12 @@ def test_metadata_with_feature_names(self): assert result == {"feature_names": ["feature1", "feature2", "feature3"]} -class TestResponseToDictFast: - """Tests for the main response_to_dict_fast function.""" +class TestConvertResponseToDict: + """Tests for the main convert_response_to_dict function.""" def test_empty_response(self): response = GetOnlineFeaturesResponse() - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) assert result == {"results": []} def test_single_feature_vector(self): @@ -185,7 +200,7 @@ def test_single_feature_vector(self): fv.values.append(Value(string_val="test")) fv.statuses.append(FieldStatus.PRESENT) - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) assert len(result["results"]) == 1 assert result["results"][0]["values"] == ["test"] @@ -202,7 +217,7 @@ def test_multiple_feature_vectors(self): fv2.values.append(Value(double_val=3.14)) fv2.statuses.append(FieldStatus.NOT_FOUND) - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) assert len(result["results"]) == 2 assert result["results"][0]["values"] == [100] @@ -218,7 +233,7 @@ def test_response_with_metadata(self): fv.values.append(Value(int64_val=123)) fv.statuses.append(FieldStatus.PRESENT) - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) assert "metadata" in result assert result["metadata"]["feature_names"] == ["driver_id", "driver_rating"] @@ -231,7 +246,7 @@ def test_response_with_timestamps(self): ts = fv.event_timestamps.add() ts.seconds = 1609459200 - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) assert len(result["results"][0]["event_timestamps"]) == 1 assert "2021-01-01" in result["results"][0]["event_timestamps"][0] @@ -250,7 +265,7 @@ def test_all_status_types(self): fv.values.append(Value(int32_val=1)) fv.statuses.append(status) - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) expected_statuses = [ "INVALID", @@ -268,7 +283,7 @@ def test_null_values_become_none(self): fv.values.append(Value(null_val=0)) fv.statuses.extend([FieldStatus.NULL_VALUE, FieldStatus.NULL_VALUE]) - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) assert result["results"][0]["values"] == [None, None] @@ -279,13 +294,13 @@ def test_list_values_are_native_lists(self): fv.values.append(Value(string_list_val=StringList(val=["a", "b"]))) fv.statuses.extend([FieldStatus.PRESENT, FieldStatus.PRESENT]) - result = response_to_dict_fast(response) + result = convert_response_to_dict(response) assert result["results"][0]["values"] == [[1, 2, 3], ["a", "b"]] -class TestResponseToDictFastConsistency: - """Tests ensuring response_to_dict_fast matches MessageToDict with patch.""" +class TestConvertResponseToDictConsistency: + """Tests ensuring convert_response_to_dict matches MessageToDict with patch.""" @pytest.fixture(autouse=True) def setup_proto_json_patch(self): @@ -316,7 +331,7 @@ def test_values_match_patched_message_to_dict(self): """Ensure value serialization matches proto_json.patch() format.""" response = self._build_complex_response(8) - fast_result = response_to_dict_fast(response) + fast_result = convert_response_to_dict(response) standard_result = MessageToDict(response, preserving_proto_field_name=True) assert set(fast_result.keys()) == set(standard_result.keys()) @@ -331,7 +346,7 @@ def test_metadata_matches_patched_format(self): """Ensure metadata format matches proto_json.patch() format.""" response = self._build_complex_response(5) - fast_result = response_to_dict_fast(response) + fast_result = convert_response_to_dict(response) standard_result = MessageToDict(response, preserving_proto_field_name=True) if "metadata" in standard_result: @@ -341,6 +356,21 @@ def test_metadata_matches_patched_format(self): == standard_result["metadata"]["feature_names"] ) + def test_bytes_values_match_patched_message_to_dict(self): + """Ensure bytes serialization matches proto_json.patch() format (base64).""" + response = GetOnlineFeaturesResponse() + fv = response.results.add() + fv.values.append(Value(bytes_val=b"\x00\x01\x02\xff")) + fv.statuses.append(FieldStatus.PRESENT) + + fast_result = convert_response_to_dict(response) + standard_result = MessageToDict(response, preserving_proto_field_name=True) + + assert ( + fast_result["results"][0]["values"] + == standard_result["results"][0]["values"] + ) + class TestPerformance: """Performance tests to validate the optimization claim.""" @@ -369,18 +399,18 @@ def _build_large_response( @pytest.mark.slow def test_faster_than_message_to_dict(self): - """Verify response_to_dict_fast is faster than MessageToDict.""" + """Verify convert_response_to_dict is faster than MessageToDict.""" proto_json.patch() response = self._build_large_response(num_entities=50, num_features=100) iterations = 100 for _ in range(10): - response_to_dict_fast(response) + convert_response_to_dict(response) MessageToDict(response, preserving_proto_field_name=True) start = time.perf_counter() for _ in range(iterations): - response_to_dict_fast(response) + convert_response_to_dict(response) fast_time = time.perf_counter() - start start = time.perf_counter() From 698d0a17cb2c6c79cc1364f1985f5fb5552abeb4 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Tue, 10 Mar 2026 14:46:14 +0530 Subject: [PATCH 04/12] fix: Return raw bytes to match proto_json.patch() behavior Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 9 +++++---- sdk/python/tests/unit/test_feature_server_utils.py | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index 849040026a3..a65e776f02f 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -4,7 +4,6 @@ Values are serialized as native Python types (not wrapped dicts). """ -import base64 from datetime import datetime, timezone from typing import Any, Dict, Optional @@ -52,12 +51,14 @@ def convert_response_to_dict(response: GetOnlineFeaturesResponse) -> Dict[str, A def _value_to_native(v: Value) -> Optional[Any]: - """Convert a Value proto to native Python type (matches proto_json.patch() format).""" + """Convert a Value proto to native Python type (matches proto_json.patch() format). + + Note: proto_json.patch() modifies MessageToDict to return raw bytes instead of + base64 encoding, so we return raw bytes here to match that behavior. + """ which = v.WhichOneof("val") if which is None or which == "null_val": return None - elif which == "bytes_val": - return base64.b64encode(v.bytes_val).decode("ascii") elif "_list_" in which: return list(getattr(v, which).val) else: diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index 5d090290b0b..bb0e772843e 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -21,7 +21,6 @@ Related issue: https://github.com/feast-dev/feast/issues/6013 """ -import base64 import time import pytest @@ -99,7 +98,7 @@ def test_bytes_val(self): data = b"\x00\x01\x02\x03" v = Value(bytes_val=data) result = _value_to_native(v) - assert result == base64.b64encode(data).decode("ascii") + assert result == data def test_double_list_val(self): v = Value(double_list_val=DoubleList(val=[1.1, 2.2, 3.3])) @@ -357,7 +356,7 @@ def test_metadata_matches_patched_format(self): ) def test_bytes_values_match_patched_message_to_dict(self): - """Ensure bytes serialization matches proto_json.patch() format (base64).""" + """Ensure bytes serialization matches proto_json.patch() format (raw bytes).""" response = GetOnlineFeaturesResponse() fv = response.results.add() fv.values.append(Value(bytes_val=b"\x00\x01\x02\xff")) From 0c0637b14b25fe4bfcb9d6314f6a2f3fbe90c461 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 15:05:40 +0530 Subject: [PATCH 05/12] fix: serialize top-level status field in convert_response_to_dict Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 3 +++ sdk/python/tests/unit/test_feature_server_utils.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index a65e776f02f..3a610159ea7 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -47,6 +47,9 @@ def convert_response_to_dict(response: GetOnlineFeaturesResponse) -> Dict[str, A if response.HasField("metadata"): result["metadata"] = _metadata_to_dict(response.metadata) + if response.status: + result["status"] = response.status + return result diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index bb0e772843e..0ee7f73865f 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -275,6 +275,17 @@ def test_all_status_types(self): ] assert result["results"][0]["statuses"] == expected_statuses + def test_status_field_included_when_true(self): + response = GetOnlineFeaturesResponse() + response.status = True + result = convert_response_to_dict(response) + assert result.get("status") is True + + def test_status_field_omitted_when_false(self): + response = GetOnlineFeaturesResponse() + result = convert_response_to_dict(response) + assert "status" not in result + def test_null_values_become_none(self): response = GetOnlineFeaturesResponse() fv = response.results.add() From 3ac17556ef181c8dc3a366c94aa7d6fd022243e2 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 20:26:13 +0530 Subject: [PATCH 06/12] fix: replace deprecated ORJSONResponse with JSONResponse Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index fc68f5f8ebb..bca34e918e2 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -38,7 +38,7 @@ ) from fastapi.concurrency import run_in_threadpool from fastapi.logger import logger -from fastapi.responses import JSONResponse, ORJSONResponse +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, field_validator @@ -394,7 +394,7 @@ async def get_online_features(request: GetOnlineFeaturesRequest) -> Any: response_dict = await run_in_threadpool( convert_response_to_dict, response.proto ) - return ORJSONResponse(content=response_dict) + return JSONResponse(content=response_dict) @app.post( "/retrieve-online-documents", @@ -433,7 +433,7 @@ async def retrieve_online_documents( response_dict = await run_in_threadpool( convert_response_to_dict, response.proto ) - return ORJSONResponse(content=response_dict) + return JSONResponse(content=response_dict) @app.post("/push", dependencies=[Depends(inject_user_details)]) async def push(request: PushFeaturesRequest) -> Response: From 5fff274a0f9181e8d01c386da9c05e0f135d7414 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 20:31:50 +0530 Subject: [PATCH 07/12] fix: handle set, list, and map Value types in _value_to_native Previous implementation used "_list_" substring check which silently returned protobuf objects (not JSON-serializable) for: - *_set_val types (bytes_set_val, string_set_val, int64_set_val, etc.) - list_val / set_val (RepeatedValue with nested Values) - map_val / struct_val (Map) - map_list_val / struct_list_val (MapList) Add tests for all previously uncovered types and a float32 precision consistency test against MessageToDict. Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 18 ++++- .../tests/unit/test_feature_server_utils.py | 81 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index 3a610159ea7..830333a29f7 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -62,7 +62,23 @@ def _value_to_native(v: Value) -> Optional[Any]: which = v.WhichOneof("val") if which is None or which == "null_val": return None - elif "_list_" in which: + # RepeatedValue — nested Values that must be recursively converted + elif which in ("list_val", "set_val"): + return [_value_to_native(nested) for nested in getattr(v, which).val] + # Map — recursively convert nested Values + elif which in ("map_val", "struct_val"): + return {k: _value_to_native(vv) for k, vv in getattr(v, which).val.items()} + # MapList — list of Map + elif which in ("map_list_val", "struct_list_val"): + return [ + {k: _value_to_native(vv) for k, vv in m.val.items()} + for m in getattr(v, which).val + ] + # scalar_map_val has non-string keys and is not JSON-serializable without extra work + elif which == "scalar_map_val": + return None + # All simple list/set types (.val is a repeated scalar field) + elif "_list_" in which or "_set_" in which: return list(getattr(v, which).val) else: return getattr(v, which) diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index 0ee7f73865f..159eccb69e1 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -42,11 +42,16 @@ ) from feast.protos.feast.types.Value_pb2 import ( BoolList, + BytesList, DoubleList, FloatList, Int32List, Int64List, + Int64Set, + Map, + RepeatedValue, StringList, + StringSet, Value, ) @@ -135,6 +140,65 @@ def test_unix_timestamp_val(self): result = _value_to_native(v) assert result == 1609459200 + def test_bytes_list_val(self): + v = Value(bytes_list_val=BytesList(val=[b"\x00\x01", b"\x02\x03"])) + result = _value_to_native(v) + assert result == [b"\x00\x01", b"\x02\x03"] + + def test_unix_timestamp_list_val(self): + v = Value(int64_list_val=Int64List(val=[1609459200, 1609545600])) + result = _value_to_native(v) + assert result == [1609459200, 1609545600] + + def test_string_set_val(self): + v = Value(string_set_val=StringSet(val=["x", "y", "z"])) + result = _value_to_native(v) + assert set(result) == {"x", "y", "z"} + + def test_int64_set_val(self): + v = Value(int64_set_val=Int64Set(val=[10, 20, 30])) + result = _value_to_native(v) + assert set(result) == {10, 20, 30} + + def test_list_val_nested_values(self): + inner = RepeatedValue() + inner.val.append(Value(int64_val=1)) + inner.val.append(Value(string_val="a")) + inner.val.append(Value()) + v = Value(list_val=inner) + result = _value_to_native(v) + assert result == [1, "a", None] + + def test_set_val_nested_values(self): + inner = RepeatedValue() + inner.val.append(Value(bool_val=True)) + inner.val.append(Value(double_val=3.14)) + v = Value(set_val=inner) + result = _value_to_native(v) + assert result == [True, 3.14] + + def test_map_val(self): + m = Map() + m.val["key1"].CopyFrom(Value(int64_val=42)) + m.val["key2"].CopyFrom(Value(string_val="hello")) + v = Value(map_val=m) + result = _value_to_native(v) + assert result == {"key1": 42, "key2": "hello"} + + def test_map_val_nested_null(self): + m = Map() + m.val["present"].CopyFrom(Value(int32_val=7)) + m.val["missing"].CopyFrom(Value()) + v = Value(map_val=m) + result = _value_to_native(v) + assert result["present"] == 7 + assert result["missing"] is None + + def test_json_val(self): + v = Value(json_val='{"foo": 1}') + result = _value_to_native(v) + assert result == '{"foo": 1}' + class TestTimestampToStr: """Tests for _timestamp_to_str function.""" @@ -366,6 +430,23 @@ def test_metadata_matches_patched_format(self): == standard_result["metadata"]["feature_names"] ) + def test_float_val_precision_matches_message_to_dict(self): + """float32 storage causes truncation; both paths must return identical values.""" + response = GetOnlineFeaturesResponse() + fv = response.results.add() + # 3.14 is not exactly representable as float32; both implementations + # should return the same truncated float64 representation + fv.values.append(Value(float_val=3.14)) + fv.statuses.append(FieldStatus.PRESENT) + + fast_result = convert_response_to_dict(response) + standard_result = MessageToDict(response, preserving_proto_field_name=True) + + assert ( + fast_result["results"][0]["values"] + == standard_result["results"][0]["values"] + ) + def test_bytes_values_match_patched_message_to_dict(self): """Ensure bytes serialization matches proto_json.patch() format (raw bytes).""" response = GetOnlineFeaturesResponse() From b2f29090b9d36be92e0a1f1b98cb4fefe2c99652 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 20:42:08 +0530 Subject: [PATCH 08/12] fix: warn on scalar_map_val; fix test_unix_timestamp_list_val; document set-type behavior Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 9 +++- .../tests/unit/test_feature_server_utils.py | 52 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index 830333a29f7..f47d62a5095 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -4,12 +4,15 @@ Values are serialized as native Python types (not wrapped dicts). """ +import logging from datetime import datetime, timezone from typing import Any, Dict, Optional from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse from feast.protos.feast.types.Value_pb2 import Value +logger = logging.getLogger(__name__) + # FieldStatus enum mapping (protos/feast/serving/ServingService.proto) _STATUS_NAMES: Dict[int, str] = { 0: "INVALID", @@ -74,8 +77,12 @@ def _value_to_native(v: Value) -> Optional[Any]: {k: _value_to_native(vv) for k, vv in m.val.items()} for m in getattr(v, which).val ] - # scalar_map_val has non-string keys and is not JSON-serializable without extra work + # scalar_map_val has non-string keys; full conversion requires extra work and + # this type is not returned by standard get_online_features paths today. elif which == "scalar_map_val": + logger.warning( + "scalar_map_val is not yet supported by convert_response_to_dict; value will be None" + ) return None # All simple list/set types (.val is a repeated scalar field) elif "_list_" in which or "_set_" in which: diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index 159eccb69e1..157abf6ed29 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -21,6 +21,7 @@ Related issue: https://github.com/feast-dev/feast/issues/6013 """ +import json import time import pytest @@ -146,7 +147,7 @@ def test_bytes_list_val(self): assert result == [b"\x00\x01", b"\x02\x03"] def test_unix_timestamp_list_val(self): - v = Value(int64_list_val=Int64List(val=[1609459200, 1609545600])) + v = Value(unix_timestamp_list_val=Int64List(val=[1609459200, 1609545600])) result = _value_to_native(v) assert result == [1609459200, 1609545600] @@ -447,6 +448,24 @@ def test_float_val_precision_matches_message_to_dict(self): == standard_result["results"][0]["values"] ) + def test_set_types_return_flat_list(self): + """set types (string_set_val, int64_set_val, etc.) return flat lists. + + Note: proto_json.patch() does not explicitly handle _set_ types — they fall + through to the else branch and return the raw proto object, which MessageToDict + then serializes as {"val": [...]}. Our implementation normalizes these to flat + lists, which is more useful for API consumers and consistent with list types. + """ + response = GetOnlineFeaturesResponse() + fv = response.results.add() + fv.values.append(Value(string_set_val=StringSet(val=["a", "b", "c"]))) + fv.statuses.append(FieldStatus.PRESENT) + + result = convert_response_to_dict(response) + values = result["results"][0]["values"] + assert isinstance(values[0], list), "set type should be a flat list" + assert set(values[0]) == {"a", "b", "c"} + def test_bytes_values_match_patched_message_to_dict(self): """Ensure bytes serialization matches proto_json.patch() format (raw bytes).""" response = GetOnlineFeaturesResponse() @@ -463,6 +482,37 @@ def test_bytes_values_match_patched_message_to_dict(self): ) +class TestJsonSerializability: + """Ensure convert_response_to_dict output is always JSON-serializable.""" + + def test_complex_types_are_json_serializable(self): + """map_val, list_val, set_val must not leave proto objects in the output.""" + response = GetOnlineFeaturesResponse() + fv = response.results.add() + + # map_val + m = Map() + m.val["k"].CopyFrom(Value(int64_val=1)) + fv.values.append(Value(map_val=m)) + + # list_val (RepeatedValue) + inner = RepeatedValue() + inner.val.append(Value(string_val="a")) + fv.values.append(Value(list_val=inner)) + + # int64_set_val + fv.values.append(Value(int64_set_val=Int64Set(val=[10, 20]))) + + fv.statuses.extend( + [FieldStatus.PRESENT, FieldStatus.PRESENT, FieldStatus.PRESENT] + ) + + result = convert_response_to_dict(response) + # must not raise + serialized = json.dumps(result) + assert serialized # non-empty + + class TestPerformance: """Performance tests to validate the optimization claim.""" From c559ac5e09345abaaea5d73acef05f3e8c8244c2 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 20:49:26 +0530 Subject: [PATCH 09/12] docs: document float_precision=18 trade-off; add double_val round-trip test Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 37 ++++++++++++++++++- .../tests/unit/test_feature_server_utils.py | 30 +++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index f47d62a5095..ecf16b8c935 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -6,13 +6,36 @@ import logging from datetime import datetime, timezone -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional + +from starlette.responses import Response from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse from feast.protos.feast.types.Value_pb2 import Value logger = logging.getLogger(__name__) +try: + import orjson + + def _make_json_response(data: Dict[str, Any]) -> Response: + return Response( + content=orjson.dumps(data), + media_type="application/json", + ) + +except ImportError: + import json + + def _make_json_response(data: Dict[str, Any]) -> Response: # type: ignore[misc] + return Response( + content=json.dumps(data).encode(), + media_type="application/json", + ) + + +_make_json_response: Callable[[Dict[str, Any]], Response] + # FieldStatus enum mapping (protos/feast/serving/ServingService.proto) _STATUS_NAMES: Dict[int, str] = { 0: "INVALID", @@ -24,7 +47,17 @@ def convert_response_to_dict(response: GetOnlineFeaturesResponse) -> Dict[str, Any]: - """Convert GetOnlineFeaturesResponse to dict (matches proto_json.patch() format).""" + """Convert GetOnlineFeaturesResponse to a JSON-serializable dict. + + Matches the structure produced by MessageToDict(proto, preserving_proto_field_name=True) + with proto_json.patch() applied, with one intentional difference: + + - double_val fields are returned as Python float objects (json.dumps uses Python 3.1+ + shortest round-trip form, ~15-17 sig digits) rather than 18 fixed significant digits + (float_precision=18). Values are numerically identical; only the JSON string length + may differ. This is safe for all ML feature types and avoids unnecessary precision + overhead. + """ result: Dict[str, Any] = { "results": [ { diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index 157abf6ed29..b39af942246 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -448,6 +448,36 @@ def test_float_val_precision_matches_message_to_dict(self): == standard_result["results"][0]["values"] ) + def test_double_val_precision(self): + """double_val is returned as a Python float (shortest round-trip form). + + The upstream code passed float_precision=18 to MessageToDict, which forced 18 + significant digits for doubles. Our implementation returns native Python floats + serialized by json.dumps using Python 3.1+ shortest-round-trip representation + (~15–17 sig digits). The value is identical when round-tripped through float64; + the only difference is how many trailing digits appear in the JSON string. + This is an intentional trade-off for speed and is safe for all ML feature values. + """ + import json + import struct + + # Use a value with many significant digits + pi = 3.141592653589793 + response = GetOnlineFeaturesResponse() + fv = response.results.add() + fv.values.append(Value(double_val=pi)) + fv.statuses.append(FieldStatus.PRESENT) + + result = convert_response_to_dict(response) + value = result["results"][0]["values"][0] + + # Value must round-trip correctly through json.dumps + assert value == pi + round_tripped = json.loads(json.dumps(value)) + assert round_tripped == pi + # Verify it encodes as the same float64 bit pattern + assert struct.pack("d", value) == struct.pack("d", pi) + def test_set_types_return_flat_list(self): """set types (string_set_val, int64_set_val, etc.) return flat lists. From 388f89b48897d615614ba2db515369fbc0f84bf4 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 21:20:33 +0530 Subject: [PATCH 10/12] fix: remove leftover orjson dead code causing mypy no-redef error Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 25 +----------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index ecf16b8c935..8c6deffe238 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -6,36 +6,13 @@ import logging from datetime import datetime, timezone -from typing import Any, Callable, Dict, Optional - -from starlette.responses import Response +from typing import Any, Dict, Optional from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse from feast.protos.feast.types.Value_pb2 import Value logger = logging.getLogger(__name__) -try: - import orjson - - def _make_json_response(data: Dict[str, Any]) -> Response: - return Response( - content=orjson.dumps(data), - media_type="application/json", - ) - -except ImportError: - import json - - def _make_json_response(data: Dict[str, Any]) -> Response: # type: ignore[misc] - return Response( - content=json.dumps(data).encode(), - media_type="application/json", - ) - - -_make_json_response: Callable[[Dict[str, Any]], Response] - # FieldStatus enum mapping (protos/feast/serving/ServingService.proto) _STATUS_NAMES: Dict[int, str] = { 0: "INVALID", From ecce3cf709e688db5b0fe680cd53bf7ee3101414 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 21:39:27 +0530 Subject: [PATCH 11/12] fix: base64-encode bytes_val and bytes_list_val for JSON serialization bytes_val returned raw bytes which cause json.dumps TypeError when JSONResponse serializes the response. Base64-encode these values to match standard protobuf JSON encoding for bytes fields. Fixes Nikhil's review comment: bytes_val is still problem since you are using JSONResponse. Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 17 +++++++--- .../tests/unit/test_feature_server_utils.py | 32 ++++++++++++------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index 8c6deffe238..3a75821e660 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -4,6 +4,7 @@ Values are serialized as native Python types (not wrapped dicts). """ +import base64 import logging from datetime import datetime, timezone from typing import Any, Dict, Optional @@ -67,14 +68,18 @@ def convert_response_to_dict(response: GetOnlineFeaturesResponse) -> Dict[str, A def _value_to_native(v: Value) -> Optional[Any]: - """Convert a Value proto to native Python type (matches proto_json.patch() format). + """Convert a Value proto to a JSON-serializable Python type. - Note: proto_json.patch() modifies MessageToDict to return raw bytes instead of - base64 encoding, so we return raw bytes here to match that behavior. + bytes_val and bytes_list_val are base64-encoded (RFC 4648) so that + JSONResponse can serialize them without TypeError. This matches standard + protobuf JSON encoding for bytes fields and is safe for all HTTP clients. """ which = v.WhichOneof("val") if which is None or which == "null_val": return None + # bytes must be base64-encoded for JSON serialization + elif which == "bytes_val": + return base64.b64encode(v.bytes_val).decode("ascii") # RepeatedValue — nested Values that must be recursively converted elif which in ("list_val", "set_val"): return [_value_to_native(nested) for nested in getattr(v, which).val] @@ -94,13 +99,17 @@ def _value_to_native(v: Value) -> Optional[Any]: "scalar_map_val is not yet supported by convert_response_to_dict; value will be None" ) return None - # All simple list/set types (.val is a repeated scalar field) + # bytes_list_val / bytes_set_val — base64-encode each element + elif which in ("bytes_list_val", "bytes_set_val"): + return [base64.b64encode(b).decode("ascii") for b in getattr(v, which).val] + # All other list/set types have scalar .val fields elif "_list_" in which or "_set_" in which: return list(getattr(v, which).val) else: return getattr(v, which) + def _timestamp_to_str(ts) -> str: """Convert protobuf Timestamp to RFC 3339 format with Z suffix. diff --git a/sdk/python/tests/unit/test_feature_server_utils.py b/sdk/python/tests/unit/test_feature_server_utils.py index b39af942246..2100afacb67 100644 --- a/sdk/python/tests/unit/test_feature_server_utils.py +++ b/sdk/python/tests/unit/test_feature_server_utils.py @@ -21,6 +21,7 @@ Related issue: https://github.com/feast-dev/feast/issues/6013 """ +import base64 import json import time @@ -104,7 +105,7 @@ def test_bytes_val(self): data = b"\x00\x01\x02\x03" v = Value(bytes_val=data) result = _value_to_native(v) - assert result == data + assert result == base64.b64encode(data).decode("ascii") def test_double_list_val(self): v = Value(double_list_val=DoubleList(val=[1.1, 2.2, 3.3])) @@ -144,7 +145,10 @@ def test_unix_timestamp_val(self): def test_bytes_list_val(self): v = Value(bytes_list_val=BytesList(val=[b"\x00\x01", b"\x02\x03"])) result = _value_to_native(v) - assert result == [b"\x00\x01", b"\x02\x03"] + assert result == [ + base64.b64encode(b"\x00\x01").decode("ascii"), + base64.b64encode(b"\x02\x03").decode("ascii"), + ] def test_unix_timestamp_list_val(self): v = Value(unix_timestamp_list_val=Int64List(val=[1609459200, 1609545600])) @@ -458,7 +462,6 @@ def test_double_val_precision(self): the only difference is how many trailing digits appear in the JSON string. This is an intentional trade-off for speed and is safe for all ML feature values. """ - import json import struct # Use a value with many significant digits @@ -496,20 +499,25 @@ def test_set_types_return_flat_list(self): assert isinstance(values[0], list), "set type should be a flat list" assert set(values[0]) == {"a", "b", "c"} - def test_bytes_values_match_patched_message_to_dict(self): - """Ensure bytes serialization matches proto_json.patch() format (raw bytes).""" + def test_bytes_val_is_base64_encoded(self): + """bytes_val is base64-encoded so JSONResponse can serialize it. + + This intentionally differs from proto_json.patch() which returns raw bytes. + Raw bytes are not JSON-serializable; base64 is the standard protobuf JSON + encoding for bytes fields and is safe for all HTTP clients. + """ response = GetOnlineFeaturesResponse() fv = response.results.add() - fv.values.append(Value(bytes_val=b"\x00\x01\x02\xff")) + data = b"\x00\x01\x02\xff" + fv.values.append(Value(bytes_val=data)) fv.statuses.append(FieldStatus.PRESENT) - fast_result = convert_response_to_dict(response) - standard_result = MessageToDict(response, preserving_proto_field_name=True) + result = convert_response_to_dict(response) + encoded = result["results"][0]["values"][0] - assert ( - fast_result["results"][0]["values"] - == standard_result["results"][0]["values"] - ) + assert encoded == base64.b64encode(data).decode("ascii") + # Must be JSON-serializable + assert json.dumps(encoded) class TestJsonSerializability: From 25e99b15c85632c1342b12ae719b16769b06a281 Mon Sep 17 00:00:00 2001 From: abhijeet-dhumal Date: Wed, 27 May 2026 21:43:26 +0530 Subject: [PATCH 12/12] style: remove extra blank line in feature_server_utils.py Signed-off-by: abhijeet-dhumal --- sdk/python/feast/feature_server_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/python/feast/feature_server_utils.py b/sdk/python/feast/feature_server_utils.py index 3a75821e660..7d714ada3d0 100644 --- a/sdk/python/feast/feature_server_utils.py +++ b/sdk/python/feast/feature_server_utils.py @@ -109,7 +109,6 @@ def _value_to_native(v: Value) -> Optional[Any]: return getattr(v, which) - def _timestamp_to_str(ts) -> str: """Convert protobuf Timestamp to RFC 3339 format with Z suffix.