Skip to content

Commit 3cf2fcf

Browse files
joeyorlandovstpme
andauthored
optimize GET /schedules internal API endpoint (#1169)
# What this PR does Fixes slow internal`GET /schedules` endpoints. Using the fake-data generation script in #1128, I generated 65 calendar schedules in my local setup. This resulted in the following endpoint performance: ![Screenshot 2023-01-24 at 12 03 16](https://user-images.githubusercontent.com/9406895/214276618-1a9848ba-eb84-49ec-a099-fdd96beac93f.png) The responses which show ~76 queries were run on the latest `dev` branch. Responses w/ ~26 queries were run on this branch. Additionally: - add typing to a few methods in `apps/schedules/ical_utils.py` - document `apps/api/permissions/__init__.py:user_is_authorized` function ## Which issue(s) this PR fixes grafana/oncall-private#1552 ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated Co-authored-by: Vadim Stepanov <[email protected]>
1 parent de5d876 commit 3cf2fcf

File tree

9 files changed

+148
-63
lines changed

9 files changed

+148
-63
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727

2828
- Allow users with `viewer` role to fetch cloud connection status using the internal API ([#1181](https://github.com/grafana/oncall/pull/1181))
2929
- When removing the Slack ChatOps integration, make it more explicit to the user what the implications of doing so are
30+
- Improve performance of `GET /api/internal/v1/schedules` endpoint ([#1169](https://github.com/grafana/oncall/pull/1169))
3031

3132
### Fixed
3233

engine/apps/api/permissions/__init__.py

+11
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ def get_most_authorized_role(
7373

7474

7575
def user_is_authorized(user, required_permissions: typing.List[LegacyAccessControlCompatiblePermission]) -> bool:
76+
"""
77+
This function checks whether `user` has all permissions in `required_permissions`. RBAC permissions are used
78+
if RBAC is enabled for the organization, otherwise the fallback basic role is checked.
79+
80+
Parameters
81+
----------
82+
user : apps.user_management.models.user.User
83+
The user to check permissions for
84+
required_permissions : typing.List[LegacyAccessControlCompatiblePermission]
85+
A list of permissions that a user must have to be considered authorized
86+
"""
7687
if user.organization.is_rbac_permissions_enabled:
7788
user_permissions = [u["action"] for u in user.permissions]
7889
required_permissions = [p.value for p in required_permissions]

engine/apps/api/serializers/schedule_base.py

+3-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
from django.utils import timezone
21
from rest_framework import serializers
32

43
from apps.api.serializers.user_group import UserGroupSerializer
5-
from apps.schedules.ical_utils import list_users_to_notify_from_ical
64
from apps.schedules.models import OnCallSchedule
75
from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule
86
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
@@ -67,11 +65,9 @@ def get_warnings(self, obj):
6765
return warnings
6866

6967
def get_on_call_now(self, obj):
70-
users_on_call = list_users_to_notify_from_ical(obj, timezone.datetime.now(timezone.utc))
71-
if users_on_call is not None:
72-
return [user.short() for user in users_on_call]
73-
else:
74-
return []
68+
# Serializer context is set here: apps.api.views.schedule.ScheduleView.get_serializer_context
69+
users = self.context["oncall_users"].get(obj.pk, [])
70+
return [user.short() for user in users]
7571

7672
def get_number_of_escalation_chains(self, obj):
7773
# num_escalation_chains param added in queryset via annotate. Check ScheduleView.get_queryset

engine/apps/api/views/schedule.py

+10
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,19 @@ def can_update_user_groups(self):
104104

105105
return user_group.can_be_updated
106106

107+
@cached_property
108+
def oncall_users(self):
109+
"""
110+
The result of this method is cached and is reused for the whole lifetime of a request,
111+
since self.get_serializer_context() is called multiple times for every instance in the queryset.
112+
"""
113+
queryset = self.get_queryset()
114+
return queryset.get_oncall_users()
115+
107116
def get_serializer_context(self):
108117
context = super().get_serializer_context()
109118
context.update({"can_update_user_groups": self.can_update_user_groups})
119+
context.update({"oncall_users": self.oncall_users})
110120
return context
111121

112122
def _annotate_queryset(self, queryset):

engine/apps/schedules/ical_utils.py

+100-35
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import datetime
44
import logging
55
import re
6+
import typing
67
from collections import namedtuple
78
from typing import TYPE_CHECKING
89

@@ -35,60 +36,64 @@
3536
"""
3637
if TYPE_CHECKING:
3738
from apps.schedules.models import OnCallSchedule
38-
from apps.user_management.models import User
39+
from apps.user_management.models import Organization, User
40+
from apps.user_management.models.user import UserQuerySet
41+
42+
logger = logging.getLogger(__name__)
43+
logger.setLevel(logging.DEBUG)
3944

4045

41-
def users_in_ical(usernames_from_ical, organization, include_viewers=False):
46+
def users_in_ical(
47+
usernames_from_ical: typing.List[str],
48+
organization: Organization,
49+
include_viewers=False,
50+
users_to_filter: typing.Optional[UserQuerySet] = None,
51+
) -> UserQuerySet:
4252
"""
43-
Parse ical file and return list of users found
44-
NOTE: only grafana username will be used, consider adding grafana email and id
53+
This method returns a `UserQuerySet`, filtered by users whose username, or case-insensitive e-mail,
54+
is present in `usernames_from_ical`. If `include_viewers` is set to `True`, users are further filtered down
55+
based on their granted permissions.
56+
57+
Parameters
58+
----------
59+
usernames_from_ical : typing.List[str]
60+
A list of usernames present in the ical feed
61+
organization : apps.user_management.models.organization.Organization
62+
The organization in question
63+
include_viewers : bool
64+
Whether or not the list should be further filtered to exclude users based on granted permissions
65+
users_to_filter : typing.Optional[UserQuerySet]
66+
Filter users without making SQL queries if users_to_filter arg is provided
67+
users_to_filter is passed in `apps.schedules.ical_utils.get_oncall_users_for_multiple_schedules`
4568
"""
4669
from apps.user_management.models import User
4770

71+
emails_from_ical = [username.lower() for username in usernames_from_ical]
72+
73+
if users_to_filter is not None:
74+
return list(
75+
{user for user in users_to_filter if user.username in usernames_from_ical or user.email in emails_from_ical}
76+
)
77+
4878
users_found_in_ical = organization.users
4979
if not include_viewers:
50-
# TODO: this is a breaking change....
5180
users_found_in_ical = users_found_in_ical.filter(
5281
**User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization)
5382
)
5483

55-
user_emails = [v.lower() for v in usernames_from_ical]
5684
users_found_in_ical = users_found_in_ical.filter(
57-
(Q(username__in=usernames_from_ical) | Q(email__lower__in=user_emails))
85+
(Q(username__in=usernames_from_ical) | Q(email__lower__in=emails_from_ical))
5886
).distinct()
5987

60-
# Here is the example how we extracted users previously, using slack fields too
61-
# user_roles_found_in_ical = team.org_user_role.filter(role__in=[ROLE_ADMIN, ROLE_USER]).filter(
62-
# Q(
63-
# Q(amixr_user__slack_user_identities__slack_team_identity__amixr_team=team) &
64-
# Q(
65-
# Q(amixr_user__slack_user_identities__profile_display_name__in=usernames_from_ical) |
66-
# Q(amixr_user__slack_user_identities__cached_name__in=usernames_from_ical) |
67-
# Q(amixr_user__slack_user_identities__slack_id__in=[username.split(" ")[0] for username in
68-
# usernames_from_ical]) |
69-
# Q(amixr_user__slack_user_identities__cached_slack_login__in=usernames_from_ical) |
70-
# Q(amixr_user__slack_user_identities__profile_real_name__in=usernames_from_ical)
71-
# )
72-
# )
73-
# |
74-
# Q(username__in=usernames_from_ical)
75-
# ).annotate(is_deleted_sui=Subquery(slack_user_identity_subquery.values("deleted")[:1])).filter(
76-
# ~Q(is_deleted_sui=True) | Q(is_deleted_sui__isnull=True)).distinct()
77-
# return user_roles_found_in_ical
78-
7988
return users_found_in_ical
8089

8190

8291
@timed_lru_cache(timeout=100)
83-
def memoized_users_in_ical(usernames_from_ical, organization):
92+
def memoized_users_in_ical(usernames_from_ical: typing.List[str], organization: Organization) -> UserQuerySet:
8493
# using in-memory cache instead of redis to avoid pickling python objects
8594
return users_in_ical(usernames_from_ical, organization)
8695

8796

88-
logger = logging.getLogger(__name__)
89-
logger.setLevel(logging.DEBUG)
90-
91-
9297
# used for display schedule events on web
9398
def list_of_oncall_shifts_from_ical(
9499
schedule,
@@ -288,17 +293,29 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date):
288293
return sorted(empty_shifts, key=lambda dt: dt.start)
289294

290295

291-
def list_users_to_notify_from_ical(schedule, events_datetime=None, include_viewers=False):
296+
def list_users_to_notify_from_ical(
297+
schedule, events_datetime=None, include_viewers=False, users_to_filter=None
298+
) -> UserQuerySet:
292299
"""
293300
Retrieve on-call users for the current time
294301
"""
295302
events_datetime = events_datetime if events_datetime else timezone.datetime.now(timezone.utc)
296303
return list_users_to_notify_from_ical_for_period(
297-
schedule, events_datetime, events_datetime, include_viewers=include_viewers
304+
schedule,
305+
events_datetime,
306+
events_datetime,
307+
include_viewers=include_viewers,
308+
users_to_filter=users_to_filter,
298309
)
299310

300311

301-
def list_users_to_notify_from_ical_for_period(schedule, start_datetime, end_datetime, include_viewers=False):
312+
def list_users_to_notify_from_ical_for_period(
313+
schedule,
314+
start_datetime,
315+
end_datetime,
316+
include_viewers=False,
317+
users_to_filter=None,
318+
) -> UserQuerySet:
302319
# get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always
303320
# be the first
304321
calendars = schedule.get_icalendars()
@@ -316,7 +333,9 @@ def list_users_to_notify_from_ical_for_period(schedule, start_datetime, end_date
316333
parsed_ical_events.setdefault(current_priority, []).extend(current_usernames)
317334
# find users by usernames. if users are not found for shift, get users from lower priority
318335
for _, usernames in sorted(parsed_ical_events.items(), reverse=True):
319-
users_found_in_ical = users_in_ical(usernames, schedule.organization, include_viewers=include_viewers)
336+
users_found_in_ical = users_in_ical(
337+
usernames, schedule.organization, include_viewers=include_viewers, users_to_filter=users_to_filter
338+
)
320339
if users_found_in_ical:
321340
break
322341
if users_found_in_ical:
@@ -325,6 +344,52 @@ def list_users_to_notify_from_ical_for_period(schedule, start_datetime, end_date
325344
return users_found_in_ical
326345

327346

347+
def get_oncall_users_for_multiple_schedules(
348+
schedules, events_datetime=None
349+
) -> typing.Dict[OnCallSchedule, typing.List[User]]:
350+
from apps.user_management.models import User
351+
352+
if events_datetime is None:
353+
events_datetime = timezone.datetime.now(timezone.utc)
354+
355+
# Exit early if there are no schedules
356+
if not schedules.exists():
357+
return {}
358+
359+
# Assume all schedules from the queryset belong to the same organization
360+
organization = schedules[0].organization
361+
362+
# Gather usernames from all events from all schedules
363+
usernames = set()
364+
for schedule in schedules.all():
365+
calendars = schedule.get_icalendars()
366+
for calendar in calendars:
367+
if calendar is None:
368+
continue
369+
events = ical_events.get_events_from_ical_between(calendar, events_datetime, events_datetime)
370+
for event in events:
371+
current_usernames, _ = get_usernames_from_ical_event(event)
372+
usernames.update(current_usernames)
373+
374+
# Fetch relevant users from the db
375+
emails = [username.lower() for username in usernames]
376+
users = organization.users.filter(
377+
Q(**User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization))
378+
& (Q(username__in=usernames) | Q(email__lower__in=emails))
379+
)
380+
381+
# Get on-call users
382+
oncall_users = {}
383+
for schedule in schedules.all():
384+
# pass user list to list_users_to_notify_from_ical
385+
schedule_oncall_users = list_users_to_notify_from_ical(
386+
schedule, events_datetime=events_datetime, users_to_filter=users
387+
)
388+
oncall_users.update({schedule.pk: schedule_oncall_users})
389+
390+
return oncall_users
391+
392+
328393
def parse_username_from_string(string):
329394
"""
330395
Parse on-call shift user from the given string

engine/apps/schedules/models/on_call_schedule.py

+2-14
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818

1919
from apps.schedules.ical_utils import (
2020
fetch_ical_file_or_get_error,
21+
get_oncall_users_for_multiple_schedules,
2122
list_of_empty_shifts_in_schedule,
2223
list_of_gaps_in_schedule,
2324
list_of_oncall_shifts_from_ical,
24-
list_users_to_notify_from_ical,
2525
)
2626
from apps.schedules.models import CustomOnCallShift
2727
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
@@ -43,19 +43,7 @@ def generate_public_primary_key_for_oncall_schedule_channel():
4343

4444
class OnCallScheduleQuerySet(PolymorphicQuerySet):
4545
def get_oncall_users(self, events_datetime=None):
46-
if events_datetime is None:
47-
events_datetime = timezone.datetime.now(timezone.utc)
48-
49-
users = set()
50-
51-
for schedule in self.all():
52-
schedule_oncall_users = list_users_to_notify_from_ical(schedule, events_datetime=events_datetime)
53-
if schedule_oncall_users is None:
54-
continue
55-
56-
users.update(schedule_oncall_users)
57-
58-
return list(users)
46+
return get_oncall_users_for_multiple_schedules(self, events_datetime)
5947

6048

6149
class OnCallSchedule(PolymorphicModel):

engine/apps/schedules/tests/test_custom_on_call_shift.py

+19-5
Original file line numberDiff line numberDiff line change
@@ -1295,7 +1295,7 @@ def test_get_oncall_users_for_empty_schedule(
12951295
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
12961296
schedules = OnCallSchedule.objects.filter(pk=schedule.pk)
12971297

1298-
assert schedules.get_oncall_users() == []
1298+
assert schedules.get_oncall_users()[schedule.pk] == []
12991299

13001300

13011301
@pytest.mark.django_db
@@ -1357,15 +1357,29 @@ def test_get_oncall_users_for_multiple_schedules(
13571357

13581358
schedules = OnCallSchedule.objects.filter(pk__in=[schedule_1.pk, schedule_2.pk])
13591359

1360-
expected = set(schedules.get_oncall_users(events_datetime=now + timezone.timedelta(seconds=1)))
1360+
def _extract_oncall_users_from_schedules(schedules):
1361+
return set(user for schedule in schedules.values() for user in schedule)
1362+
1363+
expected = _extract_oncall_users_from_schedules(
1364+
schedules.get_oncall_users(events_datetime=now + timezone.timedelta(seconds=1))
1365+
)
13611366
assert expected == {user_1, user_2}
13621367

1363-
expected = set(schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=10, seconds=1)))
1368+
expected = _extract_oncall_users_from_schedules(
1369+
schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=10, seconds=1))
1370+
)
13641371
assert expected == {user_1, user_2, user_3}
13651372

1366-
assert schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=30, seconds=1)) == [user_3]
1373+
assert _extract_oncall_users_from_schedules(
1374+
schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=30, seconds=1))
1375+
) == {user_3}
13671376

1368-
assert schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=40, seconds=1)) == []
1377+
assert (
1378+
_extract_oncall_users_from_schedules(
1379+
schedules.get_oncall_users(events_datetime=now + timezone.timedelta(minutes=40, seconds=1))
1380+
)
1381+
== set()
1382+
)
13691383

13701384

13711385
@pytest.mark.django_db

engine/apps/slack/models/slack_usergroup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def can_be_updated(self) -> bool:
6969

7070
@property
7171
def oncall_slack_user_identities(self):
72-
users = self.oncall_schedules.get_oncall_users()
72+
users = set(user for schedule in self.oncall_schedules.get_oncall_users().values() for user in schedule)
7373
slack_user_identities = [user.slack_user_identity for user in users if user.slack_user_identity is not None]
7474
return slack_user_identities
7575

engine/apps/slack/tests/test_user_group.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_oncall_slack_user_identities(
3939
)
4040
user_3 = make_user_for_organization(organization)
4141

42-
with patch.object(OnCallScheduleQuerySet, "get_oncall_users", return_value=[user_1, user_2, user_3]):
42+
with patch.object(OnCallScheduleQuerySet, "get_oncall_users", return_value={"schedule1": [user_1, user_2, user_3]}):
4343
assert user_group.oncall_slack_user_identities == [slack_user_identity_1, slack_user_identity_2]
4444

4545

0 commit comments

Comments
 (0)