Merge ~cjwatson/launchpad:project-download-queries into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 1f911649f0a427f3d80814e075128df40f7aa075
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:project-download-queries
Merge into: launchpad:master
Diff against target: 407 lines (+143/-108)
7 files modified
dev/null (+0/-93)
lib/lp/registry/browser/product.py (+6/-5)
lib/lp/registry/browser/tests/test_product.py (+87/-1)
lib/lp/registry/browser/tests/test_views.py (+1/-2)
lib/lp/registry/model/productrelease.py (+8/-3)
lib/lp/services/librarian/interfaces/__init__.py (+4/-1)
lib/lp/services/librarian/model.py (+37/-3)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+412706@code.launchpad.net

Commit message

Preload more librarian references on Product:+download

Description of the change

The Product:+download page needs `ProductReleaseFile.signature` and `ProductReleaseFile.libraryfile.last_downloaded` for each file, and so needs to preload these to avoid excessive query counts. The former is easy; the latter requires some minor PostgreSQL gymnastics.

I took the opportunity to convert the corresponding tests away from doctests first.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

LGTM

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
2index 942b4b9..3f58926 100644
3--- a/lib/lp/registry/browser/product.py
4+++ b/lib/lp/registry/browser/product.py
5@@ -208,6 +208,7 @@ from lp.services.fields import (
6 PublicPersonChoice,
7 URIField,
8 )
9+from lp.services.librarian.interfaces import ILibraryFileAliasSet
10 from lp.services.propertycache import cachedproperty
11 from lp.services.webapp import (
12 ApplicationMenu,
13@@ -848,8 +849,10 @@ class ReleaseWithFiles:
14 # returns all releases sorted properly.
15 product = self.parent.parent
16 release_delegates = product.release_by_id.values()
17- files = getUtility(IProductReleaseSet).getFilesForReleases(
18- release_delegates)
19+ files = list(getUtility(IProductReleaseSet).getFilesForReleases(
20+ release_delegates))
21+ getUtility(ILibraryFileAliasSet).preloadLastDownloaded(
22+ {file.libraryfile for file in files})
23 for release_delegate in release_delegates:
24 release_delegate._files = []
25 for file in files:
26@@ -1271,8 +1274,6 @@ class ProductDownloadFilesView(LaunchpadView,
27 ProductDownloadFileMixin):
28 """View class for the product's file downloads page."""
29
30- batch_size = config.launchpad.download_batch_size
31-
32 @property
33 def page_title(self):
34 return "%s project files" % self.context.displayname
35@@ -1304,7 +1305,7 @@ class ProductDownloadFilesView(LaunchpadView,
36 if pair not in series_and_releases:
37 series_and_releases.append(pair)
38 batch = BatchNavigator(series_and_releases, self.request,
39- size=self.batch_size)
40+ size=config.launchpad.download_batch_size)
41 batch.setHeadings("release", "releases")
42 return batch
43
44diff --git a/lib/lp/registry/browser/tests/product-files-views.txt b/lib/lp/registry/browser/tests/product-files-views.txt
45deleted file mode 100644
46index c999548..0000000
47--- a/lib/lp/registry/browser/tests/product-files-views.txt
48+++ /dev/null
49@@ -1,93 +0,0 @@
50- Product Download Files Page
51-=============================
52-
53-Test for the product/+download page.
54-
55- >>> product = factory.makeProduct(name='alfajore')
56- >>> productseries = factory.makeProductSeries(
57- ... product=product, name="sammy")
58- >>> milestone = factory.makeMilestone(productseries=productseries,
59- ... name="apple")
60- >>> release_file = factory.makeProductReleaseFile(
61- ... product=product, productseries=productseries, milestone=milestone)
62- >>> view = create_initialized_view(product, '+download')
63-
64- >>> view.batch_size
65- 4
66-
67- >>> batch = view.series_and_releases_batch.currentBatch()
68- >>> print(len(list(batch)))
69- 1
70-
71- >>> def print_series_release(sr):
72- ... print("%s from the %s series" % (sr.release.name_with_codename,
73- ... sr.series.name))
74-
75- >>> for sr in batch:
76- ... print_series_release(sr)
77- apple from the sammy series
78-
79- >>> product = factory.makeProduct(name='bombilla')
80- >>> for i in range(1,5):
81- ... productseries = factory.makeProductSeries(
82- ... product=product, name="s%d"%i)
83- ... for j in range(1,4):
84- ... milestone = factory.makeMilestone(productseries=productseries,
85- ... name="%d.%d"%(i,j))
86- ... release_file = factory.makeProductReleaseFile(
87- ... product=product, productseries=productseries,
88- ... milestone=milestone)
89- >>> view = create_initialized_view(product, '+download')
90- >>> batch = view.series_and_releases_batch.currentBatch()
91- >>> print(len(batch))
92- 4
93- >>> for sr in batch:
94- ... print_series_release(sr)
95- 4.3 from the s4 series
96- 4.2 from the s4 series
97- 4.1 from the s4 series
98- 3.3 from the s3 series
99-
100- >>> batch = batch.nextBatch()
101- >>> for sr in batch:
102- ... print_series_release(sr)
103- 3.2 from the s3 series
104- 3.1 from the s3 series
105- 2.3 from the s2 series
106- 2.2 from the s2 series
107-
108-For an administrator of the project, at the bottom of each batched
109-page will be links to add new files for each series and release.
110-
111- >>> from lp.testing.pages import (
112- ... extract_text, find_tag_by_id)
113- >>> ignored = login_person(product.owner)
114- >>> view = create_initialized_view(product, '+download',
115- ... principal=product.owner)
116- >>> admin_links = find_tag_by_id(view.render(), 'admin-links')
117- >>> content = extract_text(admin_links)
118- >>> print(content)
119- Add download file to the s4 series for release: 4.3, 4.2, 4.1
120- Add download file to the s3 series for release: 3.3, 3.2, 3.1
121- Add download file to the s2 series for release: 2.3, 2.2, 2.1
122- Add download file to the s1 series for release: 1.3, 1.2, 1.1
123-
124-
125-Product index
126--------------
127-
128-The product index view shows the latest release for the project.
129-
130- >>> view = create_initialized_view(product, name='+index')
131- >>> print(view.latest_release_with_download_files.version)
132- 4.3
133-
134-Obsolete series are ignored.
135-
136- >>> from lp.registry.interfaces.series import SeriesStatus
137-
138- >>> obsolete_series = product.getSeries('s4')
139- >>> obsolete_series.status = SeriesStatus.OBSOLETE
140- >>> view = create_initialized_view(product, name='+index')
141- >>> print(view.latest_release_with_download_files.version)
142- 3.3
143diff --git a/lib/lp/registry/browser/tests/test_product.py b/lib/lp/registry/browser/tests/test_product.py
144index 3e71422..1f571fe 100644
145--- a/lib/lp/registry/browser/tests/test_product.py
146+++ b/lib/lp/registry/browser/tests/test_product.py
147@@ -1,4 +1,4 @@
148-# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
149+# Copyright 2010-2021 Canonical Ltd. This software is licensed under the
150 # GNU Affero General Public License version 3 (see the file LICENSE).
151
152 """Tests for product views."""
153@@ -6,6 +6,7 @@
154 __all__ = ['make_product_form']
155
156 import re
157+from textwrap import dedent
158
159 from lazr.restful.fields import Reference
160 from lazr.restful.interfaces import (
161@@ -55,6 +56,7 @@ from lp.registry.interfaces.product import (
162 License,
163 )
164 from lp.registry.interfaces.productseries import IProductSeries
165+from lp.registry.interfaces.series import SeriesStatus
166 from lp.registry.model.product import Product
167 from lp.services.config import config
168 from lp.services.database.interfaces import IStore
169@@ -69,6 +71,7 @@ from lp.testing import (
170 login_celebrity,
171 login_person,
172 person_logged_in,
173+ record_two_runs,
174 StormStatementRecorder,
175 TestCaseWithFactory,
176 )
177@@ -835,6 +838,89 @@ class TestProductEditView(BrowserTestCase):
178 InformationType.PUBLIC, updated_product.information_type)
179
180
181+class TestProductDownloadFilesView(TestCaseWithFactory):
182+ """Test `ProductDownloadFilesView`."""
183+
184+ layer = LaunchpadFunctionalLayer
185+
186+ def makeProductAndReleases(self):
187+ product = self.factory.makeProduct()
188+ for i in range(1, 5):
189+ productseries = self.factory.makeProductSeries(
190+ product=product, name="s%d" % i)
191+ for j in range(1, 4):
192+ milestone = self.factory.makeMilestone(
193+ productseries=productseries, name="%d.%d" % (i, j))
194+ self.factory.makeProductReleaseFile(
195+ product=product, productseries=productseries,
196+ milestone=milestone)
197+ return product
198+
199+ def test_series_and_releases_batch(self):
200+ self.pushConfig("launchpad", download_batch_size=4)
201+ product = self.makeProductAndReleases()
202+ view = create_initialized_view(product, "+download")
203+ batch = view.series_and_releases_batch.currentBatch()
204+ self.assertEqual(
205+ [("4.3", "s4"), ("4.2", "s4"), ("4.1", "s4"), ("3.3", "s3")],
206+ [(sr.release.name_with_codename, sr.series.name) for sr in batch])
207+ batch = batch.nextBatch()
208+ self.assertEqual(
209+ [("3.2", "s3"), ("3.1", "s3"), ("2.3", "s2"), ("2.2", "s2")],
210+ [(sr.release.name_with_codename, sr.series.name) for sr in batch])
211+
212+ def test_query_count(self):
213+ self.pushConfig("launchpad", download_batch_size=20)
214+ product = self.factory.makeProduct()
215+
216+ def create_series_and_releases():
217+ productseries = self.factory.makeProductSeries(product=product)
218+ for _ in range(3):
219+ self.factory.makeProductReleaseFile(
220+ product=product, productseries=productseries)
221+
222+ def render_product():
223+ create_initialized_view(product, "+download").render()
224+
225+ recorder1, recorder2 = record_two_runs(
226+ render_product, create_series_and_releases, 2)
227+ self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
228+
229+ def test_add_download_file_links(self):
230+ # Project administrators have links at the bottom of each batched
231+ # page to add new files for each series and release.
232+ product = self.makeProductAndReleases()
233+ login_person(product.owner)
234+ view = create_initialized_view(
235+ product, "+download", principal=product.owner)
236+ admin_links = find_tag_by_id(view.render(), "admin-links")
237+ expected_links = dedent("""
238+ Add download file to the s4 series for release: 4.3, 4.2, 4.1
239+ Add download file to the s3 series for release: 3.3, 3.2, 3.1
240+ Add download file to the s2 series for release: 2.3, 2.2, 2.1
241+ Add download file to the s1 series for release: 1.3, 1.2, 1.1
242+ """)
243+ self.assertTextMatchesExpressionIgnoreWhitespace(
244+ expected_links, extract_text(admin_links))
245+
246+ def test_latest_release_on_index(self):
247+ # The project index view shows the latest release for the project.
248+ product = self.makeProductAndReleases()
249+ view = create_initialized_view(product, "+index")
250+ self.assertEqual(
251+ "4.3", view.latest_release_with_download_files.version)
252+
253+ def test_latest_release_ignores_obsolete_series(self):
254+ # The project index view ignores obsolete series for the purpose of
255+ # showing the latest release.
256+ product = self.makeProductAndReleases()
257+ with person_logged_in(product.owner):
258+ product.getSeries("s4").status = SeriesStatus.OBSOLETE
259+ view = create_initialized_view(product, "+index")
260+ self.assertEqual(
261+ "3.3", view.latest_release_with_download_files.version)
262+
263+
264 class ProductSetReviewLicensesViewTestCase(TestCaseWithFactory):
265 """Tests the ProductSetReviewLicensesView."""
266
267diff --git a/lib/lp/registry/browser/tests/test_views.py b/lib/lp/registry/browser/tests/test_views.py
268index 02ac47f..35254f4 100644
269--- a/lib/lp/registry/browser/tests/test_views.py
270+++ b/lib/lp/registry/browser/tests/test_views.py
271@@ -1,4 +1,4 @@
272-# Copyright 2009 Canonical Ltd. This software is licensed under the
273+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
274 # GNU Affero General Public License version 3 (see the file LICENSE).
275
276 """
277@@ -34,7 +34,6 @@ special_test_layer = {
278 'milestone-views.txt': LaunchpadFunctionalLayer,
279 'person-views.txt': LaunchpadFunctionalLayer,
280 'product-edit-people-view.txt': LaunchpadFunctionalLayer,
281- 'product-files-views.txt': LaunchpadFunctionalLayer,
282 'product-views.txt': LaunchpadFunctionalLayer,
283 'productseries-views.txt': LaunchpadFunctionalLayer,
284 'projectgroup-views.txt': LaunchpadFunctionalLayer,
285diff --git a/lib/lp/registry/model/productrelease.py b/lib/lp/registry/model/productrelease.py
286index 3b88d33..00c7914 100644
287--- a/lib/lp/registry/model/productrelease.py
288+++ b/lib/lp/registry/model/productrelease.py
289@@ -1,4 +1,4 @@
290-# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
291+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
292 # GNU Affero General Public License version 3 (see the file LICENSE).
293
294 __all__ = [
295@@ -281,9 +281,14 @@ class ProductReleaseSet(object):
296 return EmptyResultSet()
297 return ProductReleaseFile.select(
298 """ProductReleaseFile.productrelease IN %s""" % (
299- sqlvalues([release.id for release in releases])),
300+ sqlvalues([release.id for release in releases])),
301 orderBy='-date_uploaded',
302- prejoins=['libraryfile', 'libraryfile.content', 'productrelease'])
303+ prejoins=[
304+ 'libraryfile',
305+ 'libraryfile.content',
306+ 'productrelease',
307+ 'signature',
308+ ])
309
310
311 def productrelease_to_milestone(productrelease):
312diff --git a/lib/lp/services/librarian/interfaces/__init__.py b/lib/lp/services/librarian/interfaces/__init__.py
313index 6b3646e..73b390b 100644
314--- a/lib/lp/services/librarian/interfaces/__init__.py
315+++ b/lib/lp/services/librarian/interfaces/__init__.py
316@@ -1,4 +1,4 @@
317-# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
318+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
319 # GNU Affero General Public License version 3 (see the file LICENSE).
320
321 """Librarian interfaces."""
322@@ -175,6 +175,9 @@ class ILibraryFileAliasSet(Interface):
323 given sha256.
324 """
325
326+ def preloadLastDownloaded(self, lfas):
327+ """Preload last_downloaded for a collection of `LibraryFileAlias`es."""
328+
329
330 class ILibraryFileDownloadCount(Interface):
331 """Download count of a given file in a given day."""
332diff --git a/lib/lp/services/librarian/model.py b/lib/lp/services/librarian/model.py
333index 3e730eb..2c513d3 100644
334--- a/lib/lp/services/librarian/model.py
335+++ b/lib/lp/services/librarian/model.py
336@@ -1,4 +1,4 @@
337-# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
338+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
339 # GNU Affero General Public License version 3 (see the file LICENSE).
340
341 __all__ = [
342@@ -40,7 +40,10 @@ from lp.services.database.constants import (
343 UTC_NOW,
344 )
345 from lp.services.database.datetimecol import UtcDateTimeCol
346-from lp.services.database.interfaces import IMasterStore
347+from lp.services.database.interfaces import (
348+ IMasterStore,
349+ IStore,
350+ )
351 from lp.services.database.sqlbase import (
352 session_store,
353 SQLBase,
354@@ -66,6 +69,10 @@ from lp.services.librarian.interfaces.client import (
355 IRestrictedLibrarianClient,
356 LIBRARIAN_SERVER_DEFAULT_TIMEOUT,
357 )
358+from lp.services.propertycache import (
359+ cachedproperty,
360+ get_property_cache,
361+ )
362 from lp.services.tokens import create_token
363
364
365@@ -180,7 +187,7 @@ class LibraryFileAlias(SQLBase):
366 self._datafile.close()
367 self._datafile = None
368
369- @property
370+ @cachedproperty
371 def last_downloaded(self):
372 """See `ILibraryFileAlias`."""
373 store = Store.of(self)
374@@ -271,6 +278,33 @@ class LibraryFileAliasSet(object):
375 AND LibraryFileContent.sha256 = '%s'
376 """ % sha256, clauseTables=['LibraryFileContent'])
377
378+ def preloadLastDownloaded(self, lfas):
379+ """See `ILibraryFileAliasSet`."""
380+ store = IStore(LibraryFileAlias)
381+ results = store.find(
382+ (LibraryFileDownloadCount.libraryfilealias_id,
383+ LibraryFileDownloadCount.day),
384+ LibraryFileDownloadCount.libraryfilealias_id.is_in(
385+ sorted(lfa.id for lfa in lfas)))
386+ results.order_by(
387+ # libraryfilealias doesn't need to be descending for
388+ # correctness, but this allows the index on
389+ # LibraryFileDownloadCount (libraryfilealias, day, country) to
390+ # satisfy this query efficiently.
391+ Desc(LibraryFileDownloadCount.libraryfilealias_id),
392+ Desc(LibraryFileDownloadCount.day))
393+ # Request the first row for each LFA, which corresponds to the most
394+ # recent day due to the above ordering.
395+ results.config(
396+ distinct=(LibraryFileDownloadCount.libraryfilealias_id,))
397+ now = datetime.now(pytz.utc).date()
398+ lfas_by_id = {lfa.id: lfa for lfa in lfas}
399+ for lfa_id, day in results:
400+ get_property_cache(lfas_by_id[lfa_id]).last_downloaded = now - day
401+ del lfas_by_id[lfa_id]
402+ for lfa in lfas_by_id.values():
403+ get_property_cache(lfa).last_downloaded = None
404+
405
406 @implementer(ILibraryFileDownloadCount)
407 class LibraryFileDownloadCount(SQLBase):

Subscribers

People subscribed via source and target branches

to status/vote changes: