@@ -79,6 +79,72 @@ class SparkFeatureViewQueryContext(offline_utils.FeatureViewQueryContext):
7979 max_date_partition : str
8080
8181
82+ def _apply_bfv_transformations_for_historical (
83+ spark_session : SparkSession ,
84+ feature_views : List [FeatureView ],
85+ query_context : List [offline_utils .FeatureViewQueryContext ],
86+ ) -> List [offline_utils .FeatureViewQueryContext ]:
87+ """
88+ For BatchFeatureViews, redirect get_historical_features to read from the
89+ pre-materialized offline store (batch_source.path) when available, avoiding
90+ expensive UDF re-execution on raw data.
91+
92+ Precedence:
93+ 1. offline=True + batch_source.path set -> read pre-computed parquet
94+ 2. Python/pandas UDF present -> execute UDF on raw source (fallback)
95+ 3. Otherwise -> pass through unchanged
96+ """
97+ from dataclasses import replace
98+
99+ fv_by_name = {fv .projection .name_to_use (): fv for fv in feature_views }
100+ new_contexts = []
101+
102+ for ctx in query_context :
103+ fv = fv_by_name .get (ctx .name )
104+ if fv is None or not isinstance (fv , BatchFeatureView ):
105+ new_contexts .append (ctx )
106+ continue
107+
108+ if (
109+ getattr (fv , "offline" , False )
110+ and isinstance (fv .batch_source , SparkSource )
111+ and fv .batch_source .path
112+ ):
113+ tmp_view = f"__feast_offline_{ ctx .name } _{ uuid .uuid4 ().hex [:8 ]} "
114+ file_format = fv .batch_source .file_format or "parquet"
115+ df = spark_session .read .format (file_format ).load (fv .batch_source .path )
116+ df .createOrReplaceTempView (tmp_view )
117+ ctx = replace (ctx , table_subquery = tmp_view )
118+ elif (
119+ hasattr (fv , "feature_transformation" )
120+ and fv .feature_transformation is not None
121+ and (
122+ getattr (fv .feature_transformation , "mode" , None )
123+ in ("python" , "pandas" )
124+ or getattr (
125+ getattr (fv .feature_transformation , "mode" , None ), "value" , None
126+ )
127+ in ("python" , "pandas" )
128+ )
129+ ):
130+ udf = getattr (fv .feature_transformation , "udf" , None ) or getattr (
131+ fv , "udf" , None
132+ )
133+ if udf is not None :
134+ temp_view_name = f"__feast_bfv_{ ctx .name } _{ uuid .uuid4 ().hex [:8 ]} "
135+ spark_session .conf .set ("spark.sql.runSQLOnFiles" , "true" )
136+ raw_df = spark_session .sql (
137+ f"SELECT * FROM { ctx .table_subquery } "
138+ )
139+ transformed_df = udf (raw_df )
140+ transformed_df .createOrReplaceTempView (temp_view_name )
141+ ctx = replace (ctx , table_subquery = temp_view_name )
142+
143+ new_contexts .append (ctx )
144+
145+ return new_contexts
146+
147+
82148class SparkOfflineStore (OfflineStore ):
83149 @staticmethod
84150 def pull_latest_from_table_or_query (
@@ -261,8 +327,10 @@ def get_historical_features(
261327 entity_df_event_timestamp_range ,
262328 )
263329
264- query_context = _apply_bfv_transformations (
265- spark_session , feature_views , query_context
330+ query_context = _apply_bfv_transformations_for_historical (
331+ spark_session = spark_session ,
332+ feature_views = feature_views ,
333+ query_context = query_context ,
266334 )
267335
268336 spark_query_context = [
0 commit comments