-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(releases): Cache calls to compare-commits #112494
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |||||||||
| from sentry_sdk import set_tag | ||||||||||
| from taskbroker_client.retry import Retry | ||||||||||
|
|
||||||||||
| from sentry import features | ||||||||||
| from sentry.constants import ObjectStatus | ||||||||||
| from sentry.exceptions import InvalidIdentity, PluginError | ||||||||||
| from sentry.integrations.source_code_management.metrics import ( | ||||||||||
|
|
@@ -28,11 +29,21 @@ | |||||||||
| from sentry.users.models.user import User | ||||||||||
| from sentry.users.services.user import RpcUser | ||||||||||
| from sentry.users.services.user.service import user_service | ||||||||||
| from sentry.utils.cache import cache | ||||||||||
| from sentry.utils.email import MessageBuilder | ||||||||||
| from sentry.utils.hashlib import md5_text | ||||||||||
| from sentry.utils.http import absolute_uri | ||||||||||
|
|
||||||||||
| logger = logging.getLogger(__name__) | ||||||||||
|
|
||||||||||
| GITHUB_FETCH_COMMITS_COMPARE_CACHE_FEATURE = ( | ||||||||||
| "organizations:integrations-github-fetch-commits-compare-cache" | ||||||||||
| ) | ||||||||||
| GITHUB_FETCH_COMMITS_COMPARE_CACHE_TTL_SECONDS = 120 | ||||||||||
| GITHUB_CACHEABLE_REPOSITORY_PROVIDERS = frozenset( | ||||||||||
| ("integrations:github", "integrations:github_enterprise") | ||||||||||
| ) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def generate_invalid_identity_email(identity, commit_failure=False): | ||||||||||
| new_context = { | ||||||||||
|
|
@@ -63,6 +74,63 @@ | |||||||||
| # we're future proofing this function a bit so it could be used with other code | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def get_github_compare_commits_cache_key( | ||||||||||
| organization_id: int, repository_id: int, provider: str, start_sha: str | None, end_sha: str | ||||||||||
| ) -> str: | ||||||||||
| digest = md5_text( | ||||||||||
| organization_id, repository_id, provider, start_sha or "", end_sha | ||||||||||
| ).hexdigest() | ||||||||||
| return f"fetch-commits:compare-commits:v1:{digest}" | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def fetch_compare_commits( | ||||||||||
| *, | ||||||||||
| cache_enabled: bool, | ||||||||||
| repo: Repository, | ||||||||||
| provider, | ||||||||||
| is_integration_repo_provider: bool, | ||||||||||
| start_sha: str | None, | ||||||||||
| end_sha: str, | ||||||||||
| user: RpcUser | None, | ||||||||||
| lifecycle, | ||||||||||
| ): | ||||||||||
| cache_key = None | ||||||||||
| provider = repo.provider | ||||||||||
|
Check failure on line 98 in src/sentry/tasks/commits.py
|
||||||||||
sentry-warden[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||
| if ( | ||||||||||
| cache_enabled | ||||||||||
| and isinstance(provider, str) | ||||||||||
| and provider in GITHUB_CACHEABLE_REPOSITORY_PROVIDERS | ||||||||||
| and start_sha is not None | ||||||||||
| ): | ||||||||||
| cache_key = get_github_compare_commits_cache_key( | ||||||||||
| repo.organization_id, repo.id, provider, start_sha, end_sha | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| if cache_key is not None: | ||||||||||
| cached_repo_commits = cache.get(cache_key) | ||||||||||
| lifecycle.add_extra("compare_commits_cache_enabled", True) | ||||||||||
| if cached_repo_commits is not None: | ||||||||||
| lifecycle.add_extra("compare_commits_cache_hit", True) | ||||||||||
| return cached_repo_commits | ||||||||||
|
|
||||||||||
| lifecycle.add_extra("compare_commits_cache_hit", False) | ||||||||||
| else: | ||||||||||
| lifecycle.add_extra("compare_commits_cache_enabled", False) | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is lifecycle?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's something the integrations team added to add more context to what events happen in the lifecycle of an event. |
||||||||||
|
|
||||||||||
| if is_integration_repo_provider: | ||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This if/else comes from the main loop: sentry/src/sentry/tasks/commits.py Lines 174 to 177 in 82814b2
|
||||||||||
| repo_commits = provider.compare_commits(repo, start_sha, end_sha) | ||||||||||
| else: | ||||||||||
| repo_commits = provider.compare_commits(repo, start_sha, end_sha, actor=user) | ||||||||||
|
|
||||||||||
| if cache_key is not None: | ||||||||||
| cache.set( | ||||||||||
| cache_key, | ||||||||||
| repo_commits, | ||||||||||
| GITHUB_FETCH_COMMITS_COMPARE_CACHE_TTL_SECONDS, | ||||||||||
| ) | ||||||||||
| return repo_commits | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def handle_invalid_identity(identity, commit_failure=False): | ||||||||||
| # email the user | ||||||||||
| msg = generate_invalid_identity_email(identity, commit_failure) | ||||||||||
|
|
@@ -97,6 +165,11 @@ | |||||||||
| except Release.DoesNotExist: | ||||||||||
| pass | ||||||||||
|
|
||||||||||
| organization = release.organization | ||||||||||
| github_compare_commits_cache_enabled = features.has( | ||||||||||
| GITHUB_FETCH_COMMITS_COMPARE_CACHE_FEATURE, organization, actor=user | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| for ref in refs: | ||||||||||
| repo = ( | ||||||||||
| Repository.objects.filter( | ||||||||||
|
|
@@ -171,10 +244,16 @@ | |||||||||
| } | ||||||||||
| ) | ||||||||||
| try: | ||||||||||
| if is_integration_repo_provider: | ||||||||||
| repo_commits = provider.compare_commits(repo, start_sha, end_sha) | ||||||||||
| else: | ||||||||||
| repo_commits = provider.compare_commits(repo, start_sha, end_sha, actor=user) | ||||||||||
| repo_commits = fetch_compare_commits( | ||||||||||
| cache_enabled=github_compare_commits_cache_enabled, | ||||||||||
| repo=repo, | ||||||||||
| provider=provider, | ||||||||||
| is_integration_repo_provider=is_integration_repo_provider, | ||||||||||
| start_sha=start_sha, | ||||||||||
| end_sha=end_sha, | ||||||||||
| user=user, | ||||||||||
| lifecycle=lifecycle, | ||||||||||
| ) | ||||||||||
|
Check failure on line 256 in src/sentry/tasks/commits.py
|
||||||||||
| except NotImplementedError: | ||||||||||
| pass | ||||||||||
| except IntegrationResourceNotFoundError: | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,11 +17,19 @@ | |
| from sentry.testutils.asserts import assert_slo_metric | ||
| from sentry.testutils.cases import TestCase | ||
| from sentry.testutils.silo import assume_test_silo_mode, control_silo_test | ||
| from sentry.utils.cache import cache | ||
| from social_auth.models import UserSocialAuth | ||
|
|
||
|
|
||
| @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") | ||
| class FetchCommitsTest(TestCase): | ||
| def _github_compare_commits_result(self, repo_name: str, end_sha: str) -> list[dict[str, str]]: | ||
| return [ | ||
| {"id": "62de626b7c7cfb8e77efb4273b1a3df4123e6216", "repository": repo_name}, | ||
| {"id": "58de626b7c7cfb8e77efb4273b1a3df4123e6345", "repository": repo_name}, | ||
| {"id": end_sha, "repository": repo_name}, | ||
| ] | ||
|
|
||
| def _test_simple_action(self, user, org): | ||
| repo = Repository.objects.create(name="example", provider="dummy", organization_id=org.id) | ||
| release = Release.objects.create(organization_id=org.id, version="abcabcabc") | ||
|
|
@@ -86,6 +94,154 @@ def test_duplicate_repositories(self, mock_record: MagicMock) -> None: | |
| Repository.objects.create(name="example", provider="dummy", organization_id=org.id) | ||
| self._test_simple_action(user=self.user, org=org) | ||
|
|
||
| @patch("sentry.integrations.github.repository.GitHubRepositoryProvider.compare_commits") | ||
| def test_github_compare_commits_cache_flag_disabled( | ||
| self, mock_compare_commits: MagicMock, mock_record: MagicMock | ||
| ) -> None: | ||
| self.login_as(user=self.user) | ||
| cache.clear() | ||
|
|
||
| org = self.create_organization(owner=self.user, name="baz") | ||
| repo = Repository.objects.create( | ||
| name="example", | ||
| provider="integrations:github", | ||
| organization_id=org.id, | ||
| ) | ||
| previous_release = Release.objects.create(organization_id=org.id, version="old-release") | ||
| previous_commit = Commit.objects.create( | ||
| organization_id=org.id, repository_id=repo.id, key="a" * 40 | ||
| ) | ||
| ReleaseHeadCommit.objects.create( | ||
| organization_id=org.id, | ||
| repository_id=repo.id, | ||
| release=previous_release, | ||
| commit=previous_commit, | ||
| ) | ||
|
|
||
| refs = [{"repository": repo.name, "commit": "b" * 40}] | ||
| mock_compare_commits.return_value = self._github_compare_commits_result(repo.name, "b" * 40) | ||
|
|
||
| first_release = Release.objects.create(organization_id=org.id, version="new-release-1") | ||
| second_release = Release.objects.create(organization_id=org.id, version="new-release-2") | ||
|
|
||
| with self.tasks(): | ||
| fetch_commits( | ||
| release_id=first_release.id, | ||
| user_id=self.user.id, | ||
| refs=refs, | ||
| previous_release_id=previous_release.id, | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) | ||
| fetch_commits( | ||
| release_id=second_release.id, | ||
| user_id=self.user.id, | ||
| refs=refs, | ||
| previous_release_id=previous_release.id, | ||
| ) | ||
|
|
||
| assert mock_compare_commits.call_count == 2 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without the feature we call it twice. |
||
|
|
||
| @patch("sentry.integrations.github.repository.GitHubRepositoryProvider.compare_commits") | ||
| def test_github_compare_commits_cache_flag_enabled( | ||
| self, mock_compare_commits: MagicMock, mock_record: MagicMock | ||
| ) -> None: | ||
| self.login_as(user=self.user) | ||
| cache.clear() | ||
|
|
||
| org = self.create_organization(owner=self.user, name="baz") | ||
| repo = Repository.objects.create( | ||
| name="example", | ||
| provider="integrations:github", | ||
| organization_id=org.id, | ||
| ) | ||
| previous_release = Release.objects.create(organization_id=org.id, version="old-release") | ||
| previous_commit = Commit.objects.create( | ||
| organization_id=org.id, repository_id=repo.id, key="a" * 40 | ||
| ) | ||
| ReleaseHeadCommit.objects.create( | ||
| organization_id=org.id, | ||
| repository_id=repo.id, | ||
| release=previous_release, | ||
| commit=previous_commit, | ||
| ) | ||
|
|
||
| refs = [{"repository": repo.name, "commit": "b" * 40}] | ||
| mock_compare_commits.return_value = self._github_compare_commits_result(repo.name, "b" * 40) | ||
|
|
||
| first_release = Release.objects.create(organization_id=org.id, version="new-release-1") | ||
| second_release = Release.objects.create(organization_id=org.id, version="new-release-2") | ||
|
|
||
| with self.feature( | ||
| {"organizations:integrations-github-fetch-commits-compare-cache": [org.slug]} | ||
| ): | ||
| with self.tasks(): | ||
| fetch_commits( | ||
| release_id=first_release.id, | ||
| user_id=self.user.id, | ||
| refs=refs, | ||
| previous_release_id=previous_release.id, | ||
| ) | ||
| fetch_commits( | ||
| release_id=second_release.id, | ||
| user_id=self.user.id, | ||
| refs=refs, | ||
| previous_release_id=previous_release.id, | ||
| ) | ||
|
|
||
| assert mock_compare_commits.call_count == 1 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the feature flag is enabled, we only make one call. |
||
|
|
||
| @patch("sentry.integrations.github.repository.GitHubRepositoryProvider.compare_commits") | ||
| def test_github_compare_commits_cache_key_variance_on_end_sha( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test verifies that cache entries are keyed by commit range, not just repo/org. |
||
| self, mock_compare_commits: MagicMock, mock_record: MagicMock | ||
| ) -> None: | ||
| self.login_as(user=self.user) | ||
| cache.clear() | ||
|
|
||
| org = self.create_organization(owner=self.user, name="baz") | ||
| repo = Repository.objects.create( | ||
| name="example", | ||
| provider="integrations:github", | ||
| organization_id=org.id, | ||
| ) | ||
| previous_release = Release.objects.create(organization_id=org.id, version="old-release") | ||
| previous_commit = Commit.objects.create( | ||
| organization_id=org.id, repository_id=repo.id, key="a" * 40 | ||
| ) | ||
| ReleaseHeadCommit.objects.create( | ||
| organization_id=org.id, | ||
| repository_id=repo.id, | ||
| release=previous_release, | ||
| commit=previous_commit, | ||
| ) | ||
|
|
||
| refs_first = [{"repository": repo.name, "commit": "b" * 40}] | ||
| refs_second = [{"repository": repo.name, "commit": "c" * 40}] | ||
| mock_compare_commits.side_effect = [ | ||
| self._github_compare_commits_result(repo.name, "b" * 40), | ||
| self._github_compare_commits_result(repo.name, "c" * 40), | ||
| ] | ||
|
|
||
| first_release = Release.objects.create(organization_id=org.id, version="new-release-1") | ||
| second_release = Release.objects.create(organization_id=org.id, version="new-release-2") | ||
|
|
||
| with self.feature( | ||
| {"organizations:integrations-github-fetch-commits-compare-cache": [org.slug]} | ||
| ): | ||
| with self.tasks(): | ||
| fetch_commits( | ||
| release_id=first_release.id, | ||
| user_id=self.user.id, | ||
| refs=refs_first, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Passing |
||
| previous_release_id=previous_release.id, | ||
| ) | ||
| fetch_commits( | ||
| release_id=second_release.id, | ||
| user_id=self.user.id, | ||
| refs=refs_second, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Passing |
||
| previous_release_id=previous_release.id, | ||
| ) | ||
|
|
||
| assert mock_compare_commits.call_count == 2 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They each will make a call. |
||
|
|
||
| def test_release_locked(self, mock_record_event: MagicMock) -> None: | ||
| self.login_as(user=self.user) | ||
| org = self.create_organization(owner=self.user, name="baz") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As part of this PR, I'm extracting some of the code in the main for/loop into a couple of new functions (to make the final code easier to read):
sentry/src/sentry/tasks/commits.py
Line 100 in 82814b2