Merge lp:~jtv/launchpad/bug-798297 into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 13381
Proposed branch: lp:~jtv/launchpad/bug-798297
Merge into: lp:launchpad
Diff against target: 383 lines (+224/-6)
8 files modified
lib/canonical/launchpad/interfaces/_schema_circular_imports.py (+2/-0)
lib/lp/registry/browser/tests/test_distroseries_webservice.py (+15/-0)
lib/lp/registry/interfaces/distroseries.py (+24/-0)
lib/lp/registry/interfaces/distroseriesdifferencecomment.py (+17/-1)
lib/lp/registry/model/distroseries.py (+9/-0)
lib/lp/registry/model/distroseriesdifferencecomment.py (+41/-1)
lib/lp/registry/tests/test_distroseries.py (+8/-0)
lib/lp/registry/tests/test_distroseriesdifferencecomment.py (+108/-4)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-798297
Reviewer Review Type Date Requested Status
Julian Edwards (community) Approve
Review via email: mp+66816@code.launchpad.net

Commit message

[r=julian-edwards][bug=798297] Provide API access to DistroSeriesDifferenceComments.

Description of the change

= Summary =

The Ubuntu people need API access to DistroSeriesDifferenceComments. Something called Merge-o-Matic will poll for new comments and pull them into its own database.

We need to provide three kinds of filtering: by distroseries (regardless of exact use), by time the comment was made (to support update polling), and by source package name (to produce per-package timelines).

== Proposed fix ==

Provide all these through the DistroSeries devel API, using a single method.

The combination of filtering by age and distroseries can be awkward, since these two items are at opposite ends of the schema sub-graph that we need for this. Luckily, DistroSeriesDifferenceComments are really just thin immutable wrappers around Message. So instead of filtering by Message.datecreated (the real age of a comment) I find the last Message.id prior to the filter date, and look for DistroSeriesDifferenceComments whose message fields are greater than that.

It looks as if this will require a Message table scan; I'm not sure how costly that will be but expect that we'll probably want an index to support this.

== Pre-implementation notes ==

Julian says security is not a concern for this method as yet; we'll trust the security machinery to keep anyone out who isn't allowed to view the DistroSeries.

The use-case is very similar to what we have with feeds, so this may also become the basis for future feed support.

== Implementation details ==

We could have the client remember the last-polled DSDComment id, but we prefer not to expose ids and use dates instead.

== Tests ==

{{{
./bin/test -vvc lp.registry.tests.test_distroseries -t DifferenceComments
./bin/test -vvc lp.registry.tests.test_distroseriesdifferencecomment -t getForDistroSeries
}}}

== Demo and Q/A ==

We'll be able to browse (and to a limited extent) query DSDComments for a DistroSeries. They should be filtered appropriately and ordered by creation time.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/registry/tests/test_distroseriesdifferencecomment.py
  lib/lp/registry/tests/test_distroseries.py
  lib/lp/registry/model/distroseriesdifferencecomment.py
  lib/lp/registry/interfaces/distroseries.py
  lib/lp/registry/interfaces/distroseriesdifferencecomment.py
  lib/lp/registry/model/distroseries.py
  lib/canonical/launchpad/interfaces/_schema_circular_imports.py

To post a comment you must log in.
Revision history for this message
Julian Edwards (julian-edwards) wrote :

76 + :param since: A date. No comments older than this date will be
77 + returned.

We should probably refer to "timestamp" here instead of "date". This string will appear in +apidoc remember!

138 + @property
139 + def source_package_name(self):
140 + """See `IDistroSeriesDifferenceCommentSource`."""
141 + return self.distro_series_difference.source_package_name.name

I'm not sure about the name of this but I can't think of anything better - source_package_name is getting conflated between text and the object everywhere :( However there's precedence in SPPH's exported properties so I guess it doesn't matter here. Just thought I'd mention it anway.

174 + after_msgid = store.find(
175 + Max(Message.id), Message.datecreated < since).one()

If this does cause a scan on Message then we're screwed, Message is a huge table. Looking at the indexes I'm pretty sure it will since there's no index on datecreated. :(

Finally, lovely tests! You just need some more that test the webservice :)

review: Needs Fixing
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

I've replaced the "date" with "timestamp."

The source_package_name was what I thought you suggested in our pre-imp call; it's sad to see confusion there. Perhaps we ought to stick with sourcepackagename for the SourcePackageName class and source_package_name for the name of a source package.

I will get back to you on IRC with more about the query plan. We may want to slap on an index.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Tuesday 05 July 2011 11:17:33 you wrote:
> I've replaced the "date" with "timestamp."
>
> The source_package_name was what I thought you suggested in our pre-imp
> call; it's sad to see confusion there.

It's not confusion and yes I did suggest it. I'm merely pointing out that
naming stuff is hard :)

> Perhaps we ought to stick with
> sourcepackagename for the SourcePackageName class and source_package_name
> for the name of a source package.

Maybe, yeah.

> I will get back to you on IRC with more about the query plan. We may want
> to slap on an index.

Righto, cheers.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

The query that looks for the last Message.id to be omitted doesn't play well with indexes. Shouldn't usually be a problem, but can cause annoying slowdowns.

We can fix it by indexing Message.datecreated, and changing the query to:

SELECT max(id)
FROM Message
WHERE datecreated = (
    SELECT max(datecreated)
    FROM Message
    WHERE datecreated < %(since)s)

On a hot cache, this will scan back through years in a few milliseconds.

Revision history for this message
Robert Collins (lifeless) wrote :

that looks spectacularly awkward; isn't indexing datecreated and doing
select id from message where datecreated < %(since) order by
datecreated desc limit 1;

I get the following:

 explain analyze select id from message where datecreated <
'2008-06-01 10:10:10' order by datecreated desc limit 1;

QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
 Limit (cost=0.00..0.08 rows=1 width=12) (actual time=0.027..0.028
rows=1 loops=1)
   -> Index Scan Backward using message_created on message
(cost=0.00..194232.18 rows=2286416 width=12) (actual time=0.026..0.026
rows=1 loops=1)
         Index Cond: (datecreated < '2008-06-01 10:10:10'::timestamp
without time zone)
 Total runtime: 0.067 ms

with a simple
create index message_created on message (datecreated);

-Rob

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

I think this is ready for landing now. The required index for Message is already on its way in.

Revision history for this message
Julian Edwards (julian-edwards) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/interfaces/_schema_circular_imports.py'
2--- lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2011-06-23 18:26:26 +0000
3+++ lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2011-07-06 11:22:26 +0000
4@@ -516,6 +516,8 @@
5 DistroSeriesDifferenceType)
6 patch_collection_return_type(
7 IDistroSeries, 'getDifferencesTo', IDistroSeriesDifference)
8+patch_collection_return_type(
9+ IDistroSeries, 'getDifferenceComments', IDistroSeriesDifferenceComment)
10
11
12 # IDistroSeriesDifference
13
14=== modified file 'lib/lp/registry/browser/tests/test_distroseries_webservice.py'
15--- lib/lp/registry/browser/tests/test_distroseries_webservice.py 2011-04-27 15:12:08 +0000
16+++ lib/lp/registry/browser/tests/test_distroseries_webservice.py 2011-07-06 11:22:26 +0000
17@@ -3,6 +3,8 @@
18
19 __metaclass__ = type
20
21+from datetime import timedelta
22+
23 from canonical.testing import AppServerLayer
24 from lp.registry.enum import (
25 DistroSeriesDifferenceStatus,
26@@ -42,3 +44,16 @@
27 source_package_name_filter=ds_diff.source_package_name.name,
28 child_version_higher=False,
29 parent_series=self._wsFor(ds_diff.parent_series)))
30+
31+ def test_getDifferenceComments(self):
32+ # getDifferenceComments queries DistroSeriesDifferenceComments
33+ # by distroseries, and optionally creation date and/or source
34+ # package name.
35+ comment = self.factory.makeDistroSeriesDifferenceComment()
36+ dsd = comment.distro_series_difference
37+ yesterday = comment.comment_date - timedelta(1)
38+ package_name = dsd.source_package_name.name
39+ ds_ws = self._wsFor(dsd.derived_series)
40+ search_results = ds_ws.getDifferenceComments(
41+ since=yesterday, source_package_name=package_name)
42+ self.assertContentEqual([self._wsFor(comment)], search_results)
43
44=== modified file 'lib/lp/registry/interfaces/distroseries.py'
45--- lib/lp/registry/interfaces/distroseries.py 2011-06-20 16:04:15 +0000
46+++ lib/lp/registry/interfaces/distroseries.py 2011-07-06 11:22:26 +0000
47@@ -903,6 +903,30 @@
48 def isInitialized():
49 """Has this series been initialized?"""
50
51+ @operation_parameters(
52+ since=Datetime(
53+ title=_("Minimum creation timestamp"),
54+ description=_(
55+ "Ignore comments that are older than this."),
56+ required=False),
57+ source_package_name=TextLine(
58+ title=_("Name of source package"),
59+ description=_("Only return comments for this source package."),
60+ required=False))
61+ @operation_returns_collection_of(Interface)
62+ @export_read_operation()
63+ @operation_for_version('devel')
64+ def getDifferenceComments(since=None, source_package_name=None):
65+ """Get `IDistroSeriesDifferenceComment` items.
66+
67+ :param since: Ignore comments older than this timestamp.
68+ :param source_package_name: Return only comments for a source package
69+ with this name.
70+ :return: A Storm result set of `IDistroSeriesDifferenceComment`
71+ objects for this distroseries, ordered from oldest to newest
72+ comment.
73+ """
74+
75
76 class IDistroSeriesEditRestricted(Interface):
77 """IDistroSeries properties which require launchpad.Edit."""
78
79=== modified file 'lib/lp/registry/interfaces/distroseriesdifferencecomment.py'
80--- lib/lp/registry/interfaces/distroseriesdifferencecomment.py 2011-05-12 21:33:10 +0000
81+++ lib/lp/registry/interfaces/distroseriesdifferencecomment.py 2011-07-06 11:22:26 +0000
82@@ -1,4 +1,4 @@
83-# Copyright 2010 Canonical Ltd. This software is licensed under the
84+# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
85 # GNU Affero General Public License version 3 (see the file LICENSE).
86
87 """Distribution series difference messages."""
88@@ -20,6 +20,7 @@
89 Datetime,
90 Int,
91 Text,
92+ TextLine,
93 )
94
95 from canonical.launchpad import _
96@@ -56,6 +57,11 @@
97 comment_date = exported(Datetime(
98 title=_('Comment date.'), readonly=True))
99
100+ source_package_name = exported(TextLine(
101+ title=_("Source package name"), required=True, readonly=True,
102+ description=_(
103+ "Name of the source package that this comment is for.")))
104+
105
106 class IDistroSeriesDifferenceCommentSource(Interface):
107 """A utility of this interface can be used to create comments."""
108@@ -72,3 +78,13 @@
109
110 def getForDifference(distro_series_difference, id):
111 """Return the `IDistroSeriesDifferenceComment` with the given id."""
112+
113+ def getForDistroSeries(distroseries, since=None):
114+ """Get comments for `distroseries` (since `since` if given).
115+
116+ :param distroseries: The `DistroSeries` to find comments for.
117+ :param since: A timestamp. No comments older than this will be
118+ returned.
119+ :return: A result set of `DistroSeriesDifferenceComment`s, ordered
120+ from oldest to newest.
121+ """
122
123=== modified file 'lib/lp/registry/model/distroseries.py'
124--- lib/lp/registry/model/distroseries.py 2011-06-17 14:23:52 +0000
125+++ lib/lp/registry/model/distroseries.py 2011-07-06 11:22:26 +0000
126@@ -101,6 +101,9 @@
127 from lp.registry.interfaces.distroseriesdifference import (
128 IDistroSeriesDifferenceSource,
129 )
130+from lp.registry.interfaces.distroseriesdifferencecomment import (
131+ IDistroSeriesDifferenceCommentSource,
132+ )
133 from lp.registry.interfaces.person import validate_public_person
134 from lp.registry.interfaces.pocket import (
135 PackagePublishingPocket,
136@@ -1895,6 +1898,12 @@
137 published = self.main_archive.getPublishedSources(distroseries=self)
138 return not published.is_empty()
139
140+ def getDifferenceComments(self, since=None, source_package_name=None):
141+ """See `IDistroSeries`."""
142+ comment_source = getUtility(IDistroSeriesDifferenceCommentSource)
143+ return comment_source.getForDistroSeries(
144+ self, since=since, source_package_name=source_package_name)
145+
146
147 class DistroSeriesSet:
148 implements(IDistroSeriesSet)
149
150=== modified file 'lib/lp/registry/model/distroseriesdifferencecomment.py'
151--- lib/lp/registry/model/distroseriesdifferencecomment.py 2011-05-12 21:33:10 +0000
152+++ lib/lp/registry/model/distroseriesdifferencecomment.py 2011-07-06 11:22:26 +0000
153@@ -1,4 +1,4 @@
154-# Copyright 2010 Canonical Ltd. This software is licensed under the
155+# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
156 # GNU Affero General Public License version 3 (see the file LICENSE).
157
158 """A comment/message for a difference between two distribution series."""
159@@ -12,6 +12,7 @@
160 from email.Utils import make_msgid
161
162 from storm.locals import (
163+ Desc,
164 Int,
165 Reference,
166 Storm,
167@@ -30,6 +31,7 @@
168 IDistroSeriesDifferenceComment,
169 IDistroSeriesDifferenceCommentSource,
170 )
171+from lp.registry.model.sourcepackagename import SourcePackageName
172
173
174 class DistroSeriesDifferenceComment(Storm):
175@@ -63,6 +65,11 @@
176 """See `IDistroSeriesDifferenceComment`."""
177 return self.message.datecreated
178
179+ @property
180+ def source_package_name(self):
181+ """See `IDistroSeriesDifferenceCommentSource`."""
182+ return self.distro_series_difference.source_package_name.name
183+
184 @staticmethod
185 def new(distro_series_difference, owner, comment):
186 """See `IDistroSeriesDifferenceCommentSource`."""
187@@ -90,3 +97,36 @@
188 DSDComment,
189 DSDComment.distro_series_difference == distro_series_difference,
190 DSDComment.id == id).one()
191+
192+ @staticmethod
193+ def getForDistroSeries(distroseries, since=None,
194+ source_package_name=None):
195+ """See `IDistroSeriesDifferenceCommentSource`."""
196+ # Avoid circular imports.
197+ from lp.registry.model.distroseriesdifference import (
198+ DistroSeriesDifference,
199+ )
200+ store = IStore(DistroSeriesDifferenceComment)
201+ DSD = DistroSeriesDifference
202+ DSDComment = DistroSeriesDifferenceComment
203+ conditions = [
204+ DSDComment.distro_series_difference_id == DSD.id,
205+ DSD.derived_series_id == distroseries.id,
206+ ]
207+
208+ if source_package_name is not None:
209+ conditions += [
210+ SourcePackageName.id == DSD.source_package_name_id,
211+ SourcePackageName.name == source_package_name,
212+ ]
213+
214+ if since is not None:
215+ older_messages = store.find(
216+ Message.id, Message.datecreated < since).order_by(
217+ Desc(Message.datecreated))
218+ preceding_message = older_messages.first()
219+ if preceding_message is not None:
220+ conditions.append(DSDComment.message_id > preceding_message)
221+
222+ return store.find(DSDComment, *conditions).order_by(
223+ DSDComment.message_id)
224
225=== modified file 'lib/lp/registry/tests/test_distroseries.py'
226--- lib/lp/registry/tests/test_distroseries.py 2011-06-14 19:49:18 +0000
227+++ lib/lp/registry/tests/test_distroseries.py 2011-07-06 11:22:26 +0000
228@@ -256,6 +256,14 @@
229 distroseries=distroseries, archive=distroseries.main_archive)
230 self.assertTrue(distroseries.isInitialized())
231
232+ def test_getDifferenceComments_gets_DistroSeriesDifferenceComments(self):
233+ distroseries = self.factory.makeDistroSeries()
234+ dsd = self.factory.makeDistroSeriesDifference(
235+ derived_series=distroseries)
236+ comment = self.factory.makeDistroSeriesDifferenceComment(dsd)
237+ self.assertContentEqual(
238+ [comment], distroseries.getDifferenceComments())
239+
240
241 class TestDistroSeriesPackaging(TestCaseWithFactory):
242
243
244=== modified file 'lib/lp/registry/tests/test_distroseriesdifferencecomment.py'
245--- lib/lp/registry/tests/test_distroseriesdifferencecomment.py 2010-10-06 18:53:53 +0000
246+++ lib/lp/registry/tests/test_distroseriesdifferencecomment.py 2011-07-06 11:22:26 +0000
247@@ -1,20 +1,41 @@
248-# Copyright 2010 Canonical Ltd. This software is licensed under the
249+# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
250 # GNU Affero General Public License version 3 (see the file LICENSE).
251
252 """Model tests for the DistroSeriesDifferenceComment class."""
253
254 __metaclass__ = type
255
256+from datetime import timedelta
257+from random import randint
258 from storm.store import Store
259 from zope.component import getUtility
260
261 from canonical.launchpad.webapp.testing import verifyObject
262-from canonical.testing.layers import DatabaseFunctionalLayer
263+from canonical.testing.layers import (
264+ DatabaseFunctionalLayer,
265+ ZopelessDatabaseLayer,
266+ )
267 from lp.registry.interfaces.distroseriesdifferencecomment import (
268 IDistroSeriesDifferenceComment,
269 IDistroSeriesDifferenceCommentSource,
270 )
271 from lp.testing import TestCaseWithFactory
272+from lp.testing.matchers import Provides
273+
274+
275+def get_comment_source():
276+ """Get `IDistroSeriesDifferemtCommentSource` utility."""
277+ return getUtility(IDistroSeriesDifferenceCommentSource)
278+
279+
280+def flip_coin(*args):
281+ """Random comparison function. Returns -1 or 1 randomly."""
282+ return 1 - 2 * randint(0, 1)
283+
284+
285+def randomize_list(original_list):
286+ """Sort a list (or other iterable) in random order. Return list."""
287+ return sorted(original_list, cmp=flip_coin)
288
289
290 class DistroSeriesDifferenceCommentTestCase(TestCaseWithFactory):
291@@ -64,7 +85,90 @@
292 dsd_comment = self.factory.makeDistroSeriesDifferenceComment()
293 Store.of(dsd_comment).flush()
294
295- comment_src = getUtility(IDistroSeriesDifferenceCommentSource)
296 self.assertEqual(
297- dsd_comment, comment_src.getForDifference(
298+ dsd_comment, get_comment_source().getForDifference(
299 dsd_comment.distro_series_difference, dsd_comment.id))
300+
301+ def test_source_package_name_returns_package_name(self):
302+ # The comment "knows" the name of the source package it's for.
303+ package_name = self.factory.getUniqueUnicode()
304+ dsd = self.factory.makeDistroSeriesDifference(
305+ source_package_name_str=package_name)
306+ comment = self.factory.makeDistroSeriesDifferenceComment(dsd)
307+ self.assertEqual(package_name, comment.source_package_name)
308+
309+
310+class TestDistroSeriesDifferenceCommentSource(TestCaseWithFactory):
311+
312+ layer = ZopelessDatabaseLayer
313+
314+ def test_implements_interface(self):
315+ self.assertThat(
316+ get_comment_source(),
317+ Provides(IDistroSeriesDifferenceCommentSource))
318+
319+ def test_getForDistroSeries_returns_result_set(self):
320+ series = self.factory.makeDistroSeries()
321+ source = get_comment_source()
322+ self.assertTrue(source.getForDistroSeries(series).is_empty())
323+
324+ def test_getForDistroSeries_matches_on_distroseries(self):
325+ dsd = self.factory.makeDistroSeriesDifference()
326+ series = dsd.derived_series
327+ comment = self.factory.makeDistroSeriesDifferenceComment(dsd)
328+ source = get_comment_source()
329+ self.assertContentEqual([comment], source.getForDistroSeries(series))
330+
331+ def test_getForDistroSeries_filters_by_distroseries(self):
332+ dsd = self.factory.makeDistroSeriesDifference()
333+ other_series = self.factory.makeDistroSeries()
334+ self.factory.makeDistroSeriesDifferenceComment(dsd)
335+ source = get_comment_source()
336+ self.assertContentEqual([], source.getForDistroSeries(other_series))
337+
338+ def test_getForDistroSeries_matches_on_since(self):
339+ dsd = self.factory.makeDistroSeriesDifference()
340+ series = dsd.derived_series
341+ comment = self.factory.makeDistroSeriesDifferenceComment(dsd)
342+ source = get_comment_source()
343+ yesterday = comment.comment_date - timedelta(1)
344+ self.assertContentEqual(
345+ [comment], source.getForDistroSeries(series, since=yesterday))
346+
347+ def test_getForDistroSeries_filters_by_since(self):
348+ dsd = self.factory.makeDistroSeriesDifference()
349+ series = dsd.derived_series
350+ comment = self.factory.makeDistroSeriesDifferenceComment(dsd)
351+ source = get_comment_source()
352+ tomorrow = comment.comment_date + timedelta(1)
353+ self.assertContentEqual(
354+ [], source.getForDistroSeries(series, since=tomorrow))
355+
356+ def test_getForDistroSeries_orders_by_age(self):
357+ series = self.factory.makeDistroSeries()
358+ dsds = randomize_list([
359+ self.factory.makeDistroSeriesDifference(derived_series=series)
360+ for counter in xrange(5)])
361+ comments = [
362+ self.factory.makeDistroSeriesDifferenceComment(dsd)
363+ for dsd in dsds]
364+ source = get_comment_source()
365+ self.assertEqual(comments, list(source.getForDistroSeries(series)))
366+
367+ def test_getForDistroSeries_matches_on_package_name(self):
368+ dsd = self.factory.makeDistroSeriesDifference()
369+ series = dsd.derived_series
370+ package_name = dsd.source_package_name.name
371+ comment = self.factory.makeDistroSeriesDifferenceComment(dsd)
372+ source = get_comment_source()
373+ self.assertContentEqual([comment], source.getForDistroSeries(
374+ series, source_package_name=package_name))
375+
376+ def test_getForDistroSeries_filters_by_package_name(self):
377+ dsd = self.factory.makeDistroSeriesDifference()
378+ series = dsd.derived_series
379+ other_package = self.factory.getUniqueUnicode()
380+ self.factory.makeDistroSeriesDifferenceComment(dsd)
381+ source = get_comment_source()
382+ self.assertContentEqual([], source.getForDistroSeries(
383+ series, source_package_name=other_package))