Merge ~pappacena/launchpad:gitrepo-pending-merges-tuning into launchpad:master

Proposed by Thiago F. Pappacena
Status: Needs review
Proposed branch: ~pappacena/launchpad:gitrepo-pending-merges-tuning
Merge into: launchpad:master
Diff against target: 363 lines (+191/-28)
4 files modified
lib/lp/code/browser/branchmergeproposal.py (+64/-18)
lib/lp/code/browser/gitrepository.py (+64/-5)
lib/lp/code/browser/tests/test_gitrepository.py (+62/-4)
lib/lp/code/templates/branchmergeproposal-summary-fragment.pt (+1/-1)
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+377393@code.launchpad.net

Commit message

Small improvements in performance and allowing to limit the amount of MPs displayed on repository-pending-merges portlet (on git repository page)

Description of the change

This is not exactly ready to be merged, but I would like some opinions.
I've checked with @profilehooks.profile some methods, and took a look on what could be improved for the repository-pending-merges portlet.

There are two places spending quite a lot of processing time:
1- Fetching and counting MPs to show the /+activereivews link
This part fetches all MPs (and they can be a lot) just to count. It cannot be done on the database directly because we need to use Zope to check for permissions. I'm proposing to limit this count to a certain number, and after that just show "more than <limit> MPs" to the user.

2- List the summary of the most recent MPs for each branch
Again, this cannot be totally filtered on the database because of Zope's permission check. But, apart from limiting, I'm proposing some small improvements on the method getting the latest MP per branch.

On the test side, while I was coding the changes, I've wrote a small test to check the processing time. I'm including a slightly different version of what I've writen before, but I'm not sure if we should include this type of check based on the processing time. Specially because this is dependent on the machine running and it might eventually break under unexpected situations (think of a busy buildbot, for example).

Anyway, I'll probably need to polish a bit this MP, but I would like to have some early feedback on it.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

I think it should be possible to express an equivalent of the launchpad.View permission check on merge proposals directly in a database query. We have a good chunk of the machinery for that already (BranchCollection and GitCollection), and I think we just need to use it more effectively here.

As for the remaining limit stuff, it looks to me as though this needs to be reworked using the batch navigation system; that's normally how we handle rendering large collections.

We normally do avoid wallclock speed tests for the sorts of reasons you suggest. In most cases query count tests (especially tests for how query counts scale with the number of objects involved) are good enough proxies, and they have the great virtue of behaving deterministically. I think that would be the case here.

There's a lot of stuff to get your teeth into here! Let's discuss it at the sprint, since that's so close.

e03cd15... by Thiago F. Pappacena

removing wallclock-dependent test

63450a2... by Thiago F. Pappacena

fetching less results to check the latests

d6456e3... by Thiago F. Pappacena

avoiding mixing MPs not included in the resultset (other status, for example)

af51100... by Thiago F. Pappacena

explanation about latest_proposals_for_each_branch

Unmerged commits

af51100... by Thiago F. Pappacena

explanation about latest_proposals_for_each_branch

d6456e3... by Thiago F. Pappacena

avoiding mixing MPs not included in the resultset (other status, for example)

63450a2... by Thiago F. Pappacena

fetching less results to check the latests

e03cd15... by Thiago F. Pappacena

removing wallclock-dependent test

ec5b051... by Thiago F. Pappacena

limiting data processed to display gitrepository-pending-merges.pt

b422150... by Thiago F. Pappacena

improving performance and adding optional limit to latest_proposals_for_each_branch

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/code/browser/branchmergeproposal.py b/lib/lp/code/browser/branchmergeproposal.py
index a1b41fb..498c637 100644
--- a/lib/lp/code/browser/branchmergeproposal.py
+++ b/lib/lp/code/browser/branchmergeproposal.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Views, navigation and actions for BranchMergeProposals."""4"""Views, navigation and actions for BranchMergeProposals."""
@@ -25,6 +25,7 @@ __all__ = [
25 'latest_proposals_for_each_branch',25 'latest_proposals_for_each_branch',
26 ]26 ]
2727
28from collections import OrderedDict
28from functools import wraps29from functools import wraps
29import operator30import operator
3031
@@ -35,6 +36,8 @@ from lazr.restful.interfaces import (
35 IWebServiceClientRequest,36 IWebServiceClientRequest,
36 )37 )
37import simplejson38import simplejson
39from storm.expr import Desc, Max, Select
40from storm.store import ResultSet
38from zope.component import (41from zope.component import (
39 adapter,42 adapter,
40 getMultiAdapter,43 getMultiAdapter,
@@ -57,7 +60,10 @@ from zope.schema.vocabulary import (
57 SimpleTerm,60 SimpleTerm,
58 SimpleVocabulary,61 SimpleVocabulary,
59 )62 )
6063from zope.security.proxy import (
64 isinstance as zope_isinstance,
65 removeSecurityProxy
66 )
61from lp import _67from lp import _
62from lp.app.browser.launchpadform import (68from lp.app.browser.launchpadform import (
63 action,69 action,
@@ -93,12 +99,14 @@ from lp.code.interfaces.codereviewinlinecomment import (
93 )99 )
94from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference100from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
95from lp.code.interfaces.diff import IPreviewDiff101from lp.code.interfaces.diff import IPreviewDiff
102from lp.code.model.branchmergeproposal import BranchMergeProposal
96from lp.registry.interfaces.person import IPersonSet103from lp.registry.interfaces.person import IPersonSet
97from lp.services.comments.interfaces.conversation import (104from lp.services.comments.interfaces.conversation import (
98 IComment,105 IComment,
99 IConversation,106 IConversation,
100 )107 )
101from lp.services.config import config108from lp.services.config import config
109from lp.services.database.decoratedresultset import DecoratedResultSet
102from lp.services.features import getFeatureFlag110from lp.services.features import getFeatureFlag
103from lp.services.job.interfaces.job import JobStatus111from lp.services.job.interfaces.job import JobStatus
104from lp.services.librarian.interfaces.client import LibrarianServerError112from lp.services.librarian.interfaces.client import LibrarianServerError
@@ -127,26 +135,64 @@ from lp.services.webapp.interfaces import ILaunchBag
127from lp.services.webapp.menu import NavigationMenu135from lp.services.webapp.menu import NavigationMenu
128136
129137
130def latest_proposals_for_each_branch(proposals):138def latest_proposals_for_each_branch(proposals, limit=None):
131 """Returns the most recent merge proposals for any particular branch.139 """Returns the most recent merge proposals for any particular branch.
132140
133 Also filters out proposals that the logged in user can't see.141 Also filters out proposals that the logged in user can't see.
142
143 This method uses 2 different approaches depending on the "proposals":
144 - If it's a ResultSet object, it writes a query and fetches the most
145 recent MPs from resultset directly from the database. So, it's safe
146 to have a reasonably big resultset of BranchMergeProposals
147
148 - If it's a iterable - but not a ResultSet -, this method will sort
149 the MPs [O(log n)], iterate over the sorted list [O(n)] and keep only
150 the most recent ones by date_created.
151
152 In summary, prefer to send ResultSets as parameter
134 """153 """
135 targets = {}154 if isinstance(proposals, DecoratedResultSet):
136 for proposal in proposals:155 proposals = removeSecurityProxy(proposals).get_plain_result_set()
137 # Don't show the proposal if the user can't see it.156 if zope_isinstance(proposals, ResultSet):
138 if not check_permission('launchpad.View', proposal):157 # From the proposal IDs, get the biggest one for each branch
139 continue158 proposals_ids = proposals.get_select_expr(BranchMergeProposal.id)
140 # Only show the most recent proposal for any given target.159 most_recents = Select(
141 date_created = proposal.date_created160 Max(BranchMergeProposal.id),
142 target = proposal.merge_target161 where=BranchMergeProposal.id.is_in(proposals_ids),
143162 group_by=[
144 if target not in targets or date_created > targets[target][1]:163 BranchMergeProposal.target_branchID,
145 targets[target] = (proposal, date_created)164 BranchMergeProposal.target_git_path,
146165 BranchMergeProposal.target_git_commit_sha1]
147 return sorted(166 )
148 [proposal for proposal, date_created in targets.itervalues()],167 proposals = proposals.find(
149 key=operator.attrgetter('date_created'), reverse=True)168 BranchMergeProposal.id.is_in(most_recents))
169 proposals = proposals.order_by(Desc(BranchMergeProposal.date_created))
170 # TODO: move this permission check to database
171 return [i for i in proposals if check_permission('launchpad.View', i)]
172 else:
173 # Sort by the most recent first
174 proposals = sorted(
175 [i for i in proposals if check_permission('launchpad.View', i)],
176 key=operator.attrgetter('date_created'),
177 reverse=True)
178 # OrderedDict to keep insertion order (that means: most recent
179 # proposals first)
180 targets = OrderedDict()
181 for proposal in proposals:
182 target = proposal.merge_target
183
184 # If we already inserted some target in the dict, it was for sure
185 # the most recent (since they are sorted). Skip here and avoid
186 # check_permission (since it's a bit more expensive than a hash
187 # lookup), limit check and any other processing.
188 if target in targets:
189 continue
190
191 if limit is not None and len(targets) >= limit:
192 break
193 targets[target] = proposal
194
195 return targets.values()
150196
151197
152class BranchMergeProposalBreadcrumb(Breadcrumb):198class BranchMergeProposalBreadcrumb(Breadcrumb):
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index c05dcc1..97c5da8 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Git repository views."""4"""Git repository views."""
@@ -364,6 +364,14 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
364 label = page_title364 label = page_title
365 show_merge_links = True365 show_merge_links = True
366366
367 # see `initialize_limits` for feature flags controlling these numbers
368 # Maximum branches to count. None for unlimited
369 landing_candidates_limit = None
370
371 # Maximum MPs to be displayed. None for unlimited
372 landing_targets_limit = None
373
374
367 def initialize(self):375 def initialize(self):
368 super(GitRepositoryView, self).initialize()376 super(GitRepositoryView, self).initialize()
369 # Cache permission so that the private team owner can be rendered. The377 # Cache permission so that the private team owner can be rendered. The
@@ -374,6 +382,30 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
374 precache_permission_for_objects(382 precache_permission_for_objects(
375 self.request, "launchpad.LimitedView", authorised_people)383 self.request, "launchpad.LimitedView", authorised_people)
376384
385 # None means "not calculated", -1 if above landing_candidates_limit
386 # Any other number is the actual number of MPs
387 self._landing_candidates_count = None
388 self.initialize_limits()
389
390 def get_limit_flag_value(self, flag_name):
391 """Gets the valid limit value from the given feature flag name"""
392 try:
393 flag_candidate_limit = int(getFeatureFlag(flag_name))
394 if flag_candidate_limit <= 0:
395 return None
396 return flag_candidate_limit
397 # int('') or int(None) raises those exceptions
398 except (ValueError, TypeError):
399 return None
400
401 def initialize_limits(self):
402 """Initialize landing_candidates_limit and landing_targets_limit
403 from feature flags (unlimited by default)"""
404 self.landing_candidates_limit = self.get_limit_flag_value(
405 'code.git.show_repository_mps.landing_candidates_limit')
406 self.landing_targets_limit = self.get_limit_flag_value(
407 'code.git.show_repository_mps.landing_targets_limit')
408
377 @property409 @property
378 def git_ssh_url(self):410 def git_ssh_url(self):
379 """The git+ssh:// URL for this repository, adjusted for this user."""411 """The git+ssh:// URL for this repository, adjusted for this user."""
@@ -418,9 +450,31 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
418450
419 @cachedproperty451 @cachedproperty
420 def landing_candidates(self):452 def landing_candidates(self):
453 """Returns up to {self.landing_candidates_limit} merge proposals,
454 and sets the landing_candidates_count_text accordingly
455
456 The way it limits is still not the most efficient (it fetches the
457 MPs, and filter here for check_permission), but it's ok for most
458 real-world cases.
459 Ideally, we should filter this directly on the database"""
460 limit = self.landing_candidates_limit
421 candidates = self.context.getPrecachedLandingCandidates(self.user)461 candidates = self.context.getPrecachedLandingCandidates(self.user)
422 return [proposal for proposal in candidates462 all_proposals = []
423 if check_permission("launchpad.View", proposal)]463 self._landing_candidates_count = None
464 i = 0
465 for proposal in candidates:
466 if not check_permission("launchpad.View", proposal):
467 continue
468 i += 1
469 all_proposals.append(proposal)
470 if limit is not None and i >= limit:
471 # If we don't know the total (and only know that it's above
472 # the limit), let's mark it as -1
473 self._landing_candidates_count = -1
474 break
475 if self._landing_candidates_count is None:
476 self._landing_candidates_count = len(all_proposals)
477 return all_proposals
424478
425 def _getBranchCountText(self, count):479 def _getBranchCountText(self, count):
426 """Help to show user friendly text."""480 """Help to show user friendly text."""
@@ -428,19 +482,24 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
428 return 'No branches'482 return 'No branches'
429 elif count == 1:483 elif count == 1:
430 return '1 branch'484 return '1 branch'
485 elif count == -1:
486 return '%s branches or more' % self.landing_candidates_limit
431 else:487 else:
432 return '%s branches' % count488 return '%s branches' % count
433489
434 @cachedproperty490 @cachedproperty
435 def landing_candidate_count_text(self):491 def landing_candidate_count_text(self):
436 return self._getBranchCountText(len(self.landing_candidates))492 if self._landing_candidates_count is None:
493 self.landing_candidates # call the property to calculate count
494 return self._getBranchCountText(self._landing_candidates_count)
437495
438 @cachedproperty496 @cachedproperty
439 def landing_targets(self):497 def landing_targets(self):
440 """Return a filtered list of active landing targets."""498 """Return a filtered list of active landing targets."""
441 targets = self.context.getPrecachedLandingTargets(499 targets = self.context.getPrecachedLandingTargets(
442 self.user, only_active=True)500 self.user, only_active=True)
443 return latest_proposals_for_each_branch(targets)501 return latest_proposals_for_each_branch(
502 targets, limit=self.landing_targets_limit)
444503
445 @property504 @property
446 def show_rescan_link(self):505 def show_rescan_link(self):
diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
index a38120c..d100088 100644
--- a/lib/lp/code/browser/tests/test_gitrepository.py
+++ b/lib/lp/code/browser/tests/test_gitrepository.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Unit tests for GitRepositoryView."""4"""Unit tests for GitRepositoryView."""
@@ -8,7 +8,10 @@ from __future__ import absolute_import, print_function, unicode_literals
8__metaclass__ = type8__metaclass__ = type
99
10import base6410import base64
11from datetime import datetime11from datetime import (
12 datetime,
13 timedelta,
14 )
12import doctest15import doctest
13from itertools import chain16from itertools import chain
14from operator import attrgetter17from operator import attrgetter
@@ -24,6 +27,7 @@ from testtools.matchers import (
24 DocTestMatches,27 DocTestMatches,
25 Equals,28 Equals,
26 Is,29 Is,
30 LessThan,
27 MatchesDict,31 MatchesDict,
28 MatchesListwise,32 MatchesListwise,
29 MatchesSetwise,33 MatchesSetwise,
@@ -349,11 +353,65 @@ class TestGitRepositoryView(BrowserTestCase):
349 set_state=BranchMergeProposalStatus.NEEDS_REVIEW)353 set_state=BranchMergeProposalStatus.NEEDS_REVIEW)
350 with FeatureFixture({"code.git.show_repository_mps": "on"}):354 with FeatureFixture({"code.git.show_repository_mps": "on"}):
351 with person_logged_in(target_repository.owner):355 with person_logged_in(target_repository.owner):
352 browser = self.getViewBrowser(356 with StormStatementRecorder() as recorder:
353 source_repository, user=source_repository.owner)357 browser = self.getViewBrowser(
358 source_repository, user=source_repository.owner)
359 self.assertThat(recorder, HasQueryCount(LessThan(47)))
354 self.assertIsNotNone(360 self.assertIsNotNone(
355 find_tag_by_id(browser.contents, 'landing-targets'))361 find_tag_by_id(browser.contents, 'landing-targets'))
356362
363 def test_view_with_landing_targets_multiple_mps_performance(self):
364 """This is a performance test to make sure the Git repo page with
365 MPs has load time close enough to the load time when we disable the
366 MPs.
367
368 To reduce the likelyhood of this test breaking because of machine
369 circunstances or at the buildbot, the marging is 10%, which should
370 be good enough for this test scenario"""
371 product = self.factory.makeProduct(name="foo", vcs=VCSType.GIT)
372 target_repository = self.factory.makeGitRepository(target=product)
373 source_repository = self.factory.makeGitRepository(target=product)
374 # creates 200 branches in each repo, and 100 MP
375 for i in range(100):
376 [source_master, source_devel] = self.factory.makeGitRefs(
377 source_repository,
378 paths=["refs/heads/master%s" % i, "refs/heads/devel%s" % i])
379 [target_master, target_devel] = self.factory.makeGitRefs(
380 target_repository,
381 paths=["refs/heads/master%s" % i, "refs/heads/develop%s" % i])
382
383 self.factory.makeBranchMergeProposalForGit(
384 target_ref=source_master,
385 source_ref=source_devel,
386 set_state=BranchMergeProposalStatus.NEEDS_REVIEW)
387
388 feature_flags = {
389 "code.git.show_repository_mps": "on",
390 "code.git.show_repository_mps.landing_candidates_limit": "10",
391 "code.git.show_repository_mps.landing_targets_limit": "10"}
392 with FeatureFixture(feature_flags):
393 with person_logged_in(target_repository.owner):
394 with StormStatementRecorder() as recorder:
395 start_time = datetime.now()
396 browser = self.getViewBrowser(
397 source_repository, user=source_repository.owner)
398 rendering_time_with_mp = datetime.now() - start_time
399
400 self.assertThat(recorder, HasQueryCount(Equals(56)))
401 self.assertIsNotNone(
402 find_tag_by_id(browser.contents, 'landing-targets'))
403
404 summary_fragments = find_tags_by_class(
405 browser.contents, "branchmergepropostal-summary-fragment")
406 self.assertEqual(10, len(summary_fragments))
407
408 link_match = soupmatchers.HTMLContains(
409 soupmatchers.Tag(
410 "link to branches", "a",
411 text=re.compile("10 branches or more")),
412 )
413 self.assertThat(browser.contents, link_match)
414
357 def test_landing_targets_query_count(self):415 def test_landing_targets_query_count(self):
358 product = self.factory.makeProduct(name="foo", vcs=VCSType.GIT)416 product = self.factory.makeProduct(name="foo", vcs=VCSType.GIT)
359 target_repository = self.factory.makeGitRepository(target=product)417 target_repository = self.factory.makeGitRepository(target=product)
diff --git a/lib/lp/code/templates/branchmergeproposal-summary-fragment.pt b/lib/lp/code/templates/branchmergeproposal-summary-fragment.pt
index 355c0c0..1f4c0fc 100644
--- a/lib/lp/code/templates/branchmergeproposal-summary-fragment.pt
+++ b/lib/lp/code/templates/branchmergeproposal-summary-fragment.pt
@@ -3,7 +3,7 @@
3 xmlns:metal="http://xml.zope.org/namespaces/metal"3 xmlns:metal="http://xml.zope.org/namespaces/metal"
4 xmlns:i18n="http://xml.zope.org/namespaces/i18n">4 xmlns:i18n="http://xml.zope.org/namespaces/i18n">
55
6 <div>6 <div class="branchmergepropostal-summary-fragment">
7 <tal:merge-fragment tal:replace="structure context/@@+link-summary" />7 <tal:merge-fragment tal:replace="structure context/@@+link-summary" />
8 </div>8 </div>
9 <tal:merge-fragment tal:replace="structure context/@@+vote-summary" />9 <tal:merge-fragment tal:replace="structure context/@@+vote-summary" />