Merge lp:~gary/launchpad/bug744888 into lp:launchpad

Proposed by Gary Poster
Status: Merged
Approved by: Aaron Bentley
Approved revision: no longer in the source branch.
Merged at revision: 12829
Proposed branch: lp:~gary/launchpad/bug744888
Merge into: lp:launchpad
Diff against target: 146 lines (+74/-6)
3 files modified
lib/lp/bugs/interfaces/bug.py (+4/-4)
lib/lp/bugs/model/bug.py (+3/-0)
lib/lp/bugs/tests/test_bug.py (+67/-2)
To merge this branch: bzr merge lp:~gary/launchpad/bug744888
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+57499@code.launchpad.net

Commit message

[r=abentley][bug=744888] Snapshots of a bug should not get all of its messages. webservice bug.mute() should work.

Description of the change

This branch hopefully fixes the timeout when muting bug 1 via the webservice. Thanks to investigation by Graham and Robert, the problem appeared to be the fact that, when the webservice made snapshots made snapshots, we got all of the bugs comments ("messages"), and this extra weight pushed us over the timeout. We did that because we snapshotted "messages" and "attachments". Bug 1 has over 1400 comments as of this writing. Robert suggested that simply indicating that these attributes should not be included in snapshots should be sufficient to fix the problem. My branch implements that solution. An ec2 test run seems to indicate that it won't cause any problems.

The most questionable aspect of this is the test. I'll leave the comments to speak for themselves.

Along the way, when I was testing the problem, I discovered that the bug's mute method in the web API was not working as expected. This was getting in the way of my work. I introduced a fix for that in this branch, as well as a test to show that it and unmute both work as the webservice exposes them (unmute already did).

Thank you!

Gary

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

If StormStatementRecorder works for your test, I recommend using that instead.

Please change the SQL check as discussed on IRC:
<abentley> gary_poster: I wonder whether there's risk of your SQL check hitting false negatives? Would it be crazy to just look for the string 'message' in the entire statement?
<gary_poster> abentley: looking for message: well...there are also "bugmessage"s in theory...it's obviously a balance between possible false negatives and false positives.
<gary_poster> I guess we could try being a bit more aggressive. I'd be OK with simply splitting on whitespace and making sure there are no strings that start with "message" (which would include tables). If people find that too restrictive in the future they can adjust the test.

Aside from that, looks fine.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/interfaces/bug.py'
2--- lib/lp/bugs/interfaces/bug.py 2011-04-05 17:53:52 +0000
3+++ lib/lp/bugs/interfaces/bug.py 2011-04-13 21:05:25 +0000
4@@ -294,11 +294,11 @@
5 title=_("List of bug attachments."),
6 value_type=Reference(schema=IBugAttachment),
7 readonly=True)
8- attachments = exported(
9+ attachments = doNotSnapshot(exported(
10 CollectionField(
11 title=_("List of bug attachments."),
12 value_type=Reference(schema=IBugAttachment),
13- readonly=True))
14+ readonly=True)))
15 questions = Attribute("List of questions related to this bug.")
16 specifications = Attribute("List of related specifications.")
17 linked_branches = exported(
18@@ -394,13 +394,13 @@
19 readonly=True,
20 value_type=Reference(schema=IMessage)))
21
22- indexed_messages = exported(
23+ indexed_messages = doNotSnapshot(exported(
24 CollectionField(
25 title=_("The messages related to this object, in reverse "
26 "order of creation (so newest first)."),
27 readonly=True,
28 value_type=Reference(schema=IMessage)),
29- exported_as='messages')
30+ exported_as='messages'))
31
32 def _indexed_messages(include_content=False, include_parents=False):
33 """Low level query for getting bug messages.
34
35=== modified file 'lib/lp/bugs/model/bug.py'
36--- lib/lp/bugs/model/bug.py 2011-04-12 13:17:34 +0000
37+++ lib/lp/bugs/model/bug.py 2011-04-13 21:05:25 +0000
38@@ -850,6 +850,9 @@
39
40 def mute(self, person, muted_by):
41 """See `IBug`."""
42+ if person is None:
43+ # This may be a webservice request.
44+ person = muted_by
45 # If there's an existing subscription, update it.
46 store = Store.of(self)
47 subscriptions = store.find(
48
49=== modified file 'lib/lp/bugs/tests/test_bug.py'
50--- lib/lp/bugs/tests/test_bug.py 2011-03-23 16:28:51 +0000
51+++ lib/lp/bugs/tests/test_bug.py 2011-04-13 21:05:25 +0000
52@@ -5,16 +5,19 @@
53
54 __metaclass__ = type
55
56+from lazr.lifecycle.snapshot import Snapshot
57+from zope.interface import providedBy
58+
59 from canonical.testing.layers import DatabaseFunctionalLayer
60
61 from lp.bugs.enum import BugNotificationLevel
62 from lp.testing import (
63- feature_flags,
64 person_logged_in,
65- set_feature_flag,
66+ StormStatementRecorder,
67 TestCaseWithFactory,
68 )
69
70+
71 class TestBugSubscriptionMethods(TestCaseWithFactory):
72 layer = DatabaseFunctionalLayer
73
74@@ -55,6 +58,15 @@
75 BugNotificationLevel.NOTHING,
76 muted_subscription.bug_notification_level)
77
78+ def test_mute_mutes_muter(self):
79+ # When exposed in the web API, the mute method regards the
80+ # first, `person` argument as optional, and the second
81+ # `muted_by` argument is supplied from the request. In this
82+ # case, the person should be the muter.
83+ with person_logged_in(self.person):
84+ self.bug.mute(None, self.person)
85+ self.assertTrue(self.bug.isMuted(self.person))
86+
87 def test_mute_mutes_user_with_existing_subscription(self):
88 # Bug.mute() will update an existing subscription so that it
89 # becomes muted.
90@@ -74,3 +86,56 @@
91 self.assertTrue(self.bug.isMuted(self.person))
92 self.bug.unmute(self.person, self.person)
93 self.assertFalse(self.bug.isMuted(self.person))
94+
95+ def test_unmute_mutes_unmuter(self):
96+ # When exposed in the web API, the unmute method regards the
97+ # first, `person` argument as optional, and the second
98+ # `unmuted_by` argument is supplied from the request. In this
99+ # case, the person should be the muter.
100+ with person_logged_in(self.person):
101+ self.bug.mute(self.person, self.person)
102+ self.bug.unmute(None, self.person)
103+ self.assertFalse(self.bug.isMuted(self.person))
104+
105+
106+class TestBugSnapshotting(TestCaseWithFactory):
107+ layer = DatabaseFunctionalLayer
108+
109+ def setUp(self):
110+ super(TestBugSnapshotting, self).setUp()
111+ self.bug = self.factory.makeBug()
112+ self.person = self.factory.makePerson()
113+
114+ def test_bug_snapshot_does_not_include_messages(self):
115+ # A snapshot of a bug does not include its messages or
116+ # attachments (which get the messages from the database). If it
117+ # does, the webservice can become unusable if changes are made
118+ # to bugs with many comments, such as bug 1. See, for instance,
119+ # bug 744888. This test is primarily to keep the problem from
120+ # slipping in again. To do so, we resort to somewhat
121+ # extraordinary measures. In addition to verifying that the
122+ # snapshot does not have the attributes that currently trigger
123+ # the problem, we also actually look at the SQL that is
124+ # generated by creating the snapshot. With this, we can verify
125+ # that the Message table is not included. This is ugly, but
126+ # this has a chance of fighting against future eager loading
127+ # optimizations that might trigger the problem again.
128+ with person_logged_in(self.person):
129+ with StormStatementRecorder() as recorder:
130+ snapshot = Snapshot(self.bug, providing=providedBy(self.bug))
131+ sql_statements = recorder.statements
132+ # This uses "self" as a marker to show that the attribute does not
133+ # exist. We do not use hasattr because it eats exceptions.
134+ #self.assertTrue(getattr(snapshot, 'messages', self) is self)
135+ #self.assertTrue(getattr(snapshot, 'attachments', self) is self)
136+ for sql in sql_statements:
137+ # We are going to be aggressive about looking for the problem in
138+ # the SQL. We'll split the SQL up by whitespace, and then look
139+ # for strings that start with "message". If that is too
140+ # aggressive in the future from some reason, please do adjust the
141+ # test appropriately.
142+ sql_tokens = sql.lower().split()
143+ self.assertEqual(
144+ [token for token in sql_tokens
145+ if token.startswith('message')],
146+ [])