Skip to content

Commit 0b92945

Browse files
authored
fix: FIT-671: Ensure FSM feature flagging and handling of model property capture is lazy and JIT (#8787)
1 parent 1666752 commit 0b92945

File tree

3 files changed

+96
-109
lines changed

3 files changed

+96
-109
lines changed

label_studio/core/current_request.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ def set_user(cls, user):
4949
if getattr(user, 'active_organization_id', None):
5050
cls.set_organization_id(user.active_organization_id)
5151

52+
# PERFORMANCE: Cache FSM enabled state at request level when user is set
53+
# This allows all downstream code to check a simple boolean property
54+
# instead of repeatedly calling feature flag checks and possibly having to resolve the user, org and other related objects
55+
cls._cache_fsm_enabled_state(user)
56+
5257
@classmethod
5358
def set_fsm_disabled(cls, disabled: bool):
5459
"""
@@ -72,6 +77,50 @@ def is_fsm_disabled(cls) -> bool:
7277
"""
7378
return cls.get('fsm_disabled', False)
7479

80+
@classmethod
81+
def _cache_fsm_enabled_state(cls, user):
82+
"""
83+
Cache the FSM enabled state for this request/thread.
84+
85+
PERFORMANCE: This is called once when the user is first set (typically in middleware).
86+
It checks the feature flag once and caches the result, so all downstream code
87+
can check a simple boolean property instead of repeatedly calling feature flag checks.
88+
89+
This eliminates thousands of feature flag lookups per request.
90+
91+
Args:
92+
user: The user to check FSM feature flag for
93+
"""
94+
try:
95+
from core.feature_flags import flag_set
96+
97+
# Only import when needed to avoid circular imports
98+
99+
# Check feature flag once and cache the result
100+
fsm_enabled = flag_set('fflag_feat_fit_568_finite_state_management', user=user) if user else False
101+
cls.set('fsm_enabled_cached', fsm_enabled)
102+
except Exception:
103+
# If feature flag check fails, assume disabled to be safe
104+
cls.set('fsm_enabled_cached', False)
105+
106+
@classmethod
107+
def is_fsm_enabled(cls) -> bool:
108+
"""
109+
Check if FSM is enabled for the current request/thread.
110+
111+
PERFORMANCE: Returns cached value that was set when user was first set.
112+
This avoids repeated feature flag lookups throughout the request.
113+
114+
Returns:
115+
True if FSM is enabled, False otherwise (includes manual disable via set_fsm_disabled)
116+
"""
117+
# Check manual override first (for tests and bulk operations)
118+
if cls.is_fsm_disabled():
119+
return False
120+
121+
# Return cached feature flag state (set once per request in _cache_fsm_enabled_state)
122+
return cls.get('fsm_enabled_cached', False)
123+
75124
@classmethod
76125
def get_job_data(cls) -> dict:
77126
"""

label_studio/fsm/models.py

Lines changed: 37 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -66,58 +66,27 @@ def __init__(self, *args, **kwargs):
6666
@classmethod
6767
def from_db(cls, db, field_names, values):
6868
"""
69-
Override from_db to capture original values when loading from database.
69+
Override from_db to store raw DB values for lazy capture.
7070
7171
Django calls this method instead of __init__ when loading models from the database.
72-
We need to capture the original field values here for change detection.
72+
73+
PERFORMANCE: We store the raw field values here without processing them.
74+
This avoids accessing any ForeignKey fields (which would trigger queries).
75+
We only process these values into _original_values in save() when we actually
76+
need them for change detection.
7377
"""
7478
instance = super().from_db(db, field_names, values)
7579
# Initialize as empty dict for safe access
76-
instance._original_values = {}
77-
# Capture original values immediately after loading from DB
78-
# This ensures we have the baseline for change detection on the first save
79-
instance._capture_original_values()
80-
return instance
81-
82-
def _capture_original_values(self):
83-
"""
84-
Capture current field values for change detection.
85-
86-
This allows us to detect which fields changed during save operations,
87-
which is crucial for determining appropriate FSM transitions.
88-
89-
For ForeignKey fields, we store the PK instead of the object to avoid
90-
circular references and recursion issues.
91-
92-
Deferred fields (not yet loaded from DB) are skipped to prevent infinite
93-
recursion when accessing them would trigger refresh_from_db().
94-
95-
This is called after each save to refresh the baseline for the next save.
96-
"""
97-
self._original_values = {}
98-
99-
# Get deferred fields to avoid triggering recursive database loads
100-
# Deferred fields haven't been loaded yet, so they can't have changed
101-
deferred_fields = self.get_deferred_fields()
102-
103-
for field in self._meta.fields:
104-
# Skip deferred fields to prevent recursion via refresh_from_db()
105-
if field.attname in deferred_fields:
106-
continue
80+
instance._original_values = dict(zip(field_names, values))
10781

108-
value = getattr(self, field.name, None)
109-
# For ForeignKey fields, store PK to avoid circular references
110-
if field.is_relation and field.many_to_one and value is not None:
111-
self._original_values[field.name] = value.pk if hasattr(value, 'pk') else value
112-
else:
113-
self._original_values[field.name] = value
82+
return instance
11483

11584
def __reduce_ex__(self, protocol):
11685
"""
11786
Override serialization to exclude internal FSM tracking fields.
11887
11988
Django's serialization uses pickle which calls __reduce_ex__.
120-
We exclude _original_values since it's only needed for runtime
89+
We exclude FSM tracking fields since they're only needed for runtime
12190
change detection, not for serialization/restoration.
12291
"""
12392
# Get the default reduction
@@ -160,19 +129,16 @@ def _get_changed_fields(self) -> Dict[str, tuple]:
160129
# Only check fields that were captured in _original_values
161130
# Fields that were deferred during capture won't be in _original_values
162131
# and should be considered unchanged
163-
if field.name not in self._original_values:
132+
if field.attname not in self._original_values:
133+
continue
134+
if field.is_relation and field.many_to_many:
164135
continue
165136

166-
old_value = self._original_values[field.name]
167-
new_value = getattr(self, field.name, None)
137+
old_value = self._original_values[field.attname]
138+
new_value = getattr(self, field.attname, None)
168139

169-
# For ForeignKey fields, old_value is stored as PK, so compare PK to PK
170-
if field.is_relation and field.many_to_one:
171-
new_pk = new_value.pk if new_value and hasattr(new_value, 'pk') else new_value
172-
if old_value != new_pk:
173-
changed[field.name] = (old_value, new_value)
174-
elif old_value != new_value:
175-
changed[field.name] = (old_value, new_value)
140+
if old_value != new_value:
141+
changed[field.attname] = (old_value, new_value)
176142
return changed
177143

178144
def _determine_fsm_transitions(self, is_creating: bool = None, changed_fields: dict = None) -> list:
@@ -353,56 +319,26 @@ def _should_execute_fsm(self) -> bool:
353319
Check if FSM processing should be executed.
354320
355321
Returns False if:
356-
- Feature flag is disabled
357-
- User context is unavailable (tests must set CurrentContext explicitly)
322+
- Feature flag is disabled (cached at request level)
323+
- Manually disabled via set_fsm_disabled() (for tests/bulk operations)
358324
- Explicitly skipped via instance attribute
359325
360326
Returns:
361327
True if FSM should execute, False otherwise
362328
363-
Note:
364-
CurrentContext is available in web requests and background jobs.
365-
In tests, it must be set explicitly for the user/organization.
329+
PERFORMANCE: Uses cached FSM enabled state from CurrentContext that was set
330+
once per request when user was initialized. This is a simple boolean check
331+
instead of repeated feature flag lookups and user authentication checks.
366332
"""
367333
# Check for instance-level skip flag
368334
if getattr(self, '_skip_fsm', False):
369335
return False
370336

371-
# Use the centralized FSM enabled check from utils
372-
# This handles feature flag and thread-local overrides
373-
try:
374-
from core.current_request import CurrentContext
375-
from fsm.utils import is_fsm_enabled
337+
# Fast path: Check cached FSM enabled state
338+
# This was set once per request in CurrentContext.set_user()
339+
from core.current_request import CurrentContext
376340

377-
# Get user from CurrentContext - don't fall back to AnonymousUser
378-
# If no user in context (e.g., tests without explicit setup), return False
379-
try:
380-
user = CurrentContext.get_user()
381-
user_type = type(user).__name__ if user else None
382-
user_authenticated = getattr(user, 'is_authenticated', None) if user else None
383-
logger.info(
384-
f'FSM check for {self.__class__.__name__}(id={getattr(self, "pk", None)}): '
385-
f'user_type={user_type}, authenticated={user_authenticated}'
386-
)
387-
if user is None:
388-
logger.info(f'FSM check: User is None, skipping FSM for {self.__class__.__name__}')
389-
return False
390-
# Check if user is authenticated (not AnonymousUser)
391-
if not user.is_authenticated:
392-
logger.info(
393-
f'FSM check: User {user_type} not authenticated, skipping FSM for {self.__class__.__name__}'
394-
)
395-
return False
396-
except Exception:
397-
# CurrentContext not available or no user set
398-
# This is expected in tests that don't set up context
399-
logger.info(f'FSM check: Exception getting user, skipping FSM for {self.__class__.__name__}')
400-
return False
401-
402-
return is_fsm_enabled(user=user)
403-
except Exception as e:
404-
logger.debug(f'FSM check failed: {e}')
405-
return False
341+
return CurrentContext.is_fsm_enabled()
406342

407343
def save(self, *args, **kwargs):
408344
"""
@@ -423,25 +359,29 @@ def save(self, *args, **kwargs):
423359
Returns:
424360
Whatever super().save() returns
425361
"""
426-
# Check for explicit FSM skip flag
427-
skip_fsm = kwargs.pop('skip_fsm', False)
428-
429-
# Also check CurrentContext for skip_fsm flag (for context manager usage)
430-
if not skip_fsm:
431-
from core.current_request import CurrentContext
362+
from core.current_request import CurrentContext
432363

433-
skip_fsm = CurrentContext.get('skip_fsm', False)
364+
# Check for explicit FSM skip flag
365+
skip_fsm = kwargs.pop('skip_fsm', CurrentContext.is_fsm_disabled())
434366

435367
# Check if this is a creation vs update
436368
is_creating = self._state.adding
437369

438370
# Capture changed fields before save (only for updates)
439-
# Note: _original_values should already be populated by from_db() or previous save()
440371
changed_fields = {} if is_creating else self._get_changed_fields()
441372

442373
# Perform the actual save
443374
result = super().save(*args, **kwargs)
444375

376+
# After successful save, update _original_values to current values
377+
# This ensures subsequent saves can detect changes correctly
378+
# Store attname values (raw PK for ForeignKey fields) to match from_db() format
379+
self._original_values = {}
380+
for field in self._meta.fields:
381+
if field.is_relation and field.many_to_many:
382+
continue
383+
self._original_values[field.attname] = getattr(self, field.attname, None)
384+
445385
# After successful save, trigger FSM transitions if enabled and not skipped
446386
should_execute = not skip_fsm and self._should_execute_fsm()
447387

@@ -500,9 +440,6 @@ def save(self, *args, **kwargs):
500440
exc_info=True,
501441
)
502442

503-
# Update original values after save for next time
504-
self._capture_original_values()
505-
506443
return result
507444

508445
def _execute_fsm_transition(self, transition_name: str, is_creating: bool, changed_fields: Dict[str, tuple]):

label_studio/fsm/utils.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
import uuid_utils
1818
from core.current_request import CurrentContext
19-
from core.feature_flags import flag_set
2019

2120
logger = logging.getLogger(__name__)
2221

@@ -257,22 +256,24 @@ def is_fsm_enabled(user=None) -> bool:
257256
"""
258257
Check if FSM is enabled via feature flags and thread-local override.
259258
259+
PERFORMANCE: This function now checks the cached FSM state that was set
260+
when the user was first initialized in CurrentContext. This avoids repeated
261+
feature flag lookups throughout the request.
262+
260263
The check order is:
261264
1. Check thread-local override (for test cleanup, bulk operations)
262-
2. Check feature flag
265+
2. Check cached feature flag state (set once per request)
266+
3. Fallback to direct feature flag check (for edge cases without context)
263267
264268
Args:
265-
user: User for feature flag evaluation (optional)
269+
user: User for feature flag evaluation (optional, used as fallback only)
266270
267271
Returns:
268272
True if FSM should be active
269273
"""
270-
# Check thread-local override first
271-
if CurrentContext.is_fsm_disabled():
272-
return False
273-
274-
# Then check feature flag
275-
return flag_set('fflag_feat_fit_568_finite_state_management', user=user)
274+
# Fast path: Check cached state from CurrentContext
275+
# This is set once per request when user is initialized
276+
return CurrentContext.is_fsm_enabled()
276277

277278

278279
def get_current_state_safe(entity, user=None) -> Optional[str]:

0 commit comments

Comments
 (0)