Repository: feast-dev/feast

Issue:
Add async variant of `get_online_features`
**Is your feature request related to a problem? Please describe.**
Putting feast's python online store interface in production is a hard sell as it is, but the fact what there's no way to make it async makes it even harder. Using `get_online_features` with good online store implementation might be suitable for services with relatively low traffic, but there comes a point beyond which it's hard to scale.

**Describe the solution you'd like**
Add `get_online_features_async` method to `FeatureStore` class. The method will either use new `online_read_async` method from the configured online store or throw an exception if the online store doesn't implement async variant.  

**Describe alternatives you've considered**
The alternative is to forgo this, treat python `get_online_features` as something that comes with practical limitations and focus on developing the alternatives in other languages, for example java feature server.

**Additional context**
I admit I haven't really done any benchmarking to compare the alternatives, the claims are mostly just common sense conclusions, but I'll gladly be corrected if others disagree.


Discussion:
I have developed a custom SDK implementation with an async `get_online_features` method. I've been using Redis as online store and also had to override the function `def _get_client(self, online_store_config: RedisOnlineStoreConfig):` since the `redis.asyncio` package is used to create redis client instead of sync one.
Shot us a PR. :)
@shuchu @tokoko sorry for late response. May I can raise a PR for this change. I'm analyzing changes based on suggestions above. The change is not that simple, as `await` requires changes to be propagated in different parts of the code. These are the changes I see that must be done:

```python
class FeatureStore:

    # new async version of existing method
    async def get_online_features_async( 
        self,
        features: Union[List[str], FeatureService],
        entity_rows: List[Dict[str, Any]],
        full_feature_names: bool = False,
    ) -> OnlineResponse:

       ...

        return await self._get_online_features_async(
            features=features,
            entity_values=columnar,
            full_feature_names=full_feature_names,
            native_entity_values=True,
        )

    # new async version of existing method
    async def _get_online_features_async(
        self,
        features: Union[List[str], FeatureService],
        entity_values: Mapping[
            str, Union[Sequence[Any], Sequence[Value], RepeatedValue]
        ],
        full_feature_names: bool = False,
        native_entity_values: bool = True,
    ):

        # do a bunch of things

        for table, requested_features in grouped_refs:

            ...

            # await async method call
            feature_data = await self._read_from_online_store(
                table_entity_values,
                provider,
                requested_features,
                table,
            )

       # do another bunch of things

    # new async version of existing method
    async def _read_from_online_store(
        self,
        entity_rows: Iterable[Mapping[str, Value]],
        provider: Provider,
        requested_features: List[str],
        table: FeatureView,
    ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[Value]]]:

        ...

        # await async method call
        read_rows = await provider.online_read(
            config=self.config,
            table=table,
            entity_keys=entity_key_protos,
            requested_features=requested_features,
        )

        ...
```

Change Provider:

```python
class Provider(ABC):

    # new async version of existing method
    async def online_read_async(
        self,
        config: RepoConfig,
        table: FeatureView,
        entity_keys: List[EntityKeyProto],
        requested_features: Optional[List[str]] = None,
    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
```

```python
class PassthroughProvider(Provider):

    # new async version of existing method
    async def online_read_async(
        self,
        config: RepoConfig,
        table: FeatureView,
        entity_keys: List[EntityKeyProto],
        requested_features: Optional[List[str]] = None,
    ) -> List:
                
            # await async function call
            result = await self.online_store.online_read_async(
                config, table, entity_keys, requested_features
            )
```

Add new method to `OnlineStore` interface:

```python
class OnlineStore:

    # new async version of existing method
    async def online_read_async(
        self,
        config: RepoConfig,
        table: FeatureView,
        entity_keys: List[EntityKeyProto],
        requested_features: Optional[List[str]] = None,
    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:

```

Write specific implementations for online stores. Example for `RedisOnlineStore` that would have a async implementation:

```python
class RedisOnlineStore(OnlineStore):

    # new async version of existing method
    async def online_read_async(
        self,
        config: RepoConfig,
        table: FeatureView,
        entity_keys: List[EntityKeyProto],
        requested_features: Optional[List[str]] = None,
    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:

        ...

        client = self._get_client_async(online_store_config)

        # execute async redis commands

```

Implementation example for `BigtableOnlineStore` that wouldn't have a async implementation:

```python
class BigtableOnlineStore(OnlineStore):

    async def online_read_async(
        self,
        config: RepoConfig,
        table: FeatureView,
        entity_keys: List[EntityKeyProto],
        requested_features: Optional[List[str]] = None,
    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
        raise NotImplementedError("Async online read method is not implemented for this online store")
```

wdyt? Does it make sense or am I forgetting something?

looks spot on to me. One thing about the changes to `OnlineStore` abstract class, maybe it can be added with a default implementation (that raises an exception) so only the online stores that implement async will have to be touched...

> ```python
> # do a bunch of things
> ```

Do you plan to do some refactoring here or copy-paste for now? I'm fine with either for starters.
> looks spot on to me. One thing about the changes to OnlineStore abstract class, maybe it can be added with a default implementation (that raises an exception) so only the online stores that implement async will have to be touched...

makes sense.

> Do you plan to do some refactoring here or copy-paste for now? I'm fine with either for starters.

I'll try to move some parts to separate functions so the code becomes more maintainable - any future changes will be reflected in both sync and async implementations.



Patch:
diff --git a/README.md b/README.md
index e9b7ff47436..a1e06774dac 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
       <img src="docs/assets/feast_logo.png" width="550">
     </a>
 </p>
-<br /> 
+<br />
 
 [![unit-tests](https://github.com/feast-dev/feast/actions/workflows/unit_tests.yml/badge.svg?branch=master&event=push)](https://github.com/feast-dev/feast/actions/workflows/unit_tests.yml)
 [![integration-tests-and-build](https://github.com/feast-dev/feast/actions/workflows/master_only.yml/badge.svg?branch=master&event=push)](https://github.com/feast-dev/feast/actions/workflows/master_only.yml)
diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py
index f45dbb1bc8f..270ea45a26c 100644
--- a/sdk/python/feast/feature_store.py
+++ b/sdk/python/feast/feature_store.py
@@ -1550,6 +1550,54 @@ def get_online_features(
             native_entity_values=True,
         )
 
+    @log_exceptions_and_usage
+    async def get_online_features_async(
+        self,
+        features: Union[List[str], FeatureService],
+        entity_rows: List[Dict[str, Any]],
+        full_feature_names: bool = False,
+    ) -> OnlineResponse:
+        """
+        [Alpha] Retrieves the latest online feature data asynchronously.
+
+        Note: This method will download the full feature registry the first time it is run. If you are using a
+        remote registry like GCS or S3 then that may take a few seconds. The registry remains cached up to a TTL
+        duration (which can be set to infinity). If the cached registry is stale (more time than the TTL has
+        passed), then a new registry will be downloaded synchronously by this method. This download may
+        introduce latency to online feature retrieval. In order to avoid synchronous downloads, please call
+        refresh_registry() prior to the TTL being reached. Remember it is possible to set the cache TTL to
+        infinity (cache forever).
+
+        Args:
+            features: The list of features that should be retrieved from the online store. These features can be
+                specified either as a list of string feature references or as a feature service. String feature
+                references must have format "feature_view:feature", e.g. "customer_fv:daily_transactions".
+            entity_rows: A list of dictionaries where each key-value is an entity-name, entity-value pair.
+            full_feature_names: If True, feature names will be prefixed with the corresponding feature view name,
+                changing them from the format "feature" to "feature_view__feature" (e.g. "daily_transactions"
+                changes to "customer_fv__daily_transactions").
+
+        Returns:
+            OnlineResponse containing the feature data in records.
+
+        Raises:
+            Exception: No entity with the specified name exists.
+        """
+        columnar: Dict[str, List[Any]] = {k: [] for k in entity_rows[0].keys()}
+        for entity_row in entity_rows:
+            for key, value in entity_row.items():
+                try:
+                    columnar[key].append(value)
+                except KeyError as e:
+                    raise ValueError("All entity_rows must have the same keys.") from e
+
+        return await self._get_online_features_async(
+            features=features,
+            entity_values=columnar,
+            full_feature_names=full_feature_names,
+            native_entity_values=True,
+        )
+
     def _get_online_request_context(
         self, features: Union[List[str], FeatureService], full_feature_names: bool
     ):
@@ -1609,7 +1657,7 @@ def _get_online_request_context(
             entityless_case,
         )
 
-    def _get_online_features(
+    def _prepare_entities_to_read_from_online_store(
         self,
         features: Union[List[str], FeatureService],
         entity_values: Mapping[
@@ -1619,7 +1667,7 @@ def _get_online_features(
         native_entity_values: bool = True,
     ):
         (
-            _feature_refs,
+            feature_refs,
             requested_on_demand_feature_views,
             entity_name_to_join_key_map,
             entity_type_map,
@@ -1694,6 +1742,40 @@ def _get_online_features(
                 [DUMMY_ENTITY_VAL] * num_rows, DUMMY_ENTITY.value_type
             )
 
+        return (
+            join_key_values,
+            grouped_refs,
+            entity_name_to_join_key_map,
+            requested_on_demand_feature_views,
+            feature_refs,
+            requested_result_row_names,
+            online_features_response,
+        )
+
+    def _get_online_features(
+        self,
+        features: Union[List[str], FeatureService],
+        entity_values: Mapping[
+            str, Union[Sequence[Any], Sequence[Value], RepeatedValue]
+        ],
+        full_feature_names: bool = False,
+        native_entity_values: bool = True,
+    ):
+        (
+            join_key_values,
+            grouped_refs,
+            entity_name_to_join_key_map,
+            requested_on_demand_feature_views,
+            feature_refs,
+            requested_result_row_names,
+            online_features_response,
+        ) = self._prepare_entities_to_read_from_online_store(
+            features=features,
+            entity_values=entity_values,
+            full_feature_names=full_feature_names,
+            native_entity_values=native_entity_values,
+        )
+
         provider = self._get_provider()
         for table, requested_features in grouped_refs:
             # Get the correct set of entity values with the correct join keys.
@@ -1724,7 +1806,71 @@ def _get_online_features(
         if requested_on_demand_feature_views:
             self._augment_response_with_on_demand_transforms(
                 online_features_response,
-                _feature_refs,
+                feature_refs,
+                requested_on_demand_feature_views,
+                full_feature_names,
+            )
+
+        self._drop_unneeded_columns(
+            online_features_response, requested_result_row_names
+        )
+        return OnlineResponse(online_features_response)
+
+    async def _get_online_features_async(
+        self,
+        features: Union[List[str], FeatureService],
+        entity_values: Mapping[
+            str, Union[Sequence[Any], Sequence[Value], RepeatedValue]
+        ],
+        full_feature_names: bool = False,
+        native_entity_values: bool = True,
+    ):
+        (
+            join_key_values,
+            grouped_refs,
+            entity_name_to_join_key_map,
+            requested_on_demand_feature_views,
+            feature_refs,
+            requested_result_row_names,
+            online_features_response,
+        ) = self._prepare_entities_to_read_from_online_store(
+            features=features,
+            entity_values=entity_values,
+            full_feature_names=full_feature_names,
+            native_entity_values=native_entity_values,
+        )
+
+        provider = self._get_provider()
+        for table, requested_features in grouped_refs:
+            # Get the correct set of entity values with the correct join keys.
+            table_entity_values, idxs = self._get_unique_entities(
+                table,
+                join_key_values,
+                entity_name_to_join_key_map,
+            )
+
+            # Fetch feature data for the minimum set of Entities.
+            feature_data = await self._read_from_online_store_async(
+                table_entity_values,
+                provider,
+                requested_features,
+                table,
+            )
+
+            # Populate the result_rows with the Features from the OnlineStore inplace.
+            self._populate_response_from_feature_data(
+                feature_data,
+                idxs,
+                online_features_response,
+                full_feature_names,
+                requested_features,
+                table,
+            )
+
+        if requested_on_demand_feature_views:
+            self._augment_response_with_on_demand_transforms(
+                online_features_response,
+                feature_refs,
                 requested_on_demand_feature_views,
                 full_feature_names,
             )
@@ -1965,38 +2111,24 @@ def _get_unique_entities(
         )
         return unique_entities, indexes
 
-    def _read_from_online_store(
+    def _get_entity_key_protos(
         self,
         entity_rows: Iterable[Mapping[str, Value]],
-        provider: Provider,
-        requested_features: List[str],
-        table: FeatureView,
-    ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[Value]]]:
-        """Read and process data from the OnlineStore for a given FeatureView.
-
-        This method guarantees that the order of the data in each element of the
-        List returned is the same as the order of `requested_features`.
-
-        This method assumes that `provider.online_read` returns data for each
-        combination of Entities in `entity_rows` in the same order as they
-        are provided.
-        """
+    ) -> List[EntityKeyProto]:
         # Instantiate one EntityKeyProto per Entity.
         entity_key_protos = [
             EntityKeyProto(join_keys=row.keys(), entity_values=row.values())
             for row in entity_rows
         ]
+        return entity_key_protos
 
-        # Fetch data for Entities.
-        read_rows = provider.online_read(
-            config=self.config,
-            table=table,
-            entity_keys=entity_key_protos,
-            requested_features=requested_features,
-        )
-
-        # Each row is a set of features for a given entity key. We only need to convert
-        # the data to Protobuf once.
+    def _convert_rows_to_protobuf(
+        self,
+        requested_features: List[str],
+        read_rows: List[Tuple[Optional[datetime], Optional[Dict[str, Value]]]],
+    ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[Value]]]:
+        # Each row is a set of features for a given entity key.
+        # We only need to convert the data to Protobuf once.
         null_value = Value()
         read_row_protos = []
         for read_row in read_rows:
@@ -2023,6 +2155,53 @@ def _read_from_online_store(
             read_row_protos.append((event_timestamps, statuses, values))
         return read_row_protos
 
+    def _read_from_online_store(
+        self,
+        entity_rows: Iterable[Mapping[str, Value]],
+        provider: Provider,
+        requested_features: List[str],
+        table: FeatureView,
+    ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[Value]]]:
+        """Read and process data from the OnlineStore for a given FeatureView.
+
+        This method guarantees that the order of the data in each element of the
+        List returned is the same as the order of `requested_features`.
+
+        This method assumes that `provider.online_read` returns data for each
+        combination of Entities in `entity_rows` in the same order as they
+        are provided.
+        """
+        entity_key_protos = self._get_entity_key_protos(entity_rows)
+
+        # Fetch data for Entities.
+        read_rows = provider.online_read(
+            config=self.config,
+            table=table,
+            entity_keys=entity_key_protos,
+            requested_features=requested_features,
+        )
+
+        return self._convert_rows_to_protobuf(requested_features, read_rows)
+
+    async def _read_from_online_store_async(
+        self,
+        entity_rows: Iterable[Mapping[str, Value]],
+        provider: Provider,
+        requested_features: List[str],
+        table: FeatureView,
+    ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[Value]]]:
+        entity_key_protos = self._get_entity_key_protos(entity_rows)
+
+        # Fetch data for Entities.
+        read_rows = await provider.online_read_async(
+            config=self.config,
+            table=table,
+            entity_keys=entity_key_protos,
+            requested_features=requested_features,
+        )
+
+        return self._convert_rows_to_protobuf(requested_features, read_rows)
+
     def _retrieve_from_online_store(
         self,
         provider: Provider,
diff --git a/sdk/python/feast/infra/online_stores/online_store.py b/sdk/python/feast/infra/online_stores/online_store.py
index 2a81e370427..7dd03a84178 100644
--- a/sdk/python/feast/infra/online_stores/online_store.py
+++ b/sdk/python/feast/infra/online_stores/online_store.py
@@ -80,6 +80,31 @@ def online_read(
         """
         pass
 
+    async def online_read_async(
+        self,
+        config: RepoConfig,
+        table: FeatureView,
+        entity_keys: List[EntityKeyProto],
+        requested_features: Optional[List[str]] = None,
+    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
+        """
+        Reads features values for the given entity keys asynchronously.
+
+        Args:
+            config: The config for the current feature store.
+            table: The feature view whose feature values should be read.
+            entity_keys: The list of entity keys for which feature values should be read.
+            requested_features: The list of features that should be read.
+
+        Returns:
+            A list of the same length as entity_keys. Each item in the list is a tuple where the first
+            item is the event timestamp for the row, and the second item is a dict mapping feature names
+            to values, which are returned in proto format.
+        """
+        raise NotImplementedError(
+            f"Online store {self.__class__.__name__} does not support online read async"
+        )
+
     @abstractmethod
     def update(
         self,
diff --git a/sdk/python/feast/infra/online_stores/redis.py b/sdk/python/feast/infra/online_stores/redis.py
index 6f6c2fb45c6..f681d8473e4 100644
--- a/sdk/python/feast/infra/online_stores/redis.py
+++ b/sdk/python/feast/infra/online_stores/redis.py
@@ -42,6 +42,7 @@
 
 try:
     from redis import Redis
+    from redis import asyncio as redis_asyncio
     from redis.cluster import ClusterNode, RedisCluster
     from redis.sentinel import Sentinel
 except ImportError as e:
@@ -90,6 +91,9 @@ class RedisOnlineStore(OnlineStore):
     """
 
     _client: Optional[Union[Redis, RedisCluster]] = None
+    _client_async: Optional[Union[redis_asyncio.Redis, redis_asyncio.RedisCluster]] = (
+        None
+    )
 
     def delete_entity_values(self, config: RepoConfig, join_keys: List[str]):
         client = self._get_client(config.online_store)
@@ -234,6 +238,30 @@ def _get_client(self, online_store_config: RedisOnlineStoreConfig):
                 self._client = Redis(**kwargs)
         return self._client
 
+    async def _get_client_async(self, online_store_config: RedisOnlineStoreConfig):
+        if not self._client_async:
+            startup_nodes, kwargs = self._parse_connection_string(
+                online_store_config.connection_string
+            )
+            if online_store_config.redis_type == RedisType.redis_cluster:
+                kwargs["startup_nodes"] = [
+                    redis_asyncio.cluster.ClusterNode(**node) for node in startup_nodes
+                ]
+                self._client_async = redis_asyncio.RedisCluster(**kwargs)
+            elif online_store_config.redis_type == RedisType.redis_sentinel:
+                sentinel_hosts = []
+                for item in startup_nodes:
+                    sentinel_hosts.append((item["host"], int(item["port"])))
+
+                sentinel = redis_asyncio.Sentinel(sentinel_hosts, **kwargs)
+                master = sentinel.master_for(online_store_config.sentinel_master)
+                self._client_async = master
+            else:
+                kwargs["host"] = startup_nodes[0]["host"]
+                kwargs["port"] = startup_nodes[0]["port"]
+                self._client_async = redis_asyncio.Redis(**kwargs)
+        return self._client_async
+
     @log_exceptions_and_usage(online_store="redis")
     def online_write_batch(
         self,
@@ -304,6 +332,49 @@ def online_write_batch(
             if progress:
                 progress(len(results))
 
+    def _generate_redis_keys_for_entities(
+        self, config: RepoConfig, entity_keys: List[EntityKeyProto]
+    ) -> List[bytes]:
+        keys = []
+        for entity_key in entity_keys:
+            redis_key_bin = _redis_key(
+                config.project,
+                entity_key,
+                entity_key_serialization_version=config.entity_key_serialization_version,
+            )
+            keys.append(redis_key_bin)
+        return keys
+
+    def _generate_hset_keys_for_features(
+        self,
+        feature_view: FeatureView,
+        requested_features: Optional[List[str]] = None,
+    ) -> Tuple[List[str], List[str]]:
+        if not requested_features:
+            requested_features = [f.name for f in feature_view.features]
+
+        hset_keys = [_mmh3(f"{feature_view.name}:{k}") for k in requested_features]
+
+        ts_key = f"_ts:{feature_view.name}"
+        hset_keys.append(ts_key)
+        requested_features.append(ts_key)
+
+        return requested_features, hset_keys
+
+    def _convert_redis_values_to_protobuf(
+        self,
+        redis_values: List[List[ByteString]],
+        feature_view: str,
+        requested_features: List[str],
+    ):
+        result: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]] = []
+        for values in redis_values:
+            features = self._get_features_for_entity(
+                values, feature_view, requested_features
+            )
+            result.append(features)
+        return result
+
     @log_exceptions_and_usage(online_store="redis")
     def online_read(
         self,
@@ -316,39 +387,51 @@ def online_read(
         assert isinstance(online_store_config, RedisOnlineStoreConfig)
 
         client = self._get_client(online_store_config)
-        feature_view = table.name
-        project = config.project
+        feature_view = table
 
-        result: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]] = []
+        requested_features, hset_keys = self._generate_hset_keys_for_features(
+            feature_view, requested_features
+        )
+        keys = self._generate_redis_keys_for_entities(config, entity_keys)
 
-        if not requested_features:
-            requested_features = [f.name for f in table.features]
+        with client.pipeline(transaction=False) as pipe:
+            for redis_key_bin in keys:
+                pipe.hmget(redis_key_bin, hset_keys)
+            with tracing_span(name="remote_call"):
+                redis_values = pipe.execute()
 
-        hset_keys = [_mmh3(f"{feature_view}:{k}") for k in requested_features]
+        return self._convert_redis_values_to_protobuf(
+            redis_values, feature_view.name, requested_features
+        )
 
-        ts_key = f"_ts:{feature_view}"
-        hset_keys.append(ts_key)
-        requested_features.append(ts_key)
+    @log_exceptions_and_usage(online_store="redis")
+    async def online_read_async(
+        self,
+        config: RepoConfig,
+        table: FeatureView,
+        entity_keys: List[EntityKeyProto],
+        requested_features: Optional[List[str]] = None,
+    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
+        online_store_config = config.online_store
+        assert isinstance(online_store_config, RedisOnlineStoreConfig)
 
-        keys = []
-        for entity_key in entity_keys:
-            redis_key_bin = _redis_key(
-                project,
-                entity_key,
-                entity_key_serialization_version=config.entity_key_serialization_version,
-            )
-            keys.append(redis_key_bin)
-        with client.pipeline(transaction=False) as pipe:
+        client = await self._get_client_async(online_store_config)
+        feature_view = table
+
+        requested_features, hset_keys = self._generate_hset_keys_for_features(
+            feature_view, requested_features
+        )
+        keys = self._generate_redis_keys_for_entities(config, entity_keys)
+
+        async with client.pipeline(transaction=False) as pipe:
             for redis_key_bin in keys:
                 pipe.hmget(redis_key_bin, hset_keys)
             with tracing_span(name="remote_call"):
-                redis_values = pipe.execute()
-        for values in redis_values:
-            features = self._get_features_for_entity(
-                values, feature_view, requested_features
-            )
-            result.append(features)
-        return result
+                redis_values = await pipe.execute()
+
+        return self._convert_redis_values_to_protobuf(
+            redis_values, feature_view.name, requested_features
+        )
 
     def _get_features_for_entity(
         self,
diff --git a/sdk/python/feast/infra/passthrough_provider.py b/sdk/python/feast/infra/passthrough_provider.py
index 2f3e30018af..97c2820d415 100644
--- a/sdk/python/feast/infra/passthrough_provider.py
+++ b/sdk/python/feast/infra/passthrough_provider.py
@@ -188,6 +188,22 @@ def online_read(
             )
         return result
 
+    @log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001))
+    async def online_read_async(
+        self,
+        config: RepoConfig,
+        table: FeatureView,
+        entity_keys: List[EntityKeyProto],
+        requested_features: Optional[List[str]] = None,
+    ) -> List:
+        set_usage_attribute("provider", self.__class__.__name__)
+        result = []
+        if self.online_store:
+            result = await self.online_store.online_read_async(
+                config, table, entity_keys, requested_features
+            )
+        return result
+
     @log_exceptions_and_usage(sampler=RatioSampler(ratio=0.001))
     def retrieve_online_documents(
         self,
diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py
index 02fba0c1f6b..68d36da17f7 100644
--- a/sdk/python/feast/infra/provider.py
+++ b/sdk/python/feast/infra/provider.py
@@ -229,6 +229,30 @@ def online_read(
         """
         pass
 
+    @abstractmethod
+    async def online_read_async(
+        self,
+        config: RepoConfig,
+        table: FeatureView,
+        entity_keys: List[EntityKeyProto],
+        requested_features: Optional[List[str]] = None,
+    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
+        """
+        Reads features values for the given entity keys asynchronously.
+
+        Args:
+            config: The config for the current feature store.
+            table: The feature view whose feature values should be read.
+            entity_keys: The list of entity keys for which feature values should be read.
+            requested_features: The list of features that should be read.
+
+        Returns:
+            A list of the same length as entity_keys. Each item in the list is a tuple where the first
+            item is the event timestamp for the row, and the second item is a dict mapping feature names
+            to values, which are returned in proto format.
+        """
+        pass
+
     @abstractmethod
     def retrieve_saved_dataset(
         self, config: RepoConfig, dataset: SavedDataset
diff --git a/sdk/python/feast/online_response.py b/sdk/python/feast/online_response.py
index 050b374340e..a4e5694127f 100644
--- a/sdk/python/feast/online_response.py
+++ b/sdk/python/feast/online_response.py
@@ -50,7 +50,7 @@ def to_dict(self, include_event_timestamps: bool = False) -> Dict[str, Any]:
         Converts GetOnlineFeaturesResponse features into a dictionary form.
 
         Args:
-        is_with_event_timestamps: bool Optionally include feature timestamps in the dictionary
+        include_event_timestamps: bool Optionally include feature timestamps in the dictionary
         """
         response: Dict[str, List[Any]] = {}
 
@@ -74,7 +74,7 @@ def to_df(self, include_event_timestamps: bool = False) -> pd.DataFrame:
         Converts GetOnlineFeaturesResponse features into Panda dataframe form.
 
         Args:
-        is_with_event_timestamps: bool Optionally include feature timestamps in the dataframe
+        include_event_timestamps: bool Optionally include feature timestamps in the dataframe
         """
 
         return pd.DataFrame(self.to_dict(include_event_timestamps))
@@ -84,7 +84,7 @@ def to_arrow(self, include_event_timestamps: bool = False) -> pa.Table:
         Converts GetOnlineFeaturesResponse features into pyarrow Table.
 
         Args:
-        is_with_event_timestamps: bool Optionally include feature timestamps in the table
+        include_event_timestamps: bool Optionally include feature timestamps in the table
         """
 
         return pa.Table.from_pydict(self.to_dict(include_event_timestamps))


Test patch:
diff --git a/sdk/python/tests/foo_provider.py b/sdk/python/tests/foo_provider.py
index f869d82e114..eb7fe5d6ac8 100644
--- a/sdk/python/tests/foo_provider.py
+++ b/sdk/python/tests/foo_provider.py
@@ -82,6 +82,15 @@ def online_read(
     ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
         return []
 
+    async def online_read_async(
+        self,
+        config: RepoConfig,
+        table: FeatureView,
+        entity_keys: List[EntityKeyProto],
+        requested_features: Optional[List[str]] = None,
+    ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]:
+        return []
+
     def retrieve_saved_dataset(self, config: RepoConfig, dataset: SavedDataset):
         pass
 
diff --git a/sdk/python/tests/unit/infra/online_store/test_redis.py b/sdk/python/tests/unit/infra/online_store/test_redis.py
new file mode 100644
index 00000000000..c26c2f25c5f
--- /dev/null
+++ b/sdk/python/tests/unit/infra/online_store/test_redis.py
@@ -0,0 +1,130 @@
+import pytest
+from google.protobuf.timestamp_pb2 import Timestamp
+
+from feast import Entity, FeatureView, Field, FileSource, RepoConfig
+from feast.infra.online_stores.redis import RedisOnlineStore
+from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto
+from feast.protos.feast.types.Value_pb2 import Value as ValueProto
+from feast.types import Int32
+
+
+@pytest.fixture
+def redis_online_store() -> RedisOnlineStore:
+    return RedisOnlineStore()
+
+
+@pytest.fixture
+def repo_config():
+    return RepoConfig(
+        provider="local",
+        project="test",
+        entity_key_serialization_version=2,
+        registry="dummy_registry.db",
+    )
+
+
+@pytest.fixture
+def feature_view():
+    file_source = FileSource(name="my_file_source", path="test.parquet")
+    entity = Entity(name="entity", join_keys=["entity"])
+    feature_view = FeatureView(
+        name="feature_view_1",
+        entities=[entity],
+        schema=[
+            Field(name="feature_10", dtype=Int32),
+            Field(name="feature_11", dtype=Int32),
+            Field(name="feature_12", dtype=Int32),
+        ],
+        source=file_source,
+    )
+    return feature_view
+
+
+def test_generate_entity_redis_keys(redis_online_store: RedisOnlineStore, repo_config):
+    entity_keys = [
+        EntityKeyProto(join_keys=["entity"], entity_values=[ValueProto(int32_val=1)]),
+    ]
+
+    actual = redis_online_store._generate_redis_keys_for_entities(
+        repo_config, entity_keys
+    )
+    expected = [
+        b"\x02\x00\x00\x00entity\x03\x00\x00\x00\x04\x00\x00\x00\x01\x00\x00\x00test"
+    ]
+    assert actual == expected
+
+
+def test_generate_hset_keys_for_features(
+    redis_online_store: RedisOnlineStore, feature_view
+):
+    actual = redis_online_store._generate_hset_keys_for_features(feature_view)
+    expected = (
+        ["feature_10", "feature_11", "feature_12", "_ts:feature_view_1"],
+        [b"&m_9", b"\xc37\x9a\xbf", b"wr\xb5d", "_ts:feature_view_1"],
+    )
+    assert actual == expected
+
+
+def test_generate_hset_keys_for_features_with_requested_features(
+    redis_online_store: RedisOnlineStore, feature_view
+):
+    actual = redis_online_store._generate_hset_keys_for_features(
+        feature_view=feature_view, requested_features=["my-feature-view:feature1"]
+    )
+    expected = (
+        ["my-feature-view:feature1", "_ts:feature_view_1"],
+        [b"Si\x86J", "_ts:feature_view_1"],
+    )
+    assert actual == expected
+
+
+def test_convert_redis_values_to_protobuf(
+    redis_online_store: RedisOnlineStore, feature_view
+):
+    requested_features = [
+        "feature_view_1:feature_10",
+        "feature_view_1:feature_11",
+        "_ts:feature_view_1",
+    ]
+    values = [
+        [
+            ValueProto(int32_val=1).SerializeToString(),
+            ValueProto(int32_val=2).SerializeToString(),
+            Timestamp().SerializeToString(),
+        ]
+    ]
+
+    features = redis_online_store._convert_redis_values_to_protobuf(
+        redis_values=values,
+        feature_view=feature_view.name,
+        requested_features=requested_features,
+    )
+    assert isinstance(features, list)
+    assert len(features) == 1
+
+    timestamp, features = features[0]
+    assert features["feature_view_1:feature_10"].int32_val == 1
+    assert features["feature_view_1:feature_11"].int32_val == 2
+
+
+def test_get_features_for_entity(redis_online_store: RedisOnlineStore, feature_view):
+    requested_features = [
+        "feature_view_1:feature_10",
+        "feature_view_1:feature_11",
+        "_ts:feature_view_1",
+    ]
+    values = [
+        ValueProto(int32_val=1).SerializeToString(),
+        ValueProto(int32_val=2).SerializeToString(),
+        Timestamp().SerializeToString(),
+    ]
+
+    timestamp, features = redis_online_store._get_features_for_entity(
+        values=values,
+        feature_view=feature_view.name,
+        requested_features=requested_features,
+    )
+    assert "feature_view_1:feature_10" in features
+    assert "feature_view_1:feature_11" in features
+    assert features["feature_view_1:feature_10"].int32_val == 1
+    assert features["feature_view_1:feature_11"].int32_val == 2