diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 2f655ddb924..36ff2c9d4eb 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -2253,6 +2253,91 @@ async def write_to_online_store_async( provider = self._get_provider() await provider.ingest_df_async(feature_view, df) + async def update_online_store( + self, + feature_view_name: str, + df: pd.DataFrame, + update_expressions: Dict[str, str], + allow_registry_cache: bool = True, + ) -> None: + """ + Update features using DynamoDB-specific list operations. + + This method provides efficient in-place list updates using DynamoDB's native + UpdateItem operations with list_append and other expressions. This is more + efficient than the read-modify-write pattern for array-based features. + + Args: + feature_view_name: The feature view to update. + df: DataFrame with new values to append/prepend to existing lists. + update_expressions: Dict mapping feature names to DynamoDB update expressions. + Examples: + - {"transactions": "list_append(transactions, :new_val)"} # append + - {"recent_items": "list_append(:new_val, recent_items)"} # prepend + allow_registry_cache: Whether to allow cached registry. + + Raises: + NotImplementedError: If online store doesn't support update expressions. + ValueError: If the feature view or update expressions are invalid. + + Example: + # Append new transactions to existing transaction history + await store.update_online_store( + feature_view_name="user_transactions", + df=new_transactions_df, + update_expressions={ + "transaction_history": "list_append(transaction_history, :new_val)", + "recent_amounts": "list_append(:new_val, recent_amounts)" # prepend + } + ) + """ + # Check if online store supports update expressions + provider = self._get_provider() + if not hasattr(provider.online_store, "update_online_store_async"): + raise NotImplementedError( + f"Online store {type(provider.online_store).__name__} " + "does not support async update expressions. This feature is only available " + "with DynamoDB online store." + ) + + feature_view, df = self._get_feature_view_and_df_for_online_write( + feature_view_name=feature_view_name, + df=df, + allow_registry_cache=allow_registry_cache, + transform_on_write=False, # Don't transform for updates + ) + + # Validate that the dataframe has meaningful feature data + if df is not None: + if df.empty: + warnings.warn("Cannot update with empty dataframe") + return + + # Check if feature columns are empty + feature_column_names = [f.name for f in feature_view.features] + if feature_column_names: + feature_df = df[feature_column_names] + if feature_df.empty or feature_df.isnull().all().all(): + warnings.warn("Cannot update with empty feature columns") + return + + # Prepare data for online store + from feast.infra.passthrough_provider import PassthroughProvider + + rows_to_write = PassthroughProvider._prep_rows_to_write_for_ingestion( + feature_view=feature_view, + df=df, + ) + + # Call DynamoDB-specific async method + await provider.online_store.update_online_store_async( + config=self.config, + table=feature_view, + data=rows_to_write, + update_expressions=update_expressions, + progress=None, + ) + def write_to_offline_store( self, feature_view_name: str, diff --git a/sdk/python/feast/infra/online_stores/dynamodb.py b/sdk/python/feast/infra/online_stores/dynamodb.py index b5175bbf2f2..0353e2c2d72 100644 --- a/sdk/python/feast/infra/online_stores/dynamodb.py +++ b/sdk/python/feast/infra/online_stores/dynamodb.py @@ -734,6 +734,328 @@ def _to_client_batch_get_payload(online_config, table_name, batch): } } + def update_online_store( + self, + config: RepoConfig, + table: FeatureView, + data: List[ + Tuple[EntityKeyProto, Dict[str, ValueProto], datetime, Optional[datetime]] + ], + update_expressions: Dict[str, str], + progress: Optional[Callable[[int], Any]] = None, + ) -> None: + """ + Update features in DynamoDB using UpdateItem with custom UpdateExpression. + + This method provides DynamoDB-specific list update functionality using + native UpdateItem operations with list_append and other expressions. + + Args: + config: The RepoConfig for the current FeatureStore. + table: Feast FeatureView. + data: Feature data to update. Each tuple contains an entity key, + feature values, event timestamp, and optional created timestamp. + update_expressions: Dict mapping feature names to DynamoDB update expressions. + Examples: + - "transactions": "list_append(transactions, :new_val)" + - "recent_items": "list_append(:new_val, recent_items)" # prepend + progress: Optional progress callback function. + """ + online_config = config.online_store + assert isinstance(online_config, DynamoDBOnlineStoreConfig) + + dynamodb_resource = self._get_dynamodb_resource( + online_config.region, + online_config.endpoint_url, + online_config.session_based_auth, + ) + + table_instance = dynamodb_resource.Table( + _get_table_name(online_config, config, table) + ) + + # Process each entity update + for entity_key, features, timestamp, _ in _latest_data_to_write(data): + entity_id = compute_entity_id( + entity_key, + entity_key_serialization_version=config.entity_key_serialization_version, + ) + + self._update_item_with_expression( + table_instance, + entity_id, + features, + timestamp, + update_expressions, + config, + ) + + if progress: + progress(1) + + async def update_online_store_async( + self, + config: RepoConfig, + table: FeatureView, + data: List[ + Tuple[EntityKeyProto, Dict[str, ValueProto], datetime, Optional[datetime]] + ], + update_expressions: Dict[str, str], + progress: Optional[Callable[[int], Any]] = None, + ) -> None: + """ + Async version of update_online_store. + """ + online_config = config.online_store + assert isinstance(online_config, DynamoDBOnlineStoreConfig) + + table_name = _get_table_name(online_config, config, table) + client = await self._get_aiodynamodb_client( + online_config.region, + online_config.max_pool_connections, + online_config.keepalive_timeout, + online_config.connect_timeout, + online_config.read_timeout, + online_config.total_max_retry_attempts, + online_config.retry_mode, + online_config.endpoint_url, + ) + + # Process each entity update + for entity_key, features, timestamp, _ in _latest_data_to_write(data): + entity_id = compute_entity_id( + entity_key, + entity_key_serialization_version=config.entity_key_serialization_version, + ) + + await self._update_item_with_expression_async( + client, + table_name, + entity_id, + features, + timestamp, + update_expressions, + config, + ) + + if progress: + progress(1) + + def _update_item_with_expression( + self, + table_instance, + entity_id: str, + features: Dict[str, ValueProto], + timestamp: datetime, + update_expressions: Dict[str, str], + config: RepoConfig, + ): + """Execute DynamoDB UpdateItem with list operations via read-modify-write.""" + # Read existing item to get current values for list operations + existing_values: Dict[str, ValueProto] = {} + item_exists = False + try: + response = table_instance.get_item(Key={"entity_id": entity_id}) + if "Item" in response: + item_exists = True + if "values" in response["Item"]: + for feat_name, val_bin in response["Item"]["values"].items(): + val = ValueProto() + val.ParseFromString(val_bin.value) + existing_values[feat_name] = val + except ClientError: + pass + + # Build final feature values by applying list operations + final_features: Dict[str, ValueProto] = {} + for feature_name, value_proto in features.items(): + if feature_name in update_expressions: + final_features[feature_name] = self._apply_list_operation( + existing_values.get(feature_name), + value_proto, + update_expressions[feature_name], + ) + else: + final_features[feature_name] = value_proto + + # For new items, use put_item + if not item_exists: + item = { + "entity_id": entity_id, + "event_ts": str(utils.make_tzaware(timestamp)), + "values": {k: v.SerializeToString() for k, v in final_features.items()}, + } + table_instance.put_item(Item=item) + return + + # Build UpdateExpression for existing items + update_expr_parts: list[str] = [] + expression_attribute_values: Dict[str, Any] = {} + expression_attribute_names: Dict[str, str] = { + "#values": "values", + "#event_ts": "event_ts", + } + + update_expr_parts.append("#event_ts = :event_ts") + expression_attribute_values[":event_ts"] = str(utils.make_tzaware(timestamp)) + + for feature_name, value_proto in final_features.items(): + feat_attr = f"#feat_{feature_name}" + val_name = f":val_{feature_name}" + expression_attribute_names[feat_attr] = feature_name + expression_attribute_values[val_name] = value_proto.SerializeToString() # type: ignore[assignment] + update_expr_parts.append(f"#values.{feat_attr} = {val_name}") + + try: + table_instance.update_item( + Key={"entity_id": entity_id}, + UpdateExpression="SET " + ", ".join(update_expr_parts), + ExpressionAttributeNames=expression_attribute_names, + ExpressionAttributeValues=expression_attribute_values, + ) + except ClientError as e: + logger.error(f"Failed to update item {entity_id}: {e}") + raise + + def _apply_list_operation( + self, existing: Optional[ValueProto], new_value: ValueProto, update_expr: str + ) -> ValueProto: + """Apply list operation (append/prepend) and return merged ValueProto.""" + result = ValueProto() + is_prepend = update_expr.strip().startswith("list_append(:new_val") + existing_list = self._extract_list_values(existing) if existing else [] + new_list = self._extract_list_values(new_value) + merged = new_list + existing_list if is_prepend else existing_list + new_list + self._set_list_values(result, new_value, merged) + return result + + def _extract_list_values(self, value_proto: ValueProto) -> list: + """Extract list values from ValueProto.""" + if value_proto.HasField("string_list_val"): + return list(value_proto.string_list_val.val) + elif value_proto.HasField("int32_list_val"): + return list(value_proto.int32_list_val.val) + elif value_proto.HasField("int64_list_val"): + return list(value_proto.int64_list_val.val) + elif value_proto.HasField("float_list_val"): + return list(value_proto.float_list_val.val) + elif value_proto.HasField("double_list_val"): + return list(value_proto.double_list_val.val) + elif value_proto.HasField("bool_list_val"): + return list(value_proto.bool_list_val.val) + elif value_proto.HasField("bytes_list_val"): + return list(value_proto.bytes_list_val.val) + return [] + + def _set_list_values( + self, result: ValueProto, template: ValueProto, values: list + ) -> None: + """Set list values on result ValueProto based on template type.""" + if template.HasField("string_list_val"): + result.string_list_val.val.extend(values) + elif template.HasField("int32_list_val"): + result.int32_list_val.val.extend(values) + elif template.HasField("int64_list_val"): + result.int64_list_val.val.extend(values) + elif template.HasField("float_list_val"): + result.float_list_val.val.extend(values) + elif template.HasField("double_list_val"): + result.double_list_val.val.extend(values) + elif template.HasField("bool_list_val"): + result.bool_list_val.val.extend(values) + elif template.HasField("bytes_list_val"): + result.bytes_list_val.val.extend(values) + + async def _update_item_with_expression_async( + self, + client, + table_name: str, + entity_id: str, + features: Dict[str, ValueProto], + timestamp: datetime, + update_expressions: Dict[str, str], + config: RepoConfig, + ): + """Async version of _update_item_with_expression.""" + # Read existing item + existing_values: Dict[str, ValueProto] = {} + item_exists = False + try: + response = await client.get_item( + TableName=table_name, Key={"entity_id": {"S": entity_id}} + ) + if "Item" in response: + item_exists = True + if "values" in response["Item"] and "M" in response["Item"]["values"]: + for feat_name, val_data in response["Item"]["values"]["M"].items(): + if "B" in val_data: + val = ValueProto() + val.ParseFromString(val_data["B"]) + existing_values[feat_name] = val + except ClientError: + pass + + # Build final feature values + final_features: Dict[str, ValueProto] = {} + for feature_name, value_proto in features.items(): + if feature_name in update_expressions: + final_features[feature_name] = self._apply_list_operation( + existing_values.get(feature_name), + value_proto, + update_expressions[feature_name], + ) + else: + final_features[feature_name] = value_proto + + # For new items, use put_item + if not item_exists: + item = { + "entity_id": {"S": entity_id}, + "event_ts": {"S": str(utils.make_tzaware(timestamp))}, + "values": { + "M": { + k: {"B": v.SerializeToString()} + for k, v in final_features.items() + } + }, + } + await client.put_item(TableName=table_name, Item=item) + return + + # Build UpdateExpression for existing items + update_expr_parts: list[str] = [] + expression_attribute_values: Dict[str, Any] = {} + expression_attribute_names: Dict[str, str] = { + "#values": "values", + "#event_ts": "event_ts", + } + + update_expr_parts.append("#event_ts = :event_ts") + expression_attribute_values[":event_ts"] = { + "S": str(utils.make_tzaware(timestamp)) + } + + for feature_name, value_proto in final_features.items(): + feat_attr = f"#feat_{feature_name}" + val_name = f":val_{feature_name}" + expression_attribute_names[feat_attr] = feature_name + expression_attribute_values[val_name] = { + "B": value_proto.SerializeToString() + } + update_expr_parts.append(f"#values.{feat_attr} = {val_name}") + + try: + await client.update_item( + TableName=table_name, + Key={"entity_id": {"S": entity_id}}, + UpdateExpression="SET " + ", ".join(update_expr_parts), + ExpressionAttributeNames=expression_attribute_names, + ExpressionAttributeValues=expression_attribute_values, + ) + except ClientError as e: + logger.error(f"Failed to update item {entity_id}: {e}") + raise + # Global async client functions removed - now using instance methods diff --git a/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py b/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py index 7c99a07d7aa..6dd8a99f884 100644 --- a/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py +++ b/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py @@ -483,3 +483,300 @@ def to_ek_proto(val): actual = list(_latest_data_to_write(data)) expected = [data[2], data[1], data[4]] assert expected == actual + + +def _create_entity_key(entity_id: str) -> EntityKeyProto: + """Helper function to create EntityKeyProto for testing.""" + return EntityKeyProto( + join_keys=["customer"], entity_values=[ValueProto(string_val=entity_id)] + ) + + +def _create_string_list_value(items: list[str]) -> ValueProto: + """Helper function to create ValueProto with string list.""" + from feast.protos.feast.types.Value_pb2 import StringList + + return ValueProto(string_list_val=StringList(val=items)) + + +def _create_int32_list_value(items: list[int]) -> ValueProto: + """Helper function to create ValueProto with int32 list.""" + from feast.protos.feast.types.Value_pb2 import Int32List + + return ValueProto(int32_list_val=Int32List(val=items)) + + +def _extract_string_list(value_proto: ValueProto) -> list[str]: + """Helper function to extract string list from ValueProto.""" + return list(value_proto.string_list_val.val) + + +def _extract_int32_list(value_proto: ValueProto) -> list[int]: + """Helper function to extract int32 list from ValueProto.""" + return list(value_proto.int32_list_val.val) + + +@mock_dynamodb +def test_dynamodb_update_online_store_list_append(repo_config, dynamodb_online_store): + """Test DynamoDB update_online_store with list_append operation.""" + + table_name = f"{TABLE_NAME}_update_list_append" + create_test_table(PROJECT, table_name, REGION) + + # Create initial data with existing transactions + initial_data = [ + ( + _create_entity_key("entity1"), + {"transactions": _create_string_list_value(["tx1", "tx2"])}, + datetime.utcnow(), + None, + ) + ] + + # Write initial data using standard method + dynamodb_online_store.online_write_batch( + repo_config, MockFeatureView(name=table_name), initial_data, None + ) + + # Update with list_append - should append new transaction + update_data = [ + ( + _create_entity_key("entity1"), + {"transactions": _create_string_list_value(["tx3"])}, + datetime.utcnow(), + None, + ) + ] + + update_expressions = {"transactions": "list_append(transactions, :new_val)"} + + dynamodb_online_store.update_online_store( + repo_config, + MockFeatureView(name=table_name), + update_data, + update_expressions, + None, + ) + + # Verify result - should have all three transactions + result = dynamodb_online_store.online_read( + repo_config, MockFeatureView(name=table_name), [_create_entity_key("entity1")] + ) + + assert len(result) == 1 + assert result[0][0] is not None # timestamp should exist + assert result[0][1] is not None # features should exist + transactions = result[0][1]["transactions"] + assert _extract_string_list(transactions) == ["tx1", "tx2", "tx3"] + + +@mock_dynamodb +def test_dynamodb_update_online_store_list_prepend(repo_config, dynamodb_online_store): + """Test DynamoDB update_online_store with list prepend operation.""" + + table_name = f"{TABLE_NAME}_update_list_prepend" + create_test_table(PROJECT, table_name, REGION) + + # Create initial data + initial_data = [ + ( + _create_entity_key("entity1"), + {"recent_items": _create_string_list_value(["item2", "item3"])}, + datetime.utcnow(), + None, + ) + ] + + dynamodb_online_store.online_write_batch( + repo_config, MockFeatureView(name=table_name), initial_data, None + ) + + # Update with list prepend - should add new item at the beginning + update_data = [ + ( + _create_entity_key("entity1"), + {"recent_items": _create_string_list_value(["item1"])}, + datetime.utcnow(), + None, + ) + ] + + update_expressions = {"recent_items": "list_append(:new_val, recent_items)"} + + dynamodb_online_store.update_online_store( + repo_config, + MockFeatureView(name=table_name), + update_data, + update_expressions, + None, + ) + + # Verify result - new item should be first + result = dynamodb_online_store.online_read( + repo_config, MockFeatureView(name=table_name), [_create_entity_key("entity1")] + ) + + assert len(result) == 1 + recent_items = result[0][1]["recent_items"] + assert _extract_string_list(recent_items) == ["item1", "item2", "item3"] + + +@mock_dynamodb +def test_dynamodb_update_online_store_new_entity(repo_config, dynamodb_online_store): + """Test DynamoDB update_online_store with new entity (no existing data).""" + + table_name = f"{TABLE_NAME}_update_new_entity" + create_test_table(PROJECT, table_name, REGION) + + # Update entity that doesn't exist yet - should create new item + update_data = [ + ( + _create_entity_key("new_entity"), + {"transactions": _create_string_list_value(["tx1"])}, + datetime.utcnow(), + None, + ) + ] + + update_expressions = {"transactions": "list_append(transactions, :new_val)"} + + dynamodb_online_store.update_online_store( + repo_config, + MockFeatureView(name=table_name), + update_data, + update_expressions, + None, + ) + + # Verify result - should create new item with the transaction + result = dynamodb_online_store.online_read( + repo_config, + MockFeatureView(name=table_name), + [_create_entity_key("new_entity")], + ) + + assert len(result) == 1 + assert result[0][0] is not None # timestamp should exist + assert result[0][1] is not None # features should exist + transactions = result[0][1]["transactions"] + assert _extract_string_list(transactions) == ["tx1"] + + +@mock_dynamodb +def test_dynamodb_update_online_store_mixed_operations( + repo_config, dynamodb_online_store +): + """Test DynamoDB update_online_store with mixed update and replace operations.""" + + table_name = f"{TABLE_NAME}_update_mixed" + create_test_table(PROJECT, table_name, REGION) + + # Create initial data + initial_data = [ + ( + _create_entity_key("entity1"), + { + "transactions": _create_string_list_value(["tx1"]), + "user_score": ValueProto(int32_val=100), + }, + datetime.utcnow(), + None, + ) + ] + + dynamodb_online_store.online_write_batch( + repo_config, MockFeatureView(name=table_name), initial_data, None + ) + + # Update with mixed operations - append to list and replace scalar + update_data = [ + ( + _create_entity_key("entity1"), + { + "transactions": _create_string_list_value(["tx2"]), + "user_score": ValueProto(int32_val=150), + }, + datetime.utcnow(), + None, + ) + ] + + update_expressions = { + "transactions": "list_append(transactions, :new_val)", + # user_score will use standard replacement (no expression) + } + + dynamodb_online_store.update_online_store( + repo_config, + MockFeatureView(name=table_name), + update_data, + update_expressions, + None, + ) + + # Verify result + result = dynamodb_online_store.online_read( + repo_config, MockFeatureView(name=table_name), [_create_entity_key("entity1")] + ) + + assert len(result) == 1 + features = result[0][1] + + # Transactions should be appended + transactions = features["transactions"] + assert _extract_string_list(transactions) == ["tx1", "tx2"] + + # User score should be replaced + user_score = features["user_score"] + assert user_score.int32_val == 150 + + +@mock_dynamodb +def test_dynamodb_update_online_store_int_list(repo_config, dynamodb_online_store): + """Test DynamoDB update_online_store with integer list.""" + + table_name = f"{TABLE_NAME}_update_int_list" + create_test_table(PROJECT, table_name, REGION) + + # Create initial data with integer list + initial_data = [ + ( + _create_entity_key("entity1"), + {"scores": _create_int32_list_value([10, 20])}, + datetime.utcnow(), + None, + ) + ] + + dynamodb_online_store.online_write_batch( + repo_config, MockFeatureView(name=table_name), initial_data, None + ) + + # Update with list_append for integer list + update_data = [ + ( + _create_entity_key("entity1"), + {"scores": _create_int32_list_value([30])}, + datetime.utcnow(), + None, + ) + ] + + update_expressions = {"scores": "list_append(scores, :new_val)"} + + dynamodb_online_store.update_online_store( + repo_config, + MockFeatureView(name=table_name), + update_data, + update_expressions, + None, + ) + + # Verify result + result = dynamodb_online_store.online_read( + repo_config, MockFeatureView(name=table_name), [_create_entity_key("entity1")] + ) + + assert len(result) == 1 + scores = result[0][1]["scores"] + assert _extract_int32_list(scores) == [10, 20, 30] diff --git a/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py b/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py index cbb9d3d334a..debe14beee2 100644 --- a/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py +++ b/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py @@ -1,7 +1,8 @@ from datetime import datetime, timedelta from tempfile import mkstemp -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch +import pandas as pd import pytest from pytest_lazyfixture import lazy_fixture @@ -15,6 +16,7 @@ from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_NAME, FeatureView from feast.field import Field from feast.infra.offline_stores.file_source import FileSource +from feast.infra.online_stores.dynamodb import DynamoDBOnlineStore from feast.infra.online_stores.sqlite import SqliteOnlineStoreConfig from feast.permissions.action import AuthzedAction from feast.permissions.permission import Permission @@ -862,3 +864,73 @@ def test_registry_config_cache_mode_can_be_set(): config = RegistryConfig(cache_mode="sync") assert config.cache_mode == "sync" + + +# Tests for update_online_store functionality + + +@pytest.mark.asyncio +async def test_update_online_store_not_supported(): + """Test that update raises NotImplementedError for non-DynamoDB stores.""" + + fd, registry_path = mkstemp() + fd, online_store_path = mkstemp() + store = FeatureStore( + config=RepoConfig( + registry=registry_path, + project="default", + provider="local", + online_store=SqliteOnlineStoreConfig(path=online_store_path), + entity_key_serialization_version=3, + ) + ) + + df = pd.DataFrame({"entity_id": ["1", "2"], "transactions": [["tx1"], ["tx2"]]}) + + update_expressions = {"transactions": "list_append(transactions, :new_val)"} + + with pytest.raises(NotImplementedError) as exc_info: + await store.update_online_store( + feature_view_name="test_fv", df=df, update_expressions=update_expressions + ) + + assert "does not support async update expressions" in str(exc_info.value) + assert "DynamoDB online store" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_update_online_store_success(): + """Test successful update_online_store call.""" + + with ( + patch( + "feast.feature_store.FeatureStore._get_feature_view_and_df_for_online_write" + ) as mock_get_fv_df, + patch( + "feast.infra.passthrough_provider.PassthroughProvider._prep_rows_to_write_for_ingestion" + ) as mock_prep, + patch("feast.feature_store.load_repo_config") as mock_load_config, + patch("feast.feature_store.Registry"), + patch("feast.feature_store.get_provider") as mock_get_provider, + ): + mock_online_store = Mock(spec=DynamoDBOnlineStore) + mock_online_store.update_online_store_async = AsyncMock() + mock_provider = Mock() + mock_provider.online_store = mock_online_store + mock_get_provider.return_value = mock_provider + mock_prep.return_value = [] + mock_load_config.return_value = Mock() + + df = pd.DataFrame({"entity_id": ["1", "2"], "transactions": [["tx1"], ["tx2"]]}) + mock_feature_view = Mock() + mock_feature_view.features = [Field(name="transactions", dtype=Array(String))] + mock_get_fv_df.return_value = (mock_feature_view, df) + + store = FeatureStore() + update_expressions = {"transactions": "list_append(transactions, :new_val)"} + + await store.update_online_store( + feature_view_name="test_fv", df=df, update_expressions=update_expressions + ) + + mock_online_store.update_online_store_async.assert_called_once()