← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging ~cjwatson/launchpad:project-download-queries into launchpad:master.

Commit message:
Preload more librarian references on Product:+download

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1952958 in Launchpad itself: "Timeout error on Product:+download"
  https://bugs.launchpad.net/launchpad/+bug/1952958

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/412706

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:project-download-queries into launchpad:master.
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 (
     PublicPersonChoice,
     URIField,
     )
+from lp.services.librarian.interfaces import ILibraryFileAliasSet
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     ApplicationMenu,
@@ -848,8 +849,10 @@ class ReleaseWithFiles:
             # returns all releases sorted properly.
             product = self.parent.parent
             release_delegates = product.release_by_id.values()
-            files = getUtility(IProductReleaseSet).getFilesForReleases(
-                release_delegates)
+            files = list(getUtility(IProductReleaseSet).getFilesForReleases(
+                release_delegates))
+            getUtility(ILibraryFileAliasSet).preloadLastDownloaded(
+                {file.libraryfile for file in files})
             for release_delegate in release_delegates:
                 release_delegate._files = []
             for file in files:
@@ -1271,8 +1274,6 @@ class ProductDownloadFilesView(LaunchpadView,
                                ProductDownloadFileMixin):
     """View class for the product's file downloads page."""
 
-    batch_size = config.launchpad.download_batch_size
-
     @property
     def page_title(self):
         return "%s project files" % self.context.displayname
@@ -1304,7 +1305,7 @@ class ProductDownloadFilesView(LaunchpadView,
                     if pair not in series_and_releases:
                         series_and_releases.append(pair)
         batch = BatchNavigator(series_and_releases, self.request,
-                               size=self.batch_size)
+                               size=config.launchpad.download_batch_size)
         batch.setHeadings("release", "releases")
         return batch
 
diff --git a/lib/lp/registry/browser/tests/product-files-views.txt b/lib/lp/registry/browser/tests/product-files-views.txt
deleted file mode 100644
index c999548..0000000
--- a/lib/lp/registry/browser/tests/product-files-views.txt
+++ /dev/null
@@ -1,93 +0,0 @@
- Product Download Files Page
-=============================
-
-Test for the product/+download page.
-
-    >>> product = factory.makeProduct(name='alfajore')
-    >>> productseries = factory.makeProductSeries(
-    ...     product=product, name="sammy")
-    >>> milestone = factory.makeMilestone(productseries=productseries,
-    ...                                   name="apple")
-    >>> release_file = factory.makeProductReleaseFile(
-    ...     product=product, productseries=productseries, milestone=milestone)
-    >>> view = create_initialized_view(product, '+download')
-
-    >>> view.batch_size
-    4
-
-    >>> batch = view.series_and_releases_batch.currentBatch()
-    >>> print(len(list(batch)))
-    1
-
-    >>> def print_series_release(sr):
-    ...     print("%s from the %s series" % (sr.release.name_with_codename,
-    ...                                      sr.series.name))
-
-    >>> for sr in batch:
-    ...     print_series_release(sr)
-    apple from the sammy series
-
-    >>> product = factory.makeProduct(name='bombilla')
-    >>> for i in range(1,5):
-    ...     productseries = factory.makeProductSeries(
-    ...         product=product, name="s%d"%i)
-    ...     for j in range(1,4):
-    ...         milestone = factory.makeMilestone(productseries=productseries,
-    ...                                           name="%d.%d"%(i,j))
-    ...         release_file = factory.makeProductReleaseFile(
-    ...             product=product, productseries=productseries,
-    ...             milestone=milestone)
-    >>> view = create_initialized_view(product, '+download')
-    >>> batch = view.series_and_releases_batch.currentBatch()
-    >>> print(len(batch))
-    4
-    >>> for sr in batch:
-    ...     print_series_release(sr)
-    4.3 from the s4 series
-    4.2 from the s4 series
-    4.1 from the s4 series
-    3.3 from the s3 series
-
-    >>> batch = batch.nextBatch()
-    >>> for sr in batch:
-    ...     print_series_release(sr)
-    3.2 from the s3 series
-    3.1 from the s3 series
-    2.3 from the s2 series
-    2.2 from the s2 series
-
-For an administrator of the project, at the bottom of each batched
-page will be links to add new files for each series and release.
-
-    >>> from lp.testing.pages import (
-    ...     extract_text, find_tag_by_id)
-    >>> ignored = login_person(product.owner)
-    >>> view = create_initialized_view(product, '+download',
-    ...                                principal=product.owner)
-    >>> admin_links = find_tag_by_id(view.render(), 'admin-links')
-    >>> content = extract_text(admin_links)
-    >>> print(content)
-    Add download file to the s4 series for release: 4.3, 4.2, 4.1
-    Add download file to the s3 series for release: 3.3, 3.2, 3.1
-    Add download file to the s2 series for release: 2.3, 2.2, 2.1
-    Add download file to the s1 series for release: 1.3, 1.2, 1.1
-
-
-Product index
--------------
-
-The product index view shows the latest release for the project.
-
-    >>> view = create_initialized_view(product, name='+index')
-    >>> print(view.latest_release_with_download_files.version)
-    4.3
-
-Obsolete series are ignored.
-
-    >>> from lp.registry.interfaces.series import SeriesStatus
-
-    >>> obsolete_series = product.getSeries('s4')
-    >>> obsolete_series.status = SeriesStatus.OBSOLETE
-    >>> view = create_initialized_view(product, name='+index')
-    >>> print(view.latest_release_with_download_files.version)
-    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 @@
-# Copyright 2010-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for product views."""
@@ -6,6 +6,7 @@
 __all__ = ['make_product_form']
 
 import re
+from textwrap import dedent
 
 from lazr.restful.fields import Reference
 from lazr.restful.interfaces import (
@@ -55,6 +56,7 @@ from lp.registry.interfaces.product import (
     License,
     )
 from lp.registry.interfaces.productseries import IProductSeries
+from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.model.product import Product
 from lp.services.config import config
 from lp.services.database.interfaces import IStore
@@ -69,6 +71,7 @@ from lp.testing import (
     login_celebrity,
     login_person,
     person_logged_in,
+    record_two_runs,
     StormStatementRecorder,
     TestCaseWithFactory,
     )
@@ -835,6 +838,89 @@ class TestProductEditView(BrowserTestCase):
             InformationType.PUBLIC, updated_product.information_type)
 
 
+class TestProductDownloadFilesView(TestCaseWithFactory):
+    """Test `ProductDownloadFilesView`."""
+
+    layer = LaunchpadFunctionalLayer
+
+    def makeProductAndReleases(self):
+        product = self.factory.makeProduct()
+        for i in range(1, 5):
+            productseries = self.factory.makeProductSeries(
+                product=product, name="s%d" % i)
+            for j in range(1, 4):
+                milestone = self.factory.makeMilestone(
+                    productseries=productseries, name="%d.%d" % (i, j))
+                self.factory.makeProductReleaseFile(
+                    product=product, productseries=productseries,
+                    milestone=milestone)
+        return product
+
+    def test_series_and_releases_batch(self):
+        self.pushConfig("launchpad", download_batch_size=4)
+        product = self.makeProductAndReleases()
+        view = create_initialized_view(product, "+download")
+        batch = view.series_and_releases_batch.currentBatch()
+        self.assertEqual(
+            [("4.3", "s4"), ("4.2", "s4"), ("4.1", "s4"), ("3.3", "s3")],
+            [(sr.release.name_with_codename, sr.series.name) for sr in batch])
+        batch = batch.nextBatch()
+        self.assertEqual(
+            [("3.2", "s3"), ("3.1", "s3"), ("2.3", "s2"), ("2.2", "s2")],
+            [(sr.release.name_with_codename, sr.series.name) for sr in batch])
+
+    def test_query_count(self):
+        self.pushConfig("launchpad", download_batch_size=20)
+        product = self.factory.makeProduct()
+
+        def create_series_and_releases():
+            productseries = self.factory.makeProductSeries(product=product)
+            for _ in range(3):
+                self.factory.makeProductReleaseFile(
+                    product=product, productseries=productseries)
+
+        def render_product():
+            create_initialized_view(product, "+download").render()
+
+        recorder1, recorder2 = record_two_runs(
+            render_product, create_series_and_releases, 2)
+        self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
+    def test_add_download_file_links(self):
+        # Project administrators have links at the bottom of each batched
+        # page to add new files for each series and release.
+        product = self.makeProductAndReleases()
+        login_person(product.owner)
+        view = create_initialized_view(
+            product, "+download", principal=product.owner)
+        admin_links = find_tag_by_id(view.render(), "admin-links")
+        expected_links = dedent("""
+            Add download file to the s4 series for release: 4.3, 4.2, 4.1
+            Add download file to the s3 series for release: 3.3, 3.2, 3.1
+            Add download file to the s2 series for release: 2.3, 2.2, 2.1
+            Add download file to the s1 series for release: 1.3, 1.2, 1.1
+            """)
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            expected_links, extract_text(admin_links))
+
+    def test_latest_release_on_index(self):
+        # The project index view shows the latest release for the project.
+        product = self.makeProductAndReleases()
+        view = create_initialized_view(product, "+index")
+        self.assertEqual(
+            "4.3", view.latest_release_with_download_files.version)
+
+    def test_latest_release_ignores_obsolete_series(self):
+        # The project index view ignores obsolete series for the purpose of
+        # showing the latest release.
+        product = self.makeProductAndReleases()
+        with person_logged_in(product.owner):
+            product.getSeries("s4").status = SeriesStatus.OBSOLETE
+        view = create_initialized_view(product, "+index")
+        self.assertEqual(
+            "3.3", view.latest_release_with_download_files.version)
+
+
 class ProductSetReviewLicensesViewTestCase(TestCaseWithFactory):
     """Tests the ProductSetReviewLicensesView."""
 
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 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """
@@ -34,7 +34,6 @@ special_test_layer = {
     'milestone-views.txt': LaunchpadFunctionalLayer,
     'person-views.txt': LaunchpadFunctionalLayer,
     'product-edit-people-view.txt': LaunchpadFunctionalLayer,
-    'product-files-views.txt': LaunchpadFunctionalLayer,
     'product-views.txt': LaunchpadFunctionalLayer,
     'productseries-views.txt': LaunchpadFunctionalLayer,
     '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 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __all__ = [
@@ -281,9 +281,14 @@ class ProductReleaseSet(object):
             return EmptyResultSet()
         return ProductReleaseFile.select(
             """ProductReleaseFile.productrelease IN %s""" % (
-            sqlvalues([release.id for release in releases])),
+                sqlvalues([release.id for release in releases])),
             orderBy='-date_uploaded',
-            prejoins=['libraryfile', 'libraryfile.content', 'productrelease'])
+            prejoins=[
+                'libraryfile',
+                'libraryfile.content',
+                'productrelease',
+                'signature',
+                ])
 
 
 def 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 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Librarian interfaces."""
@@ -175,6 +175,9 @@ class ILibraryFileAliasSet(Interface):
         given sha256.
         """
 
+    def preloadLastDownloaded(self, lfas):
+        """Preload last_downloaded for a collection of `LibraryFileAlias`es."""
+
 
 class ILibraryFileDownloadCount(Interface):
     """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 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __all__ = [
@@ -40,7 +40,10 @@ from lp.services.database.constants import (
     UTC_NOW,
     )
 from lp.services.database.datetimecol import UtcDateTimeCol
-from lp.services.database.interfaces import IMasterStore
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
 from lp.services.database.sqlbase import (
     session_store,
     SQLBase,
@@ -66,6 +69,10 @@ from lp.services.librarian.interfaces.client import (
     IRestrictedLibrarianClient,
     LIBRARIAN_SERVER_DEFAULT_TIMEOUT,
     )
+from lp.services.propertycache import (
+    cachedproperty,
+    get_property_cache,
+    )
 from lp.services.tokens import create_token
 
 
@@ -180,7 +187,7 @@ class LibraryFileAlias(SQLBase):
             self._datafile.close()
             self._datafile = None
 
-    @property
+    @cachedproperty
     def last_downloaded(self):
         """See `ILibraryFileAlias`."""
         store = Store.of(self)
@@ -271,6 +278,33 @@ class LibraryFileAliasSet(object):
             AND LibraryFileContent.sha256 = '%s'
             """ % sha256, clauseTables=['LibraryFileContent'])
 
+    def preloadLastDownloaded(self, lfas):
+        """See `ILibraryFileAliasSet`."""
+        store = IStore(LibraryFileAlias)
+        results = store.find(
+            (LibraryFileDownloadCount.libraryfilealias_id,
+             LibraryFileDownloadCount.day),
+            LibraryFileDownloadCount.libraryfilealias_id.is_in(
+                sorted(lfa.id for lfa in lfas)))
+        results.order_by(
+            # libraryfilealias doesn't need to be descending for
+            # correctness, but this allows the index on
+            # LibraryFileDownloadCount (libraryfilealias, day, country) to
+            # satisfy this query efficiently.
+            Desc(LibraryFileDownloadCount.libraryfilealias_id),
+            Desc(LibraryFileDownloadCount.day))
+        # Request the first row for each LFA, which corresponds to the most
+        # recent day due to the above ordering.
+        results.config(
+            distinct=(LibraryFileDownloadCount.libraryfilealias_id,))
+        now = datetime.now(pytz.utc).date()
+        lfas_by_id = {lfa.id: lfa for lfa in lfas}
+        for lfa_id, day in results:
+            get_property_cache(lfas_by_id[lfa_id]).last_downloaded = now - day
+            del lfas_by_id[lfa_id]
+        for lfa in lfas_by_id.values():
+            get_property_cache(lfa).last_downloaded = None
+
 
 @implementer(ILibraryFileDownloadCount)
 class LibraryFileDownloadCount(SQLBase):