Skip to content

Commit 92625cf

Browse files
authored
fix(flags): evict $feature_flag_called dedupe tracker incrementally instead of clearing it (#821)
fix(flags): evict flag-called tracker entries incrementally `SizeLimitedDict.__setitem__` cleared the entire mapping when it reached `max_size`. The only user is `Client.distinct_ids_feature_flags_reported`, the `$feature_flag_called` dedupe tracker, so every 50,000th new distinct ID wiped dedupe state for all previously seen distinct IDs and their next flag read re-emitted an event they had already deduped. Evict only the oldest (least-recently-inserted) key instead, which is what the sdk-specs feature-flag-called-tracker contract requires: capacity pressure must not clear the whole tracker, and full clears are reserved for lifecycle boundaries such as shutdown. Generated-By: PostHog Code Task-Id: af4d7563-5297-47da-95f8-75da9a218fde Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
1 parent 7b6a8d8 commit 92625cf

4 files changed

Lines changed: 107 additions & 11 deletions

File tree

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+
The `$feature_flag_called` dedupe tracker now evicts its oldest entry when it reaches capacity instead of clearing every entry. Previously, each time a client accumulated 50,000 distinct IDs the whole tracker was wiped, so the next flag read for every previously seen distinct ID re-emitted a `$feature_flag_called` event it had already deduped.

‎posthog/test/test_feature_flags.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6436,9 +6436,57 @@ def test_capture_multiple_users_doesnt_out_of_memory(
64366436
)
64376437

64386438
self.assertEqual(
6439-
len(client.distinct_ids_feature_flags_reported), i % 100 + 1
6439+
len(client.distinct_ids_feature_flags_reported), min(i + 1, 100)
64406440
)
64416441

6442+
@mock.patch.object(Client, "capture")
6443+
@mock.patch("posthog.client.flags")
6444+
def test_flag_called_dedupe_survives_tracker_capacity_pressure(
6445+
self, patch_flags, patch_capture
6446+
):
6447+
"""Hitting the tracker's capacity must not re-open dedupe for every user.
6448+
6449+
Evicting only the oldest entry keeps the still-tracked users deduped; clearing
6450+
the whole tracker would make each of them emit a duplicate
6451+
`$feature_flag_called` on their next read.
6452+
"""
6453+
client = Client(FAKE_TEST_API_KEY, secret_key=FAKE_TEST_API_KEY)
6454+
client.distinct_ids_feature_flags_reported.max_size = 3
6455+
client.feature_flags = [
6456+
{
6457+
"id": 1,
6458+
"name": "Beta Feature",
6459+
"key": "complex-flag",
6460+
"active": True,
6461+
"filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
6462+
}
6463+
]
6464+
6465+
def flag_called_count_for(distinct_id):
6466+
return sum(
6467+
1
6468+
for call in patch_capture.call_args_list
6469+
if call.args
6470+
and call.args[0] == "$feature_flag_called"
6471+
and call.kwargs.get("distinct_id") == distinct_id
6472+
)
6473+
6474+
# Fill the tracker, then push one more user through to force an eviction.
6475+
for i in range(4):
6476+
client.get_feature_flag_result("complex-flag", f"user-{i}")
6477+
6478+
self.assertEqual(len(client.distinct_ids_feature_flags_reported), 3)
6479+
self.assertNotIn("user-0", client.distinct_ids_feature_flags_reported)
6480+
6481+
# Users still in the tracker stay deduped across the eviction.
6482+
for i in range(1, 4):
6483+
client.get_feature_flag_result("complex-flag", f"user-{i}")
6484+
self.assertEqual(flag_called_count_for(f"user-{i}"), 1)
6485+
6486+
# The evicted user is treated as unseen again, which is the expected cost.
6487+
client.get_feature_flag_result("complex-flag", "user-0")
6488+
self.assertEqual(flag_called_count_for("user-0"), 2)
6489+
64426490

64436491
class TestConsistency(unittest.TestCase):
64446492
@classmethod

‎posthog/test/test_size_limited_dict.py‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,46 @@ def test_size_limited_dict(self, size: int, iterations: int) -> None:
1414
values[i] = i
1515

1616
assert values[i] == i
17-
assert len(values) == i % size + 1
18-
19-
if i % size == 0:
20-
# old numbers should've been removed
21-
self.assertIsNone(values.get(i - 1))
22-
self.assertIsNone(values.get(i - 3))
23-
self.assertIsNone(values.get(i - 5))
24-
self.assertIsNone(values.get(i - 9))
17+
# Capacity is a ceiling, not a reset point: the dict fills up and then
18+
# stays full instead of collapsing back to one entry.
19+
assert len(values) == min(i + 1, size)
20+
21+
# The most recent `size` keys survive; only what fell off the front is gone.
22+
for recent in range(max(0, i - size + 1), i + 1):
23+
assert values.get(recent) == recent
24+
if i >= size:
25+
self.assertIsNone(values.get(i - size))
26+
27+
@parameterized.expand([(10, 100), (5, 20), (20, 200)])
28+
def test_size_limited_dict_evicts_incrementally(
29+
self, size: int, iterations: int
30+
) -> None:
31+
"""Passing capacity must drop one oldest entry, not clear the whole mapping."""
32+
values = utils.SizeLimitedDict(size, lambda _: -1)
33+
34+
for i in range(size):
35+
values[i] = i
36+
37+
for i in range(size, iterations):
38+
values[i] = i
39+
40+
assert len(values) == size
41+
# Exactly one entry was evicted per insertion, so everything but the
42+
# oldest key is still deduped.
43+
self.assertIsNone(values.get(i - size))
44+
assert values.get(i - size + 1) == i - size + 1
45+
46+
def test_size_limited_dict_overwrite_at_capacity_does_not_evict(self) -> None:
47+
values = utils.SizeLimitedDict(3, lambda _: -1)
48+
for i in range(3):
49+
values[i] = i
50+
51+
values[0] = "updated"
52+
53+
assert len(values) == 3
54+
assert values[0] == "updated"
55+
assert values[1] == 1
56+
assert values[2] == 2
2557

2658
def test_size_limited_dict_forwards_defaultdict_args_and_kwargs(self) -> None:
2759
values = utils.SizeLimitedDict(

‎posthog/utils.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,24 @@ def is_valid_regex(value) -> bool:
152152

153153

154154
class SizeLimitedDict(defaultdict):
155+
"""A ``defaultdict`` that evicts its oldest entries once it reaches ``max_size``.
156+
157+
Eviction is incremental: inserting a new key past capacity removes only the
158+
least-recently-inserted key, never the whole mapping. This matters for the
159+
``$feature_flag_called`` dedupe tracker, whose only bound this is — wiping every
160+
entry under capacity pressure would let the next flag read for every previously
161+
seen ``distinct_id`` re-emit an event it had already deduped.
162+
"""
163+
155164
def __init__(self, max_size, *args, **kwargs):
156165
super().__init__(*args, **kwargs)
157166
self.max_size = max_size
158167

159168
def __setitem__(self, key, value):
160-
if len(self) >= self.max_size:
161-
self.clear()
169+
if key not in self:
170+
# dicts iterate in insertion order, so the first key is the oldest one.
171+
while self and len(self) >= self.max_size:
172+
del self[next(iter(self))]
162173

163174
super().__setitem__(key, value)
164175

0 commit comments

Comments
 (0)