Skip to content

Commit 9105ae0

Browse files
authored
fix(Segment Membership): Stagger the recurring count refresh fan-out (#7943)
1 parent e77c851 commit 9105ae0

4 files changed

Lines changed: 70 additions & 20 deletions

File tree

api/app/settings/common.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,6 +1483,17 @@
14831483
SEGMENT_MEMBERSHIP_REFRESH_INTERVAL_HOURS = env.int(
14841484
"SEGMENT_MEMBERSHIP_REFRESH_INTERVAL_HOURS", default=6
14851485
)
1486+
SEGMENT_MEMBERSHIP_REFRESH_PROJECT_STAGGER_WINDOW_HOURS = env.int(
1487+
"SEGMENT_MEMBERSHIP_REFRESH_PROJECT_STAGGER_WINDOW_HOURS", default=1
1488+
)
1489+
if (
1490+
SEGMENT_MEMBERSHIP_REFRESH_PROJECT_STAGGER_WINDOW_HOURS
1491+
> SEGMENT_MEMBERSHIP_REFRESH_INTERVAL_HOURS
1492+
):
1493+
raise ImproperlyConfigured(
1494+
"SEGMENT_MEMBERSHIP_REFRESH_PROJECT_STAGGER_WINDOW_HOURS must not exceed "
1495+
"SEGMENT_MEMBERSHIP_REFRESH_INTERVAL_HOURS."
1496+
)
14861497
SEGMENT_MEMBERSHIP_DELETE_REFRESH_DELAY_SECONDS = env.int(
14871498
"SEGMENT_MEMBERSHIP_DELETE_REFRESH_DELAY_SECONDS",
14881499
default=120, # We can expect the identity deletion to propagate by T+120 seconds based on Edge CDC SLO.

api/segment_membership/tasks.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,23 @@ def refresh_all_segment_counts() -> None:
174174
if not settings.CLICKHOUSE_ENABLED:
175175
return
176176

177-
project_ids = Segment.live_objects.values_list("project_id", flat=True)
178-
for project in (
179-
Project.objects.filter(id__in=project_ids)
177+
projects = (
178+
Project.objects.filter(
179+
Exists(Segment.live_objects.filter(project=OuterRef("pk")))
180+
)
180181
.select_related("organisation")
181-
.iterator()
182-
):
183-
enqueue_membership_refresh(project)
182+
.order_by("organisation_id", "id")
183+
)
184+
total = projects.count()
185+
if not total:
186+
return
187+
188+
spacing = timedelta(
189+
hours=settings.SEGMENT_MEMBERSHIP_REFRESH_PROJECT_STAGGER_WINDOW_HOURS
190+
) / (total + 1)
191+
now = timezone.now()
192+
for index, project in enumerate(projects.iterator()):
193+
enqueue_membership_refresh(project, delay_until=now + spacing * index)
184194

185195

186196
@register_task_handler(

api/tests/unit/segment_membership/test_unit_segment_membership_tasks.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, timedelta
22
from datetime import timezone as dt_timezone
33
from unittest.mock import MagicMock
44

@@ -13,6 +13,7 @@
1313
from task_processor.task_run_method import TaskRunMethod
1414

1515
from environments.models import Environment
16+
from organisations.models import Organisation
1617
from projects.models import Project
1718
from segment_membership import tasks
1819
from segment_membership.models import SegmentMembershipCount, SegmentMembershipSeed
@@ -375,27 +376,55 @@ def test_refresh_all_segment_counts__no_clickhouse_creds__skips(
375376
enqueue.assert_not_called()
376377

377378

378-
def test_refresh_all_segment_counts__live_segment_projects__delegates_to_enqueue(
379+
def test_refresh_all_segment_counts__no_live_segments__does_nothing(
380+
mocker: MockerFixture,
381+
settings: SettingsWrapper,
382+
project: Project,
383+
) -> None:
384+
# Given a project but no live segments, so there are no organisations to seed
385+
settings.CLICKHOUSE_ENABLED = True
386+
enqueue = mocker.patch.object(tasks, "enqueue_membership_refresh")
387+
388+
# When
389+
refresh_all_segment_counts()
390+
391+
# Then
392+
enqueue.assert_not_called()
393+
394+
395+
def test_refresh_all_segment_counts__live_segment_projects__staggers_evenly_by_org(
379396
mocker: MockerFixture,
380397
settings: SettingsWrapper,
381398
project: Project,
382399
segment: Segment,
383400
) -> None:
384-
# Given two projects with live segments (flag + debounce gating is the
385-
# helper's job, so refresh_all just delegates one call per project)
386-
project_b = Project.objects.create(
387-
name="project-b", organisation=project.organisation
388-
)
401+
# Given three live-segment projects across two organisations
402+
project_a2 = Project.objects.create(name="a2", organisation=project.organisation)
403+
Segment.objects.create(name="seg-a2", project=project_a2)
404+
org_b = Organisation.objects.create(name="org-b")
405+
project_b = Project.objects.create(name="b1", organisation=org_b)
389406
Segment.objects.create(name="seg-b", project=project_b)
390407
settings.CLICKHOUSE_ENABLED = True
408+
settings.SEGMENT_MEMBERSHIP_REFRESH_PROJECT_STAGGER_WINDOW_HOURS = 4
391409
enqueue = mocker.patch.object(tasks, "enqueue_membership_refresh")
392410

393411
# When
394412
refresh_all_segment_counts()
395413

396-
# Then
397-
dispatched_ids = {call.args[0].id for call in enqueue.call_args_list}
398-
assert dispatched_ids == {project.id, project_b.id}
414+
# Then one call per project, ordered by (organisation_id, id) so an
415+
# organisation's projects refresh contiguously
416+
called = [
417+
(call.args[0].id, call.kwargs["delay_until"]) for call in enqueue.call_args_list
418+
]
419+
expected_order = sorted(
420+
(project, project_a2, project_b), key=lambda p: (p.organisation_id, p.id)
421+
)
422+
assert [pid for pid, _ in called] == [p.id for p in expected_order]
423+
# spread evenly across the window: spacing = window / (total + 1)
424+
spacing = timedelta(hours=4) / 4
425+
delays = [delay for _, delay in called]
426+
assert delays[1] - delays[0] == spacing
427+
assert delays[2] - delays[1] == spacing
399428

400429

401430
def test_refresh_project_segment_counts__no_clickhouse_creds__skips(

docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ Attributes:
388388
### `segment_membership.refresh.project.completed`
389389

390390
Logged at `info` from:
391-
- `api/segment_membership/tasks.py:252`
391+
- `api/segment_membership/tasks.py:262`
392392

393393
Attributes:
394394
- `membership_counts.count`
@@ -398,16 +398,16 @@ Attributes:
398398
### `segment_membership.refresh.project.failed`
399399

400400
Logged at `exception` from:
401-
- `api/segment_membership/tasks.py:225`
401+
- `api/segment_membership/tasks.py:235`
402402

403403
Attributes:
404404
- `project.id`
405405

406406
### `segment_membership.refresh.project.skipped`
407407

408408
Logged at `info` from:
409-
- `api/segment_membership/tasks.py:196`
410-
- `api/segment_membership/tasks.py:205`
409+
- `api/segment_membership/tasks.py:206`
410+
- `api/segment_membership/tasks.py:215`
411411

412412
Attributes:
413413
- `project.id`

0 commit comments

Comments
 (0)