Merge lp:~lamont/maas/bug-1567176 into lp:~maas-committers/maas/trunk
- bug-1567176
- Merge into trunk
Status: | Rejected |
---|---|
Rejected by: | MAAS Lander |
Proposed branch: | lp:~lamont/maas/bug-1567176 |
Merge into: | lp:~maas-committers/maas/trunk |
Diff against target: |
224 lines (+103/-16) 4 files modified
src/maasserver/models/fabric.py (+32/-8) src/maasserver/models/space.py (+31/-8) src/maasserver/models/tests/test_fabric.py (+20/-0) src/maasserver/models/tests/test_space.py (+20/-0) |
To merge this branch: | bzr merge lp:~lamont/maas/bug-1567176 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gavin Panella (community) | Disapprove | ||
Mike Pontillo (community) | Needs Fixing | ||
Review via email: mp+291545@code.launchpad.net |
Commit message
Harden space/fabric creation to avoid duplicate names, even temporarily.
Description of the change
Harden space/fabric creation to avoid duplicate names, even temporarily.
- 4902. By LaMont Jones
-
restore CleanSave.save before the assertEqual.
LaMont Jones (lamont) wrote : | # |
Mike Pontillo (mpontillo) wrote : | # |
I don't like the random numbers used here, but I don't have a better idea at the moment. We might have to let it slide unless someone else can think of a better way to do it.
You should be able to use self.patch() to mock rather than replacing methods with your mock by hand. This will ensure that all the proper cleanup happens, even in exception cases.
Lee Trager (ltrager) wrote : | # |
Looks good, I'm just wondering if the retry loop can ever get into a deadlock state.
Gavin Panella (allenap) wrote : | # |
Why is it important to give fabrics names like "fabric-$id", where $id
is the primary key? This seems arbitrary, is causing problems, and
exposing surrogate keys to users is often considered bad form anyway.
My suggestion is to use a "maasserve_
fabric_name_id = Sequence(
"
owner=
def generate_
return "fabric-%d" % next(fabric_
Then there's no need for looping, checking for IntegrityError, and so
forth.
Another alternative might be to lock the table at the start of save():
LOCK TABLE maasserver_fabric IN SHARE MODE
A SHARE lock will prevent concurrent data changes.
http://
http://
I may have missed an important justification for what's going on in this
branch. However, on the basis of what I currently know, this branch is
an over-complicated fix to the problem.
> ... (since many things are not done inside of transactions) ...
[...]
> Making everything that inserts a space or fabric be transactional
> would be a solution, but I believe it's an invasive one to consider.
The intention in MAAS is that ALL database access is transactional, run
with at least REPEATABLE READ isolation. If you find something (not
including the test suite) that's writing to OR reading from the database
outside of an explicit transaction, it's highly likely to be a bug.
Blake Rouse (blake-rouse) wrote : | # |
> Why is it important to give fabrics names like "fabric-$id", where $id
> is the primary key? This seems arbitrary, is causing problems, and
> exposing surrogate keys to users is often considered bad form anyway.
It is important that the fabric/space names come from the ID of the fabric. That way it makes since when the user is looking at the result of the API.
>
> My suggestion is to use a "maasserve_
>
> fabric_name_id = Sequence(
> "maasserver_
> owner="
Doesn't postgres already create a sequence for the ID of the fabric/space. Why not just get the nextval of the fabric/space ID and set the ID and name in the initial INSERT call so its always correct. That will prevent the conflict and ensure that the ID and name always match. It would also simplify this logic a lot.
>
> def generate_
> return "fabric-%d" % next(fabric_
>
> Then there's no need for looping, checking for IntegrityError, and so
> forth.
>
> Another alternative might be to lock the table at the start of save():
>
> LOCK TABLE maasserver_fabric IN SHARE MODE
>
> A SHARE lock will prevent concurrent data changes.
>
> http://
> http://
>
> I may have missed an important justification for what's going on in this
> branch. However, on the basis of what I currently know, this branch is
> an over-complicated fix to the problem.
>
> > ... (since many things are not done inside of transactions) ...
> [...]
> > Making everything that inserts a space or fabric be transactional
> > would be a solution, but I believe it's an invasive one to consider.
>
> The intention in MAAS is that ALL database access is transactional, run
> with at least REPEATABLE READ isolation. If you find something (not
> including the test suite) that's writing to OR reading from the database
> outside of an explicit transaction, it's highly likely to be a bug.
Gavin Panella (allenap) wrote : | # |
> > Why is it important to give fabrics names like "fabric-$id", where
> > $id is the primary key? This seems arbitrary, is causing problems,
> > and exposing surrogate keys to users is often considered bad form
> > anyway.
>
> It is important that the fabric/space names come from the ID of the
> fabric. That way it makes since when the user is looking at the result
> of the API.
Typically a user ought not to be looking at the result of the API;
that's for machines. True, our CLI will currently show them all this,
but that won't be the case in future. It's not an absolute requirement,
but we should seek to hide surrogate keys from users.
[...]
> Doesn't postgres already create a sequence for the ID of the
> fabric/space. Why not just get the nextval of the fabric/space ID and
> set the ID and name in the initial INSERT call so its always correct.
> That will prevent the conflict and ensure that the ID and name always
> match. It would also simplify this logic a lot.
Yes, this is possible too.
MAAS Lander (maas-lander) wrote : | # |
Transitioned to Git.
lp:maas has now moved from Bzr to Git.
Please propose your branches with Launchpad using Git.
git clone https:/
Unmerged revisions
- 4902. By LaMont Jones
-
restore CleanSave.save before the assertEqual.
- 4901. By LaMont Jones
-
Harden space/fabric creation to avoid duplicate names, even temporarily.
Preview Diff
1 | === modified file 'src/maasserver/models/fabric.py' | |||
2 | --- src/maasserver/models/fabric.py 2016-04-07 19:37:40 +0000 | |||
3 | +++ src/maasserver/models/fabric.py 2016-04-11 20:53:39 +0000 | |||
4 | @@ -10,6 +10,7 @@ | |||
5 | 10 | ] | 10 | ] |
6 | 11 | 11 | ||
7 | 12 | import datetime | 12 | import datetime |
8 | 13 | import random | ||
9 | 13 | import re | 14 | import re |
10 | 14 | 15 | ||
11 | 15 | from django.core.exceptions import ( | 16 | from django.core.exceptions import ( |
12 | @@ -209,24 +210,47 @@ | |||
13 | 209 | 210 | ||
14 | 210 | def save(self, *args, **kwargs): | 211 | def save(self, *args, **kwargs): |
15 | 211 | # Name will get set by clean_name() if None or empty, and there is an | 212 | # Name will get set by clean_name() if None or empty, and there is an |
17 | 212 | # id. We just need to handle names here for creation. | 213 | # id. We just need to handle names here for creation. It is possible |
18 | 214 | # that we could collide with another thread creating a fabric as well, | ||
19 | 215 | # that gets resolved by picking random almost-correct (reserved) names | ||
20 | 216 | # until the save works, then renaming it to the proper default name. | ||
21 | 213 | created = self.id is None | 217 | created = self.id is None |
24 | 214 | super().save(*args, **kwargs) | 218 | saved = False |
25 | 215 | if self.name is None or self.name == '': | 219 | save_name = self.name |
26 | 220 | while not saved: | ||
27 | 221 | try: | ||
28 | 222 | super().save(*args, **kwargs) | ||
29 | 223 | saved = True | ||
30 | 224 | except IntegrityError as err: | ||
31 | 225 | # A leading zero still hits the reserved names, but won't | ||
32 | 226 | # actually be generated by us, regardless of the ID. Pick | ||
33 | 227 | # random IDs until we get through. | ||
34 | 228 | if (err.args[0].startswith('duplicate key value violates')): | ||
35 | 229 | self.name = 'fabric-0%d' % random.randint(1, 999999) | ||
36 | 230 | spcl = re.compile('^fabric-0\d+$') | ||
37 | 231 | if (save_name is None or save_name == '' or spcl.search(save_name)): | ||
38 | 216 | # If we got here, then we have a newly created fabric that needs a | 232 | # If we got here, then we have a newly created fabric that needs a |
41 | 217 | # default name. | 233 | # default name. Since we may have strayed from None above, we need |
42 | 218 | self.name = "fabric-%d" % self.id | 234 | # to explicitly set it, rather than relying on clean_name to do so. |
43 | 219 | fabric = Fabric.objects.get(id=self.id) | 235 | fabric = Fabric.objects.get(id=self.id) |
44 | 236 | fabric.name = "fabric-%d" % self.id | ||
45 | 237 | self.name = fabric.name | ||
46 | 220 | fabric.save() | 238 | fabric.save() |
47 | 221 | # Create default VLAN if this is a fabric creation. | 239 | # Create default VLAN if this is a fabric creation. |
48 | 222 | if created: | 240 | if created: |
49 | 223 | self._create_default_vlan() | 241 | self._create_default_vlan() |
50 | 224 | 242 | ||
51 | 225 | def clean_name(self): | 243 | def clean_name(self): |
53 | 226 | reserved = re.compile('^fabric-\d+$') | 244 | resvd = re.compile('^fabric-(\d+)$') |
54 | 227 | if self.name is not None and self.name != '': | 245 | if self.name is not None and self.name != '': |
57 | 228 | if reserved.search(self.name): | 246 | res = resvd.search(self.name) |
58 | 229 | if (self.id is None or self.name != 'fabric-%d' % self.id): | 247 | if res is not None: |
59 | 248 | num = res.group(1) | ||
60 | 249 | if self.id is None and num.startswith('0') and int(num) > 0: | ||
61 | 250 | # "fabric-0\d+ is only valid for creation names, and will | ||
62 | 251 | # get replaced in save with the default name. | ||
63 | 252 | pass | ||
64 | 253 | elif self.id is None or self.name != 'fabric-%d' % self.id: | ||
65 | 230 | raise ValidationError( | 254 | raise ValidationError( |
66 | 231 | {'name': ["Reserved fabric name."]}) | 255 | {'name': ["Reserved fabric name."]}) |
67 | 232 | elif self.id is not None: | 256 | elif self.id is not None: |
68 | 233 | 257 | ||
69 | === modified file 'src/maasserver/models/space.py' | |||
70 | --- src/maasserver/models/space.py 2016-04-07 19:37:40 +0000 | |||
71 | +++ src/maasserver/models/space.py 2016-04-11 20:53:39 +0000 | |||
72 | @@ -9,6 +9,7 @@ | |||
73 | 9 | ] | 9 | ] |
74 | 10 | 10 | ||
75 | 11 | import datetime | 11 | import datetime |
76 | 12 | import random | ||
77 | 12 | import re | 13 | import re |
78 | 13 | 14 | ||
79 | 14 | from django.core.exceptions import ( | 15 | from django.core.exceptions import ( |
80 | @@ -155,10 +156,16 @@ | |||
81 | 155 | return "space-%s" % self.id | 156 | return "space-%s" % self.id |
82 | 156 | 157 | ||
83 | 157 | def clean_name(self): | 158 | def clean_name(self): |
85 | 158 | reserved = re.compile('^space-\d+$') | 159 | resvd = re.compile('^space-(\d+)$') |
86 | 159 | if self.name is not None and self.name != '': | 160 | if self.name is not None and self.name != '': |
89 | 160 | if reserved.search(self.name): | 161 | res = resvd.search(self.name) |
90 | 161 | if (self.id is None or self.name != 'space-%d' % self.id): | 162 | if res is not None: |
91 | 163 | num = res.group(1) | ||
92 | 164 | if self.id is None and num.startswith('0') and int(num) > 0: | ||
93 | 165 | # "space-0\d+ is only valid for creation names, and will | ||
94 | 166 | # get replaced in save with the default name. | ||
95 | 167 | pass | ||
96 | 168 | elif self.id is None or self.name != 'space-%d' % self.id: | ||
97 | 162 | raise ValidationError( | 169 | raise ValidationError( |
98 | 163 | {'name': ["Reserved space name."]}) | 170 | {'name': ["Reserved space name."]}) |
99 | 164 | elif self.id is not None: | 171 | elif self.id is not None: |
100 | @@ -168,13 +175,29 @@ | |||
101 | 168 | 175 | ||
102 | 169 | def save(self, *args, **kwargs): | 176 | def save(self, *args, **kwargs): |
103 | 170 | # Name will get set by clean_name() if None or empty, and there is an | 177 | # Name will get set by clean_name() if None or empty, and there is an |
107 | 171 | # id. We just need to handle names here for creation. | 178 | # id. We just need to handle names here for creation. It is possible |
108 | 172 | super().save(*args, **kwargs) | 179 | # that we could collide with another thread creating a space as well, |
109 | 173 | if self.name is None or self.name == '': | 180 | # that gets resolved by picking random almost-correct (reserved) names |
110 | 181 | # until the save works, then renaming it to the proper default name. | ||
111 | 182 | saved = False | ||
112 | 183 | save_name = self.name | ||
113 | 184 | while not saved: | ||
114 | 185 | try: | ||
115 | 186 | super().save(*args, **kwargs) | ||
116 | 187 | saved = True | ||
117 | 188 | except IntegrityError as err: | ||
118 | 189 | # A leading zero means that we are creating a space with a | ||
119 | 190 | # default id. | ||
120 | 191 | if (err.args[0].startswith('duplicate key value violates')): | ||
121 | 192 | self.name = 'space-0%d' % random.randint(1, 999999) | ||
122 | 193 | spcl = re.compile('^space-0\d+$') | ||
123 | 194 | if (save_name is None or save_name == '' or spcl.search(save_name)): | ||
124 | 174 | # If we got here, then we have a newly created space that needs a | 195 | # If we got here, then we have a newly created space that needs a |
127 | 175 | # default name. | 196 | # default name. Since we may have strayed from None above, we need |
128 | 176 | self.name = "space-%d" % self.id | 197 | # to explicitly set it, rather than relying on clean_name to do so. |
129 | 177 | space = Space.objects.get(id=self.id) | 198 | space = Space.objects.get(id=self.id) |
130 | 199 | space.name = "space-%d" % self.id | ||
131 | 200 | self.name = space.name | ||
132 | 178 | space.save() | 201 | space.save() |
133 | 179 | 202 | ||
134 | 180 | def clean(self, *args, **kwargs): | 203 | def clean(self, *args, **kwargs): |
135 | 181 | 204 | ||
136 | === modified file 'src/maasserver/models/tests/test_fabric.py' | |||
137 | --- src/maasserver/models/tests/test_fabric.py 2016-04-07 19:37:40 +0000 | |||
138 | +++ src/maasserver/models/tests/test_fabric.py 2016-04-11 20:53:39 +0000 | |||
139 | @@ -6,6 +6,9 @@ | |||
140 | 6 | __all__ = [] | 6 | __all__ = [] |
141 | 7 | 7 | ||
142 | 8 | 8 | ||
143 | 9 | import random | ||
144 | 10 | import re | ||
145 | 11 | |||
146 | 9 | from django.core.exceptions import ( | 12 | from django.core.exceptions import ( |
147 | 10 | PermissionDenied, | 13 | PermissionDenied, |
148 | 11 | ValidationError, | 14 | ValidationError, |
149 | @@ -15,6 +18,7 @@ | |||
150 | 15 | INTERFACE_TYPE, | 18 | INTERFACE_TYPE, |
151 | 16 | NODE_PERMISSION, | 19 | NODE_PERMISSION, |
152 | 17 | ) | 20 | ) |
153 | 21 | from maasserver.models.cleansave import CleanSave | ||
154 | 18 | from maasserver.models.fabric import ( | 22 | from maasserver.models.fabric import ( |
155 | 19 | DEFAULT_FABRIC_NAME, | 23 | DEFAULT_FABRIC_NAME, |
156 | 20 | Fabric, | 24 | Fabric, |
157 | @@ -154,6 +158,22 @@ | |||
158 | 154 | fabric = factory.make_Fabric(name=name) | 158 | fabric = factory.make_Fabric(name=name) |
159 | 155 | self.assertEqual(name, fabric.get_name()) | 159 | self.assertEqual(name, fabric.get_name()) |
160 | 156 | 160 | ||
161 | 161 | def test_save_handles_collision(self): | ||
162 | 162 | real_save = CleanSave.save | ||
163 | 163 | |||
164 | 164 | def mock_save(inst, *args, **kwargs): | ||
165 | 165 | spcl = re.compile('^fabric-0\d+$') | ||
166 | 166 | if (inst.name is None or ( | ||
167 | 167 | spcl.search(inst.name) and random.randint(0, 1) == 1)): | ||
168 | 168 | raise IntegrityError( | ||
169 | 169 | 'duplicate key value violates unique constraint ') | ||
170 | 170 | else: | ||
171 | 171 | real_save(inst, *args, **kwargs) | ||
172 | 172 | CleanSave.save = mock_save | ||
173 | 173 | fabric = Fabric.objects.create(name=None) | ||
174 | 174 | CleanSave.save = real_save | ||
175 | 175 | self.assertEqual("fabric-%d" % fabric.id, fabric.name) | ||
176 | 176 | |||
177 | 157 | def test_creates_fabric_with_default_vlan(self): | 177 | def test_creates_fabric_with_default_vlan(self): |
178 | 158 | name = factory.make_name('name') | 178 | name = factory.make_name('name') |
179 | 159 | fabric = factory.make_Fabric(name=name) | 179 | fabric = factory.make_Fabric(name=name) |
180 | 160 | 180 | ||
181 | === modified file 'src/maasserver/models/tests/test_space.py' | |||
182 | --- src/maasserver/models/tests/test_space.py 2016-04-07 19:37:40 +0000 | |||
183 | +++ src/maasserver/models/tests/test_space.py 2016-04-11 20:53:39 +0000 | |||
184 | @@ -7,6 +7,9 @@ | |||
185 | 7 | __all__ = [] | 7 | __all__ = [] |
186 | 8 | 8 | ||
187 | 9 | 9 | ||
188 | 10 | import random | ||
189 | 11 | import re | ||
190 | 12 | |||
191 | 10 | from django.core.exceptions import ( | 13 | from django.core.exceptions import ( |
192 | 11 | PermissionDenied, | 14 | PermissionDenied, |
193 | 12 | ValidationError, | 15 | ValidationError, |
194 | @@ -14,6 +17,7 @@ | |||
195 | 14 | from django.db.models import ProtectedError | 17 | from django.db.models import ProtectedError |
196 | 15 | from django.db.utils import IntegrityError | 18 | from django.db.utils import IntegrityError |
197 | 16 | from maasserver.enum import NODE_PERMISSION | 19 | from maasserver.enum import NODE_PERMISSION |
198 | 20 | from maasserver.models.cleansave import CleanSave | ||
199 | 17 | from maasserver.models.space import ( | 21 | from maasserver.models.space import ( |
200 | 18 | DEFAULT_SPACE_NAME, | 22 | DEFAULT_SPACE_NAME, |
201 | 19 | Space, | 23 | Space, |
202 | @@ -165,6 +169,22 @@ | |||
203 | 165 | factory.make_Space, | 169 | factory.make_Space, |
204 | 166 | name='space-1999') | 170 | name='space-1999') |
205 | 167 | 171 | ||
206 | 172 | def test_save_handles_collision(self): | ||
207 | 173 | real_save = CleanSave.save | ||
208 | 174 | |||
209 | 175 | def mock_save(inst, *args, **kwargs): | ||
210 | 176 | spcl = re.compile('^space-0\d+$') | ||
211 | 177 | if (inst.name is None or ( | ||
212 | 178 | spcl.search(inst.name) and random.randint(0, 1) == 1)): | ||
213 | 179 | raise IntegrityError( | ||
214 | 180 | 'duplicate key value violates unique constraint ') | ||
215 | 181 | else: | ||
216 | 182 | real_save(inst, *args, **kwargs) | ||
217 | 183 | CleanSave.save = mock_save | ||
218 | 184 | space = Space.objects.create(name=None) | ||
219 | 185 | CleanSave.save = real_save | ||
220 | 186 | self.assertEqual("space-%d" % space.id, space.name) | ||
221 | 187 | |||
222 | 168 | def test_create_sets_name(self): | 188 | def test_create_sets_name(self): |
223 | 169 | space = Space.objects.create(name=None) | 189 | space = Space.objects.create(name=None) |
224 | 170 | self.assertEqual("space-%d" % space.id, space.name) | 190 | self.assertEqual("space-%d" % space.id, space.name) |
The crux of the issue here is that if postgres is enforcing unique=True on name, then (since many things are not done inside of transactions), then default names become a source of conflict (the default name relies on the id, which is not known until the commit succeeds, and multiple branches can be in the middle of the insert-and-update logic at the same time.)
Making everything that inserts a space or fabric be transactional would be a solution, but I believe it's an invasive one to consider.
If we don't have the database enforce uniqueness, then we get near-uniqueness from the very sorts of situations that we run into above (where two inserts of spaces _with_ names that are the same, happen to pass in the night and both succeed.) TBF, this is an extremely unlikely case. If we do hit it, then we have the situation where two users create spaces (with the same name), and then whoever updates next fails, even though they didn't change the name....