Skip to content

Commit 4399d08

Browse files
authored
fix(flags): honor versioned local property matching (#922)
* fix(flags): honor versioned local property matching * fix(flags): keep result cache I/O outside snapshot publication * fix(flags): bind Redis results to definition snapshots * fix(flags): isolate unverifiable remote cache provenance * test(flags): cover cache provenance in targeted mutation suite * fix(flags): keep local payloads on the evaluated snapshot * fix(flags): restore cold-worker remote cache fallback * test(flags): cover remote cache provenance in mutation suite * fix(flags): retire invalidated Redis snapshots for legacy readers
1 parent 473fa39 commit 4399d08

11 files changed

Lines changed: 1977 additions & 103 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Honor the definitions snapshot's `property_matching_version` during local feature flag evaluation, including person, group, cohort, and flag dependency conditions. Version 2 uses explicit boolean equality; missing/1 retains legacy truthiness. Preserve the selector through definition caches and invalidate evaluated results on version-only refreshes. Bind Client-managed Redis results to their definitions snapshot so invalidated entries cannot revive after a worker restart. Older entries without snapshot metadata become cache misses for these clients.

‎posthog/client.py‎

Lines changed: 182 additions & 63 deletions
Large diffs are not rendered by default.

‎posthog/feature_flags.py‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def evaluate_flag_dependency(
150150
properties,
151151
cohort_properties,
152152
device_id=None,
153+
property_matching_version: int = 1,
153154
):
154155
"""
155156
Evaluate a flag dependency condition under local evaluation.
@@ -260,6 +261,7 @@ def evaluate_flag_dependency(
260261
flags_by_key=flags_by_key,
261262
evaluation_cache=evaluation_cache,
262263
device_id=device_id,
264+
property_matching_version=property_matching_version,
263265
bucketing_value=dep_bucketing_value,
264266
)
265267
evaluation_cache[dep_flag_key] = dep_result
@@ -350,6 +352,7 @@ def match_feature_flag_properties(
350352
group_type_mapping=None,
351353
groups=None,
352354
group_properties=None,
355+
property_matching_version: int = 1,
353356
) -> FlagValue:
354357
if bucketing_value is None:
355358
warnings.warn(
@@ -416,6 +419,7 @@ def match_feature_flag_properties(
416419
evaluation_cache,
417420
bucketing_value=effective_bucketing,
418421
device_id=device_id,
422+
property_matching_version=property_matching_version,
419423
)
420424
if match_result == ConditionMatch.MATCH:
421425
variant_override = condition.get("variant")
@@ -463,6 +467,7 @@ def is_condition_match(
463467
*,
464468
bucketing_value,
465469
device_id=None,
470+
property_matching_version: int = 1,
466471
) -> ConditionMatch:
467472
rollout_percentage = condition.get("rollout_percentage")
468473
if len(condition.get("properties") or []) > 0:
@@ -477,6 +482,7 @@ def is_condition_match(
477482
evaluation_cache,
478483
distinct_id,
479484
device_id=device_id,
485+
property_matching_version=property_matching_version,
480486
)
481487
elif property_type == "flag":
482488
matches = evaluate_flag_dependency(
@@ -487,9 +493,10 @@ def is_condition_match(
487493
properties,
488494
cohort_properties,
489495
device_id=device_id,
496+
property_matching_version=property_matching_version,
490497
)
491498
else:
492-
matches = match_property(prop, properties)
499+
matches = match_property(prop, properties, property_matching_version)
493500
if not matches:
494501
return ConditionMatch.NO_MATCH
495502

@@ -593,7 +600,9 @@ def _is_truthy_property_value(value) -> bool:
593600
return False
594601

595602

596-
def match_property(property, property_values) -> bool:
603+
def match_property(
604+
property, property_values, property_matching_version: int = 1
605+
) -> bool:
597606
# only looks for matches where key exists in override_property_values
598607
key = property.get("key")
599608
operator = property.get("operator") or "exact"
@@ -623,7 +632,12 @@ def match_property(property, property_values) -> bool:
623632

624633
def compute_exact_match(value, override_value):
625634
override_string = _value_to_string(override_value).lower()
626-
if _is_truthy_or_falsy_property_value(value):
635+
# Empty filters retain recursive truthiness in both matching modes.
636+
if value == []:
637+
return _is_truthy_property_value(override_value)
638+
if property_matching_version != 2 and _is_truthy_or_falsy_property_value(
639+
value
640+
):
627641
return _is_truthy_property_value(value) == _is_truthy_property_value(
628642
override_value
629643
)
@@ -814,6 +828,7 @@ def match_cohort(
814828
evaluation_cache=None,
815829
distinct_id=None,
816830
device_id=None,
831+
property_matching_version: int = 1,
817832
) -> bool:
818833
# Cohort properties are in the form of property groups like this:
819834
# {
@@ -839,6 +854,7 @@ def match_cohort(
839854
evaluation_cache,
840855
distinct_id,
841856
device_id=device_id,
857+
property_matching_version=property_matching_version,
842858
)
843859

844860
operator = property.get("operator") or "exact"
@@ -857,6 +873,7 @@ def match_property_group(
857873
evaluation_cache=None,
858874
distinct_id=None,
859875
device_id=None,
876+
property_matching_version: int = 1,
860877
) -> bool:
861878
# The backend serializes its canonical empty PropertyGroup as {}.
862879
if property_group == {}:
@@ -893,6 +910,7 @@ def match_property_group(
893910
evaluation_cache,
894911
distinct_id,
895912
device_id=device_id,
913+
property_matching_version=property_matching_version,
896914
)
897915
negation = False
898916
elif prop.get("type") == "cohort":
@@ -904,6 +922,7 @@ def match_property_group(
904922
evaluation_cache,
905923
distinct_id,
906924
device_id=device_id,
925+
property_matching_version=property_matching_version,
907926
)
908927
negation = prop.get("negation", False)
909928
elif prop.get("type") == "flag":
@@ -915,10 +934,13 @@ def match_property_group(
915934
property_values,
916935
cohort_properties,
917936
device_id=device_id,
937+
property_matching_version=property_matching_version,
918938
)
919939
negation = prop.get("negation", False)
920940
else:
921-
matches = match_property(prop, property_values)
941+
matches = match_property(
942+
prop, property_values, property_matching_version
943+
)
922944
negation = prop.get("negation", False)
923945

924946
effective_match = matches != bool(negation)

‎posthog/flag_definition_cache.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ class FlagDefinitionCacheData(TypedDict):
4040
flags: List of feature flag definition dictionaries from the API.
4141
group_type_mapping: Mapping of group type indices to group names.
4242
cohorts: Dictionary of cohort definitions for local evaluation.
43+
property_matching_version: Exact/is_not matching selector. Missing means
44+
legacy; only version 2 enables explicit matching.
4345
minimal_flag_called_events: Server-controlled gate for minimal
4446
``$feature_flag_called`` events. Treated as False when absent.
4547
"""
@@ -48,6 +50,7 @@ class FlagDefinitionCacheData(TypedDict):
4850
group_type_mapping: Required[Dict[str, str]]
4951
cohorts: Required[Dict[str, Any]]
5052
minimal_flag_called_events: NotRequired[bool]
53+
property_matching_version: NotRequired[int]
5154

5255

5356
@runtime_checkable

0 commit comments

Comments
 (0)