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
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index 942b4b9..3f58926 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -208,6 +208,7 @@ from lp.services.fields import (
208 PublicPersonChoice,208 PublicPersonChoice,
209 URIField,209 URIField,
210 )210 )
211from lp.services.librarian.interfaces import ILibraryFileAliasSet
211from lp.services.propertycache import cachedproperty212from lp.services.propertycache import cachedproperty
212from lp.services.webapp import (213from lp.services.webapp import (
213 ApplicationMenu,214 ApplicationMenu,
@@ -848,8 +849,10 @@ class ReleaseWithFiles:
848 # returns all releases sorted properly.849 # returns all releases sorted properly.
849 product = self.parent.parent850 product = self.parent.parent
850 release_delegates = product.release_by_id.values()851 release_delegates = product.release_by_id.values()
851 files = getUtility(IProductReleaseSet).getFilesForReleases(852 files = list(getUtility(IProductReleaseSet).getFilesForReleases(
852 release_delegates)853 release_delegates))
854 getUtility(ILibraryFileAliasSet).preloadLastDownloaded(
855 {file.libraryfile for file in files})
853 for release_delegate in release_delegates:856 for release_delegate in release_delegates:
854 release_delegate._files = []857 release_delegate._files = []
855 for file in files:858 for file in files:
@@ -1271,8 +1274,6 @@ class ProductDownloadFilesView(LaunchpadView,
1271 ProductDownloadFileMixin):1274 ProductDownloadFileMixin):
1272 """View class for the product's file downloads page."""1275 """View class for the product's file downloads page."""
12731276
1274 batch_size = config.launchpad.download_batch_size
1275
1276 @property1277 @property
1277 def page_title(self):1278 def page_title(self):
1278 return "%s project files" % self.context.displayname1279 return "%s project files" % self.context.displayname
@@ -1304,7 +1305,7 @@ class ProductDownloadFilesView(LaunchpadView,
1304 if pair not in series_and_releases:1305 if pair not in series_and_releases:
1305 series_and_releases.append(pair)1306 series_and_releases.append(pair)
1306 batch = BatchNavigator(series_and_releases, self.request,1307 batch = BatchNavigator(series_and_releases, self.request,
1307 size=self.batch_size)1308 size=config.launchpad.download_batch_size)
1308 batch.setHeadings("release", "releases")1309 batch.setHeadings("release", "releases")
1309 return batch1310 return batch
13101311
diff --git a/lib/lp/registry/browser/tests/product-files-views.txt b/lib/lp/registry/browser/tests/product-files-views.txt
1311deleted file mode 1006441312deleted file mode 100644
index c999548..0000000
--- a/lib/lp/registry/browser/tests/product-files-views.txt
+++ /dev/null
@@ -1,93 +0,0 @@
1 Product Download Files Page
2=============================
3
4Test for the product/+download page.
5
6 >>> product = factory.makeProduct(name='alfajore')
7 >>> productseries = factory.makeProductSeries(
8 ... product=product, name="sammy")
9 >>> milestone = factory.makeMilestone(productseries=productseries,
10 ... name="apple")
11 >>> release_file = factory.makeProductReleaseFile(
12 ... product=product, productseries=productseries, milestone=milestone)
13 >>> view = create_initialized_view(product, '+download')
14
15 >>> view.batch_size
16 4
17
18 >>> batch = view.series_and_releases_batch.currentBatch()
19 >>> print(len(list(batch)))
20 1
21
22 >>> def print_series_release(sr):
23 ... print("%s from the %s series" % (sr.release.name_with_codename,
24 ... sr.series.name))
25
26 >>> for sr in batch:
27 ... print_series_release(sr)
28 apple from the sammy series
29
30 >>> product = factory.makeProduct(name='bombilla')
31 >>> for i in range(1,5):
32 ... productseries = factory.makeProductSeries(
33 ... product=product, name="s%d"%i)
34 ... for j in range(1,4):
35 ... milestone = factory.makeMilestone(productseries=productseries,
36 ... name="%d.%d"%(i,j))
37 ... release_file = factory.makeProductReleaseFile(
38 ... product=product, productseries=productseries,
39 ... milestone=milestone)
40 >>> view = create_initialized_view(product, '+download')
41 >>> batch = view.series_and_releases_batch.currentBatch()
42 >>> print(len(batch))
43 4
44 >>> for sr in batch:
45 ... print_series_release(sr)
46 4.3 from the s4 series
47 4.2 from the s4 series
48 4.1 from the s4 series
49 3.3 from the s3 series
50
51 >>> batch = batch.nextBatch()
52 >>> for sr in batch:
53 ... print_series_release(sr)
54 3.2 from the s3 series
55 3.1 from the s3 series
56 2.3 from the s2 series
57 2.2 from the s2 series
58
59For an administrator of the project, at the bottom of each batched
60page will be links to add new files for each series and release.
61
62 >>> from lp.testing.pages import (
63 ... extract_text, find_tag_by_id)
64 >>> ignored = login_person(product.owner)
65 >>> view = create_initialized_view(product, '+download',
66 ... principal=product.owner)
67 >>> admin_links = find_tag_by_id(view.render(), 'admin-links')
68 >>> content = extract_text(admin_links)
69 >>> print(content)
70 Add download file to the s4 series for release: 4.3, 4.2, 4.1
71 Add download file to the s3 series for release: 3.3, 3.2, 3.1
72 Add download file to the s2 series for release: 2.3, 2.2, 2.1
73 Add download file to the s1 series for release: 1.3, 1.2, 1.1
74
75
76Product index
77-------------
78
79The product index view shows the latest release for the project.
80
81 >>> view = create_initialized_view(product, name='+index')
82 >>> print(view.latest_release_with_download_files.version)
83 4.3
84
85Obsolete series are ignored.
86
87 >>> from lp.registry.interfaces.series import SeriesStatus
88
89 >>> obsolete_series = product.getSeries('s4')
90 >>> obsolete_series.status = SeriesStatus.OBSOLETE
91 >>> view = create_initialized_view(product, name='+index')
92 >>> print(view.latest_release_with_download_files.version)
93 3.3
diff --git a/lib/lp/registry/browser/tests/test_product.py b/lib/lp/registry/browser/tests/test_product.py
index 3e71422..1f571fe 100644
--- a/lib/lp/registry/browser/tests/test_product.py
+++ b/lib/lp/registry/browser/tests/test_product.py
@@ -1,4 +1,4 @@
1# Copyright 2010-2020 Canonical Ltd. This software is licensed under the1# Copyright 2010-2021 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"""Tests for product views."""4"""Tests for product views."""
@@ -6,6 +6,7 @@
6__all__ = ['make_product_form']6__all__ = ['make_product_form']
77
8import re8import re
9from textwrap import dedent
910
10from lazr.restful.fields import Reference11from lazr.restful.fields import Reference
11from lazr.restful.interfaces import (12from lazr.restful.interfaces import (
@@ -55,6 +56,7 @@ from lp.registry.interfaces.product import (
55 License,56 License,
56 )57 )
57from lp.registry.interfaces.productseries import IProductSeries58from lp.registry.interfaces.productseries import IProductSeries
59from lp.registry.interfaces.series import SeriesStatus
58from lp.registry.model.product import Product60from lp.registry.model.product import Product
59from lp.services.config import config61from lp.services.config import config
60from lp.services.database.interfaces import IStore62from lp.services.database.interfaces import IStore
@@ -69,6 +71,7 @@ from lp.testing import (
69 login_celebrity,71 login_celebrity,
70 login_person,72 login_person,
71 person_logged_in,73 person_logged_in,
74 record_two_runs,
72 StormStatementRecorder,75 StormStatementRecorder,
73 TestCaseWithFactory,76 TestCaseWithFactory,
74 )77 )
@@ -835,6 +838,89 @@ class TestProductEditView(BrowserTestCase):
835 InformationType.PUBLIC, updated_product.information_type)838 InformationType.PUBLIC, updated_product.information_type)
836839
837840
841class TestProductDownloadFilesView(TestCaseWithFactory):
842 """Test `ProductDownloadFilesView`."""
843
844 layer = LaunchpadFunctionalLayer
845
846 def makeProductAndReleases(self):
847 product = self.factory.makeProduct()
848 for i in range(1, 5):
849 productseries = self.factory.makeProductSeries(
850 product=product, name="s%d" % i)
851 for j in range(1, 4):
852 milestone = self.factory.makeMilestone(
853 productseries=productseries, name="%d.%d" % (i, j))
854 self.factory.makeProductReleaseFile(
855 product=product, productseries=productseries,
856 milestone=milestone)
857 return product
858
859 def test_series_and_releases_batch(self):
860 self.pushConfig("launchpad", download_batch_size=4)
861 product = self.makeProductAndReleases()
862 view = create_initialized_view(product, "+download")
863 batch = view.series_and_releases_batch.currentBatch()
864 self.assertEqual(
865 [("4.3", "s4"), ("4.2", "s4"), ("4.1", "s4"), ("3.3", "s3")],
866 [(sr.release.name_with_codename, sr.series.name) for sr in batch])
867 batch = batch.nextBatch()
868 self.assertEqual(
869 [("3.2", "s3"), ("3.1", "s3"), ("2.3", "s2"), ("2.2", "s2")],
870 [(sr.release.name_with_codename, sr.series.name) for sr in batch])
871
872 def test_query_count(self):
873 self.pushConfig("launchpad", download_batch_size=20)
874 product = self.factory.makeProduct()
875
876 def create_series_and_releases():
877 productseries = self.factory.makeProductSeries(product=product)
878 for _ in range(3):
879 self.factory.makeProductReleaseFile(
880 product=product, productseries=productseries)
881
882 def render_product():
883 create_initialized_view(product, "+download").render()
884
885 recorder1, recorder2 = record_two_runs(
886 render_product, create_series_and_releases, 2)
887 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
888
889 def test_add_download_file_links(self):
890 # Project administrators have links at the bottom of each batched
891 # page to add new files for each series and release.
892 product = self.makeProductAndReleases()
893 login_person(product.owner)
894 view = create_initialized_view(
895 product, "+download", principal=product.owner)
896 admin_links = find_tag_by_id(view.render(), "admin-links")
897 expected_links = dedent("""
898 Add download file to the s4 series for release: 4.3, 4.2, 4.1
899 Add download file to the s3 series for release: 3.3, 3.2, 3.1
900 Add download file to the s2 series for release: 2.3, 2.2, 2.1
901 Add download file to the s1 series for release: 1.3, 1.2, 1.1
902 """)
903 self.assertTextMatchesExpressionIgnoreWhitespace(
904 expected_links, extract_text(admin_links))
905
906 def test_latest_release_on_index(self):
907 # The project index view shows the latest release for the project.
908 product = self.makeProductAndReleases()
909 view = create_initialized_view(product, "+index")
910 self.assertEqual(
911 "4.3", view.latest_release_with_download_files.version)
912
913 def test_latest_release_ignores_obsolete_series(self):
914 # The project index view ignores obsolete series for the purpose of
915 # showing the latest release.
916 product = self.makeProductAndReleases()
917 with person_logged_in(product.owner):
918 product.getSeries("s4").status = SeriesStatus.OBSOLETE
919 view = create_initialized_view(product, "+index")
920 self.assertEqual(
921 "3.3", view.latest_release_with_download_files.version)
922
923
838class ProductSetReviewLicensesViewTestCase(TestCaseWithFactory):924class ProductSetReviewLicensesViewTestCase(TestCaseWithFactory):
839 """Tests the ProductSetReviewLicensesView."""925 """Tests the ProductSetReviewLicensesView."""
840926
diff --git a/lib/lp/registry/browser/tests/test_views.py b/lib/lp/registry/browser/tests/test_views.py
index 02ac47f..35254f4 100644
--- a/lib/lp/registry/browser/tests/test_views.py
+++ b/lib/lp/registry/browser/tests/test_views.py
@@ -1,4 +1,4 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 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"""4"""
@@ -34,7 +34,6 @@ special_test_layer = {
34 'milestone-views.txt': LaunchpadFunctionalLayer,34 'milestone-views.txt': LaunchpadFunctionalLayer,
35 'person-views.txt': LaunchpadFunctionalLayer,35 'person-views.txt': LaunchpadFunctionalLayer,
36 'product-edit-people-view.txt': LaunchpadFunctionalLayer,36 'product-edit-people-view.txt': LaunchpadFunctionalLayer,
37 'product-files-views.txt': LaunchpadFunctionalLayer,
38 'product-views.txt': LaunchpadFunctionalLayer,37 'product-views.txt': LaunchpadFunctionalLayer,
39 'productseries-views.txt': LaunchpadFunctionalLayer,38 'productseries-views.txt': LaunchpadFunctionalLayer,
40 'projectgroup-views.txt': LaunchpadFunctionalLayer,39 'projectgroup-views.txt': LaunchpadFunctionalLayer,
diff --git a/lib/lp/registry/model/productrelease.py b/lib/lp/registry/model/productrelease.py
index 3b88d33..00c7914 100644
--- a/lib/lp/registry/model/productrelease.py
+++ b/lib/lp/registry/model/productrelease.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2013 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 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__all__ = [4__all__ = [
@@ -281,9 +281,14 @@ class ProductReleaseSet(object):
281 return EmptyResultSet()281 return EmptyResultSet()
282 return ProductReleaseFile.select(282 return ProductReleaseFile.select(
283 """ProductReleaseFile.productrelease IN %s""" % (283 """ProductReleaseFile.productrelease IN %s""" % (
284 sqlvalues([release.id for release in releases])),284 sqlvalues([release.id for release in releases])),
285 orderBy='-date_uploaded',285 orderBy='-date_uploaded',
286 prejoins=['libraryfile', 'libraryfile.content', 'productrelease'])286 prejoins=[
287 'libraryfile',
288 'libraryfile.content',
289 'productrelease',
290 'signature',
291 ])
287292
288293
289def productrelease_to_milestone(productrelease):294def productrelease_to_milestone(productrelease):
diff --git a/lib/lp/services/librarian/interfaces/__init__.py b/lib/lp/services/librarian/interfaces/__init__.py
index 6b3646e..73b390b 100644
--- a/lib/lp/services/librarian/interfaces/__init__.py
+++ b/lib/lp/services/librarian/interfaces/__init__.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 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"""Librarian interfaces."""4"""Librarian interfaces."""
@@ -175,6 +175,9 @@ class ILibraryFileAliasSet(Interface):
175 given sha256.175 given sha256.
176 """176 """
177177
178 def preloadLastDownloaded(self, lfas):
179 """Preload last_downloaded for a collection of `LibraryFileAlias`es."""
180
178181
179class ILibraryFileDownloadCount(Interface):182class ILibraryFileDownloadCount(Interface):
180 """Download count of a given file in a given day."""183 """Download count of a given file in a given day."""
diff --git a/lib/lp/services/librarian/model.py b/lib/lp/services/librarian/model.py
index 3e730eb..2c513d3 100644
--- a/lib/lp/services/librarian/model.py
+++ b/lib/lp/services/librarian/model.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2016 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 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__all__ = [4__all__ = [
@@ -40,7 +40,10 @@ from lp.services.database.constants import (
40 UTC_NOW,40 UTC_NOW,
41 )41 )
42from lp.services.database.datetimecol import UtcDateTimeCol42from lp.services.database.datetimecol import UtcDateTimeCol
43from lp.services.database.interfaces import IMasterStore43from lp.services.database.interfaces import (
44 IMasterStore,
45 IStore,
46 )
44from lp.services.database.sqlbase import (47from lp.services.database.sqlbase import (
45 session_store,48 session_store,
46 SQLBase,49 SQLBase,
@@ -66,6 +69,10 @@ from lp.services.librarian.interfaces.client import (
66 IRestrictedLibrarianClient,69 IRestrictedLibrarianClient,
67 LIBRARIAN_SERVER_DEFAULT_TIMEOUT,70 LIBRARIAN_SERVER_DEFAULT_TIMEOUT,
68 )71 )
72from lp.services.propertycache import (
73 cachedproperty,
74 get_property_cache,
75 )
69from lp.services.tokens import create_token76from lp.services.tokens import create_token
7077
7178
@@ -180,7 +187,7 @@ class LibraryFileAlias(SQLBase):
180 self._datafile.close()187 self._datafile.close()
181 self._datafile = None188 self._datafile = None
182189
183 @property190 @cachedproperty
184 def last_downloaded(self):191 def last_downloaded(self):
185 """See `ILibraryFileAlias`."""192 """See `ILibraryFileAlias`."""
186 store = Store.of(self)193 store = Store.of(self)
@@ -271,6 +278,33 @@ class LibraryFileAliasSet(object):
271 AND LibraryFileContent.sha256 = '%s'278 AND LibraryFileContent.sha256 = '%s'
272 """ % sha256, clauseTables=['LibraryFileContent'])279 """ % sha256, clauseTables=['LibraryFileContent'])
273280
281 def preloadLastDownloaded(self, lfas):
282 """See `ILibraryFileAliasSet`."""
283 store = IStore(LibraryFileAlias)
284 results = store.find(
285 (LibraryFileDownloadCount.libraryfilealias_id,
286 LibraryFileDownloadCount.day),
287 LibraryFileDownloadCount.libraryfilealias_id.is_in(
288 sorted(lfa.id for lfa in lfas)))
289 results.order_by(
290 # libraryfilealias doesn't need to be descending for
291 # correctness, but this allows the index on
292 # LibraryFileDownloadCount (libraryfilealias, day, country) to
293 # satisfy this query efficiently.
294 Desc(LibraryFileDownloadCount.libraryfilealias_id),
295 Desc(LibraryFileDownloadCount.day))
296 # Request the first row for each LFA, which corresponds to the most
297 # recent day due to the above ordering.
298 results.config(
299 distinct=(LibraryFileDownloadCount.libraryfilealias_id,))
300 now = datetime.now(pytz.utc).date()
301 lfas_by_id = {lfa.id: lfa for lfa in lfas}
302 for lfa_id, day in results:
303 get_property_cache(lfas_by_id[lfa_id]).last_downloaded = now - day
304 del lfas_by_id[lfa_id]
305 for lfa in lfas_by_id.values():
306 get_property_cache(lfa).last_downloaded = None
307
274308
275@implementer(ILibraryFileDownloadCount)309@implementer(ILibraryFileDownloadCount)
276class LibraryFileDownloadCount(SQLBase):310class LibraryFileDownloadCount(SQLBase):

Subscribers

People subscribed via source and target branches

to status/vote changes: