Merge lp:~danilo/launchpad/devel-bug-720826-delete-race into lp:launchpad

Proposed by Данило Шеган
Status: Merged
Approved by: Данило Шеган
Approved revision: no longer in the source branch.
Merged at revision: 12513
Proposed branch: lp:~danilo/launchpad/devel-bug-720826-delete-race
Merge into: lp:launchpad
Diff against target: 92 lines (+31/-5)
2 files modified
lib/lp/bugs/model/bugsubscriptionfilter.py (+17/-4)
lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py (+14/-1)
To merge this branch: bzr merge lp:~danilo/launchpad/devel-bug-720826-delete-race
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve
Review via email: mp+51890@code.launchpad.net

Commit message

[incr] [r=abentley][bug=720826] Make BSF.delete() method set all the data back to default values for the final one and add race-condition guard.

Description of the change

= Improvements to BugSubscriptionFilter.delete() =

BugSubscriptionFilter.delete() is not complete yet: it's supposed to revert
all the fields to their default values.

There is also a potential for a race-condition which we guard against by
locking all the rows that must not change when we do the check and delete
using SELECT FOR UPDATE. I have not provided a test-case because that's
likely to be pretty complex.

If we wanted to guard on the database level, we could add a trigger
that stops removal using something like

  https://pastebin.canonical.com/44142/

However, Stuart believes that's too complex for very little benefit.
I agree that what we want to guard against is malicious users, and not
developers who can shoot themselves in the foot (because we've got so
many ways to do it) if they don't use the recommended API for deletion.
So, the trigger is left to the side for now.

== Pre-implementation notes ==

Discussed this with Stuart. He recommends against the trigger and just guarding the Python code (though, he's partial to that as well). To quote his example:

"If you have a form """( ) bugsubscription a (x) bugsubscription b""", it is reasonable to assume someone clicks 'delete', thinks 'oh shit', changes the check box and clicks submit again. So catching this in Python is reasonable."

== Tests ==

bin/test -cvvt BugSubscriptionFilter.test_delete -t BugSubscriptionFilter.test_has_other_filters

== Demo and Q/A ==

Go to https://bugs.qastaging.launchpad.net/people/+me/+structural-subscriptions and try removing the final filter with some settings on the last filter.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/model/bugsubscriptionfilter.py
  lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) :
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/model/bugsubscriptionfilter.py'
2--- lib/lp/bugs/model/bugsubscriptionfilter.py 2011-03-01 16:47:47 +0000
3+++ lib/lp/bugs/model/bugsubscriptionfilter.py 2011-03-02 16:49:41 +0000
4@@ -12,12 +12,14 @@
5 Bool,
6 Int,
7 Reference,
8+ SQL,
9 Store,
10 Unicode,
11 )
12 from zope.interface import implements
13
14 from canonical.database.enumcol import DBEnum
15+from canonical.database.sqlbase import sqlvalues
16 from canonical.launchpad import searchbuilder
17 from canonical.launchpad.interfaces.lpstorm import IStore
18 from lp.bugs.enum import BugNotificationLevel
19@@ -193,6 +195,12 @@
20 def _has_other_filters(self):
21 """Are there other filters for parent `StructuralSubscription`?"""
22 store = Store.of(self)
23+ # Avoid race conditions by locking all the rows
24+ # that we do our check over.
25+ store.execute(SQL(
26+ """SELECT * FROM BugSubscriptionFilter
27+ WHERE structuralsubscription=%s
28+ FOR UPDATE""" % sqlvalues(self.structural_subscription_id)))
29 return bool(store.find(
30 BugSubscriptionFilter,
31 (BugSubscriptionFilter.structural_subscription ==
32@@ -202,9 +210,14 @@
33 def delete(self):
34 """See `IBugSubscriptionFilter`."""
35 self.importances = self.statuses = self.tags = ()
36- # Revert bug notification level to the default.
37- self.bug_notification_level = (
38- IBugSubscriptionFilter.getDescriptionFor(
39- 'bug_notification_level').default)
40+ # Revert attributes to their default values from the interface.
41+ for attribute in ['bug_notification_level', 'find_all_tags',
42+ 'include_any_tags', 'exclude_any_tags']:
43+ default_value = IBugSubscriptionFilter.getDescriptionFor(
44+ attribute).default
45+ setattr(self, attribute, default_value)
46+
47+ self.description = None
48+
49 if self._has_other_filters():
50 Store.of(self).remove(self)
51
52=== modified file 'lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py'
53--- lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py 2011-03-01 15:51:44 +0000
54+++ lib/lp/bugs/model/tests/test_bugsubscriptionfilter.py 2011-03-02 16:49:41 +0000
55@@ -114,23 +114,36 @@
56 # Delete.
57 bug_subscription_filter.delete()
58 IStore(bug_subscription_filter).flush()
59+ # It doesn't exist in the database anymore.
60 self.assertIs(None, Store.of(bug_subscription_filter))
61
62 def test_delete_final(self):
63 # Final remaining `BugSubscriptionFilter` can't be deleted.
64- # Only the linked data is removed.
65+ # Only the linked data is removed and/or unset.
66 bug_subscription_filter = self.subscription.bug_filters.one()
67 bug_subscription_filter.bug_notification_level = (
68 BugNotificationLevel.LIFECYCLE)
69+ bug_subscription_filter.find_all_tags = True
70+ bug_subscription_filter.exclude_any_tags = True
71+ bug_subscription_filter.include_any_tags = True
72+ bug_subscription_filter.description = u"Description"
73 bug_subscription_filter.importances = [BugTaskImportance.LOW]
74 bug_subscription_filter.statuses = [BugTaskStatus.NEW]
75 bug_subscription_filter.tags = [u"foo"]
76 IStore(bug_subscription_filter).flush()
77 self.assertIsNot(None, Store.of(bug_subscription_filter))
78+
79 # Delete.
80 bug_subscription_filter.delete()
81 IStore(bug_subscription_filter).flush()
82+
83+ # It is not deleted from the database.
84 self.assertIsNot(None, Store.of(bug_subscription_filter))
85+ # But all the data is set back to defaults.
86+ self.assertIs(None, bug_subscription_filter.description)
87+ self.assertFalse(bug_subscription_filter.find_all_tags)
88+ self.assertFalse(bug_subscription_filter.include_any_tags)
89+ self.assertFalse(bug_subscription_filter.exclude_any_tags)
90 self.assertEquals(BugNotificationLevel.COMMENTS,
91 bug_subscription_filter.bug_notification_level)
92 self.assertContentEqual([], bug_subscription_filter.statuses)