Repository navigation
fix: sync saved groups to the evaluation context on payload refresh - #140
vazarkevych wants to merge 1 commit into
Conversation
Assigning `self._saved_groups` only rebinds the instance attribute; the evaluation context keeps referencing the previous dict. The global context was therefore refreshed as a side effect of `set_features()`, which meant: - On the streaming (SSE) and stale-while-revalidate paths, evaluation does no inline refresh, so a payload's saved groups never reached `$inGroup` / `$notInGroup` at all until some later call happened to invoke set_features(). - A payload carrying `savedGroups` without `features` never took effect, because set_features() is skipped entirely. Plain polling masked the bug: every eval calls _ensure_fresh_features(), which re-runs load_features() -> set_features() and flushes the pending value. Adds set_saved_groups() (mirroring set_features) which writes straight through to the global context, and routes all four payload paths through it: HTTP load, async load, the repository update callback and the SSE handler. Both regression tests fail on the previous behavior.
|
| stale-while-revalidate paths, where evaluation does no inline | ||
| refresh.""" | ||
| self._saved_groups = saved_groups if saved_groups is not None else {} | ||
| self._global_ctx.saved_groups = self._saved_groups |
There was a problem hiding this comment.
During a payload refresh, this assignment updates the currently published global context before set_features() publishes the remaining payload state. Since evaluations read that context without locking and retain it by reference, a concurrent evaluation can combine new saved groups with features from the previous payload generation and return an incorrect feature value. Publish a replacement global context atomically under _payload_lock instead of mutating the active snapshot.
Prompt To Fix With AI
This is a comment left during a code review.
Path: growthbook/growthbook.py
Line: 1269
Comment:
**Mixed Payload Snapshots**
During a payload refresh, this assignment updates the currently published global context before `set_features()` publishes the remaining payload state. Since evaluations read that context without locking and retain it by reference, a concurrent evaluation can combine new saved groups with features from the previous payload generation and return an incorrect feature value. Publish a replacement global context atomically under `_payload_lock` instead of mutating the active snapshot.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.|
Correct, and it goes further than the race: this PR no longer fixes anything. Main has since solved the original problem in Verified both scenarios from this PR's regression tests against current main So the setter here would only add a second path doing the same job, and as you Closing rather than reworking: taking the suggestion would mean reimplementing |
Saved groups from a refreshed feature payload could silently never reach
$inGroup/$notInGroupevaluation.The bug
Assigning
self._saved_groupsonly rebinds the instance attribute — theevaluation context keeps referencing the previous dict. The global context was
therefore only refreshed as a side effect of
set_features(), which assigns_global_ctx.saved_groupson its way through.That side effect does not always happen:
_ensure_fresh_features()returns early in both modes, so evaluation does no inline refresh. A
payload's saved groups never took effect until some later call happened to
invoke
set_features().savedGroupsbut nofeatures.set_features()isskipped entirely, so the update was lost outright.
Plain polling masked it: every eval calls
_ensure_fresh_features(), whichre-runs
load_features()→set_features()and flushes the pending value.That is why this survived unnoticed.
Reproduced before the fix — after an SSE
featuresevent carrying{"vips": ["u1"]}, the instance attribute held the new groups while_global_ctx.saved_groupswas still{}, and a$inGroup-gated featureevaluated to
Falseinstead ofTrue.The fix
Adds
set_saved_groups(), mirroringset_features(), which writes straightthrough to the global context, and routes all four payload paths through it:
the HTTP load, the async load, the repository update callback and the SSE
handler. Adds
get_saved_groups()for symmetry.set_features()keeps its own_global_ctx.saved_groupsassignment — now ano-op in practice, but it still covers direct mutation of
_saved_groups.Testing
Two regression tests, both of which fail on the previous behavior:
savedGroupswithoutfeaturestakes effect787 tests pass,
mypyclean, no new flake8 findings.Note for reviewers: a caller-side
set_saved_groups()is overwritten by thenext payload refresh, exactly as a caller-side
set_features()would be. Thatis existing, intended behavior — not changed here.