Skip to content

Commit 6d5ce47

Browse files
refactor: separate transformation logic from execution decisions with auto-inference
Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent 5c8b93c commit 6d5ce47

6 files changed

Lines changed: 128 additions & 340 deletions

File tree

sdk/python/feast/feature_store.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -966,14 +966,26 @@ def apply(
966966
services_to_update,
967967
)
968968

969-
# Handle dual registration for online_enabled FeatureViews
970-
online_enabled_views = [
969+
# Handle dual registration for FeatureViews with online transform execution
970+
dual_registration_views = [
971971
view
972972
for view in views_to_update
973-
if hasattr(view, "online_enabled") and view.online_enabled
973+
if (
974+
hasattr(view, "transform_when")
975+
and view.transform_when
976+
and (
977+
view.transform_when in ["batch_on_read", "batch_on_write"]
978+
or (
979+
hasattr(view.transform_when, "value")
980+
and view.transform_when.value in ["batch_on_read", "batch_on_write"]
981+
)
982+
)
983+
and hasattr(view, "online")
984+
and view.online
985+
)
974986
]
975987

976-
for fv in online_enabled_views:
988+
for fv in dual_registration_views:
977989
# Create OnDemandFeatureView for online serving with same transformation
978990
if hasattr(fv, "feature_transformation") and fv.feature_transformation:
979991
# Create ODFV with same transformation logic

sdk/python/feast/feature_view.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
FeatureTransformationV2 as FeatureTransformationProto,
4141
)
4242
from feast.transformation.base import Transformation
43-
from feast.transformation.mode import TransformationMode, TransformationTiming
43+
from feast.transformation.mode import TransformationMode, TransformExecutionPattern
4444
from feast.types import from_value_type
4545
from feast.value_type import ValueType
4646

@@ -109,8 +109,7 @@ class FeatureView(BaseFeatureView):
109109
materialization_intervals: List[Tuple[datetime, datetime]]
110110
mode: Optional[Union["TransformationMode", str]]
111111
feature_transformation: Optional[Transformation]
112-
when: Optional[Union[TransformationTiming, str]]
113-
online_enabled: bool
112+
transform_when: Optional[Union["TransformExecutionPattern", str]]
114113

115114
def __init__(
116115
self,
@@ -128,8 +127,7 @@ def __init__(
128127
owner: str = "",
129128
mode: Optional[Union["TransformationMode", str]] = None,
130129
feature_transformation: Optional[Transformation] = None,
131-
when: Optional[Union[TransformationTiming, str]] = None,
132-
online_enabled: bool = False,
130+
transform_when: Optional[Union["TransformExecutionPattern", str]] = None,
133131
):
134132
"""
135133
Creates a FeatureView object.
@@ -157,10 +155,8 @@ def __init__(
157155
when transformations are applied. Choose from TransformationMode enum values.
158156
feature_transformation (optional): The transformation object containing the UDF and
159157
mode for this feature view. Used for derived feature views.
160-
when (optional): The timing for when transformation should execute. Choose from
161-
TransformationTiming enum values (on_read, on_write, batch, streaming).
162-
online_enabled (optional): Whether to enable dual registration for both batch
163-
materialization and online serving with Feature Server.
158+
transform_when (optional): The timing for when transformation should execute. Choose from
159+
TransformExecutionPattern enum values (batch_only, batch_on_read, batch_on_write).
164160
165161
Raises:
166162
ValueError: A field mapping conflicts with an Entity or a Feature.
@@ -176,8 +172,27 @@ def __init__(
176172
or self.feature_transformation is None
177173
):
178174
self.feature_transformation = feature_transformation
179-
self.when = when
180-
self.online_enabled = online_enabled
175+
self.transform_when = transform_when
176+
177+
# Auto-infer online setting based on transform_when pattern
178+
if transform_when in [TransformExecutionPattern.BATCH_ON_READ, TransformExecutionPattern.BATCH_ON_WRITE]:
179+
if online is False:
180+
raise ValueError(
181+
f"Cannot set online=False with transform_when='{transform_when}'. "
182+
f"Online execution patterns require online=True."
183+
)
184+
self.online = True # Auto-infer online=True
185+
elif transform_when == "batch_on_read" or transform_when == "batch_on_write":
186+
# Handle string values as well
187+
if online is False:
188+
raise ValueError(
189+
f"Cannot set online=False with transform_when='{transform_when}'. "
190+
f"Online execution patterns require online=True."
191+
)
192+
self.online = True # Auto-infer online=True
193+
else:
194+
# For batch_only or None, respect the provided online setting
195+
self.online = online
181196

182197
# Normalize source
183198
self.stream_source = None
@@ -280,7 +295,7 @@ def __init__(
280295
owner=owner,
281296
source=self.batch_source,
282297
)
283-
self.online = online
298+
# Note: self.online is now set by auto-inference logic above
284299
self.offline = offline
285300
self.mode = mode
286301
self.materialization_intervals = []

sdk/python/feast/transformation/base.py

Lines changed: 4 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
TRANSFORMATION_CLASS_FOR_TYPE,
2323
get_transformation_class_from_type,
2424
)
25-
from feast.transformation.mode import TransformationMode, TransformationTiming
25+
from feast.transformation.mode import TransformationMode, TransformExecutionPattern
2626

2727
# Online compatibility constants
2828
ONLINE_COMPATIBLE_MODES = {"python", "pandas"}
@@ -143,13 +143,6 @@ def infer_features(self, *args, **kwargs) -> Any:
143143

144144
def transformation(
145145
mode: Union[TransformationMode, str], # Support both enum and string
146-
when: Optional[str] = None,
147-
online: Optional[bool] = None,
148-
sources: Optional[
149-
List[Union["FeatureView", "FeatureViewProjection", "RequestSource"]]
150-
] = None,
151-
schema: Optional[List[Field]] = None,
152-
entities: Optional[List[Entity]] = None,
153146
name: Optional[str] = None,
154147
tags: Optional[Dict[str, str]] = None,
155148
description: Optional[str] = "",
@@ -173,24 +166,6 @@ def decorator(user_function):
173166
valid_modes = [m.value for m in TransformationMode]
174167
raise ValueError(f"Invalid mode '{mode}'. Valid options: {valid_modes}")
175168

176-
# Validate timing if provided
177-
if when is not None:
178-
try:
179-
TransformationTiming(when.lower()) # Validate timing string
180-
except ValueError:
181-
valid_timings = [t.value for t in TransformationTiming]
182-
raise ValueError(
183-
f"Invalid timing '{when}'. Valid options: {valid_timings}"
184-
)
185-
186-
# Validate online compatibility
187-
if online and not is_online_compatible(mode_str):
188-
compatible_modes = list(ONLINE_COMPATIBLE_MODES)
189-
raise ValueError(
190-
f"Mode '{mode_str}' cannot run online in Feature Server. "
191-
f"Use {compatible_modes} for online transformations."
192-
)
193-
194169
# Create transformation object
195170
udf_string = dill.source.getsource(user_function)
196171
mainify(user_function)
@@ -204,67 +179,8 @@ def decorator(user_function):
204179
udf_string=udf_string,
205180
)
206181

207-
# If FeatureView parameters are provided, create and return FeatureView
208-
if any(
209-
param is not None for param in [when, online, sources, schema, entities]
210-
):
211-
# Import FeatureView here to avoid circular imports
212-
from feast.feature_view import FeatureView
213-
214-
# Validate required parameters when creating FeatureView
215-
if when is None:
216-
raise ValueError(
217-
"'when' parameter is required when creating FeatureView"
218-
)
219-
if online is None:
220-
raise ValueError(
221-
"'online' parameter is required when creating FeatureView"
222-
)
223-
if sources is None:
224-
raise ValueError(
225-
"'sources' parameter is required when creating FeatureView"
226-
)
227-
if schema is None:
228-
raise ValueError(
229-
"'schema' parameter is required when creating FeatureView"
230-
)
231-
232-
# Handle source parameter correctly for FeatureView constructor
233-
if not sources:
234-
raise ValueError("At least one source must be provided for FeatureView")
235-
elif len(sources) == 1:
236-
# Single source - pass directly (works for DataSource or FeatureView)
237-
source_param = sources[0]
238-
else:
239-
# Multiple sources - pass as list (must be List[FeatureView])
240-
from feast.feature_view import FeatureView as FV
241-
242-
for src in sources:
243-
if not isinstance(src, (FV, type(src).__name__ == "FeatureView")):
244-
raise ValueError(
245-
"Multiple sources must be FeatureViews, not DataSources"
246-
)
247-
source_param = sources
248-
249-
# Create FeatureView with transformation
250-
fv = FeatureView(
251-
name=name or user_function.__name__,
252-
source=source_param,
253-
entities=entities or [],
254-
schema=schema,
255-
feature_transformation=transformation_obj,
256-
when=when,
257-
online_enabled=online,
258-
description=description,
259-
tags=tags,
260-
owner=owner,
261-
mode=mode_str,
262-
)
263-
functools.update_wrapper(wrapper=fv, wrapped=user_function)
264-
return fv
265-
else:
266-
# Backward compatibility: return Transformation object
267-
functools.update_wrapper(wrapper=transformation_obj, wrapped=user_function)
268-
return transformation_obj
182+
# Return Transformation object with function metadata preserved
183+
functools.update_wrapper(wrapper=transformation_obj, wrapped=user_function)
184+
return transformation_obj
269185

270186
return decorator

sdk/python/feast/transformation/mode.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ class TransformationMode(Enum):
1111
SUBSTRAIT = "substrait"
1212

1313

14-
class TransformationTiming(Enum):
15-
ON_READ = "on_read" # Execute during get_online_features()
16-
ON_WRITE = "on_write" # Execute during materialization, cache results
17-
BATCH = "batch" # Scheduled batch processing
18-
STREAMING = "streaming" # Real-time stream processing
14+
class TransformExecutionPattern(Enum):
15+
BATCH_ONLY = "batch_only" # Pure batch: only in batch compute engine
16+
BATCH_ON_READ = "batch_on_read" # Batch + feature server on read (lazy)
17+
BATCH_ON_WRITE = "batch_on_write" # Batch + feature server on ingestion (eager)

sdk/python/tests/unit/test_dual_registration.py

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Unit tests for dual registration functionality in FeatureStore.
33
4-
Tests that online_enabled=True FeatureViews get automatically registered
4+
Tests that online=True FeatureViews get automatically registered
55
as both batch FeatureViews and OnDemandFeatureViews for serving.
66
"""
77

@@ -20,9 +20,9 @@
2020
class TestDualRegistration:
2121
"""Test dual registration functionality"""
2222

23-
def test_online_enabled_creates_odfv(self):
24-
"""Test that online_enabled=True creates an OnDemandFeatureView"""
25-
# Create a FeatureView with online_enabled=True
23+
def test_online_creates_odfv(self):
24+
"""Test that online=True creates an OnDemandFeatureView"""
25+
# Create a FeatureView with online=True
2626
driver = Entity(name="driver", join_keys=["driver_id"])
2727
mock_source = FileSource(path="test.parquet", timestamp_field="ts")
2828

@@ -37,8 +37,8 @@ def test_online_enabled_creates_odfv(self):
3737
entities=[driver],
3838
schema=[Field(name="feature1", dtype=Float64)],
3939
feature_transformation=test_transformation,
40-
when="on_write",
41-
online_enabled=True,
40+
transform_when="batch_on_write",
41+
# online=True auto-inferred from transform_when
4242
)
4343

4444
# Mock registry and provider
@@ -96,7 +96,7 @@ def capture_feature_view(view, project, commit):
9696
# Verify original FV
9797
assert original_fv is not None
9898
assert original_fv.name == "test_fv"
99-
assert original_fv.online_enabled
99+
assert original_fv.online
100100
assert original_fv.feature_transformation is not None
101101

102102
# Verify generated ODFV
@@ -109,7 +109,7 @@ def capture_feature_view(view, project, commit):
109109
assert generated_odfv.tags["dual_registration"] == "true"
110110

111111
def test_no_dual_registration_when_online_disabled(self):
112-
"""Test that online_enabled=False does not create ODFV"""
112+
"""Test that online=False does not create ODFV"""
113113
driver = Entity(name="driver", join_keys=["driver_id"])
114114
mock_source = FileSource(path="test.parquet", timestamp_field="ts")
115115

@@ -118,7 +118,7 @@ def test_no_dual_registration_when_online_disabled(self):
118118
source=mock_source,
119119
entities=[driver],
120120
schema=[Field(name="feature1", dtype=Float64)],
121-
online_enabled=False, # Disabled
121+
online=False, # Disabled
122122
)
123123

124124
# Mock FeatureStore
@@ -163,7 +163,7 @@ def test_no_dual_registration_without_transformation(self):
163163
source=mock_source,
164164
entities=[driver],
165165
schema=[Field(name="feature1", dtype=Float64)],
166-
online_enabled=True, # Enabled
166+
online=True, # Enabled
167167
# No feature_transformation
168168
)
169169

@@ -199,31 +199,35 @@ def test_no_dual_registration_without_transformation(self):
199199
assert isinstance(applied_views[0], FeatureView)
200200
assert not isinstance(applied_views[0], OnDemandFeatureView)
201201

202-
def test_enhanced_decorator_with_dual_registration(self):
203-
"""Test end-to-end: enhanced @transformation decorator -> dual registration"""
202+
def test_separate_transformation_and_feature_view_with_dual_registration(self):
203+
"""Test: create separate transformation and FeatureView -> dual registration"""
204204
driver = Entity(name="driver", join_keys=["driver_id"])
205205

206-
# Create FeatureView using enhanced decorator with dummy source
206+
# Create transformation separately
207+
@transformation(mode="python", name="doubling_transform")
208+
def doubling_transform_func(inputs):
209+
return [{"doubled": inp.get("value", 0) * 2} for inp in inputs]
210+
211+
# Create FeatureView with transformation and dual registration settings
207212
dummy_source = FileSource(
208213
path="test.parquet", timestamp_field="event_timestamp"
209214
)
210215

211-
@transformation(
212-
mode="python",
213-
when="on_write",
214-
online=True,
215-
sources=[dummy_source],
216-
schema=[Field(name="doubled", dtype=Float64)],
217-
entities=[driver],
216+
fv = FeatureView(
218217
name="doubling_transform",
218+
source=dummy_source,
219+
entities=[driver],
220+
schema=[Field(name="doubled", dtype=Float64)],
221+
feature_transformation=doubling_transform_func,
222+
transform_when="batch_on_write",
223+
# online=True auto-inferred from transform_when
219224
)
220-
def doubling_transform(inputs):
221-
return [{"doubled": inp.get("value", 0) * 2} for inp in inputs]
222225

223226
# Verify it's a FeatureView with the right properties
224-
assert isinstance(doubling_transform, FeatureView)
225-
assert doubling_transform.online_enabled
226-
assert doubling_transform.feature_transformation is not None
227+
assert isinstance(fv, FeatureView)
228+
assert fv.online # Auto-inferred
229+
assert fv.transform_when == "batch_on_write"
230+
assert fv.feature_transformation is not None
227231

228232
# Mock FeatureStore and apply
229233
# Create FeatureStore instance with mocked initialization
@@ -250,7 +254,7 @@ def doubling_transform(inputs):
250254
fs._provider.teardown_infra = Mock()
251255

252256
# Apply the FeatureView
253-
fs.apply(doubling_transform)
257+
fs.apply(fv)
254258

255259
# Should create both original FV and ODFV
256260
assert len(applied_views) == 2
@@ -266,7 +270,7 @@ def doubling_transform(inputs):
266270
test_input = [{"value": 5}]
267271
expected_output = [{"doubled": 10}]
268272

269-
original_udf = doubling_transform.feature_transformation.udf
273+
original_udf = fv.feature_transformation.udf
270274
odfv_udf = odfv.feature_transformation.udf
271275

272276
assert original_udf(test_input) == expected_output

0 commit comments

Comments
 (0)