|
12 | 12 | from feast.online_response import OnlineResponse |
13 | 13 | from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto |
14 | 14 | from feast.protos.feast.types.Value_pb2 import Value as ValueProto |
15 | | -from feast.types import Float32, Int64, String |
| 15 | +from feast.types import Float32, Int64, String, UnixTimestamp |
16 | 16 | from feast.value_type import ValueType |
17 | 17 |
|
18 | 18 |
|
@@ -661,3 +661,121 @@ def test_all_base_class_params_forwarded( |
661 | 661 | assert expected_keys.issubset(req_body.keys()), ( |
662 | 662 | f"Missing keys in req_body: {expected_keys - req_body.keys()}" |
663 | 663 | ) |
| 664 | + |
| 665 | + |
| 666 | +class TestProtoValueToTransportValue: |
| 667 | + """Tests for RemoteOnlineStore._proto_value_to_transport_value.""" |
| 668 | + |
| 669 | + def test_unix_timestamp_val_returns_raw_int(self): |
| 670 | + """UnixTimestamp scalar should return the raw int64 epoch seconds.""" |
| 671 | + proto_val = ValueProto(unix_timestamp_val=1700000000) |
| 672 | + result = RemoteOnlineStore._proto_value_to_transport_value(proto_val) |
| 673 | + assert result == 1700000000 |
| 674 | + assert isinstance(result, int) |
| 675 | + |
| 676 | + def test_unix_timestamp_list_val_returns_int_list(self): |
| 677 | + """UnixTimestamp list should return a list of int64 epoch seconds.""" |
| 678 | + proto_val = ValueProto() |
| 679 | + proto_val.unix_timestamp_list_val.val.extend([1700000000, 1700000001]) |
| 680 | + result = RemoteOnlineStore._proto_value_to_transport_value(proto_val) |
| 681 | + assert result == [1700000000, 1700000001] |
| 682 | + assert all(isinstance(v, int) for v in result) |
| 683 | + |
| 684 | + def test_unix_timestamp_set_val_returns_flat_list(self): |
| 685 | + """UnixTimestamp set should return a list (not a set) of int64 |
| 686 | + epoch seconds, so the value is JSON-serializable.""" |
| 687 | + proto_val = ValueProto() |
| 688 | + proto_val.unix_timestamp_set_val.val.extend([1700000000, 1700000001]) |
| 689 | + result = RemoteOnlineStore._proto_value_to_transport_value(proto_val) |
| 690 | + assert result == [1700000000, 1700000001] |
| 691 | + assert isinstance(result, list) |
| 692 | + |
| 693 | + def test_unix_timestamp_val_null_sentinel_returns_raw_int(self): |
| 694 | + """The NULL_TIMESTAMP sentinel should still be passed through as |
| 695 | + a raw int64 — it is up to the server to interpret it.""" |
| 696 | + proto_val = ValueProto(unix_timestamp_val=-9223372036854775808) |
| 697 | + result = RemoteOnlineStore._proto_value_to_transport_value(proto_val) |
| 698 | + assert result == -9223372036854775808 |
| 699 | + assert isinstance(result, int) |
| 700 | + |
| 701 | + def test_empty_proto_returns_none(self): |
| 702 | + """A ValueProto with no val set should return None.""" |
| 703 | + proto_val = ValueProto() |
| 704 | + result = RemoteOnlineStore._proto_value_to_transport_value(proto_val) |
| 705 | + assert result is None |
| 706 | + |
| 707 | + |
| 708 | +class TestRemoteOnlineStoreWriteBatch: |
| 709 | + """Tests for RemoteOnlineStore.online_write_batch.""" |
| 710 | + |
| 711 | + @pytest.fixture |
| 712 | + def remote_store(self): |
| 713 | + return RemoteOnlineStore() |
| 714 | + |
| 715 | + @pytest.fixture |
| 716 | + def config(self): |
| 717 | + return RepoConfig( |
| 718 | + project="test_project", |
| 719 | + online_store=RemoteOnlineStoreConfig( |
| 720 | + type="remote", path="http://localhost:6566" |
| 721 | + ), |
| 722 | + registry="dummy_registry", |
| 723 | + ) |
| 724 | + |
| 725 | + @pytest.fixture |
| 726 | + def feature_view(self): |
| 727 | + entity = Entity( |
| 728 | + name="user_id", description="User ID", value_type=ValueType.INT64 |
| 729 | + ) |
| 730 | + source = FileSource(path="test.parquet", timestamp_field="event_timestamp") |
| 731 | + return FeatureView( |
| 732 | + name="test_feature_view", |
| 733 | + entities=[entity], |
| 734 | + ttl=timedelta(days=1), |
| 735 | + schema=[ |
| 736 | + Field(name="user_id", dtype=Int64), |
| 737 | + Field(name="feature1", dtype=String), |
| 738 | + Field(name="feature2", dtype=UnixTimestamp), |
| 739 | + ], |
| 740 | + source=source, |
| 741 | + ) |
| 742 | + |
| 743 | + @patch("feast.infra.online_stores.remote.post_remote_online_write") |
| 744 | + def test_unix_timestamp_value_serialized_as_int( |
| 745 | + self, mock_post, remote_store, config, feature_view |
| 746 | + ): |
| 747 | + """online_write_batch should send int64 epoch seconds in the |
| 748 | + DataFrame for UnixTimestamp features.""" |
| 749 | + mock_response = Mock() |
| 750 | + mock_response.status_code = 200 |
| 751 | + mock_post.return_value = mock_response |
| 752 | + |
| 753 | + entity_key = EntityKeyProto( |
| 754 | + join_keys=["user_id"], |
| 755 | + entity_values=[ValueProto(int64_val=42)], |
| 756 | + ) |
| 757 | + feature_values = { |
| 758 | + "feature1": ValueProto(string_val="hello"), |
| 759 | + "feature2": ValueProto(unix_timestamp_val=1700000000), |
| 760 | + } |
| 761 | + event_ts = datetime(2023, 11, 15, 0, 0, 0) |
| 762 | + created_ts = datetime(2023, 11, 14, 0, 0, 0) |
| 763 | + data = [(entity_key, feature_values, event_ts, created_ts)] |
| 764 | + |
| 765 | + remote_store.online_write_batch( |
| 766 | + config=config, table=feature_view, data=data, progress=None |
| 767 | + ) |
| 768 | + |
| 769 | + mock_post.assert_called_once() |
| 770 | + req_body = mock_post.call_args[1]["req_body"] |
| 771 | + df = req_body["df"] |
| 772 | + |
| 773 | + # UnixTimestamp feature value must be a raw int, not a datetime |
| 774 | + assert df["feature2"] == [1700000000] |
| 775 | + assert isinstance(df["feature2"][0], int) |
| 776 | + |
| 777 | + # Other feature types should remain unchanged |
| 778 | + assert df["feature1"] == ["hello"] |
| 779 | + |
| 780 | + # Event timestamps should be ISO strings as before |
| 781 | + assert df["event_timestamp"] == ["2023-11-15T00:00:00"] |
0 commit comments