← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/bug-distrotask-timeout-1012309 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/bug-distrotask-timeout-1012309 into lp:launchpad.

Commit message:
Improve bug form and model infrastructure to avoid repeated calls to various validation methods.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1012309 in Launchpad itself: "BugTask:+distrotask timeout adding a distrotask to a new bug"
  https://bugs.launchpad.net/launchpad/+bug/1012309

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/bug-distrotask-timeout-1012309/+merge/131308

== Implementation ==

Timeouts occurred add a new bug target or editing an existing target.
The timeout was due to repeated invocations of a query on the source package tables. The call point for the query was the guessPublishedSourcePackageName() method on distribution. The root cause was twofold:

1. The bug target widget override _toFieldValue() and the zope form infrastructure calls this method repeatedly during a form submission. Sadly, this method calls guessPublishedSourcePackageName() because it doesn't trust the vocab used for the source package.

2. The form submission and model processing of the new or updated bug target validates the new bug target multiple times, as well as all existing affected targets for the other bug tasks. Again, guessPublishedSourcePackageName() is called each time.

The next effect was that a query taking 100's of ms or more on production could be run 8 or more times depending on how many bugtasks were on the bug.

The fix had multiple parts.

1. Add flags to various bugtask methods to allow validation to be bypassed (default = do validation).

2. Cache the field input -> value mapping in the bug task widget so that when it is called many times by the form infrastructure, it is cheap to do.

I also noticed a curious implementation decision on the LaunchpadTarget widget. The error() method was overridden and instead of using self._error (which is meant to be set during the field input processing), it called getInputValue() all over again and caught the raised exception. Not smart, especially when getInputValue() hits the database etc. So I fixed it all up.

== Tests ==

Internal changes, so rely on existing tests.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/widgets/launchpadtarget.py
  lib/lp/bugs/browser/bugalsoaffects.py
  lib/lp/bugs/browser/bugtask.py
  lib/lp/bugs/browser/widgets/bugtask.py
  lib/lp/bugs/model/bug.py
  lib/lp/bugs/model/bugtask.py
-- 
https://code.launchpad.net/~wallyworld/launchpad/bug-distrotask-timeout-1012309/+merge/131308
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/bug-distrotask-timeout-1012309 into lp:launchpad.
=== modified file 'lib/lp/app/widgets/launchpadtarget.py'
--- lib/lp/app/widgets/launchpadtarget.py	2012-10-04 23:15:35 +0000
+++ lib/lp/app/widgets/launchpadtarget.py	2012-10-25 05:17:04 +0000
@@ -110,35 +110,31 @@
             try:
                 return self.product_widget.getInputValue()
             except MissingInputError:
-                raise LaunchpadValidationError('Please enter a project name')
+                self._error = LaunchpadValidationError(
+                    'Please enter a project name')
+                raise self._error
             except ConversionError:
                 entered_name = self.request.form_ng.getOne(
                     "%s.product" % self.name)
-                raise LaunchpadValidationError(
+                self._error = LaunchpadValidationError(
                     "There is no project named '%s' registered in"
                     " Launchpad" % entered_name)
+                raise self._error
         elif form_value == 'package':
             try:
                 distribution = self.distribution_widget.getInputValue()
             except ConversionError:
                 entered_name = self.request.form_ng.getOne(
                     "%s.distribution" % self.name)
-                raise LaunchpadValidationError(
+                self._error = LaunchpadValidationError(
                     "There is no distribution named '%s' registered in"
                     " Launchpad" % entered_name)
-
+                raise self._error
             if self.package_widget.hasInput():
                 try:
                     package_name = self.package_widget.getInputValue()
-                except ConversionError:
-                    entered_name = self.request.form_ng.getOne(
-                        '%s.package' % self.name)
-                    raise LaunchpadValidationError(
-                        "There is no package named '%s' published in %s."
-                         % (entered_name, distribution.displayname))
-                if package_name is None:
-                    return distribution
-                try:
+                    if package_name is None:
+                        return distribution
                     if IDistributionSourcePackage.providedBy(package_name):
                         dsp = package_name
                     else:
@@ -146,10 +142,13 @@
                             distribution.guessPublishedSourcePackageName(
                                 package_name.name))
                         dsp = distribution.getSourcePackage(source_name)
-                except NotFoundError:
-                    raise LaunchpadValidationError(
+                except (ConversionError, NotFoundError):
+                    entered_name = self.request.form_ng.getOne(
+                        '%s.package' % self.name)
+                    self._error = LaunchpadValidationError(
                         "There is no package named '%s' published in %s."
-                        % (package_name.name, distribution.displayname))
+                         % (entered_name, distribution.displayname))
+                    raise self._error
                 return dsp
             else:
                 return distribution
@@ -172,15 +171,6 @@
         else:
             raise AssertionError('Not a valid value: %r' % value)
 
-    def error(self):
-        """See zope.app.form.interfaces.IBrowserWidget."""
-        try:
-            if self.hasInput():
-                self.getInputValue()
-        except InputErrors as error:
-            self._error = error
-        return super(LaunchpadTargetWidget, self).error()
-
     def __call__(self):
         """See zope.app.form.interfaces.IBrowserWidget."""
         self.setUpSubWidgets()

=== modified file 'lib/lp/bugs/browser/bugalsoaffects.py'
--- lib/lp/bugs/browser/bugalsoaffects.py	2012-10-04 23:15:35 +0000
+++ lib/lp/bugs/browser/bugalsoaffects.py	2012-10-25 05:17:04 +0000
@@ -285,13 +285,10 @@
         else:
             task_target = data['distribution']
             if data.get('sourcepackagename') is not None:
-                spn_or_dsp = data['sourcepackagename']
-                if IDistributionSourcePackage.providedBy(spn_or_dsp):
-                    task_target = spn_or_dsp
-                else:
-                    task_target = task_target.getSourcePackage(spn_or_dsp)
+                task_target = data['sourcepackagename']
+        # The new target has already been validated so don't do it again.
         self.task_added = self.context.bug.addTask(
-            getUtility(ILaunchBag).user, task_target)
+            getUtility(ILaunchBag).user, task_target, validate_target=False)
         task_added = self.task_added
 
         if extracted_bug is not None:
@@ -450,6 +447,8 @@
                 if sourcepackagename:
                     target = target.getSourcePackage(sourcepackagename)
                 validate_new_target(self.context.bug, target)
+                if sourcepackagename:
+                    data['sourcepackagename'] = target
             except IllegalTarget as e:
                 if sourcepackagename:
                     self.setFieldError('sourcepackagename', e[0])

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2012-10-15 02:32:30 +0000
+++ lib/lp/bugs/browser/bugtask.py	2012-10-25 05:17:04 +0000
@@ -240,7 +240,6 @@
 from lp.registry.interfaces.projectgroup import IProjectGroup
 from lp.registry.interfaces.sourcepackage import ISourcePackage
 from lp.registry.model.personroles import PersonRoles
-from lp.registry.vocabularies import MilestoneVocabulary
 from lp.services.config import config
 from lp.services.features import getFeatureFlag
 from lp.services.feeds.browser import (
@@ -1554,7 +1553,10 @@
         new_target = data.get('target')
         if new_target and new_target != self.context.target:
             try:
-                self.context.validateTransitionToTarget(new_target)
+                # The validity of the source package has already been checked
+                # by the bug target widget.
+                self.context.validateTransitionToTarget(
+                    new_target, check_source_package=False)
             except IllegalTarget as e:
                 self.setFieldError(error_field, e[0])
 
@@ -1635,9 +1637,10 @@
         # guaranteed to pass all the values. For example: bugtasks linked to a
         # bug watch don't allow editing the form, and the value is missing
         # from the form.
+        # The new target has already been validated so don't do it again.
         if new_target is not missing and bugtask.target != new_target:
             changed = True
-            bugtask.transitionToTarget(new_target, self.user)
+            bugtask.transitionToTarget(new_target, self.user, validate=False)
 
         # Now that we've updated the bugtask we can add messages about
         # milestone changes, if there were any.

=== modified file 'lib/lp/bugs/browser/widgets/bugtask.py'
--- lib/lp/bugs/browser/widgets/bugtask.py	2012-10-05 04:11:31 +0000
+++ lib/lp/bugs/browser/widgets/bugtask.py	2012-10-25 05:17:04 +0000
@@ -487,6 +487,8 @@
     It accepts both binary and source package names.
     """
 
+    cached_values = {}
+
     def getDistribution(self):
         """Get the distribution used for package validation.
 
@@ -506,7 +508,9 @@
             return self.context.missing_value
 
         distribution = self.getDistribution()
-
+        cached_value = self.cached_values.get(input)
+        if cached_value:
+            return cached_value
         try:
             source = distribution.guessPublishedSourcePackageName(input)
         except NotFoundError:
@@ -516,6 +520,7 @@
                 raise ConversionError(
                     "Launchpad doesn't know of any source package named"
                     " '%s' in %s." % (input, distribution.displayname))
+        self.cached_values[input] = source
         return source
 
 

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2012-10-18 21:24:32 +0000
+++ lib/lp/bugs/model/bug.py	2012-10-25 05:17:04 +0000
@@ -193,7 +193,6 @@
 from lp.registry.model.pillar import pillar_sort_key
 from lp.registry.model.teammembership import TeamParticipation
 from lp.services.config import config
-from lp.services.database import bulk
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.decoratedresultset import DecoratedResultSet
@@ -1247,9 +1246,10 @@
             Store.of(result).flush()
             return result
 
-    def addTask(self, owner, target):
+    def addTask(self, owner, target, validate_target=True):
         """See `IBug`."""
-        return getUtility(IBugTaskSet).createTask(self, owner, target)
+        return getUtility(IBugTaskSet).createTask(
+            self, owner, target, validate_target)
 
     def addWatch(self, bugtracker, remotebug, owner):
         """See `IBug`."""

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2012-10-19 15:11:20 +0000
+++ lib/lp/bugs/model/bugtask.py	2012-10-25 05:17:04 +0000
@@ -314,10 +314,17 @@
     return validate_person(self, attr, value)
 
 
-def validate_target(bug, target, retarget_existing=True):
+def validate_target(bug, target, retarget_existing=True,
+                    check_source_package=True):
     """Validate a bugtask target against a bug's existing tasks.
 
     Checks that no conflicting tasks already exist.
+
+    If the target is a source package, we need to check that it has been
+    published in the distribution since we don't trust the vocabulary to
+    enforce this. However, when using the UI, this check is done during the
+    validation stage of form submission and we don't want to do it again since
+    it uses an expensive query. So 'check_source_package' can be set to False.
     """
     if bug.getBugTask(target):
         raise IllegalTarget(
@@ -328,7 +335,7 @@
         ISourcePackage.providedBy(target)):
         # If the distribution has at least one series, check that the
         # source package has been published in the distribution.
-        if (target.sourcepackagename is not None and
+        if (check_source_package and target.sourcepackagename is not None and
             len(target.distribution.series) > 0):
             try:
                 target.distribution.guessPublishedSourcePackageName(
@@ -664,10 +671,11 @@
             if relevant:
                 key = bug_target_to_key(bugtask.target)
                 key['sourcepackagename'] = new_spn
+                # The relevance check above and the fact that the distro series
+                # task is already on the bug means we don't need to revalidate.
                 bugtask.transitionToTarget(
                     bug_target_from_key(**key),
-                    user,
-                    _sync_sourcepackages=False)
+                    user, validate=False, _sync_sourcepackages=False)
 
     def getContributorInfo(self, user, person):
         """See `IBugTask`."""
@@ -1035,7 +1043,7 @@
 
         self.assignee = assignee
 
-    def validateTransitionToTarget(self, target):
+    def validateTransitionToTarget(self, target, check_source_package=True):
         """See `IBugTask`."""
         from lp.registry.model.distroseries import DistroSeries
 
@@ -1094,11 +1102,17 @@
                         "tasks may only be retargeted to a different "
                         "package.")
 
-        validate_target(self.bug, target)
+        validate_target(
+            self.bug, target, check_source_package=check_source_package)
 
-    def transitionToTarget(self, target, user, _sync_sourcepackages=True):
+    def transitionToTarget(self, target, user, validate=True,
+                           _sync_sourcepackages=True):
         """See `IBugTask`.
 
+        If validate is True then we need to check that the new target is valid,
+        otherwise the check has already been done (eg during form submission)
+        and we don't need to repeat it.
+
         If _sync_sourcepackages is True (the default) and the
         sourcepackagename is being changed, any other tasks for the same
         name in this distribution will have their names updated to
@@ -1107,7 +1121,8 @@
         if self.target == target:
             return
 
-        self.validateTransitionToTarget(target)
+        if validate:
+            self.validateTransitionToTarget(target)
 
         target_before_change = self.target
 
@@ -1570,8 +1585,9 @@
         params = BugTaskSearchParams(user, **kwargs)
         return self.search(params)
 
-    def createManyTasks(self, bug, owner, targets, status=None,
-                        importance=None, assignee=None, milestone=None):
+    def createManyTasks(self, bug, owner, targets, validate_target=True,
+                        status=None, importance=None, assignee=None,
+                        milestone=None):
         """See `IBugTaskSet`."""
         if status is None:
             status = IBugTask['status'].default
@@ -1580,7 +1596,8 @@
         target_keys = []
         pillars = set()
         for target in targets:
-            validate_new_target(bug, target)
+            if validate_target:
+                validate_new_target(bug, target)
             pillars.add(target.pillar)
             target_keys.append(bug_target_to_key(target))
 
@@ -1605,8 +1622,8 @@
         removeSecurityProxy(bug)._reconcileAccess()
         return tasks
 
-    def createTask(self, bug, owner, target, status=None, importance=None,
-                   assignee=None, milestone=None):
+    def createTask(self, bug, owner, target, validate_target=True, status=None,
+                   importance=None, assignee=None, milestone=None):
         """See `IBugTaskSet`."""
         # Create tasks for accepted nominations if this is a source
         # package addition. Distribution nominations are for all the
@@ -1622,8 +1639,9 @@
                         key['sourcepackagename']))
 
         tasks = self.createManyTasks(
-            bug, owner, targets, status=status, importance=importance,
-            assignee=assignee, milestone=milestone)
+            bug, owner, targets, validate_target=validate_target,
+            status=status, importance=importance, assignee=assignee,
+            milestone=milestone)
         return [task for task in tasks if task.target == target][0]
 
     def getStatusCountsForProductSeries(self, user, product_series):


Follow ups