Merge lp:~adeuring/launchpad/bug-739052-9 into lp:launchpad

Proposed by Abel Deuring
Status: Merged
Approved by: Abel Deuring
Approved revision: no longer in the source branch.
Merged at revision: 14014
Proposed branch: lp:~adeuring/launchpad/bug-739052-9
Merge into: lp:launchpad
Diff against target: 332 lines (+145/-25)
6 files modified
lib/canonical/launchpad/webapp/batching.py (+20/-15)
lib/canonical/launchpad/webapp/tests/test_batching.py (+38/-0)
lib/lp/soyuz/browser/build.py (+19/-2)
lib/lp/soyuz/browser/configure.zcml (+1/-1)
lib/lp/soyuz/browser/tests/test_build_views.py (+47/-0)
lib/lp/soyuz/model/binarypackagebuild.py (+20/-7)
To merge this branch: bzr merge lp:~adeuring/launchpad/bug-739052-9
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Review via email: mp+76241@code.launchpad.net

Commit message

[r=lifeless][bug=739052] Use StormRangeFactory for the Distribution:+builds vie

Description of the change

This branch fixes bug 739052: Distribution:+builds timeout

The timeout is the "typical" SQL timeout, which we have more
or less frequently for queries with large resultsets.

The core of the change is that the view for pages like
https://launchpad.net/ubuntu/+builds now uses StormRangeFactory
to retrieve a batch of the result set, instead of the default
ListRangeFactory.

Working on this core fix, I noticed two problems in StormRangeFactory:

1. StormRangeFactory.getSliceByIndex() did not work with plain Storm
ResultSets, only with DecoratedResultSets.

2. StormRangeFactory.rough_length did not work with DISTINCT queries
that are ordered by more than one column.

Both problems are fixed; I also noticed that StormRangeFactory
had accumulated several lines like

    if zope_isinstance(expression, Desc):
        expression = expression.epxr

so I added a helper function plain_expression().

The next change affects the code which generates the ResultSet used
in the view: StormRangeFactory needs result sets where the order
expressions are simple Storm Column instances (or Desc(column).
Things like

    resultset.order_by('Person.id')

do not work.

Also, StormRangeFactory works only with Storm result sets, but
not with legacy SQLBase.select() results.

The result set of "our" view is created by
BinaryPackageBuild.getBuildsByArchIds(), and this method used
resultset.order_by('a string'). Out of paranoia, I also added
BinaryPackageBuild.id as an order_by() argument where the only
sort expression was a timestamp.

I was not sure where to add a test for this change: Since the
change is in model code, the test could be added to the tests
of BinaryPackageBuild itself.

On the other hand, the change is _needed_ for browser code, so
the test can also be added to the unit tests of the view class.

I opted for the latter: The test becomes a bit pointless, should
the view class (or some "intermediate code") decide to no longer
call BinaryPackageBuild.getBuildsByArchIds() but to use instead
something-completely-different.

The test nevertheless makes an assumption about what needs to be
tested: That the sorting depending on the parameter "build status"
but on nothing else.

The last change is a new class DistributionBuildRecordsView, which
is now registered in ZCML as the view for the +builds page of
IDistribtuions: BuildRecordsView itself is used also for
IDistroArchSeries, and we have four classes for other contexts
that are derived from BuildRecordsView.

If I would have changed BuildRecordsView itself to use
StormRangeFactory, I would have had to check that all five other
contexts return result sets with proper order_by() parameters.
And I know there is at least one method that needs to be changed,
so, to keep the size of the change "within limits", I decided
to define a new class property "range_factory", which is
by default ListRangeFactory, while the new class
DistributionBuildRecordsView uses StormrangeFactory.

tests:

./bin/test soyuz -vvt test_build_views

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

\o/ \o/ \o/ \o/

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/webapp/batching.py'
--- lib/canonical/launchpad/webapp/batching.py 2011-09-14 15:37:11 +0000
+++ lib/canonical/launchpad/webapp/batching.py 2011-09-20 15:15:27 +0000
@@ -243,6 +243,14 @@
243 self.shadow_values.reverse()243 self.shadow_values.reverse()
244244
245245
246def plain_expression(expression):
247 """Strip an optional DESC() from an expression."""
248 if isinstance(expression, Desc):
249 return expression.expr
250 else:
251 return expression
252
253
246class StormRangeFactory:254class StormRangeFactory:
247 """A range factory for Storm result sets.255 """A range factory for Storm result sets.
248256
@@ -304,8 +312,7 @@
304 row = (row, )312 row = (row, )
305 sort_expressions = self.getOrderBy()313 sort_expressions = self.getOrderBy()
306 for expression in sort_expressions:314 for expression in sort_expressions:
307 if zope_isinstance(expression, Desc):315 expression = plain_expression(expression)
308 expression = expression.expr
309 if not zope_isinstance(expression, PropertyColumn):316 if not zope_isinstance(expression, PropertyColumn):
310 raise StormRangeFactoryError(317 raise StormRangeFactoryError(
311 'StormRangeFactory only supports sorting by '318 'StormRangeFactory only supports sorting by '
@@ -372,8 +379,7 @@
372379
373 converted_memo = []380 converted_memo = []
374 for expression, value in zip(sort_expressions, parsed_memo):381 for expression, value in zip(sort_expressions, parsed_memo):
375 if isinstance(expression, Desc):382 expression = plain_expression(expression)
376 expression = expression.expr
377 try:383 try:
378 expression.variable_factory(value=value)384 expression.variable_factory(value=value)
379 except TypeError, error:385 except TypeError, error:
@@ -452,11 +458,6 @@
452458
453 def equalsExpressionsFromLimits(self, limits):459 def equalsExpressionsFromLimits(self, limits):
454 """Return a list [expression == memo, ...] for the given limits."""460 """Return a list [expression == memo, ...] for the given limits."""
455 def plain_expression(expression):
456 if isinstance(expression, Desc):
457 return expression.expr
458 else:
459 return expression
460461
461 result = []462 result = []
462 for expressions, memos in limits:463 for expressions, memos in limits:
@@ -570,19 +571,23 @@
570 def getSliceByIndex(self, start, end):571 def getSliceByIndex(self, start, end):
571 """See `IRangeFactory."""572 """See `IRangeFactory."""
572 sliced = self.resultset[start:end]573 sliced = self.resultset[start:end]
573 return ShadowedList(list(sliced), list(sliced.get_plain_result_set()))574 if zope_isinstance(sliced, DecoratedResultSet):
575 return ShadowedList(
576 list(sliced), list(sliced.get_plain_result_set()))
577 sliced = list(sliced)
578 return ShadowedList(sliced, sliced)
574579
575 @cachedproperty580 @cachedproperty
576 def rough_length(self):581 def rough_length(self):
577 """See `IRangeFactory."""582 """See `IRangeFactory."""
578 # get_select_expr() requires at least one column as a parameter.583 # get_select_expr() requires at least one column as a parameter.
579 # getorderBy() already knows about columns that can appear584 # getorderBy() already knows about columns that can appear
580 # in the result set, let's take just the first one.585 # in the result set, so let's use them. Moreover, for SELECT
581 column = self.getOrderBy()[0]586 # DISTINCT queries, each column used for sorting must appear
582 if zope_isinstance(column, Desc):587 # in the result.
583 column = column.expr588 columns = [plain_expression(column) for column in self.getOrderBy()]
584 select = removeSecurityProxy(self.plain_resultset).get_select_expr(589 select = removeSecurityProxy(self.plain_resultset).get_select_expr(
585 column)590 *columns)
586 explain = 'EXPLAIN ' + convert_storm_clause_to_string(select)591 explain = 'EXPLAIN ' + convert_storm_clause_to_string(select)
587 store = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)592 store = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
588 result = store.execute(explain)593 result = store.execute(explain)
589594
=== modified file 'lib/canonical/launchpad/webapp/tests/test_batching.py'
--- lib/canonical/launchpad/webapp/tests/test_batching.py 2011-09-14 15:37:11 +0000
+++ lib/canonical/launchpad/webapp/tests/test_batching.py 2011-09-20 15:15:27 +0000
@@ -747,3 +747,41 @@
747 estimated_length = range_factory.rough_length747 estimated_length = range_factory.rough_length
748 self.assertThat(estimated_length, LessThan(10))748 self.assertThat(estimated_length, LessThan(10))
749 self.assertThat(estimated_length, Not(LessThan(1)))749 self.assertThat(estimated_length, Not(LessThan(1)))
750
751 def test_rough_length_distinct_query(self):
752 # StormRangeFactory.rough_length with SELECT DISTINCT queries.
753 resultset = self.makeStormResultSet()
754 resultset.config(distinct=True)
755 resultset.order_by(Person.name, Person.id)
756 range_factory = StormRangeFactory(resultset)
757 estimated_length = range_factory.rough_length
758 self.assertThat(estimated_length, LessThan(10))
759 self.assertThat(estimated_length, Not(LessThan(1)))
760
761 def test_getSliceByIndex__storm_result_set(self):
762 # StormRangeFactory.getSliceByIndex() returns a slice of the
763 # resultset, wrapped into a ShadowedList. For plain Storm
764 # result sets, the main values and the shadow values are both
765 # the corresponding elements of the result set.
766 resultset = self.makeStormResultSet()
767 all_results = list(resultset)
768 range_factory = StormRangeFactory(resultset)
769 sliced = range_factory.getSliceByIndex(2, 4)
770 self.assertIsInstance(sliced, ShadowedList)
771 self.assertEqual(all_results[2:4], list(sliced))
772 self.assertEqual(all_results[2:4], sliced.shadow_values)
773
774 def test_getSliceByIndex__decorated_result_set(self):
775 # StormRangeFactory.getSliceByIndex() returns a slice of the
776 # resultset, wrapped into a ShadowedList. For decorated Storm
777 # result sets, the main values are the corresponding values of
778 # the decorated result set and the shadow values are the
779 # corresponding elements of the plain result set.
780 resultset = self.makeDecoratedStormResultSet()
781 all_results = list(resultset)
782 all_undecorated_results = list(resultset.get_plain_result_set())
783 range_factory = StormRangeFactory(resultset)
784 sliced = range_factory.getSliceByIndex(2, 4)
785 self.assertIsInstance(sliced, ShadowedList)
786 self.assertEqual(all_results[2:4], list(sliced))
787 self.assertEqual(all_undecorated_results[2:4], sliced.shadow_values)
750788
=== modified file 'lib/lp/soyuz/browser/build.py'
--- lib/lp/soyuz/browser/build.py 2011-09-13 05:23:16 +0000
+++ lib/lp/soyuz/browser/build.py 2011-09-20 15:15:27 +0000
@@ -14,8 +14,10 @@
14 'BuildRescoringView',14 'BuildRescoringView',
15 'BuildUrl',15 'BuildUrl',
16 'BuildView',16 'BuildView',
17 'DistributionBuildRecordsView',
17 ]18 ]
1819
20from lazr.batchnavigator import ListRangeFactory
19from lazr.delegates import delegates21from lazr.delegates import delegates
20from lazr.restful.utils import safe_hasattr22from lazr.restful.utils import safe_hasattr
21from zope.component import getUtility23from zope.component import getUtility
@@ -37,7 +39,10 @@
37 stepthrough,39 stepthrough,
38 )40 )
39from canonical.launchpad.webapp.authorization import check_permission41from canonical.launchpad.webapp.authorization import check_permission
40from canonical.launchpad.webapp.batching import BatchNavigator42from canonical.launchpad.webapp.batching import (
43 BatchNavigator,
44 StormRangeFactory,
45 )
41from canonical.launchpad.webapp.breadcrumb import Breadcrumb46from canonical.launchpad.webapp.breadcrumb import Breadcrumb
42from canonical.launchpad.webapp.interfaces import ICanonicalUrlData47from canonical.launchpad.webapp.interfaces import ICanonicalUrlData
43from lp.app.browser.launchpadform import (48from lp.app.browser.launchpadform import (
@@ -456,6 +461,8 @@
456 # only, but subclasses can set this if desired.461 # only, but subclasses can set this if desired.
457 binary_only = True462 binary_only = True
458463
464 range_factory = ListRangeFactory
465
459 @property466 @property
460 def label(self):467 def label(self):
461 return 'Builds for %s' % self.context.displayname468 return 'Builds for %s' % self.context.displayname
@@ -486,7 +493,8 @@
486 builds = self.context.getBuildRecords(493 builds = self.context.getBuildRecords(
487 build_state=self.state, name=self.text, arch_tag=self.arch_tag,494 build_state=self.state, name=self.text, arch_tag=self.arch_tag,
488 user=self.user, binary_only=binary_only)495 user=self.user, binary_only=binary_only)
489 self.batchnav = BatchNavigator(builds, self.request)496 self.batchnav = BatchNavigator(
497 builds, self.request, range_factory=self.range_factory(builds))
490 # We perform this extra step because we don't what to issue one498 # We perform this extra step because we don't what to issue one
491 # extra query to retrieve the BuildQueue for each Build (batch item)499 # extra query to retrieve the BuildQueue for each Build (batch item)
492 # A more elegant approach should be extending Batching class and500 # A more elegant approach should be extending Batching class and
@@ -639,3 +647,12 @@
639 @property647 @property
640 def no_results(self):648 def no_results(self):
641 return self.form_submitted and not self.complete_builds649 return self.form_submitted and not self.complete_builds
650
651
652class DistributionBuildRecordsView(BuildRecordsView):
653 """See BuildRecordsView."""
654
655 # SQL Queries generated by the default ListRangeFactory time out
656 # for some views, like +builds?build_state=all. StormRangeFactory
657 # is more efficient.
658 range_factory = StormRangeFactory
642659
=== modified file 'lib/lp/soyuz/browser/configure.zcml'
--- lib/lp/soyuz/browser/configure.zcml 2011-09-19 14:29:47 +0000
+++ lib/lp/soyuz/browser/configure.zcml 2011-09-20 15:15:27 +0000
@@ -785,7 +785,7 @@
785 </browser:pages>785 </browser:pages>
786 <browser:pages786 <browser:pages
787 for="lp.registry.interfaces.distribution.IDistribution"787 for="lp.registry.interfaces.distribution.IDistribution"
788 class="lp.soyuz.browser.build.BuildRecordsView"788 class="lp.soyuz.browser.build.DistributionBuildRecordsView"
789 permission="zope.Public">789 permission="zope.Public">
790 <browser:page790 <browser:page
791 name="+builds"791 name="+builds"
792792
=== modified file 'lib/lp/soyuz/browser/tests/test_build_views.py'
--- lib/lp/soyuz/browser/tests/test_build_views.py 2011-07-13 18:54:37 +0000
+++ lib/lp/soyuz/browser/tests/test_build_views.py 2011-09-20 15:15:27 +0000
@@ -8,12 +8,18 @@
8 timedelta,8 timedelta,
9 )9 )
10import pytz10import pytz
11from testtools.matchers import (
12 MatchesException,
13 Not,
14 Raises,
15 )
11from zope.component import (16from zope.component import (
12 getMultiAdapter,17 getMultiAdapter,
13 getUtility,18 getUtility,
14 )19 )
15from zope.security.proxy import removeSecurityProxy20from zope.security.proxy import removeSecurityProxy
1621
22from canonical.launchpad.webapp.interfaces import StormRangeFactoryError
17from canonical.launchpad.webapp import canonical_url23from canonical.launchpad.webapp import canonical_url
18from canonical.launchpad.webapp.servers import LaunchpadTestRequest24from canonical.launchpad.webapp.servers import LaunchpadTestRequest
19from canonical.testing.layers import LaunchpadFunctionalLayer25from canonical.testing.layers import LaunchpadFunctionalLayer
@@ -291,3 +297,44 @@
291 expected_url = canonical_url(build)297 expected_url = canonical_url(build)
292 browser = self.getUserBrowser(url)298 browser = self.getUserBrowser(url)
293 self.assertEquals(expected_url, browser.url)299 self.assertEquals(expected_url, browser.url)
300
301 def test_DistributionBuildRecordsView__range_factory(self):
302 # DistributionBuildRecordsView works with StormRangeFactory:
303 # StormRangeFactory requires result sets where the sort order
304 # is specified by Storm Column objects or by Desc(storm_column).
305 # DistributionBuildRecordsView gets its resultset from
306 # IDistribution.getBuildRecords(); the sort order of the
307 # result set depends on the parameter build_state.
308 # The order expressions for all possible values of build_state
309 # are usable by StormRangeFactory.
310 distroseries = self.factory.makeDistroSeries()
311 distribution = distroseries.distribution
312 das = self.factory.makeDistroArchSeries(distroseries=distroseries)
313 for status in BuildStatus.items:
314 build = self.factory.makeBinaryPackageBuild(
315 distroarchseries=das, archive=distroseries.main_archive,
316 status=status)
317 # BPBs in certain states need a bit tweaking to appear in
318 # the result of getBuildRecords().
319 if status == BuildStatus.FULLYBUILT:
320 with person_logged_in(self.admin):
321 build.date_started = (
322 datetime.now(pytz.UTC) - timedelta(hours=1))
323 build.date_finished = datetime.now(pytz.UTC)
324 elif status in (BuildStatus.NEEDSBUILD, BuildStatus.BUILDING):
325 build.queueBuild()
326 for status in ('built', 'failed', 'depwait', 'chrootwait',
327 'superseded', 'uploadfail', 'all', 'building',
328 'pending'):
329 view = create_initialized_view(
330 distribution, name="+builds", form={'build_state': status})
331 view.setupBuildList()
332 range_factory = view.batchnav.batch.range_factory
333
334 def test_range_factory():
335 row = range_factory.resultset.get_plain_result_set()[0]
336 range_factory.getOrderValuesFor(row)
337
338 self.assertThat(
339 test_range_factory,
340 Not(Raises(MatchesException(StormRangeFactoryError))))
294341
=== modified file 'lib/lp/soyuz/model/binarypackagebuild.py'
--- lib/lp/soyuz/model/binarypackagebuild.py 2011-08-17 13:43:13 +0000
+++ lib/lp/soyuz/model/binarypackagebuild.py 2011-09-20 15:15:27 +0000
@@ -29,9 +29,9 @@
29 EmptyResultSet,29 EmptyResultSet,
30 Store,30 Store,
31 )31 )
32from storm.zope import IResultSet
32from zope.component import getUtility33from zope.component import getUtility
33from zope.interface import implements34from zope.interface import implements
34from zope.security.proxy import removeSecurityProxy
3535
36from canonical.config import config36from canonical.config import config
37from canonical.database.sqlbase import (37from canonical.database.sqlbase import (
@@ -1021,16 +1021,21 @@
1021 BuildStatus.NEEDSBUILD,1021 BuildStatus.NEEDSBUILD,
1022 BuildStatus.BUILDING,1022 BuildStatus.BUILDING,
1023 BuildStatus.UPLOADING]:1023 BuildStatus.UPLOADING]:
1024 orderBy = ["-BuildQueue.lastscore", "BinaryPackageBuild.id"]1024 order_by = [Desc(BuildQueue.lastscore), BinaryPackageBuild.id]
1025 order_by_table = BuildQueue
1025 clauseTables.append('BuildQueue')1026 clauseTables.append('BuildQueue')
1026 clauseTables.append('BuildPackageJob')1027 clauseTables.append('BuildPackageJob')
1027 condition_clauses.append(1028 condition_clauses.append(
1028 'BuildPackageJob.build = BinaryPackageBuild.id')1029 'BuildPackageJob.build = BinaryPackageBuild.id')
1029 condition_clauses.append('BuildPackageJob.job = BuildQueue.job')1030 condition_clauses.append('BuildPackageJob.job = BuildQueue.job')
1030 elif status == BuildStatus.SUPERSEDED or status is None:1031 elif status == BuildStatus.SUPERSEDED or status is None:
1031 orderBy = ["-BuildFarmJob.date_created"]1032 order_by = [Desc(BuildFarmJob.date_created),
1033 BinaryPackageBuild.id]
1034 order_by_table = BuildFarmJob
1032 else:1035 else:
1033 orderBy = ["-BuildFarmJob.date_finished"]1036 order_by = [Desc(BuildFarmJob.date_finished),
1037 BinaryPackageBuild.id]
1038 order_by_table = BuildFarmJob
10341039
1035 # End of duplication (see XXX cprov 2006-09-25 above).1040 # End of duplication (see XXX cprov 2006-09-25 above).
10361041
@@ -1043,14 +1048,22 @@
1043 "PackageBuild.archive IN %s" %1048 "PackageBuild.archive IN %s" %
1044 sqlvalues(list(distribution.all_distro_archive_ids)))1049 sqlvalues(list(distribution.all_distro_archive_ids)))
10451050
1051 store = Store.of(distribution)
1052 clauseTables = [BinaryPackageBuild] + clauseTables
1053 result_set = store.using(*clauseTables).find(
1054 (BinaryPackageBuild, order_by_table), *condition_clauses)
1055 result_set.order_by(*order_by)
1056
1057 def get_bpp(result_row):
1058 return result_row[0]
1059
1046 return self._decorate_with_prejoins(1060 return self._decorate_with_prejoins(
1047 BinaryPackageBuild.select(' AND '.join(condition_clauses),1061 DecoratedResultSet(result_set, result_decorator=get_bpp))
1048 clauseTables=clauseTables, orderBy=orderBy))
10491062
1050 def _decorate_with_prejoins(self, result_set):1063 def _decorate_with_prejoins(self, result_set):
1051 """Decorate build records with related data prefetch functionality."""1064 """Decorate build records with related data prefetch functionality."""
1052 # Grab the native storm result set.1065 # Grab the native storm result set.
1053 result_set = removeSecurityProxy(result_set)._result_set1066 result_set = IResultSet(result_set)
1054 decorated_results = DecoratedResultSet(1067 decorated_results = DecoratedResultSet(
1055 result_set, pre_iter_hook=self._prefetchBuildData)1068 result_set, pre_iter_hook=self._prefetchBuildData)
1056 return decorated_results1069 return decorated_results