Skip to content

Commit 070b535

Browse files
authored
Make fewer requests in heartbeat, handle exceptions (#783)
This commit adjusts the heartbeat so that we make fewer requests in it. Before, this PR we'd make (n * 3) + 1 API calls, where n equals the number of configured projects: `"all_projects_are_visible"`: `1` API call `"all_projects_have_permissions"`: `n` API calls (in a threadpool) `"all_projects_components_exist"`: `n` API calls (not in a threadpool) `"all_projects_issue_types_exist"`: `n` API calls (not in a threadpool) With 22 configured prod projects, that's 67 API calls. Now, we make `n + 3` API calls: `"all_projects_are_visible"`: `1` API call "all_projects_have_permissions": `1` API call "all_project_custom_components_exist" (renamed): `n` API calls (in a threadpool) "all_projects_issue_types_exist": `1` API call For a grand total of 25 API calls in prod. This was done by using different API endpoints (or params on those endpoints) to fetch more results at once. It's annoying that Jira doesn't provide an API endpoint to fetch components by project, but we were luckily able to make an improvement This PR also adds a little more exception handling to functions we call in the heartbeat
1 parent bae7852 commit 070b535

File tree

4 files changed

+177
-151
lines changed

4 files changed

+177
-151
lines changed

jbi/services/bugzilla.py

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ def _call(self, verb, url, *args, **kwargs):
5353
# https://bmo.readthedocs.io/en/latest/api/core/v1/general.html?highlight=x-bugzilla-api-key#authentication
5454
headers = kwargs.setdefault("headers", {})
5555
headers.setdefault("x-bugzilla-api-key", self.api_key)
56-
resp = self._client.request(verb, url, *args, **kwargs)
57-
resp.raise_for_status()
56+
try:
57+
resp = self._client.request(verb, url, *args, **kwargs)
58+
resp.raise_for_status()
59+
except requests.HTTPError:
60+
logger.exception("%s %s", verb, url)
61+
raise
5862
parsed = resp.json()
5963
if parsed.get("error"):
6064
raise BugzillaClientError(parsed["message"])
@@ -136,37 +140,46 @@ def __init__(self, client: BugzillaClient) -> None:
136140
def check_health(self) -> ServiceHealth:
137141
"""Check health for Bugzilla Service"""
138142
logged_in = self.client.logged_in()
139-
140-
# Check that all JBI webhooks are enabled in Bugzilla,
141-
# and report disabled ones.
142143
all_webhooks_enabled = False
143144
if logged_in:
144-
jbi_webhooks = self.client.list_webhooks()
145-
all_webhooks_enabled = len(jbi_webhooks) > 0
146-
for webhook in jbi_webhooks:
147-
# Report errors in each webhook
148-
statsd.gauge(
149-
f"jbi.bugzilla.webhooks.{webhook.slug}.errors", webhook.errors
150-
)
151-
# Warn developers when there are errors
152-
if webhook.errors > 0:
153-
logger.warning(
154-
"Webhook %s has %s error(s)", webhook.name, webhook.errors
155-
)
156-
if not webhook.enabled:
157-
all_webhooks_enabled = False
158-
logger.error(
159-
"Webhook %s is disabled (%s errors)",
160-
webhook.name,
161-
webhook.errors,
162-
)
145+
all_webhooks_enabled = self._all_webhooks_enabled()
163146

164147
health: ServiceHealth = {
165148
"up": logged_in,
166149
"all_webhooks_enabled": all_webhooks_enabled,
167150
}
168151
return health
169152

153+
def _all_webhooks_enabled(self):
154+
# Check that all JBI webhooks are enabled in Bugzilla,
155+
# and report disabled ones.
156+
157+
try:
158+
jbi_webhooks = self.client.list_webhooks()
159+
except (BugzillaClientError, requests.HTTPError):
160+
return False
161+
162+
if len(jbi_webhooks) == 0:
163+
logger.info("No webhooks enabled")
164+
return True
165+
166+
for webhook in jbi_webhooks:
167+
# Report errors in each webhook
168+
statsd.gauge(f"jbi.bugzilla.webhooks.{webhook.slug}.errors", webhook.errors)
169+
# Warn developers when there are errors
170+
if webhook.errors > 0:
171+
logger.warning(
172+
"Webhook %s has %s error(s)", webhook.name, webhook.errors
173+
)
174+
if not webhook.enabled:
175+
logger.error(
176+
"Webhook %s is disabled (%s errors)",
177+
webhook.name,
178+
webhook.errors,
179+
)
180+
return False
181+
return True
182+
170183
def add_link_to_jira(self, context: ActionContext):
171184
"""Add link to Jira in Bugzilla ticket"""
172185
bug = context.bug

jbi/services/jira.py

Lines changed: 102 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# https://docs.python.org/3/whatsnew/3.11.html#pep-563-may-not-be-the-future
88
from __future__ import annotations
99

10-
import concurrent.futures
10+
import concurrent
1111
import json
1212
import logging
1313
from functools import lru_cache
@@ -93,7 +93,6 @@ def raise_for_status(self, *args, **kwargs):
9393
raise
9494

9595
get_server_info = instrumented_method(Jira.get_server_info)
96-
get_permissions = instrumented_method(Jira.get_permissions)
9796
get_project_components = instrumented_method(Jira.get_project_components)
9897
projects = instrumented_method(Jira.projects)
9998
update_issue = instrumented_method(Jira.update_issue)
@@ -103,6 +102,19 @@ def raise_for_status(self, *args, **kwargs):
103102
create_issue = instrumented_method(Jira.create_issue)
104103
get_project = instrumented_method(Jira.get_project)
105104

105+
@instrumented_method
106+
def permitted_projects(self, permissions: Iterable):
107+
"""Fetches projects that the user has the required permissions for
108+
109+
https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-permissions/#api-rest-api-2-permissions-project-post
110+
"""
111+
112+
response = self.post(
113+
"/rest/api/2/permissions/project",
114+
json={"permissions": list(JIRA_REQUIRED_PERMISSIONS)},
115+
)
116+
return response["projects"]
117+
106118

107119
class JiraService:
108120
"""Used by action workflows to perform action-specific Jira tasks"""
@@ -124,16 +136,24 @@ def check_health(self, actions: Actions) -> ServiceHealth:
124136
health: ServiceHealth = {
125137
"up": is_up,
126138
"all_projects_are_visible": is_up and self._all_projects_visible(actions),
127-
"all_projects_have_permissions": self._all_projects_permissions(actions),
128-
"all_projects_components_exist": is_up
129-
and self._all_projects_components_exist(actions),
139+
"all_projects_have_permissions": is_up
140+
and self._all_projects_permissions(actions),
141+
"all_project_custom_components_exist": is_up
142+
and self._all_project_custom_components_exist(actions),
130143
"all_projects_issue_types_exist": is_up
131144
and self._all_project_issue_types_exist(actions),
132145
}
133146
return health
134147

135148
def _all_projects_visible(self, actions: Actions) -> bool:
136-
visible_projects = {project["key"] for project in self.fetch_visible_projects()}
149+
try:
150+
visible_projects = {
151+
project["key"] for project in self.fetch_visible_projects()
152+
}
153+
except requests.HTTPError:
154+
logger.exception("Error fetching visible Jira projects")
155+
return False
156+
137157
missing_projects = actions.configured_jira_projects_keys - visible_projects
138158
if missing_projects:
139159
logger.error(
@@ -142,97 +162,96 @@ def _all_projects_visible(self, actions: Actions) -> bool:
142162
)
143163
return not missing_projects
144164

145-
def _all_projects_permissions(self, actions: Actions):
165+
def _all_projects_permissions(self, actions: Actions) -> bool:
146166
"""Fetches and validates that required permissions exist for the configured projects"""
147-
all_projects_perms = self._fetch_project_permissions(actions)
148-
return self._validate_permissions(all_projects_perms)
167+
try:
168+
projects = self.client.permitted_projects(JIRA_REQUIRED_PERMISSIONS)
169+
except requests.HTTPError:
170+
logger.exception(
171+
"Encountered error when trying to fetch permitted projects"
172+
)
173+
return False
149174

150-
def _fetch_project_permissions(self, actions: Actions):
151-
"""Fetches permissions for the configured projects"""
175+
projects_with_required_perms = {project["key"] for project in projects}
176+
missing_perms = (
177+
actions.configured_jira_projects_keys - projects_with_required_perms
178+
)
179+
if missing_perms:
180+
logger.warning(
181+
"Missing permissions for projects %s", ", ".join(missing_perms)
182+
)
183+
return False
152184

153-
all_projects_perms = {}
154-
# Query permissions for all configured projects in parallel threads.
185+
return True
186+
187+
def _all_project_custom_components_exist(self, actions: Actions):
155188
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
156-
futures_to_projects = {
157-
executor.submit(
158-
self.client.get_permissions,
159-
project_key=project_key,
160-
permissions=",".join(JIRA_REQUIRED_PERMISSIONS),
161-
): project_key
162-
for project_key in actions.configured_jira_projects_keys
189+
futures = {
190+
executor.submit(self._check_project_components, action): action
191+
for action in actions
192+
if action.parameters.jira_components.set_custom_components
163193
}
164-
# Obtain futures' results unordered.
165-
for future in concurrent.futures.as_completed(futures_to_projects):
166-
project_key = futures_to_projects[future]
167-
response = future.result()
168-
all_projects_perms[project_key] = response["permissions"]
169-
return all_projects_perms
170-
171-
def _validate_permissions(self, all_projects_perms):
172-
"""Validates permissions for the configured projects"""
173-
misconfigured = []
174-
for project_key, obtained_perms in all_projects_perms.items():
175-
missing_required_perms = JIRA_REQUIRED_PERMISSIONS - set(
176-
obtained_perms.keys()
177-
)
178-
not_given = set(
179-
entry["key"]
180-
for entry in obtained_perms.values()
181-
if not entry["havePermission"]
194+
195+
success = True
196+
for future in concurrent.futures.as_completed(futures):
197+
success = success and future.result()
198+
return success
199+
200+
def _check_project_components(self, action):
201+
project_key = action.parameters.jira_project_key
202+
specified_components = set(
203+
action.parameters.jira_components.set_custom_components
204+
)
205+
206+
try:
207+
all_project_components = self.client.get_project_components(project_key)
208+
except requests.HTTPError:
209+
logger.exception("Error checking project components for %s", project_key)
210+
return False
211+
212+
try:
213+
all_components_names = set(comp["name"] for comp in all_project_components)
214+
except KeyError:
215+
logger.exception(
216+
"Unexpected get_project_components response for %s",
217+
action.whiteboard_tag,
182218
)
183-
if missing_permissions := missing_required_perms.union(not_given):
184-
misconfigured.append((project_key, missing_permissions))
185-
for project_key, missing in misconfigured:
219+
return False
220+
221+
unknown = specified_components - all_components_names
222+
if unknown:
186223
logger.error(
187-
"Configured credentials don't have permissions %s on Jira project %s",
188-
",".join(missing),
224+
"Jira project %s does not have components %s",
189225
project_key,
190-
extra={
191-
"jira": {
192-
"project": project_key,
193-
}
194-
},
226+
unknown,
195227
)
196-
return not misconfigured
197-
198-
def _all_projects_components_exist(self, actions: Actions):
199-
components_by_project = {
200-
action.parameters.jira_project_key: action.parameters.jira_components.set_custom_components
201-
for action in actions
202-
}
203-
success = True
204-
for project, specified_components in components_by_project.items():
205-
all_project_components = self.client.get_project_components(project)
206-
all_components_names = set(comp["name"] for comp in all_project_components)
207-
unknown = set(specified_components) - all_components_names
208-
if unknown:
209-
logger.error(
210-
"Jira project %s does not have components %s",
211-
project,
212-
unknown,
213-
)
214-
success = False
228+
return False
215229

216-
return success
230+
return True
217231

218232
def _all_project_issue_types_exist(self, actions: Actions):
233+
projects = self.client.projects(included_archived=None, expand="issueTypes")
219234
issue_types_by_project = {
220-
action.parameters.jira_project_key: set(
221-
action.parameters.issue_type_map.values()
222-
)
223-
for action in actions
235+
project["key"]: {issue_type["name"] for issue_type in project["issueTypes"]}
236+
for project in projects
224237
}
225-
success = True
226-
for project, specified_issue_types in issue_types_by_project.items():
227-
response = self.client.get_project(project)
228-
all_issue_types_names = set(it["name"] for it in response["issueTypes"])
229-
unknown = set(specified_issue_types) - all_issue_types_names
230-
if unknown:
231-
logger.error(
232-
"Jira project %s does not have issue type %s", project, unknown
233-
)
234-
success = False
235-
return success
238+
missing_issue_types_by_project = {}
239+
for action in actions:
240+
action_issue_types = set(action.parameters.issue_type_map.values())
241+
project_issue_types = issue_types_by_project.get(
242+
action.jira_project_key, set()
243+
)
244+
if missing_issue_types := action_issue_types - project_issue_types:
245+
missing_issue_types_by_project[
246+
action.jira_project_key
247+
] = missing_issue_types
248+
if missing_issue_types_by_project:
249+
logger.warning(
250+
"Jira projects with missing issue types",
251+
extra=missing_issue_types_by_project,
252+
)
253+
return False
254+
return True
236255

237256
def get_issue(self, context: ActionContext, issue_key):
238257
"""Return the Jira issue fields or `None` if not found."""

tests/unit/services/test_jira.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -113,31 +113,32 @@ def test_jira_does_not_retry_4XX(mocked_responses, context_create_example):
113113
(["Foo"], [], False),
114114
],
115115
)
116-
def test_all_projects_components_exist(
117-
action_factory,
116+
def test_all_project_custom_components_exist(
118117
jira_components,
119118
project_components,
120119
expected_result,
121120
mocked_responses,
121+
action_factory,
122122
):
123123
url = f"{get_settings().jira_base_url}rest/api/2/project/ABC/components"
124-
mocked_responses.add(
125-
responses.GET,
126-
url,
127-
json=project_components,
128-
)
124+
if jira_components:
125+
mocked_responses.add(
126+
responses.GET,
127+
url,
128+
json=project_components,
129+
)
129130
action = action_factory(
130131
parameters={
131132
"jira_project_key": "ABC",
132133
"jira_components": JiraComponents(set_custom_components=jira_components),
133134
}
134135
)
135136
actions = Actions(root=[action])
136-
result = jira.get_service()._all_projects_components_exist(actions)
137+
result = jira.get_service()._all_project_custom_components_exist(actions)
137138
assert result is expected_result
138139

139140

140-
def test_all_projects_components_exist_no_components_param(
141+
def test_all_project_custom_components_exist_no_components_param(
141142
action_factory, mocked_responses
142143
):
143144
action = action_factory(
@@ -146,13 +147,7 @@ def test_all_projects_components_exist_no_components_param(
146147
}
147148
)
148149
actions = Actions(root=[action])
149-
url = f"{get_settings().jira_base_url}rest/api/2/project/ABC/components"
150-
mocked_responses.add(
151-
responses.GET,
152-
url,
153-
json=[],
154-
)
155-
result = jira.get_service()._all_projects_components_exist(actions)
150+
result = jira.get_service()._all_project_custom_components_exist(actions)
156151
assert result is True
157152

158153

0 commit comments

Comments
 (0)