Merge lp:~adeuring/launchpad/bug-904461 into lp:launchpad

Proposed by Abel Deuring on 2012-01-19
Status: Merged
Approved by: Abel Deuring on 2012-01-19
Approved revision: no longer in the source branch.
Merged at revision: 14712
Proposed branch: lp:~adeuring/launchpad/bug-904461
Merge into: lp:launchpad
Diff against target: 606 lines (+375/-61)
8 files modified
lib/lp/bugs/browser/cvereport.py (+67/-34)
lib/lp/bugs/browser/tests/test_cve.py (+176/-0)
lib/lp/bugs/doc/bugtask-search.txt (+2/-2)
lib/lp/bugs/doc/cve.txt (+4/-4)
lib/lp/bugs/interfaces/cve.py (+7/-4)
lib/lp/bugs/model/cve.py (+39/-9)
lib/lp/bugs/templates/bugtask-macros-cve.pt (+3/-8)
lib/lp/bugs/tests/test_cve.py (+77/-0)
To merge this branch: bzr merge lp:~adeuring/launchpad/bug-904461
Reviewer Review Type Date Requested Status
j.c.sackett (community) 2012-01-19 Approve on 2012-01-19
Richard Harding (community) code* 2012-01-19 Approve on 2012-01-19
Review via email: mp+89230@code.launchpad.net

Commit Message

[r=jcsackett,rharding][bug=904461] render +cve views more efficiently

Description of the Change

This branch fixes bug 904461: Timeout viewing CVE reports in an Ubuntu series

This timeout is not caused by slow SQL queries, but by several bottlenecks
on Python level. Related OOPS reports show SQL times of less than one second.

Thanks to lifeless, who gave me a number good hints where to look and
how to reproduce the timeout locally.

I created test data that should be roughly similar to real world data:
a few hundred bugs/bug tasks and ca 250 CVE, often linked to more than
one bug: The +cve page for Oneiric (or Natty -- I can't remeber where I
looked...) contains approximately 4800 links to CVEs, but only 240
or 250 _different_ CVEs: CVEs are often linked to several bugs.

Probably the worst bottleneck is the query

        BugCve.select("""
            BugCve.bug IN %s""" % sqlvalues(bug_ids),
            prejoins=["cve"],
            orderBy=['bug', 'cve'])

in lp.bugs.model.cve.CveSet.getBugCvesForBugTasks() (line 434 in the
diff), where one CVE is on average loaded 20 times. The SQL itself is
quite fast, but Storm spends much time in methods like load_object().

I changed this in r14627 so that only the tuples (bug_id, cve_id) are
loaded from the table BugCve; the related Bug and Cve instances are
retrieved via load_related().

Another serious bottleneck is the function canonical_url(): profile
data showed that several seconds are spent there when a page is
rendered.

The number of calls can be considerably reduced: Most calls are for
Cve instances, so we can avoid a large part of them, if we call
the function just once for each Cve. I implemented this by adding
an optional parameter cve_mapper to getBugCvesForBugTasks();
the function get_cve_display_data() in lp.bugs.browser.cvereport
is now used as cve_mapper when the +cve view is rendered.
This saved 2.5 seconds in my test setup (r14629).

Antoher noticeable improvement was to retrieve the (bug, cve) tuples
for open and resolved CVEs in one query (r14628). I am not sure though
if the improvement in my test data resembles the real world: The number
of CVEs that are linked to open _and_ resovled bug tasks was probably
too big in the test data.

The last big bottleneck was the TAL loop that renders the CVE links.
Replacing it with simple Python list and string operations (r14630,
method CVEReportView.renderCVELinks()) saved 2.9 seconds.

A final, smaller improvemet was to bulk load the Bugs and the Person
records for bug task assignees (r14631).

Timing data from this test:

    def test_open_cve_bugtasks(self):
        from time import time
        distroseries = getUtility(IDistroSeriesSet).get(15)
        total = 0.0
        LOOPS = 3
        for i in range(LOOPS):
            start = time()
            view = create_initialized_view(distroseries, '+cve')
            view.render()
            delta = time() - start
            total += delta
            print "render time", delta
        print "avg", total/LOOPS

    (Note that this test is not included in the branch: Running it makes
    sense with the right test data: The script that created my test data
    ran more than one hour -- doing this in a regular unit test as a part
    of the complete test suite does not make sense. And the sample data
    file current.sql has a size of 9MB with the new test data...)

r14626 (original timeout) 14.3 sec
r14627 more efficent retrieval of (Bug, Cve) tuples 8.4 sec
r14628 one query to retrieve open and resolved CVEs 8.1 sec
r14629 efficient generation of CVE related display data 5.6 sec
r14630 render CVE links and join them in a browser method 2.9 sec
r14631 bulk load bugs and bugtask assignees 2.9 sec

(In r14631 and r14630, caching is noticable: The first loop execution
needed ca 4 seconds, the two following loop run needed ca 2.4 seconds.)

I did _not_ test that the number of SQL queries is constant, regardless
of the number of bugs/bug tasks/CVEs because it is not, unfortunately:
IPersonSet.getPrecachedPersonsFromIDs() does not avoid later SQL queries
for ValidPersonCache records of single Person records. Bulk loading
Person records works fine though.

tests:

./bin/test bugs -vvt test_cve

no lint.

To post a comment you must log in.
Richard Harding (rharding) wrote :

Thanks Abel, this looks like it'll save a bunch of cpu cycles. I have a few questions/notes.

In the html generation, should the string that defines the template be moved outside of the method so that the variable instance isn't created each call (potentially thousands?) and instead be placed as some sort of class level constant?

Should we add a docstring to the getBugCvesForBugTasks and make note of the cve_mapper param as a callable method meant for that mapping?

I don't think it matters, but in looking at the TestCveSet test cases, I wonder if a test case with multiple bugs per cve should be exercised as well since much of the code is about dealing with that condition.

I don't think any of these are deal breakers so approving.

review: Approve (code*)
j.c.sackett (jcsackett) wrote :

> Should we add a docstring to the getBugCvesForBugTasks and make note of the
> cve_mapper param as a callable method meant for that mapping?

I see that you have a docstring for this method in the interface; given the complexity of what's going on, I think in the model code it may be worthwhile to simply add the usual "See `ICveSet`." docstring so people needing help quickly know where to go for it. I'll still approve, but would like this added before it lands.

Your tests look pretty extensive; I concur that adding multiple bugs per cve might be useful, but as you've got reasonable testing of performance and behavior of that situation elsewhere and the cve set tests verify the implementation sufficiently Rick is correct to say it's not a blocker.

One very minor point in the diff below:

> === modified file 'lib/lp/bugs/model/cve.py'
> --- lib/lp/bugs/model/cve.py 2011-12-30 06:14:56 +0000
> +++ lib/lp/bugs/model/cve.py 2012-01-19 12:21:17 +0000
> @@ -178,15 +182,41 @@
> raise AssertionError('MessageChunk without content or blob.')
> return sorted(cves, key=lambda a: a.sequence)
>
> - def getBugCvesForBugTasks(self, bugtasks):
> - bug_ids = set(task.bug.id for task in bugtasks)
> + def getBugCvesForBugTasks(self, bugtasks, cve_mapper=None):
> + bugs = load_related(Bug, bugtasks, ('bugID', ))
> + if len(bugs) == 0:
> + return []
> + bug_ids = [bug.id for bug in bugs]
> assert bug_ids, "bugtasks must be non-empty, received %r" % bugtasks

I think you can remove the assert--you return if bugs is len 0, and the
only other thing the assert would catch would be NoneType, which would blow up
on the len check. Obviously it's not hurting anything, but it's not really
necessary either.

review: Approve
Abel Deuring (adeuring) wrote :

Hi Rick,

thanks for your review. I made all changes you suggested, though the doc string for getBugCvesForBu() is just the usual terse "see `ICveSet`".

The test data generated in TestCVEReportView.setUp() now uses only five CVEs; two CVEs are associated with each test bug. This required some more changes in test_open_resolved_cve_bugtasks(), because some bugs are now associated with [cve_4, cve_0] -- but CVE are sorted by ID in CVEReportView.(open|resolved)_cve_bugtasks.

I also forgot to mention in my original MP that I think that test_open_resolved_cve_bugtasks() is quite long for unit test. I opted not to split it into a couple of smaller tests because generating the bug tasks in setUp() needs quite some time -- all three tests run in one minute on my i7 laptop, and I don't want to make that two minutes or so by splitting the long test into smaller ones.

Abel Deuring (adeuring) wrote :

On 19.01.2012 15:45, j.c.sackett wrote:
> Review: Approve
>
>> Should we add a docstring to the getBugCvesForBugTasks and make note of the
>> cve_mapper param as a callable method meant for that mapping?
>
> I see that you have a docstring for this method in the interface; given the complexity of what's going on, I think in the model code it may be worthwhile to simply add the usual "See `ICveSet`." docstring so people needing help quickly know where to go for it. I'll still approve, but would like this added before it lands.
>
> Your tests look pretty extensive; I concur that adding multiple bugs per cve might be useful, but as you've got reasonable testing of performance and behavior of that situation elsewhere and the cve set tests verify the implementation sufficiently Rick is correct to say it's not a blocker.
>
> One very minor point in the diff below:
>
>> === modified file 'lib/lp/bugs/model/cve.py'
>> --- lib/lp/bugs/model/cve.py 2011-12-30 06:14:56 +0000
>> +++ lib/lp/bugs/model/cve.py 2012-01-19 12:21:17 +0000
>> @@ -178,15 +182,41 @@
>> raise AssertionError('MessageChunk without content or blob.')
>> return sorted(cves, key=lambda a: a.sequence)
>>
>> - def getBugCvesForBugTasks(self, bugtasks):
>> - bug_ids = set(task.bug.id for task in bugtasks)
>> + def getBugCvesForBugTasks(self, bugtasks, cve_mapper=None):
>> + bugs = load_related(Bug, bugtasks, ('bugID', ))
>> + if len(bugs) == 0:
>> + return []
>> + bug_ids = [bug.id for bug in bugs]
>> assert bug_ids, "bugtasks must be non-empty, received %r" % bugtasks
>
> I think you can remove the assert--you return if bugs is len 0, and the
> only other thing the assert would catch would be NoneType, which would blow up
> on the len check. Obviously it's not hurting anything, but it's not really
> necessary either.

good spot! I noticed the assert too when working on the branch but then
simple forgot it...

j.c.sackett (jcsackett) wrote :

Thanks for removing the assert, Abel.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/cvereport.py'
2--- lib/lp/bugs/browser/cvereport.py 2012-01-01 02:58:52 +0000
3+++ lib/lp/bugs/browser/cvereport.py 2012-01-20 17:20:32 +0000
4@@ -6,23 +6,24 @@
5 __metaclass__ = type
6
7 __all__ = [
8+ 'BugTaskCve',
9 'CVEReportView',
10 ]
11
12 from zope.component import getUtility
13
14+from lp.app.browser.stringformatter import escape
15 from lp.bugs.browser.bugtask import BugTaskListingItem
16 from lp.bugs.interfaces.bugtask import (
17 BugTaskSearchParams,
18 IBugTaskSet,
19 RESOLVED_BUGTASK_STATUSES,
20- UNRESOLVED_BUGTASK_STATUSES,
21 )
22 from lp.bugs.interfaces.cve import ICveSet
23+from lp.registry.interfaces.person import IPersonSet
24 from lp.services.helpers import shortlist
25-from lp.services.propertycache import cachedproperty
26-from lp.services.searchbuilder import any
27 from lp.services.webapp import LaunchpadView
28+from lp.services.webapp.publisher import canonical_url
29
30
31 class BugTaskCve:
32@@ -39,6 +40,19 @@
33 return self.bugtasks[0].bug
34
35
36+def get_cve_display_data(cve):
37+ """Return the data we need for display for the given CVE."""
38+ return {
39+ 'displayname': cve.displayname,
40+ 'url': canonical_url(cve),
41+ }
42+
43+cve_link_template = (
44+ '<a style="text-decoration: none" href=%s">'
45+ '<img src="/@@/link" alt="" />'
46+ '<span style="text-decoration: underline">%s</span></a>')
47+
48+
49 class CVEReportView(LaunchpadView):
50 """View that builds data to be displayed in CVE reports."""
51
52@@ -46,38 +60,29 @@
53 def page_title(self):
54 return 'CVE reports for %s' % self.context.title
55
56- @cachedproperty
57- def open_cve_bugtasks(self):
58- """Find BugTaskCves for bugs with open bugtasks in the context."""
59- search_params = BugTaskSearchParams(
60- self.user, status=any(*UNRESOLVED_BUGTASK_STATUSES))
61- return self._buildBugTaskCves(search_params)
62-
63- @cachedproperty
64- def resolved_cve_bugtasks(self):
65- """Find BugTaskCves for bugs with resolved bugtasks in the context."""
66- search_params = BugTaskSearchParams(
67- self.user, status=any(*RESOLVED_BUGTASK_STATUSES))
68- return self._buildBugTaskCves(search_params)
69-
70 def setContextForParams(self, params):
71 """Update the search params for the context for a specific view."""
72 raise NotImplementedError
73
74- def _buildBugTaskCves(self, search_params):
75- """Construct a list of BugTaskCve objects, sorted by bug ID."""
76- search_params.has_cve = True
77+ def initialize(self):
78+ """See `LaunchpadView`."""
79+ super(CVEReportView, self).initialize()
80+ search_params = BugTaskSearchParams(
81+ self.user, has_cve=True)
82 bugtasks = shortlist(
83 self.context.searchTasks(search_params),
84- longest_expected=300)
85+ longest_expected=600)
86
87 if not bugtasks:
88- return []
89+ self.open_cve_bugtasks = []
90+ self.resolved_cve_bugtasks = []
91+ return
92
93 badge_properties = getUtility(IBugTaskSet).getBugTaskBadgeProperties(
94 bugtasks)
95
96- bugtaskcves = {}
97+ open_bugtaskcves = {}
98+ resolved_bugtaskcves = {}
99 for bugtask in bugtasks:
100 badges = badge_properties[bugtask]
101 # Wrap the bugtask in a BugTaskListingItem, to avoid db
102@@ -87,15 +92,43 @@
103 has_bug_branch=badges['has_branch'],
104 has_specification=badges['has_specification'],
105 has_patch=badges['has_patch'])
106- if bugtask.bug.id not in bugtaskcves:
107- bugtaskcves[bugtask.bug.id] = BugTaskCve()
108- bugtaskcves[bugtask.bug.id].bugtasks.append(bugtask)
109-
110- bugcves = getUtility(ICveSet).getBugCvesForBugTasks(bugtasks)
111- for bugcve in bugcves:
112- assert bugcve.bug.id in bugtaskcves, "Bug missing in bugcves."
113- bugtaskcves[bugcve.bug.id].cves.append(bugcve.cve)
114-
115- # Order the dictionary items by bug ID and then return only the
116+ if bugtask.status in RESOLVED_BUGTASK_STATUSES:
117+ current_bugtaskcves = resolved_bugtaskcves
118+ else:
119+ current_bugtaskcves = open_bugtaskcves
120+ if bugtask.bug.id not in current_bugtaskcves:
121+ current_bugtaskcves[bugtask.bug.id] = BugTaskCve()
122+ current_bugtaskcves[bugtask.bug.id].bugtasks.append(bugtask)
123+
124+ bugcves = getUtility(ICveSet).getBugCvesForBugTasks(
125+ bugtasks, get_cve_display_data)
126+ for bug, cve in bugcves:
127+ if bug.id in open_bugtaskcves:
128+ open_bugtaskcves[bug.id].cves.append(cve)
129+ if bug.id in resolved_bugtaskcves:
130+ resolved_bugtaskcves[bug.id].cves.append(cve)
131+
132+ # Order the dictionary items by bug ID and then store only the
133 # bugtaskcve objects.
134- return [bugtaskcve for bug, bugtaskcve in sorted(bugtaskcves.items())]
135+ self.open_cve_bugtasks = [
136+ bugtaskcve for bug, bugtaskcve
137+ in sorted(open_bugtaskcves.items())]
138+ self.resolved_cve_bugtasks = [
139+ bugtaskcve for bug, bugtaskcve
140+ in sorted(resolved_bugtaskcves.items())]
141+
142+ # The page contains links to the bug task assignees:
143+ # Pre-load the related Person and ValidPersonCache records
144+ assignee_ids = [task.assigneeID for task in bugtasks]
145+ list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
146+ assignee_ids, need_validity=True))
147+
148+ def renderCVELinks(self, cves):
149+ """Render the CVE links related to the given bug.
150+
151+ Doing this in a TAL expression is too inefficient for thousands
152+ of CVEs.
153+ """
154+ return '<br />\n'.join(
155+ cve_link_template % (cve['url'], escape(cve['displayname']))
156+ for cve in cves)
157
158=== added file 'lib/lp/bugs/browser/tests/test_cve.py'
159--- lib/lp/bugs/browser/tests/test_cve.py 1970-01-01 00:00:00 +0000
160+++ lib/lp/bugs/browser/tests/test_cve.py 2012-01-20 17:20:32 +0000
161@@ -0,0 +1,176 @@
162+# Copyright 2012 Canonical Ltd. This software is licensed under the
163+# GNU Affero General Public License version 3 (see the file LICENSE).
164+
165+"""CVE related tests."""
166+
167+from operator import attrgetter
168+import re
169+
170+from lp.bugs.interfaces.bugtask import (
171+ RESOLVED_BUGTASK_STATUSES,
172+ UNRESOLVED_BUGTASK_STATUSES,
173+ )
174+from lp.bugs.browser.cvereport import BugTaskCve
175+from lp.services.webapp.publisher import canonical_url
176+from lp.testing import (
177+ person_logged_in,
178+ TestCaseWithFactory,
179+ )
180+from lp.testing.layers import DatabaseFunctionalLayer
181+from lp.testing.views import create_initialized_view
182+
183+
184+class TestCVEReportView(TestCaseWithFactory):
185+ """Tests for CveSet."""
186+
187+ layer = DatabaseFunctionalLayer
188+
189+ def setUp(self):
190+ """Create a few bugtasks and CVEs."""
191+ super(TestCVEReportView, self).setUp()
192+ distroseries = self.factory.makeDistroSeries()
193+ self.resolved_bugtasks = []
194+ self.unresolved_bugtasks = []
195+ self.cves = {}
196+ self.getCVE = self.cveGenerator().next
197+ with person_logged_in(distroseries.owner):
198+ for status in RESOLVED_BUGTASK_STATUSES:
199+ tasks, cves = self.makeBugTasksWithCve(status, distroseries)
200+ self.resolved_bugtasks.append(tasks)
201+ self.cves[tasks[0].bug] = cves
202+ for status in UNRESOLVED_BUGTASK_STATUSES:
203+ tasks, cves = self.makeBugTasksWithCve(status, distroseries)
204+ self.unresolved_bugtasks.append(tasks)
205+ self.cves[tasks[0].bug] = cves
206+ self.view = create_initialized_view(distroseries, '+cve')
207+
208+ def makeBugTasksWithCve(self, status, distroseries):
209+ """Return two bugtasks for one bug linked to CVE."""
210+ task = self.factory.makeBugTask(
211+ target=self.factory.makeSourcePackage(distroseries=distroseries))
212+ task.transitionToStatus(status, distroseries.owner)
213+ bug = task.bug
214+ task_2 = self.factory.makeBugTask(
215+ target=self.factory.makeSourcePackage(distroseries=distroseries),
216+ bug=bug)
217+ task_2.transitionToStatus(status, distroseries.owner)
218+ cve_1 = self.getCVE()
219+ cve_2 = self.getCVE()
220+ bug.linkCVE(cve_1, distroseries.owner)
221+ bug.linkCVE(cve_2, distroseries.owner)
222+ return [task, task_2], (cve_1, cve_2)
223+
224+ def cveGenerator(self):
225+ """A generator returning five CVEs cyclically."""
226+ NUM_CVES = 5
227+ cves = [
228+ self.factory.makeCVE('2000-%04i' % count)
229+ for count in range(NUM_CVES)]
230+ cve_index = 0
231+ while True:
232+ yield cves[cve_index]
233+ cve_index = (cve_index + 1) % NUM_CVES
234+
235+ def test_render(self):
236+ # The rendered page contains all expected CVE links.
237+ html_data = self.view.render()
238+ cve_links = re.findall(
239+ r'<a style="text-decoration: none" '
240+ r'href=http://bugs.launchpad.dev/bugs/cve/\d{4}-\d{4}">'
241+ r'<img src="/@@/link" alt="" />'
242+ r'<span style="text-decoration: underline">CVE-\d{4}-\d{4}</span>'
243+ r'</a>',
244+ html_data)
245+ # We have two CVEs per bug, and one bug per bugtask status.
246+ expected = 2 * (
247+ len(RESOLVED_BUGTASK_STATUSES) + len(UNRESOLVED_BUGTASK_STATUSES))
248+ self.assertEqual(expected, len(cve_links))
249+
250+ def test_open_resolved_cve_bugtasks(self):
251+ # The properties CVEReportView.open_cve_bugtasks and
252+ # CVEReportView.resolved_cve_bugtasks are lists of
253+ # BugTaskCve instances.
254+ for item in self.view.open_cve_bugtasks:
255+ self.assertIsInstance(item, BugTaskCve)
256+ for item in self.view.resolved_cve_bugtasks:
257+ self.assertIsInstance(item, BugTaskCve)
258+
259+ open_bugs = [
260+ bugtaskcve.bug for bugtaskcve in self.view.open_cve_bugtasks]
261+ expected_open_bugs = [
262+ task.bug for task, task_2 in self.unresolved_bugtasks]
263+ self.assertEqual(expected_open_bugs, open_bugs)
264+ resolved_bugs = [
265+ bugtaskcve.bug for bugtaskcve in self.view.resolved_cve_bugtasks]
266+ expected_resolved_bugs = [
267+ task.bug for task, task_2 in self.resolved_bugtasks]
268+ self.assertEqual(expected_resolved_bugs, resolved_bugs)
269+
270+ def unwrap_bugtask_listing_items(seq):
271+ return [
272+ [item1.bugtask, item2.bugtask] for item1, item2 in seq]
273+
274+ open_bugtasks = [
275+ bugtaskcve.bugtasks
276+ for bugtaskcve in self.view.open_cve_bugtasks]
277+ open_bugtasks = unwrap_bugtask_listing_items(open_bugtasks)
278+ self.assertEqual(self.unresolved_bugtasks, open_bugtasks)
279+ resolved_bugtasks = [
280+ bugtaskcve.bugtasks
281+ for bugtaskcve in self.view.resolved_cve_bugtasks]
282+ resolved_bugtasks = unwrap_bugtask_listing_items(resolved_bugtasks)
283+ self.assertEqual(self.resolved_bugtasks, resolved_bugtasks)
284+
285+ def get_cve_data_for_task(task):
286+ """Sort the CVEs for the given bug task by ID and return
287+ their display data."""
288+ cves = sorted(self.cves[task.bug], key=attrgetter('id'))
289+ return [
290+ {
291+ 'url': canonical_url(cve),
292+ 'displayname': cve.displayname,
293+ }
294+ for cve in cves
295+ ]
296+
297+ open_cves = [
298+ bugtaskcve.cves for bugtaskcve in self.view.open_cve_bugtasks]
299+ expected_open_cves = [
300+ get_cve_data_for_task(task)
301+ for task, task_2 in self.unresolved_bugtasks]
302+ self.assertEqual(expected_open_cves, open_cves)
303+
304+ resolved_cves = [
305+ bugtaskcve.cves for bugtaskcve in self.view.resolved_cve_bugtasks]
306+ expected_resolved_cves = [
307+ get_cve_data_for_task(task)
308+ for task, task_2 in self.resolved_bugtasks]
309+ self.assertEqual(expected_resolved_cves, resolved_cves)
310+
311+ def test_renderCVELinks(self):
312+ # renderCVELinks() takes a sequence of items with CVE related
313+ # data and returns an HTML representation with links to
314+ # Launchpad pages for the CVEs.
315+ result = self.view.renderCVELinks(
316+ [
317+ {
318+ 'displayname': 'CVE-2011-0123',
319+ 'url': 'http://bugs.launchpad.dev/bugs/cve/2011-0123',
320+ },
321+ {
322+ 'displayname': 'CVE-2011-0456',
323+ 'url': 'http://bugs.launchpad.dev/bugs/cve/2011-0456',
324+ },
325+ ])
326+ expected = (
327+ '<a style="text-decoration: none" '
328+ 'href=http://bugs.launchpad.dev/bugs/cve/2011-0123">'
329+ '<img src="/@@/link" alt="" />'
330+ '<span style="text-decoration: underline">CVE-2011-0123</span>'
331+ '</a><br />\n'
332+ '<a style="text-decoration: none" '
333+ 'href=http://bugs.launchpad.dev/bugs/cve/2011-0456">'
334+ '<img src="/@@/link" alt="" />'
335+ '<span style="text-decoration: underline">CVE-2011-0456</span>'
336+ '</a>')
337+ self.assertEqual(expected, result)
338
339=== modified file 'lib/lp/bugs/doc/bugtask-search.txt'
340--- lib/lp/bugs/doc/bugtask-search.txt 2011-12-30 06:14:56 +0000
341+++ lib/lp/bugs/doc/bugtask-search.txt 2012-01-20 17:20:32 +0000
342@@ -382,8 +382,8 @@
343
344 >>> from lp.bugs.interfaces.cve import ICveSet
345 >>> def getCves(bugtask):
346- ... bugcve = getUtility(ICveSet).getBugCvesForBugTasks([bugtask])[0]
347- ... return bugcve.cve.sequence
348+ ... bug, cve = getUtility(ICveSet).getBugCvesForBugTasks([bugtask])[0]
349+ ... return cve.sequence
350 >>> params = BugTaskSearchParams(
351 ... has_cve=True, orderby='id', user=None)
352 >>> tasks_with_cves = ubuntu.searchTasks(params)
353
354=== modified file 'lib/lp/bugs/doc/cve.txt'
355--- lib/lp/bugs/doc/cve.txt 2011-12-24 17:49:30 +0000
356+++ lib/lp/bugs/doc/cve.txt 2012-01-20 17:20:32 +0000
357@@ -112,7 +112,7 @@
358 >>> ubuntu = Distribution.selectOneBy(name="ubuntu")
359 >>> ubuntu_tasks = ubuntu.searchTasks(params)
360 >>> bugcves = cveset.getBugCvesForBugTasks(ubuntu_tasks)
361- >>> [(bugcve.bug.id, bugcve.cve.title) for bugcve in bugcves]
362+ >>> [(bug.id, cve.title) for (bug, cve) in bugcves]
363 [(1, u'CVE-1999-8979 (Entry)'),
364 (2, u'CVE-1999-2345 (Candidate)')]
365
366@@ -130,11 +130,11 @@
367
368 >>> for bugtaskcve in cve_report.open_cve_bugtasks:
369 ... ([bugtask.title for bugtask in bugtaskcve.bugtasks],
370- ... [cve.title for cve in bugtaskcve.cves])
371+ ... [cve['displayname'] for cve in bugtaskcve.cves])
372 ([u'Bug #1 in mozilla-firefox (Ubuntu): "Firefox does not support SVG"'],
373- [u'CVE-1999-8979 (Entry)'])
374+ [u'CVE-1999-8979'])
375 ([u'Bug #2 in Ubuntu: "Blackhole Trash folder"'],
376- [u'CVE-1999-2345 (Candidate)'])
377+ [u'CVE-1999-2345'])
378
379 There are no resolved bugtasks linked to CVEs in Ubuntu:
380
381
382=== modified file 'lib/lp/bugs/interfaces/cve.py'
383--- lib/lp/bugs/interfaces/cve.py 2011-12-24 16:54:44 +0000
384+++ lib/lp/bugs/interfaces/cve.py 2012-01-20 17:20:32 +0000
385@@ -110,7 +110,7 @@
386 CollectionField(
387 title=_('Bugs related to this CVE entry.'),
388 readonly=True,
389- value_type=Reference(schema=Interface))) # Redefined in bug.py.
390+ value_type=Reference(schema=Interface))) # Redefined in bug.py.
391
392 # Other attributes.
393 url = exported(
394@@ -181,11 +181,14 @@
395 message.
396 """
397
398- def getBugCvesForBugTasks(bugtasks):
399- """Return BugCve objects that correspond to the supplied bugtasks.
400+ def getBugCvesForBugTasks(bugtasks, cve_mapper=None):
401+ """Return (Bug, Cve) tuples that correspond to the supplied bugtasks.
402
403- Returns an iterable of BugCve objects for bugs related to the
404+ Returns an iterable of (Bug, Cve) tuples for bugs related to the
405 supplied sequence of bugtasks.
406+
407+ If a function cve_mapper is specified, a sequence of tuples
408+ (bug, cve_mapper(cve)) is returned.
409 """
410
411 def getBugCveCount():
412
413=== modified file 'lib/lp/bugs/model/cve.py'
414--- lib/lp/bugs/model/cve.py 2011-12-30 06:14:56 +0000
415+++ lib/lp/bugs/model/cve.py 2012-01-20 17:20:32 +0000
416@@ -19,6 +19,8 @@
417 SQLRelatedJoin,
418 StringCol,
419 )
420+from storm.store import Store
421+from storm.expr import In
422 # Zope
423 from zope.interface import implements
424
425@@ -29,9 +31,11 @@
426 ICve,
427 ICveSet,
428 )
429+from lp.bugs.model.bug import Bug
430 from lp.bugs.model.bugcve import BugCve
431 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
432 from lp.bugs.model.cvereference import CveReference
433+from lp.services.database.bulk import load_related
434 from lp.services.database.constants import UTC_NOW
435 from lp.services.database.datetimecol import UtcDateTimeCol
436 from lp.services.database.enumcol import EnumCol
437@@ -43,6 +47,7 @@
438
439 cverefpat = re.compile(r'(CVE|CAN)-((19|20)\d{2}\-\d{4})')
440
441+
442 class Cve(SQLBase, BugLinkTargetMixin):
443 """A CVE database record."""
444
445@@ -148,7 +153,6 @@
446 cves = set()
447 for match in cverefpat.finditer(text):
448 # let's get the core CVE data
449- cvestate = match.group(1)
450 sequence = match.group(2)
451 # see if there is already a matching CVE ref in the db, and if
452 # not, then create it
453@@ -178,15 +182,41 @@
454 raise AssertionError('MessageChunk without content or blob.')
455 return sorted(cves, key=lambda a: a.sequence)
456
457- def getBugCvesForBugTasks(self, bugtasks):
458- bug_ids = set(task.bug.id for task in bugtasks)
459- assert bug_ids, "bugtasks must be non-empty, received %r" % bugtasks
460- return BugCve.select("""
461- BugCve.bug IN %s""" % sqlvalues(bug_ids),
462- prejoins=["cve"],
463- orderBy=['bug', 'cve'])
464+ def getBugCvesForBugTasks(self, bugtasks, cve_mapper=None):
465+ """See ICveSet."""
466+ bugs = load_related(Bug, bugtasks, ('bugID', ))
467+ if len(bugs) == 0:
468+ return []
469+ bug_ids = [bug.id for bug in bugs]
470+
471+ # Do not use BugCve instances: Storm may need a very long time
472+ # to look up the bugs and CVEs referenced by a BugCve instance
473+ # when the +cve view of a distroseries is rendered: There may
474+ # be a few thousand (bug, CVE) tuples, while the number of bugs
475+ # and CVEs is in the order of hundred. It is much more efficient
476+ # to retrieve just (bug_id, cve_id) from the BugCve table and
477+ # to map this to (Bug, CVE) here, instead of letting Storm
478+ # look up the CVE and bug for a BugCve instance, even if bugs
479+ # and CVEs are bulk loaded.
480+ store = Store.of(bugtasks[0])
481+ bugcve_ids = store.find(
482+ (BugCve.bugID, BugCve.cveID), In(BugCve.bugID, bug_ids))
483+ bugcve_ids.order_by(BugCve.bugID, BugCve.cveID)
484+ bugcve_ids = list(bugcve_ids)
485+
486+ cve_ids = set(cve_id for bug_id, cve_id in bugcve_ids)
487+ cves = store.find(Cve, In(Cve.id, list(cve_ids)))
488+
489+ if cve_mapper is None:
490+ cvemap = dict((cve.id, cve) for cve in cves)
491+ else:
492+ cvemap = dict((cve.id, cve_mapper(cve)) for cve in cves)
493+ bugmap = dict((bug.id, bug) for bug in bugs)
494+ return [
495+ (bugmap[bug_id], cvemap[cve_id])
496+ for bug_id, cve_id in bugcve_ids
497+ ]
498
499 def getBugCveCount(self):
500 """See ICveSet."""
501 return BugCve.select().count()
502-
503
504=== modified file 'lib/lp/bugs/templates/bugtask-macros-cve.pt'
505--- lib/lp/bugs/templates/bugtask-macros-cve.pt 2009-07-17 17:59:07 +0000
506+++ lib/lp/bugs/templates/bugtask-macros-cve.pt 2012-01-20 17:20:32 +0000
507@@ -20,14 +20,9 @@
508 <a tal:attributes="href bugtaskcve/bug/fmt:url"
509 tal:content="string:Bug #${bugtaskcve/bug/id}: ${bugtaskcve/bug/title}">Foo Bar does not work</a>
510 </td>
511- <td style="text-align: center">
512- <tal:block repeat="cve bugtaskcve/cves">
513- <a style="text-decoration: none"
514- tal:attributes="href cve/fmt:url"><img src="/@@/link"
515- alt="" /><span style="text-decoration: underline"
516- tal:content="cve/displayname">CVE-1999-1234</span></a>
517- <br />
518- </tal:block>
519+ <td style="text-align: center"
520+ tal:define="content python: view.renderCVELinks(bugtaskcve.cves)"
521+ tal:content="structure content">
522 </td>
523 </tr>
524 <tal:bugtask tal:repeat="bugtask bugtaskcve/bugtasks">
525
526=== added file 'lib/lp/bugs/tests/test_cve.py'
527--- lib/lp/bugs/tests/test_cve.py 1970-01-01 00:00:00 +0000
528+++ lib/lp/bugs/tests/test_cve.py 2012-01-20 17:20:32 +0000
529@@ -0,0 +1,77 @@
530+# Copyright 2012 Canonical Ltd. This software is licensed under the
531+# GNU Affero General Public License version 3 (see the file LICENSE).
532+
533+"""CVE related tests."""
534+
535+from zope.component import getUtility
536+
537+from lp.bugs.interfaces.bugtask import BugTaskSearchParams
538+from lp.bugs.interfaces.cve import ICveSet
539+from lp.services.webapp.testing import verifyObject
540+from lp.testing import (
541+ person_logged_in,
542+ TestCaseWithFactory,
543+ )
544+from lp.testing.layers import DatabaseFunctionalLayer
545+
546+
547+class TestCveSet(TestCaseWithFactory):
548+ """Tests for CveSet."""
549+
550+ layer = DatabaseFunctionalLayer
551+
552+ def setUp(self):
553+ """Create a few bugtasks and CVEs."""
554+ super(TestCveSet, self).setUp()
555+ self.distroseries = self.factory.makeDistroSeries()
556+ self.bugs = []
557+ self.cves = []
558+ self.cve_index = 0
559+ with person_logged_in(self.distroseries.owner):
560+ for count in range(4):
561+ task = self.factory.makeBugTask(target=self.distroseries)
562+ bug = task.bug
563+ self.bugs.append(bug)
564+ cve = self.makeCVE()
565+ self.cves.append(cve)
566+ bug.linkCVE(cve, self.distroseries.owner)
567+
568+ def makeCVE(self):
569+ """Create a CVE."""
570+ self.cve_index += 1
571+ return self.factory.makeCVE('2000-%04i' % self.cve_index)
572+
573+ def test_CveSet_implements_ICveSet(self):
574+ cveset = getUtility(ICveSet)
575+ self.assertTrue(verifyObject(ICveSet, cveset))
576+
577+ def test_getBugCvesForBugTasks(self):
578+ # ICveSet.getBugCvesForBugTasks() returns tuples (bug, cve)
579+ # for the given bugtasks.
580+ bugtasks = self.distroseries.searchTasks(
581+ BugTaskSearchParams(self.distroseries.owner, has_cve=True))
582+ bug_cves = getUtility(ICveSet).getBugCvesForBugTasks(bugtasks)
583+ found_bugs = [bug for bug, cve in bug_cves]
584+ found_cves = [cve for bug, cve in bug_cves]
585+ self.assertEqual(self.bugs, found_bugs)
586+ self.assertEqual(self.cves, found_cves)
587+
588+ def test_getBugCvesForBugTasks_with_mapper(self):
589+ # ICveSet.getBugCvesForBugTasks() takes a function f as an
590+ # optional argeument. This function is applied to each CVE
591+ # related to the given bugs; the method return a sequence of
592+ # tuples (bug, f(cve)).
593+ def cve_name(cve):
594+ return cve.displayname
595+
596+ bugtasks = self.distroseries.searchTasks(
597+ BugTaskSearchParams(self.distroseries.owner, has_cve=True))
598+ bug_cves = getUtility(ICveSet).getBugCvesForBugTasks(
599+ bugtasks, cve_name)
600+ found_bugs = [bug for bug, cve in bug_cves]
601+ cve_data = [cve for bug, cve in bug_cves]
602+ self.assertEqual(self.bugs, found_bugs)
603+ expected = [
604+ u'CVE-2000-0001', u'CVE-2000-0002', u'CVE-2000-0003',
605+ u'CVE-2000-0004']
606+ self.assertEqual(expected, cve_data)