|
33 | 33 | RequestDataNotFoundInEntityRowsException, |
34 | 34 | ) |
35 | 35 | from feast.field import Field |
| 36 | +from feast.infra.compute_engines.backends.pandas_backend import PandasBackend |
36 | 37 | from feast.infra.key_encoding_utils import deserialize_entity_key |
37 | 38 | from feast.protos.feast.serving.ServingService_pb2 import ( |
38 | 39 | FieldStatus, |
@@ -561,6 +562,72 @@ def construct_response_feature_vector( |
561 | 562 | ) |
562 | 563 |
|
563 | 564 |
|
| 565 | +def _get_aggregate_operations(agg_specs) -> dict: |
| 566 | + """ |
| 567 | + Convert Aggregation specs to agg_ops format for PandasBackend. |
| 568 | +
|
| 569 | + Reused from LocalFeatureBuilder logic. |
| 570 | + TODO: This logic is duplicated from LocalFeatureBuilder._get_aggregate_operations(). |
| 571 | + Consider refactoring to a shared utility module in the future. |
| 572 | + """ |
| 573 | + agg_ops = {} |
| 574 | + for agg in agg_specs: |
| 575 | + if agg.time_window is not None: |
| 576 | + raise ValueError( |
| 577 | + "Time window aggregation is not supported in online serving." |
| 578 | + ) |
| 579 | + alias = f"{agg.function}_{agg.column}" |
| 580 | + agg_ops[alias] = (agg.function, agg.column) |
| 581 | + return agg_ops |
| 582 | + |
| 583 | + |
| 584 | +def _apply_aggregations_to_response( |
| 585 | + response_data: Union[pyarrow.Table, Dict[str, List[Any]]], |
| 586 | + aggregations, |
| 587 | + group_keys: List[str], |
| 588 | + mode: str, |
| 589 | +) -> Union[pyarrow.Table, Dict[str, List[Any]]]: |
| 590 | + """ |
| 591 | + Apply aggregations using PandasBackend. |
| 592 | +
|
| 593 | + Args: |
| 594 | + response_data: Either a pyarrow.Table or dict of lists containing the data |
| 595 | + aggregations: List of Aggregation objects to apply |
| 596 | + group_keys: List of column names to group by |
| 597 | + mode: Transformation mode ("python", "pandas", or "substrait") |
| 598 | +
|
| 599 | + Returns: |
| 600 | + Aggregated data in the same format as input |
| 601 | +
|
| 602 | + TODO: Consider refactoring to support backends other than pandas in the future. |
| 603 | + """ |
| 604 | + if not aggregations: |
| 605 | + return response_data |
| 606 | + |
| 607 | + backend = PandasBackend() |
| 608 | + |
| 609 | + # Convert to pandas DataFrame |
| 610 | + if isinstance(response_data, dict): |
| 611 | + df = pd.DataFrame(response_data) |
| 612 | + else: # pyarrow.Table |
| 613 | + df = backend.from_arrow(response_data) |
| 614 | + |
| 615 | + if df.empty: |
| 616 | + return response_data |
| 617 | + |
| 618 | + # Convert aggregations to agg_ops format |
| 619 | + agg_ops = _get_aggregate_operations(aggregations) |
| 620 | + |
| 621 | + # Apply aggregations using PandasBackend |
| 622 | + result_df = backend.groupby_agg(df, group_keys, agg_ops) |
| 623 | + |
| 624 | + # Convert back to original format |
| 625 | + if mode == "python": |
| 626 | + return {col: result_df[col].tolist() for col in result_df.columns} |
| 627 | + else: # pandas or substrait |
| 628 | + return backend.to_arrow(result_df) |
| 629 | + |
| 630 | + |
564 | 631 | def _augment_response_with_on_demand_transforms( |
565 | 632 | online_features_response: GetOnlineFeaturesResponse, |
566 | 633 | feature_refs: List[str], |
@@ -605,6 +672,28 @@ def _augment_response_with_on_demand_transforms( |
605 | 672 | for odfv_name, _feature_refs in odfv_feature_refs.items(): |
606 | 673 | odfv = requested_odfv_map[odfv_name] |
607 | 674 | if not odfv.write_to_online_store: |
| 675 | + # Apply aggregations BEFORE transformation if defined |
| 676 | + if odfv.aggregations: |
| 677 | + if odfv.mode == "python": |
| 678 | + if initial_response_dict is None: |
| 679 | + initial_response_dict = initial_response.to_dict() |
| 680 | + initial_response_dict = _apply_aggregations_to_response( |
| 681 | + initial_response_dict, |
| 682 | + odfv.aggregations, |
| 683 | + odfv.entities, |
| 684 | + odfv.mode, |
| 685 | + ) |
| 686 | + elif odfv.mode in {"pandas", "substrait"}: |
| 687 | + if initial_response_arrow is None: |
| 688 | + initial_response_arrow = initial_response.to_arrow() |
| 689 | + initial_response_arrow = _apply_aggregations_to_response( |
| 690 | + initial_response_arrow, |
| 691 | + odfv.aggregations, |
| 692 | + odfv.entities, |
| 693 | + odfv.mode, |
| 694 | + ) |
| 695 | + |
| 696 | + # Apply transformation |
608 | 697 | if odfv.mode == "python": |
609 | 698 | if initial_response_dict is None: |
610 | 699 | initial_response_dict = initial_response.to_dict() |
|
0 commit comments