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
1=== modified file 'lib/canonical/launchpad/webapp/batching.py'
2--- lib/canonical/launchpad/webapp/batching.py 2011-09-14 15:37:11 +0000
3+++ lib/canonical/launchpad/webapp/batching.py 2011-09-20 15:15:27 +0000
4@@ -243,6 +243,14 @@
5 self.shadow_values.reverse()
6
7
8+def plain_expression(expression):
9+ """Strip an optional DESC() from an expression."""
10+ if isinstance(expression, Desc):
11+ return expression.expr
12+ else:
13+ return expression
14+
15+
16 class StormRangeFactory:
17 """A range factory for Storm result sets.
18
19@@ -304,8 +312,7 @@
20 row = (row, )
21 sort_expressions = self.getOrderBy()
22 for expression in sort_expressions:
23- if zope_isinstance(expression, Desc):
24- expression = expression.expr
25+ expression = plain_expression(expression)
26 if not zope_isinstance(expression, PropertyColumn):
27 raise StormRangeFactoryError(
28 'StormRangeFactory only supports sorting by '
29@@ -372,8 +379,7 @@
30
31 converted_memo = []
32 for expression, value in zip(sort_expressions, parsed_memo):
33- if isinstance(expression, Desc):
34- expression = expression.expr
35+ expression = plain_expression(expression)
36 try:
37 expression.variable_factory(value=value)
38 except TypeError, error:
39@@ -452,11 +458,6 @@
40
41 def equalsExpressionsFromLimits(self, limits):
42 """Return a list [expression == memo, ...] for the given limits."""
43- def plain_expression(expression):
44- if isinstance(expression, Desc):
45- return expression.expr
46- else:
47- return expression
48
49 result = []
50 for expressions, memos in limits:
51@@ -570,19 +571,23 @@
52 def getSliceByIndex(self, start, end):
53 """See `IRangeFactory."""
54 sliced = self.resultset[start:end]
55- return ShadowedList(list(sliced), list(sliced.get_plain_result_set()))
56+ if zope_isinstance(sliced, DecoratedResultSet):
57+ return ShadowedList(
58+ list(sliced), list(sliced.get_plain_result_set()))
59+ sliced = list(sliced)
60+ return ShadowedList(sliced, sliced)
61
62 @cachedproperty
63 def rough_length(self):
64 """See `IRangeFactory."""
65 # get_select_expr() requires at least one column as a parameter.
66 # getorderBy() already knows about columns that can appear
67- # in the result set, let's take just the first one.
68- column = self.getOrderBy()[0]
69- if zope_isinstance(column, Desc):
70- column = column.expr
71+ # in the result set, so let's use them. Moreover, for SELECT
72+ # DISTINCT queries, each column used for sorting must appear
73+ # in the result.
74+ columns = [plain_expression(column) for column in self.getOrderBy()]
75 select = removeSecurityProxy(self.plain_resultset).get_select_expr(
76- column)
77+ *columns)
78 explain = 'EXPLAIN ' + convert_storm_clause_to_string(select)
79 store = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
80 result = store.execute(explain)
81
82=== modified file 'lib/canonical/launchpad/webapp/tests/test_batching.py'
83--- lib/canonical/launchpad/webapp/tests/test_batching.py 2011-09-14 15:37:11 +0000
84+++ lib/canonical/launchpad/webapp/tests/test_batching.py 2011-09-20 15:15:27 +0000
85@@ -747,3 +747,41 @@
86 estimated_length = range_factory.rough_length
87 self.assertThat(estimated_length, LessThan(10))
88 self.assertThat(estimated_length, Not(LessThan(1)))
89+
90+ def test_rough_length_distinct_query(self):
91+ # StormRangeFactory.rough_length with SELECT DISTINCT queries.
92+ resultset = self.makeStormResultSet()
93+ resultset.config(distinct=True)
94+ resultset.order_by(Person.name, Person.id)
95+ range_factory = StormRangeFactory(resultset)
96+ estimated_length = range_factory.rough_length
97+ self.assertThat(estimated_length, LessThan(10))
98+ self.assertThat(estimated_length, Not(LessThan(1)))
99+
100+ def test_getSliceByIndex__storm_result_set(self):
101+ # StormRangeFactory.getSliceByIndex() returns a slice of the
102+ # resultset, wrapped into a ShadowedList. For plain Storm
103+ # result sets, the main values and the shadow values are both
104+ # the corresponding elements of the result set.
105+ resultset = self.makeStormResultSet()
106+ all_results = list(resultset)
107+ range_factory = StormRangeFactory(resultset)
108+ sliced = range_factory.getSliceByIndex(2, 4)
109+ self.assertIsInstance(sliced, ShadowedList)
110+ self.assertEqual(all_results[2:4], list(sliced))
111+ self.assertEqual(all_results[2:4], sliced.shadow_values)
112+
113+ def test_getSliceByIndex__decorated_result_set(self):
114+ # StormRangeFactory.getSliceByIndex() returns a slice of the
115+ # resultset, wrapped into a ShadowedList. For decorated Storm
116+ # result sets, the main values are the corresponding values of
117+ # the decorated result set and the shadow values are the
118+ # corresponding elements of the plain result set.
119+ resultset = self.makeDecoratedStormResultSet()
120+ all_results = list(resultset)
121+ all_undecorated_results = list(resultset.get_plain_result_set())
122+ range_factory = StormRangeFactory(resultset)
123+ sliced = range_factory.getSliceByIndex(2, 4)
124+ self.assertIsInstance(sliced, ShadowedList)
125+ self.assertEqual(all_results[2:4], list(sliced))
126+ self.assertEqual(all_undecorated_results[2:4], sliced.shadow_values)
127
128=== modified file 'lib/lp/soyuz/browser/build.py'
129--- lib/lp/soyuz/browser/build.py 2011-09-13 05:23:16 +0000
130+++ lib/lp/soyuz/browser/build.py 2011-09-20 15:15:27 +0000
131@@ -14,8 +14,10 @@
132 'BuildRescoringView',
133 'BuildUrl',
134 'BuildView',
135+ 'DistributionBuildRecordsView',
136 ]
137
138+from lazr.batchnavigator import ListRangeFactory
139 from lazr.delegates import delegates
140 from lazr.restful.utils import safe_hasattr
141 from zope.component import getUtility
142@@ -37,7 +39,10 @@
143 stepthrough,
144 )
145 from canonical.launchpad.webapp.authorization import check_permission
146-from canonical.launchpad.webapp.batching import BatchNavigator
147+from canonical.launchpad.webapp.batching import (
148+ BatchNavigator,
149+ StormRangeFactory,
150+ )
151 from canonical.launchpad.webapp.breadcrumb import Breadcrumb
152 from canonical.launchpad.webapp.interfaces import ICanonicalUrlData
153 from lp.app.browser.launchpadform import (
154@@ -456,6 +461,8 @@
155 # only, but subclasses can set this if desired.
156 binary_only = True
157
158+ range_factory = ListRangeFactory
159+
160 @property
161 def label(self):
162 return 'Builds for %s' % self.context.displayname
163@@ -486,7 +493,8 @@
164 builds = self.context.getBuildRecords(
165 build_state=self.state, name=self.text, arch_tag=self.arch_tag,
166 user=self.user, binary_only=binary_only)
167- self.batchnav = BatchNavigator(builds, self.request)
168+ self.batchnav = BatchNavigator(
169+ builds, self.request, range_factory=self.range_factory(builds))
170 # We perform this extra step because we don't what to issue one
171 # extra query to retrieve the BuildQueue for each Build (batch item)
172 # A more elegant approach should be extending Batching class and
173@@ -639,3 +647,12 @@
174 @property
175 def no_results(self):
176 return self.form_submitted and not self.complete_builds
177+
178+
179+class DistributionBuildRecordsView(BuildRecordsView):
180+ """See BuildRecordsView."""
181+
182+ # SQL Queries generated by the default ListRangeFactory time out
183+ # for some views, like +builds?build_state=all. StormRangeFactory
184+ # is more efficient.
185+ range_factory = StormRangeFactory
186
187=== modified file 'lib/lp/soyuz/browser/configure.zcml'
188--- lib/lp/soyuz/browser/configure.zcml 2011-09-19 14:29:47 +0000
189+++ lib/lp/soyuz/browser/configure.zcml 2011-09-20 15:15:27 +0000
190@@ -785,7 +785,7 @@
191 </browser:pages>
192 <browser:pages
193 for="lp.registry.interfaces.distribution.IDistribution"
194- class="lp.soyuz.browser.build.BuildRecordsView"
195+ class="lp.soyuz.browser.build.DistributionBuildRecordsView"
196 permission="zope.Public">
197 <browser:page
198 name="+builds"
199
200=== modified file 'lib/lp/soyuz/browser/tests/test_build_views.py'
201--- lib/lp/soyuz/browser/tests/test_build_views.py 2011-07-13 18:54:37 +0000
202+++ lib/lp/soyuz/browser/tests/test_build_views.py 2011-09-20 15:15:27 +0000
203@@ -8,12 +8,18 @@
204 timedelta,
205 )
206 import pytz
207+from testtools.matchers import (
208+ MatchesException,
209+ Not,
210+ Raises,
211+ )
212 from zope.component import (
213 getMultiAdapter,
214 getUtility,
215 )
216 from zope.security.proxy import removeSecurityProxy
217
218+from canonical.launchpad.webapp.interfaces import StormRangeFactoryError
219 from canonical.launchpad.webapp import canonical_url
220 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
221 from canonical.testing.layers import LaunchpadFunctionalLayer
222@@ -291,3 +297,44 @@
223 expected_url = canonical_url(build)
224 browser = self.getUserBrowser(url)
225 self.assertEquals(expected_url, browser.url)
226+
227+ def test_DistributionBuildRecordsView__range_factory(self):
228+ # DistributionBuildRecordsView works with StormRangeFactory:
229+ # StormRangeFactory requires result sets where the sort order
230+ # is specified by Storm Column objects or by Desc(storm_column).
231+ # DistributionBuildRecordsView gets its resultset from
232+ # IDistribution.getBuildRecords(); the sort order of the
233+ # result set depends on the parameter build_state.
234+ # The order expressions for all possible values of build_state
235+ # are usable by StormRangeFactory.
236+ distroseries = self.factory.makeDistroSeries()
237+ distribution = distroseries.distribution
238+ das = self.factory.makeDistroArchSeries(distroseries=distroseries)
239+ for status in BuildStatus.items:
240+ build = self.factory.makeBinaryPackageBuild(
241+ distroarchseries=das, archive=distroseries.main_archive,
242+ status=status)
243+ # BPBs in certain states need a bit tweaking to appear in
244+ # the result of getBuildRecords().
245+ if status == BuildStatus.FULLYBUILT:
246+ with person_logged_in(self.admin):
247+ build.date_started = (
248+ datetime.now(pytz.UTC) - timedelta(hours=1))
249+ build.date_finished = datetime.now(pytz.UTC)
250+ elif status in (BuildStatus.NEEDSBUILD, BuildStatus.BUILDING):
251+ build.queueBuild()
252+ for status in ('built', 'failed', 'depwait', 'chrootwait',
253+ 'superseded', 'uploadfail', 'all', 'building',
254+ 'pending'):
255+ view = create_initialized_view(
256+ distribution, name="+builds", form={'build_state': status})
257+ view.setupBuildList()
258+ range_factory = view.batchnav.batch.range_factory
259+
260+ def test_range_factory():
261+ row = range_factory.resultset.get_plain_result_set()[0]
262+ range_factory.getOrderValuesFor(row)
263+
264+ self.assertThat(
265+ test_range_factory,
266+ Not(Raises(MatchesException(StormRangeFactoryError))))
267
268=== modified file 'lib/lp/soyuz/model/binarypackagebuild.py'
269--- lib/lp/soyuz/model/binarypackagebuild.py 2011-08-17 13:43:13 +0000
270+++ lib/lp/soyuz/model/binarypackagebuild.py 2011-09-20 15:15:27 +0000
271@@ -29,9 +29,9 @@
272 EmptyResultSet,
273 Store,
274 )
275+from storm.zope import IResultSet
276 from zope.component import getUtility
277 from zope.interface import implements
278-from zope.security.proxy import removeSecurityProxy
279
280 from canonical.config import config
281 from canonical.database.sqlbase import (
282@@ -1021,16 +1021,21 @@
283 BuildStatus.NEEDSBUILD,
284 BuildStatus.BUILDING,
285 BuildStatus.UPLOADING]:
286- orderBy = ["-BuildQueue.lastscore", "BinaryPackageBuild.id"]
287+ order_by = [Desc(BuildQueue.lastscore), BinaryPackageBuild.id]
288+ order_by_table = BuildQueue
289 clauseTables.append('BuildQueue')
290 clauseTables.append('BuildPackageJob')
291 condition_clauses.append(
292 'BuildPackageJob.build = BinaryPackageBuild.id')
293 condition_clauses.append('BuildPackageJob.job = BuildQueue.job')
294 elif status == BuildStatus.SUPERSEDED or status is None:
295- orderBy = ["-BuildFarmJob.date_created"]
296+ order_by = [Desc(BuildFarmJob.date_created),
297+ BinaryPackageBuild.id]
298+ order_by_table = BuildFarmJob
299 else:
300- orderBy = ["-BuildFarmJob.date_finished"]
301+ order_by = [Desc(BuildFarmJob.date_finished),
302+ BinaryPackageBuild.id]
303+ order_by_table = BuildFarmJob
304
305 # End of duplication (see XXX cprov 2006-09-25 above).
306
307@@ -1043,14 +1048,22 @@
308 "PackageBuild.archive IN %s" %
309 sqlvalues(list(distribution.all_distro_archive_ids)))
310
311+ store = Store.of(distribution)
312+ clauseTables = [BinaryPackageBuild] + clauseTables
313+ result_set = store.using(*clauseTables).find(
314+ (BinaryPackageBuild, order_by_table), *condition_clauses)
315+ result_set.order_by(*order_by)
316+
317+ def get_bpp(result_row):
318+ return result_row[0]
319+
320 return self._decorate_with_prejoins(
321- BinaryPackageBuild.select(' AND '.join(condition_clauses),
322- clauseTables=clauseTables, orderBy=orderBy))
323+ DecoratedResultSet(result_set, result_decorator=get_bpp))
324
325 def _decorate_with_prejoins(self, result_set):
326 """Decorate build records with related data prefetch functionality."""
327 # Grab the native storm result set.
328- result_set = removeSecurityProxy(result_set)._result_set
329+ result_set = IResultSet(result_set)
330 decorated_results = DecoratedResultSet(
331 result_set, pre_iter_hook=self._prefetchBuildData)
332 return decorated_results