Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,27 +139,69 @@ def _prepare_repo_conf(self, config: RepoConfig, online_store_type: str):
"""
Prepare a RepoConfig for the selected online store backend.

The caller's RepoConfig is left untouched: the returned mapping is built
from a copy. Mutating it in place replaced the caller's HybridOnlineStore
config with the selected backend's config, so every later lookup of
``routing_tag`` fell back to the "tribe" default.

Args:
config: The original Feast RepoConfig.
online_store_type: The type of the online store backend to use.
Returns:
A dictionary representing the updated RepoConfig for the selected backend.
"""
rconfig = config
online_config = config.online_config
for online_store in config.online_store.online_stores:
if online_store.type.split(".")[-1].lower() == online_store_type.lower():
rconfig.online_config = online_store.conf
rconfig.online_config["type"] = online_store.type
data = rconfig.__dict__
# New dict rather than a mutated one: ``conf`` belongs to the
# caller's config and is read again on the next lookup.
online_config = {**online_store.conf, "type": online_store.type}
data = dict(config.__dict__)
data["registry"] = data["registry_config"]
data["offline_store"] = data["offline_config"]
data["online_store"] = data["online_config"]
data["online_config"] = online_config
data["online_store"] = online_config
return data

def _get_routing_tag_value(self, table: FeatureView, config: RepoConfig):
tag_name = getattr(config.online_store, "routing_tag", "tribe")
return table.tags.get(tag_name)

def _group_by_routing_tag(
self,
tables: Sequence[FeatureView],
config: RepoConfig,
require_tag: bool,
) -> Dict[str, List[FeatureView]]:
"""
Bucket FeatureViews by the (lower-cased) value of their routing tag.

Backends act on every FeatureView handed to them, so each one must only
ever see its own bucket. Passing the full list made e.g. SQLite create
tables for Redis-routed views.

Args:
tables: FeatureViews to group.
config: Feast RepoConfig.
require_tag: Raise on an untagged FeatureView instead of skipping it.
Returns:
Mapping of tag value to the FeatureViews carrying it.
Raises:
ValueError: If ``require_tag`` and a FeatureView has no routing tag.
"""
grouped: Dict[str, List[FeatureView]] = {}
for table in tables:
tribe = self._get_routing_tag_value(table, config)
if not tribe:
if require_tag:
tag_name = getattr(config.online_store, "routing_tag", "tribe")
raise ValueError(
f"FeatureView must have a '{tag_name}' tag to use HybridOnlineStore."
)
continue
grouped.setdefault(tribe.lower(), []).append(table)
return grouped

def online_write_batch(
self,
config: RepoConfig,
Expand Down Expand Up @@ -266,28 +308,32 @@ def update(
ValueError: If a FeatureView does not have the required tag.
NotImplementedError: If no online store is found for a tag value.
"""
for table in tables_to_keep:
tribe = self._get_routing_tag_value(table, config)
if not tribe:
tag_name = getattr(config.online_store, "routing_tag", "tribe")
raise ValueError(
f"FeatureView must have a '{tag_name}' tag to use HybridOnlineStore."
)
keep_by_tribe = self._group_by_routing_tag(
tables_to_keep, config, require_tag=True
)
# Untagged views on the way out are skipped rather than fatal: they may
# predate the routing tag, and there is no backend to route them to.
delete_by_tribe = self._group_by_routing_tag(
tables_to_delete, config, require_tag=False
)
for tribe in {**keep_by_tribe, **delete_by_tribe}:
online_store = self._get_online_store(tribe, config)
if online_store:
config = RepoConfig(**self._prepare_repo_conf(config, tribe))
online_store.update(
config,
tables_to_delete,
tables_to_keep,
entities_to_delete,
entities_to_keep,
partial,
)
else:
if not online_store:
raise NotImplementedError(
f"No online store found for {getattr(config.online_store, 'routing_tag', 'tribe')} tag '{tribe}'. Please check your configuration."
)
# Local name: rebinding `config` here would feed the next iteration
# the selected backend's config instead of the hybrid one, losing
# `routing_tag`.
store_config = RepoConfig(**self._prepare_repo_conf(config, tribe))
online_store.update(
store_config,
delete_by_tribe.get(tribe, []),
keep_by_tribe.get(tribe, []),
entities_to_delete,
entities_to_keep,
partial,
)

def teardown(
self,
Expand All @@ -303,25 +349,13 @@ def teardown(
tables: Sequence of FeatureViews to teardown.
entities: Sequence of Entities to teardown.
"""
# Use a set of (tribe, store_type, conf_id) to avoid duplicate teardowns for the same instance
tribes_seen = set()
online_stores_cfg = getattr(config.online_store, "online_stores", [])
tag_name = getattr(config.online_store, "routing_tag", "tribe")
for table in tables:
tribe = table.tags.get(tag_name)
if not tribe:
continue
# Find all store configs matching this tribe (supporting multiple instances of the same type)
for store_cfg in online_stores_cfg:
store_type = store_cfg.type
# Use id(store_cfg.conf) to distinguish different configs of the same type
key = (tribe, store_type, id(store_cfg.conf))
if key in tribes_seen:
continue
tribes_seen.add(key)
# Only select the online store if tribe matches the type (or you can add a mapping in config for more flexibility)
if tribe.lower() == store_type.split(".")[-1].lower():
online_store = self._get_online_store(tribe, config)
if online_store:
config = RepoConfig(**self._prepare_repo_conf(config, tribe))
online_store.teardown(config, tables, entities)
# Grouping both dedupes backends and keeps each one from tearing down
# another backend's FeatureViews. Untagged views have no backend to
# route to and are skipped, as before.
for tribe, tribe_tables in self._group_by_routing_tag(
tables, config, require_tag=False
).items():
online_store = self._get_online_store(tribe, config)
if online_store:
store_config = RepoConfig(**self._prepare_repo_conf(config, tribe))
online_store.teardown(store_config, tribe_tables, entities)
183 changes: 183 additions & 0 deletions sdk/python/tests/unit/infra/online_store/test_hybrid_online_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
from unittest.mock import patch

import pytest

from feast import Entity, FeatureView, Field, FileSource, RepoConfig, ValueType
from feast.infra.online_stores.hybrid_online_store.hybrid_online_store import (
HybridOnlineStore,
HybridOnlineStoreConfig,
)
from feast.types import PrimitiveFeastType

ROUTING_TAG = "backend"


@pytest.fixture
def entity():
return Entity(name="id", join_keys=["id"], value_type=ValueType.INT64)


def _feature_view(name: str, backend: str, entity: Entity) -> FeatureView:
return FeatureView(
name=name,
entities=[entity],
schema=[Field(name="feature1", dtype=PrimitiveFeastType.INT64)],
online=True,
tags={ROUTING_TAG: backend},
source=FileSource(
path="/tmp/feast_hybrid_test.parquet",
event_timestamp_column="event_timestamp",
),
)


@pytest.fixture
def repo_config():
return RepoConfig(
registry="test-registry.db",
project="test_project",
provider="local",
online_store=HybridOnlineStoreConfig(
routing_tag=ROUTING_TAG,
online_stores=[
HybridOnlineStoreConfig.OnlineStoresWithConfig(
type="redis",
conf={"redis_type": "redis", "connection_string": "localhost:6379"},
),
HybridOnlineStoreConfig.OnlineStoresWithConfig(
type="sqlite",
conf={"path": "/tmp/feast_hybrid_test.db"},
),
],
),
offline_store=None,
)


def test_prepare_repo_conf_does_not_mutate_caller_config(repo_config):
"""The selected backend's config must not leak back into the caller's config."""
original_online_store = repo_config.online_store
original_redis_conf = dict(repo_config.online_store.online_stores[0].conf)

HybridOnlineStore()._prepare_repo_conf(repo_config, "redis")

assert repo_config.online_store is original_online_store
assert repo_config.online_store.routing_tag == ROUTING_TAG
# `type` used to be injected into the caller's own conf dict.
assert repo_config.online_store.online_stores[0].conf == original_redis_conf


def test_update_routes_every_feature_view(repo_config, entity):
"""Regression: routing used to break from the second FeatureView onwards.

`update()` rebound `config` to the selected backend's RepoConfig, so the next
iteration read `routing_tag` off a config that no longer had one. It fell back
to the "tribe" default, found no such tag, and raised
"FeatureView must have a 'tribe' tag to use HybridOnlineStore".
"""
tables = [
_feature_view("fv_redis", "redis", entity),
_feature_view("fv_sqlite", "sqlite", entity),
]

with (
patch(
"feast.infra.online_stores.redis.RedisOnlineStore.update"
) as redis_update,
patch(
"feast.infra.online_stores.sqlite.SqliteOnlineStore.update"
) as sqlite_update,
):
HybridOnlineStore().update(
config=repo_config,
tables_to_delete=[],
tables_to_keep=tables,
entities_to_delete=[],
entities_to_keep=[entity],
partial=False,
)

assert redis_update.call_count == 1
assert sqlite_update.call_count == 1
assert repo_config.online_store.routing_tag == ROUTING_TAG


def test_update_passes_each_backend_only_its_own_tables(repo_config, entity):
"""A backend must not create or drop infrastructure for another backend's views."""
fv_redis = _feature_view("fv_redis", "redis", entity)
fv_sqlite = _feature_view("fv_sqlite", "sqlite", entity)
fv_redis_gone = _feature_view("fv_redis_gone", "redis", entity)

with (
patch(
"feast.infra.online_stores.redis.RedisOnlineStore.update"
) as redis_update,
patch(
"feast.infra.online_stores.sqlite.SqliteOnlineStore.update"
) as sqlite_update,
):
HybridOnlineStore().update(
config=repo_config,
tables_to_delete=[fv_redis_gone],
tables_to_keep=[fv_redis, fv_sqlite],
entities_to_delete=[],
entities_to_keep=[entity],
partial=False,
)

_, redis_delete, redis_keep, *_ = redis_update.call_args.args
_, sqlite_delete, sqlite_keep, *_ = sqlite_update.call_args.args
assert redis_keep == [fv_redis]
assert redis_delete == [fv_redis_gone]
assert sqlite_keep == [fv_sqlite]
assert sqlite_delete == []


def test_update_reaches_a_backend_with_only_deletions(repo_config, entity):
"""A backend whose views are all being removed still needs its update() call."""
with (
patch(
"feast.infra.online_stores.redis.RedisOnlineStore.update"
) as redis_update,
patch(
"feast.infra.online_stores.sqlite.SqliteOnlineStore.update"
) as sqlite_update,
):
HybridOnlineStore().update(
config=repo_config,
tables_to_delete=[_feature_view("fv_redis_gone", "redis", entity)],
tables_to_keep=[_feature_view("fv_sqlite", "sqlite", entity)],
entities_to_delete=[],
entities_to_keep=[entity],
partial=False,
)

assert redis_update.call_count == 1
assert sqlite_update.call_count == 1


def test_teardown_passes_each_backend_only_its_own_tables(repo_config, entity):
"""Teardown used to hand every backend the full table list."""
fv_redis = _feature_view("fv_redis", "redis", entity)
fv_sqlite = _feature_view("fv_sqlite", "sqlite", entity)
fv_sqlite2 = _feature_view("fv_sqlite2", "sqlite", entity)

with (
patch(
"feast.infra.online_stores.redis.RedisOnlineStore.teardown"
) as redis_teardown,
patch(
"feast.infra.online_stores.sqlite.SqliteOnlineStore.teardown"
) as sqlite_teardown,
):
HybridOnlineStore().teardown(
config=repo_config,
tables=[fv_redis, fv_sqlite, fv_sqlite2],
entities=[entity],
)

assert redis_teardown.call_count == 1
assert sqlite_teardown.call_count == 1
assert redis_teardown.call_args.args[1] == [fv_redis]
# Both sqlite views in one call: the old dedup dropped the second one.
assert sqlite_teardown.call_args.args[1] == [fv_sqlite, fv_sqlite2]