Visitar URL original
fix: Answer online requests that carry zero entity rows by Daksha1611 · Pull Request #6820 · feast-dev/feast · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix: Answer online requests that carry zero entity rows
Three empty-input shapes reached internal exceptions, and the feature server
turned each into an HTTP 500:

  entity_rows=[]              -> IndexError: list index out of range
  entities={"driver_id": []}  -> KeyError: Missing join key values for keys: []
  entities={}                 -> KeyError: 'pop from an empty set'

The middle case is a well-formed request. The join key was supplied, it simply
has no values, which is what a caller sends when the upstream query matched
nothing that run.

get_online_features built its columnar dict from entity_rows[0] on both the
sync and async paths, so an empty list raised before anything else ran.

_validate_entity_values ended with set_of_row_lengths.pop(); for a mapping with
no columns the set is empty and pop() raised. Report zero rows instead.

_get_unique_entities treated a join key present with zero values the same as a
join key never supplied, even though the row-wise conversion just below it
already returns empty results for that case. Raise only when nothing at all was
supplied for the view, which leaves the existing behaviour intact: a partially
supplied key set still proceeds, and a caller that supplied nothing relevant
still errors with the same message listing the expected keys.

That error is now MissingJoinKeyValuesException, carrying HTTP 400 so the server
reports a client error rather than a 500. It subclasses KeyError as well, since
that is what this condition raised before and callers catch it.

Signed-off-by: Daksha1611 <mehtadaksha1611@gmail.com>
  • Loading branch information
Daksha1611 authored and ntkathole committed Sep 9, 2026
commit 49d6c5581c7517c5f140c5aaeb199cce732b3573
18 changes: 18 additions & 0 deletions sdk/python/feast/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,24 @@ def __init__(self):
)


class MissingJoinKeyValuesException(FeastError, KeyError):
"""A required join key was not supplied for the requested feature views.

Subclasses ``KeyError`` as well, because that is what this condition raised
before it carried an HTTP status, and callers catch it.
"""

def __init__(self, missing_keys, empty_keys, provided_keys):
super().__init__(
f"Missing join key values for keys: {sorted(missing_keys)}. "
f"No values provided for keys: {sorted(empty_keys)}. "
f"Provided join_key_values: {list(provided_keys)}"
)

def http_status_code(self) -> int:
return HttpStatusCode.HTTP_400_BAD_REQUEST


class PushSourceNotFoundException(FeastError):
def __init__(self, push_source_name: str):
super().__init__(f"Unable to find push source '{push_source_name}'.")
Expand Down
10 changes: 8 additions & 2 deletions sdk/python/feast/infra/online_stores/online_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ def get_online_features(
include_feature_view_version_metadata: bool = False,
) -> OnlineResponse:
if isinstance(entity_rows, list):
columnar: Dict[str, List[Any]] = {k: [] for k in entity_rows[0].keys()}
# An empty batch carries no column names; downstream handles zero rows.
columnar: Dict[str, List[Any]] = (
{k: [] for k in entity_rows[0].keys()} if entity_rows else {}
)
for entity_row in entity_rows:
for key, value in entity_row.items():
try:
Expand Down Expand Up @@ -450,7 +453,10 @@ async def get_online_features_async(
include_feature_view_version_metadata: bool = False,
) -> OnlineResponse:
if isinstance(entity_rows, list):
columnar: Dict[str, List[Any]] = {k: [] for k in entity_rows[0].keys()}
# An empty batch carries no column names; downstream handles zero rows.
columnar: Dict[str, List[Any]] = (
{k: [] for k in entity_rows[0].keys()} if entity_rows else {}
)
for entity_row in entity_rows:
for key, value in entity_row.items():
try:
Expand Down
16 changes: 10 additions & 6 deletions sdk/python/feast/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from feast.errors import (
FeatureNameCollisionError,
FeatureViewNotFoundException,
MissingJoinKeyValuesException,
RequestDataNotFoundInEntityRowsException,
)
from feast.field import Field
Expand Down Expand Up @@ -556,7 +557,8 @@ def _validate_entity_values(join_key_values: Dict[str, List[ValueProto]]):
set_of_row_lengths = {len(v) for v in join_key_values.values()}
if len(set_of_row_lengths) > 1:
raise ValueError("All entity rows must have the same columns.")
return set_of_row_lengths.pop()
# No columns at all means no rows; popping an empty set would raise here.
return set_of_row_lengths.pop() if set_of_row_lengths else 0


def _validate_feature_refs(feature_refs: List[str], full_feature_names: bool = False):
Expand Down Expand Up @@ -1025,11 +1027,13 @@ def _get_unique_entities(
)

if missing_keys or empty_keys:
if not any(table_entity_values.values()):
raise KeyError(
f"Missing join key values for keys: {missing_keys}. "
f"No values provided for keys: {empty_keys}. "
f"Provided join_key_values: {list(join_key_values.keys())}"
# Columns present but all empty is a well-formed request that simply has
# no rows this run; the row-wise conversion below already returns empty
# results for it. Only a caller that supplied nothing at all for this
# view is reporting an error.
if not table_entity_values:
raise MissingJoinKeyValuesException(
missing_keys, empty_keys, join_key_values.keys()
)

# Convert the column-oriented table_entity_values into row-wise data.
Expand Down
54 changes: 54 additions & 0 deletions sdk/python/tests/unit/online_store/test_empty_entity_rows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""An empty batch of entities must not surface internal errors.

A caller whose upstream query matched nothing sends zero rows. That used to hit
``IndexError: list index out of range`` or ``KeyError: 'pop from an empty set'``,
which the feature server turned into a 500.
"""

import pytest
from fastapi import status as HttpStatusCode

from feast.errors import MissingJoinKeyValuesException
from feast.utils import _validate_entity_values
from tests.utils.cli_repo_creator import CliRunner, get_example_repo

FEATURES = ["driver_locations:lat", "driver_locations:lon"]


def test_validate_entity_values_treats_no_columns_as_zero_rows():
"""Popping the empty set raised instead of reporting zero rows."""
assert _validate_entity_values({}) == 0


def test_missing_join_key_values_is_a_client_error():
exc = MissingJoinKeyValuesException(["driver_id"], [], [])
# Callers caught KeyError before this carried a status; keep that working.
assert isinstance(exc, KeyError)
assert exc.http_status_code() == HttpStatusCode.HTTP_400_BAD_REQUEST


def test_join_key_present_with_zero_values_returns_an_empty_response():
"""A well-formed request that simply has no rows this run."""
runner = CliRunner()
with runner.local_repo(
get_example_repo("example_feature_repo_1.py"), "file"
) as store:
response = store.get_online_features(
features=FEATURES, entity_rows={"driver_id": []}
).to_dict()

assert set(response.keys()) == {"driver_id", "lat", "lon"}
assert all(values == [] for values in response.values())


@pytest.mark.parametrize("entity_rows", [[], {}], ids=["empty_list", "no_columns"])
def test_request_without_join_keys_raises_a_client_error(entity_rows):
"""No column names at all: report the missing join key, not an internal error."""
runner = CliRunner()
with runner.local_repo(
get_example_repo("example_feature_repo_1.py"), "file"
) as store:
with pytest.raises(MissingJoinKeyValuesException) as excinfo:
store.get_online_features(features=FEATURES, entity_rows=entity_rows)

assert "driver_id" in str(excinfo.value)