@@ -134,6 +134,7 @@ class OnDemandFeatureView(BaseFeatureView): |
134 | 134 | """ |
135 | 135 |
|
136 | 136 | _TRACK_METRICS_TAG = "feast:track_metrics" |
| 137 | + _INPUT_SCHEMA_SOURCE_PREFIX = "__input_schema__" |
137 | 138 |
|
138 | 139 | name: str |
139 | 140 | entities: Optional[List[str]] |
@@ -158,7 +159,8 @@ def __init__( # noqa: C901 |
158 | 159 | name: str, |
159 | 160 | entities: Optional[List[Entity]] = None, |
160 | 161 | schema: Optional[List[Field]] = None, |
161 | | - sources: List[OnDemandSourceType], |
| 162 | + sources: Optional[List[OnDemandSourceType]] = None, |
| 163 | + input_schema: Optional[List[Field]] = None, |
162 | 164 | udf: Optional[FunctionType] = None, |
163 | 165 | udf_string: Optional[str] = "", |
164 | 166 | feature_transformation: Optional[Transformation] = None, |
@@ -183,6 +185,11 @@ def __init__( # noqa: C901 |
183 | 185 | sources: A map from input source names to the actual input sources, which may be |
184 | 186 | feature views, or request data sources. These sources serve as inputs to the udf, |
185 | 187 | which will refer to them by name. |
| 188 | + input_schema (optional): A list of Fields describing data that is accepted as input |
| 189 | + but not stored directly as features — e.g. aggregation columns, normalization |
| 190 | + parameters, thresholds, or other contextual values passed at request time. |
| 191 | + When provided, sources is not required — an internal RequestSource will be |
| 192 | + created automatically. |
186 | 193 | udf: The user defined transformation function, which must take pandas |
187 | 194 | dataframes as inputs. |
188 | 195 | udf_string: The source code version of the udf (for diffing and displaying in Web UI) |
@@ -214,15 +221,44 @@ def __init__( # noqa: C901 |
214 | 221 | self.version = version |
215 | 222 | schema = schema or [] |
216 | 223 | self.entities = [e.name for e in entities] if entities else [DUMMY_ENTITY_NAME] |
217 | | - self.sources = sources |
| 224 | + self.input_schema = input_schema |
218 | 225 | self.mode = mode.lower() |
219 | 226 | self.udf = udf |
220 | 227 | self.udf_string = udf_string |
221 | 228 | self.source_feature_view_projections: dict[str, FeatureViewProjection] = {} |
222 | 229 | self.source_request_sources: dict[str, RequestSource] = {} |
| 230 | + self._input_schema_sentinel: Optional[RequestSource] = None |
| 231 | + |
| 232 | + # Strip any existing sentinel from sources (handles __copy__ round-trip) |
| 233 | + effective_sources: List[OnDemandSourceType] = [ |
| 234 | + s |
| 235 | + for s in (sources or []) |
| 236 | + if not ( |
| 237 | + isinstance(s, RequestSource) |
| 238 | + and s.name.startswith(self._INPUT_SCHEMA_SOURCE_PREFIX) |
| 239 | + ) |
| 240 | + ] |
| 241 | + |
| 242 | + if input_schema is not None: |
| 243 | + # Automatically create an internal RequestSource from input_schema. |
| 244 | + # Stored privately so it does not appear in source_request_sources for |
| 245 | + # external consumers (e.g. the feature server, apply(), utils.py). |
| 246 | + self._input_schema_sentinel = RequestSource( |
| 247 | + name=f"{self._INPUT_SCHEMA_SOURCE_PREFIX}{name}", |
| 248 | + schema=input_schema, |
| 249 | + ) |
| 250 | + self.source_request_sources[self._input_schema_sentinel.name] = ( |
| 251 | + self._input_schema_sentinel |
| 252 | + ) |
| 253 | + elif not effective_sources: |
| 254 | + raise ValueError( |
| 255 | + "Either 'sources' or 'input_schema' must be provided for OnDemandFeatureView." |
| 256 | + ) |
| 257 | + |
| 258 | + self.sources = effective_sources |
223 | 259 |
|
224 | 260 | # Process each source with explicit type handling |
225 | | - for odfv_source in sources: |
| 261 | + for odfv_source in effective_sources: |
226 | 262 | self._add_source_to_collections(odfv_source) |
227 | 263 |
|
228 | 264 | features: List[Field] = [] |
@@ -274,6 +310,20 @@ def __init__( # noqa: C901 |
274 | 310 | self.track_metrics = track_metrics |
275 | 311 | self.aggregations = aggregations or [] |
276 | 312 |
|
| 313 | + if input_schema is not None and self.aggregations: |
| 314 | + input_field_names = {f.name for f in input_schema} |
| 315 | + unknown = [ |
| 316 | + agg.column |
| 317 | + for agg in self.aggregations |
| 318 | + if agg.column and agg.column not in input_field_names |
| 319 | + ] |
| 320 | + if unknown: |
| 321 | + raise ValueError( |
| 322 | + f"Aggregation column(s) {unknown} not found in input_schema " |
| 323 | + f"for OnDemandFeatureView '{name}'. " |
| 324 | + f"Available fields: {sorted(input_field_names)}" |
| 325 | + ) |
| 326 | + |
277 | 327 | def _add_source_to_collections(self, odfv_source: OnDemandSourceType) -> None: |
278 | 328 | """ |
279 | 329 | Add a source to the appropriate collection with explicit type checking. |
@@ -328,6 +378,7 @@ def __copy__(self): |
328 | 378 | schema=self.features, |
329 | 379 | sources=list(self.source_feature_view_projections.values()) |
330 | 380 | + list(self.source_request_sources.values()), |
| 381 | + input_schema=self.input_schema, |
331 | 382 | feature_transformation=self.feature_transformation, |
332 | 383 | mode=self.mode, |
333 | 384 | description=self.description, |
@@ -337,6 +388,7 @@ def __copy__(self): |
337 | 388 | singleton=self.singleton, |
338 | 389 | version=self.version, |
339 | 390 | track_metrics=self.track_metrics, |
| 391 | + aggregations=self.aggregations, |
340 | 392 | ) |
341 | 393 | fv.entities = self.entities |
342 | 394 | fv.features = self.features |
@@ -536,6 +588,14 @@ def to_proto(self) -> OnDemandFeatureViewProto: |
536 | 588 | request_data_source=request_sources.to_proto() |
537 | 589 | ) |
538 | 590 |
|
| 591 | + # Serialize the input_schema sentinel so that from_proto() can reconstruct |
| 592 | + # input_schema correctly; it is excluded from source_request_sources so that |
| 593 | + # external consumers never see it as a real data source. |
| 594 | + if self._input_schema_sentinel is not None: |
| 595 | + sources[self._input_schema_sentinel.name] = OnDemandSource( |
| 596 | + request_data_source=self._input_schema_sentinel.to_proto() |
| 597 | + ) |
| 598 | + |
539 | 599 | feature_transformation = transformation_to_proto(self.feature_transformation) |
540 | 600 |
|
541 | 601 | tags = dict(self.tags) if self.tags else {} |
@@ -559,7 +619,7 @@ def to_proto(self) -> OnDemandFeatureViewProto: |
559 | 619 | owner=self.owner, |
560 | 620 | write_to_online_store=self.write_to_online_store, |
561 | 621 | singleton=self.singleton or False, |
562 | | - aggregations=self.aggregations, |
| 622 | + aggregations=[agg.to_proto() for agg in self.aggregations], |
563 | 623 | version=self.version, |
564 | 624 | ) |
565 | 625 | return OnDemandFeatureViewProto(spec=spec, meta=meta) |
@@ -585,6 +645,18 @@ def from_proto( |
585 | 645 | on_demand_feature_view_proto, skip_udf=skip_udf |
586 | 646 | ) |
587 | 647 |
|
| 648 | + # Detect and strip input_schema sentinel from sources |
| 649 | + input_schema: Optional[List[Field]] = None |
| 650 | + sources_without_sentinel: List[OnDemandSourceType] = [] |
| 651 | + for source in sources: |
| 652 | + if isinstance(source, RequestSource) and source.name.startswith( |
| 653 | + cls._INPUT_SCHEMA_SOURCE_PREFIX |
| 654 | + ): |
| 655 | + input_schema = source.schema |
| 656 | + else: |
| 657 | + sources_without_sentinel.append(source) |
| 658 | + sources = sources_without_sentinel |
| 659 | + |
588 | 660 | # Parse transformation from proto (skip UDF deserialization if requested) |
589 | 661 | transformation = cls._parse_transformation_from_proto( |
590 | 662 | on_demand_feature_view_proto, skip_udf=skip_udf |
@@ -607,6 +679,7 @@ def from_proto( |
607 | 679 | name=on_demand_feature_view_proto.spec.name, |
608 | 680 | schema=cls._parse_features_from_proto(on_demand_feature_view_proto), |
609 | 681 | sources=cast(List[OnDemandSourceType], sources), |
| 682 | + input_schema=input_schema, |
610 | 683 | feature_transformation=transformation, |
611 | 684 | mode=on_demand_feature_view_proto.spec.mode or "pandas", |
612 | 685 | description=on_demand_feature_view_proto.spec.description, |
@@ -817,6 +890,10 @@ def get_request_data_schema(self) -> dict[str, ValueType]: |
817 | 890 | raise TypeError( |
818 | 891 | f"Request source schema is not correct type: ${str(type(request_source.schema))}" |
819 | 892 | ) |
| 893 | + # Include fields from the input_schema sentinel (stored privately) |
| 894 | + if self._input_schema_sentinel is not None: |
| 895 | + for field in self._input_schema_sentinel.schema: |
| 896 | + schema[field.name] = field.dtype.to_value_type() |
820 | 897 | return schema |
821 | 898 |
|
822 | 899 | def _get_projected_feature_name(self, feature: str) -> str: |
@@ -1092,7 +1169,7 @@ def _is_array_type(self, dtype) -> bool: |
1092 | 1169 | """Check if the dtype represents an array type.""" |
1093 | 1170 | # Use proper type checking instead of string comparison |
1094 | 1171 | dtype_str = str(dtype) |
1095 | | - return "Array" in dtype_str or "List" in dtype_str |
| 1172 | + return "Array" in dtype_str or "List" in dtype_str or "Set" in dtype_str |
1096 | 1173 |
|
1097 | 1174 | def _construct_random_input( |
1098 | 1175 | self, singleton: bool = False |
@@ -1137,6 +1214,13 @@ def _construct_random_input( |
1137 | 1214 | sample_value = sample_values.get(value_type, default_value) |
1138 | 1215 | feature_dict[field.name] = sample_value |
1139 | 1216 |
|
| 1217 | + # Add input_schema fields (stored privately outside source_request_sources) |
| 1218 | + if self._input_schema_sentinel is not None: |
| 1219 | + for field in self._input_schema_sentinel.schema: |
| 1220 | + value_type = field.dtype.to_value_type() |
| 1221 | + sample_value = sample_values.get(value_type, default_value) |
| 1222 | + feature_dict[field.name] = sample_value |
| 1223 | + |
1140 | 1224 | return feature_dict |
1141 | 1225 |
|
1142 | 1226 | def _get_sample_values_by_type(self) -> dict[ValueType, list[Any]]: |
@@ -1224,13 +1308,17 @@ def on_demand_feature_view( |
1224 | 1308 | name: Optional[str] = None, |
1225 | 1309 | entities: Optional[List[Entity]] = None, |
1226 | 1310 | schema: list[Field], |
1227 | | - sources: list[ |
1228 | | - Union[ |
1229 | | - FeatureView, |
1230 | | - RequestSource, |
1231 | | - FeatureViewProjection, |
| 1311 | + sources: Optional[ |
| 1312 | + list[ |
| 1313 | + Union[ |
| 1314 | + FeatureView, |
| 1315 | + RequestSource, |
| 1316 | + FeatureViewProjection, |
| 1317 | + ] |
1232 | 1318 | ] |
1233 | | - ], |
| 1319 | + ] = None, |
| 1320 | + input_schema: Optional[list[Field]] = None, |
| 1321 | + aggregations: Optional[List[Aggregation]] = None, |
1234 | 1322 | mode: str = "pandas", |
1235 | 1323 | description: str = "", |
1236 | 1324 | tags: Optional[dict[str, str]] = None, |
@@ -1252,6 +1340,10 @@ def on_demand_feature_view( |
1252 | 1340 | sources: A map from input source names to the actual input sources, which may be |
1253 | 1341 | feature views, or request data sources. These sources serve as inputs to the udf, |
1254 | 1342 | which will refer to them by name. |
| 1343 | + input_schema (optional): A list of Fields describing data that is accepted as input |
| 1344 | + but not stored directly as features — e.g. aggregation columns, normalization |
| 1345 | + parameters, thresholds, or other contextual values passed at request time. |
| 1346 | + When provided, sources is not required. |
1255 | 1347 | mode: The mode of execution (e.g,. Pandas or Python Native) |
1256 | 1348 | description (optional): A human-readable description. |
1257 | 1349 | tags (optional): A dictionary of key-value pairs to store arbitrary metadata. |
@@ -1279,6 +1371,7 @@ def decorator(user_function): |
1279 | 1371 | on_demand_feature_view_obj = OnDemandFeatureView( |
1280 | 1372 | name=name if name is not None else user_function.__name__, |
1281 | 1373 | sources=sources, |
| 1374 | + input_schema=input_schema, |
1282 | 1375 | schema=schema, |
1283 | 1376 | mode=mode, |
1284 | 1377 | description=description, |
@@ -1288,6 +1381,7 @@ def decorator(user_function): |
1288 | 1381 | entities=entities, |
1289 | 1382 | singleton=singleton, |
1290 | 1383 | track_metrics=track_metrics, |
| 1384 | + aggregations=aggregations, |
1291 | 1385 | udf=user_function, |
1292 | 1386 | udf_string=udf_string, |
1293 | 1387 | version=version, |
|