launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #05036
[Merge] lp:~adeuring/launchpad/bug-739052-9 into lp:launchpad
Abel Deuring has proposed merging lp:~adeuring/launchpad/bug-739052-9 into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~adeuring/launchpad/bug-739052-9/+merge/76241
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
--
https://code.launchpad.net/~adeuring/launchpad/bug-739052-9/+merge/76241
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~adeuring/launchpad/bug-739052-9 into lp:launchpad.
=== 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 @@
self.shadow_values.reverse()
+def plain_expression(expression):
+ """Strip an optional DESC() from an expression."""
+ if isinstance(expression, Desc):
+ return expression.expr
+ else:
+ return expression
+
+
class StormRangeFactory:
"""A range factory for Storm result sets.
@@ -304,8 +312,7 @@
row = (row, )
sort_expressions = self.getOrderBy()
for expression in sort_expressions:
- if zope_isinstance(expression, Desc):
- expression = expression.expr
+ expression = plain_expression(expression)
if not zope_isinstance(expression, PropertyColumn):
raise StormRangeFactoryError(
'StormRangeFactory only supports sorting by '
@@ -372,8 +379,7 @@
converted_memo = []
for expression, value in zip(sort_expressions, parsed_memo):
- if isinstance(expression, Desc):
- expression = expression.expr
+ expression = plain_expression(expression)
try:
expression.variable_factory(value=value)
except TypeError, error:
@@ -452,11 +458,6 @@
def equalsExpressionsFromLimits(self, limits):
"""Return a list [expression == memo, ...] for the given limits."""
- def plain_expression(expression):
- if isinstance(expression, Desc):
- return expression.expr
- else:
- return expression
result = []
for expressions, memos in limits:
@@ -570,19 +571,23 @@
def getSliceByIndex(self, start, end):
"""See `IRangeFactory."""
sliced = self.resultset[start:end]
- return ShadowedList(list(sliced), list(sliced.get_plain_result_set()))
+ if zope_isinstance(sliced, DecoratedResultSet):
+ return ShadowedList(
+ list(sliced), list(sliced.get_plain_result_set()))
+ sliced = list(sliced)
+ return ShadowedList(sliced, sliced)
@cachedproperty
def rough_length(self):
"""See `IRangeFactory."""
# get_select_expr() requires at least one column as a parameter.
# getorderBy() already knows about columns that can appear
- # in the result set, let's take just the first one.
- column = self.getOrderBy()[0]
- if zope_isinstance(column, Desc):
- column = column.expr
+ # in the result set, so let's use them. Moreover, for SELECT
+ # DISTINCT queries, each column used for sorting must appear
+ # in the result.
+ columns = [plain_expression(column) for column in self.getOrderBy()]
select = removeSecurityProxy(self.plain_resultset).get_select_expr(
- column)
+ *columns)
explain = 'EXPLAIN ' + convert_storm_clause_to_string(select)
store = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
result = store.execute(explain)
=== 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 @@
estimated_length = range_factory.rough_length
self.assertThat(estimated_length, LessThan(10))
self.assertThat(estimated_length, Not(LessThan(1)))
+
+ def test_rough_length_distinct_query(self):
+ # StormRangeFactory.rough_length with SELECT DISTINCT queries.
+ resultset = self.makeStormResultSet()
+ resultset.config(distinct=True)
+ resultset.order_by(Person.name, Person.id)
+ range_factory = StormRangeFactory(resultset)
+ estimated_length = range_factory.rough_length
+ self.assertThat(estimated_length, LessThan(10))
+ self.assertThat(estimated_length, Not(LessThan(1)))
+
+ def test_getSliceByIndex__storm_result_set(self):
+ # StormRangeFactory.getSliceByIndex() returns a slice of the
+ # resultset, wrapped into a ShadowedList. For plain Storm
+ # result sets, the main values and the shadow values are both
+ # the corresponding elements of the result set.
+ resultset = self.makeStormResultSet()
+ all_results = list(resultset)
+ range_factory = StormRangeFactory(resultset)
+ sliced = range_factory.getSliceByIndex(2, 4)
+ self.assertIsInstance(sliced, ShadowedList)
+ self.assertEqual(all_results[2:4], list(sliced))
+ self.assertEqual(all_results[2:4], sliced.shadow_values)
+
+ def test_getSliceByIndex__decorated_result_set(self):
+ # StormRangeFactory.getSliceByIndex() returns a slice of the
+ # resultset, wrapped into a ShadowedList. For decorated Storm
+ # result sets, the main values are the corresponding values of
+ # the decorated result set and the shadow values are the
+ # corresponding elements of the plain result set.
+ resultset = self.makeDecoratedStormResultSet()
+ all_results = list(resultset)
+ all_undecorated_results = list(resultset.get_plain_result_set())
+ range_factory = StormRangeFactory(resultset)
+ sliced = range_factory.getSliceByIndex(2, 4)
+ self.assertIsInstance(sliced, ShadowedList)
+ self.assertEqual(all_results[2:4], list(sliced))
+ self.assertEqual(all_undecorated_results[2:4], sliced.shadow_values)
=== 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 @@
'BuildRescoringView',
'BuildUrl',
'BuildView',
+ 'DistributionBuildRecordsView',
]
+from lazr.batchnavigator import ListRangeFactory
from lazr.delegates import delegates
from lazr.restful.utils import safe_hasattr
from zope.component import getUtility
@@ -37,7 +39,10 @@
stepthrough,
)
from canonical.launchpad.webapp.authorization import check_permission
-from canonical.launchpad.webapp.batching import BatchNavigator
+from canonical.launchpad.webapp.batching import (
+ BatchNavigator,
+ StormRangeFactory,
+ )
from canonical.launchpad.webapp.breadcrumb import Breadcrumb
from canonical.launchpad.webapp.interfaces import ICanonicalUrlData
from lp.app.browser.launchpadform import (
@@ -456,6 +461,8 @@
# only, but subclasses can set this if desired.
binary_only = True
+ range_factory = ListRangeFactory
+
@property
def label(self):
return 'Builds for %s' % self.context.displayname
@@ -486,7 +493,8 @@
builds = self.context.getBuildRecords(
build_state=self.state, name=self.text, arch_tag=self.arch_tag,
user=self.user, binary_only=binary_only)
- self.batchnav = BatchNavigator(builds, self.request)
+ self.batchnav = BatchNavigator(
+ builds, self.request, range_factory=self.range_factory(builds))
# We perform this extra step because we don't what to issue one
# extra query to retrieve the BuildQueue for each Build (batch item)
# A more elegant approach should be extending Batching class and
@@ -639,3 +647,12 @@
@property
def no_results(self):
return self.form_submitted and not self.complete_builds
+
+
+class DistributionBuildRecordsView(BuildRecordsView):
+ """See BuildRecordsView."""
+
+ # SQL Queries generated by the default ListRangeFactory time out
+ # for some views, like +builds?build_state=all. StormRangeFactory
+ # is more efficient.
+ range_factory = StormRangeFactory
=== 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 @@
</browser:pages>
<browser:pages
for="lp.registry.interfaces.distribution.IDistribution"
- class="lp.soyuz.browser.build.BuildRecordsView"
+ class="lp.soyuz.browser.build.DistributionBuildRecordsView"
permission="zope.Public">
<browser:page
name="+builds"
=== 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 @@
timedelta,
)
import pytz
+from testtools.matchers import (
+ MatchesException,
+ Not,
+ Raises,
+ )
from zope.component import (
getMultiAdapter,
getUtility,
)
from zope.security.proxy import removeSecurityProxy
+from canonical.launchpad.webapp.interfaces import StormRangeFactoryError
from canonical.launchpad.webapp import canonical_url
from canonical.launchpad.webapp.servers import LaunchpadTestRequest
from canonical.testing.layers import LaunchpadFunctionalLayer
@@ -291,3 +297,44 @@
expected_url = canonical_url(build)
browser = self.getUserBrowser(url)
self.assertEquals(expected_url, browser.url)
+
+ def test_DistributionBuildRecordsView__range_factory(self):
+ # DistributionBuildRecordsView works with StormRangeFactory:
+ # StormRangeFactory requires result sets where the sort order
+ # is specified by Storm Column objects or by Desc(storm_column).
+ # DistributionBuildRecordsView gets its resultset from
+ # IDistribution.getBuildRecords(); the sort order of the
+ # result set depends on the parameter build_state.
+ # The order expressions for all possible values of build_state
+ # are usable by StormRangeFactory.
+ distroseries = self.factory.makeDistroSeries()
+ distribution = distroseries.distribution
+ das = self.factory.makeDistroArchSeries(distroseries=distroseries)
+ for status in BuildStatus.items:
+ build = self.factory.makeBinaryPackageBuild(
+ distroarchseries=das, archive=distroseries.main_archive,
+ status=status)
+ # BPBs in certain states need a bit tweaking to appear in
+ # the result of getBuildRecords().
+ if status == BuildStatus.FULLYBUILT:
+ with person_logged_in(self.admin):
+ build.date_started = (
+ datetime.now(pytz.UTC) - timedelta(hours=1))
+ build.date_finished = datetime.now(pytz.UTC)
+ elif status in (BuildStatus.NEEDSBUILD, BuildStatus.BUILDING):
+ build.queueBuild()
+ for status in ('built', 'failed', 'depwait', 'chrootwait',
+ 'superseded', 'uploadfail', 'all', 'building',
+ 'pending'):
+ view = create_initialized_view(
+ distribution, name="+builds", form={'build_state': status})
+ view.setupBuildList()
+ range_factory = view.batchnav.batch.range_factory
+
+ def test_range_factory():
+ row = range_factory.resultset.get_plain_result_set()[0]
+ range_factory.getOrderValuesFor(row)
+
+ self.assertThat(
+ test_range_factory,
+ Not(Raises(MatchesException(StormRangeFactoryError))))
=== 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 @@
EmptyResultSet,
Store,
)
+from storm.zope import IResultSet
from zope.component import getUtility
from zope.interface import implements
-from zope.security.proxy import removeSecurityProxy
from canonical.config import config
from canonical.database.sqlbase import (
@@ -1021,16 +1021,21 @@
BuildStatus.NEEDSBUILD,
BuildStatus.BUILDING,
BuildStatus.UPLOADING]:
- orderBy = ["-BuildQueue.lastscore", "BinaryPackageBuild.id"]
+ order_by = [Desc(BuildQueue.lastscore), BinaryPackageBuild.id]
+ order_by_table = BuildQueue
clauseTables.append('BuildQueue')
clauseTables.append('BuildPackageJob')
condition_clauses.append(
'BuildPackageJob.build = BinaryPackageBuild.id')
condition_clauses.append('BuildPackageJob.job = BuildQueue.job')
elif status == BuildStatus.SUPERSEDED or status is None:
- orderBy = ["-BuildFarmJob.date_created"]
+ order_by = [Desc(BuildFarmJob.date_created),
+ BinaryPackageBuild.id]
+ order_by_table = BuildFarmJob
else:
- orderBy = ["-BuildFarmJob.date_finished"]
+ order_by = [Desc(BuildFarmJob.date_finished),
+ BinaryPackageBuild.id]
+ order_by_table = BuildFarmJob
# End of duplication (see XXX cprov 2006-09-25 above).
@@ -1043,14 +1048,22 @@
"PackageBuild.archive IN %s" %
sqlvalues(list(distribution.all_distro_archive_ids)))
+ store = Store.of(distribution)
+ clauseTables = [BinaryPackageBuild] + clauseTables
+ result_set = store.using(*clauseTables).find(
+ (BinaryPackageBuild, order_by_table), *condition_clauses)
+ result_set.order_by(*order_by)
+
+ def get_bpp(result_row):
+ return result_row[0]
+
return self._decorate_with_prejoins(
- BinaryPackageBuild.select(' AND '.join(condition_clauses),
- clauseTables=clauseTables, orderBy=orderBy))
+ DecoratedResultSet(result_set, result_decorator=get_bpp))
def _decorate_with_prejoins(self, result_set):
"""Decorate build records with related data prefetch functionality."""
# Grab the native storm result set.
- result_set = removeSecurityProxy(result_set)._result_set
+ result_set = IResultSet(result_set)
decorated_results = DecoratedResultSet(
result_set, pre_iter_hook=self._prefetchBuildData)
return decorated_results