Merge lp:~lamont/maas/bug-1567176 into lp:~maas-committers/maas/trunk

Proposed by LaMont Jones
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
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.

To post a comment you must log in.
lp:~lamont/maas/bug-1567176 updated
4902. By LaMont Jones

restore CleanSave.save before the assertEqual.

Revision history for this message
LaMont Jones (lamont) wrote :

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....

Revision history for this message
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.

review: Needs Fixing
Revision history for this message
Lee Trager (ltrager) wrote :

Looks good, I'm just wondering if the retry loop can ever get into a deadlock state.

Revision history for this message
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_seq" sequence:

  fabric_name_id = Sequence(
      "maasserver_fabric_name_id_seq", cycle=False,
      owner="maasserver_fabric.name")

   def generate_fabric_name():
       return "fabric-%d" % next(fabric_name_id)

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://www.postgresql.org/docs/current/static/sql-lock.html
http://www.postgresql.org/docs/current/static/explicit-locking.html

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.

review: Disapprove
Revision history for this message
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_seq" sequence:
>
> fabric_name_id = Sequence(
> "maasserver_fabric_name_id_seq", cycle=False,
> owner="maasserver_fabric.name")

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_fabric_name():
> return "fabric-%d" % next(fabric_name_id)
>
> 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://www.postgresql.org/docs/current/static/sql-lock.html
> http://www.postgresql.org/docs/current/static/explicit-locking.html
>
> 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.

Revision history for this message
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.

Revision history for this message
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://git.launchpad.net/maas

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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/models/fabric.py'
--- src/maasserver/models/fabric.py 2016-04-07 19:37:40 +0000
+++ src/maasserver/models/fabric.py 2016-04-11 20:53:39 +0000
@@ -10,6 +10,7 @@
10 ]10 ]
1111
12import datetime12import datetime
13import random
13import re14import re
1415
15from django.core.exceptions import (16from django.core.exceptions import (
@@ -209,24 +210,47 @@
209210
210 def save(self, *args, **kwargs):211 def save(self, *args, **kwargs):
211 # Name will get set by clean_name() if None or empty, and there is an212 # Name will get set by clean_name() if None or empty, and there is an
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
214 # that we could collide with another thread creating a fabric as well,
215 # that gets resolved by picking random almost-correct (reserved) names
216 # until the save works, then renaming it to the proper default name.
213 created = self.id is None217 created = self.id is None
214 super().save(*args, **kwargs)218 saved = False
215 if self.name is None or self.name == '':219 save_name = self.name
220 while not saved:
221 try:
222 super().save(*args, **kwargs)
223 saved = True
224 except IntegrityError as err:
225 # A leading zero still hits the reserved names, but won't
226 # actually be generated by us, regardless of the ID. Pick
227 # random IDs until we get through.
228 if (err.args[0].startswith('duplicate key value violates')):
229 self.name = 'fabric-0%d' % random.randint(1, 999999)
230 spcl = re.compile('^fabric-0\d+$')
231 if (save_name is None or save_name == '' or spcl.search(save_name)):
216 # If we got here, then we have a newly created fabric that needs a232 # If we got here, then we have a newly created fabric that needs a
217 # default name.233 # default name. Since we may have strayed from None above, we need
218 self.name = "fabric-%d" % self.id234 # to explicitly set it, rather than relying on clean_name to do so.
219 fabric = Fabric.objects.get(id=self.id)235 fabric = Fabric.objects.get(id=self.id)
236 fabric.name = "fabric-%d" % self.id
237 self.name = fabric.name
220 fabric.save()238 fabric.save()
221 # Create default VLAN if this is a fabric creation.239 # Create default VLAN if this is a fabric creation.
222 if created:240 if created:
223 self._create_default_vlan()241 self._create_default_vlan()
224242
225 def clean_name(self):243 def clean_name(self):
226 reserved = re.compile('^fabric-\d+$')244 resvd = re.compile('^fabric-(\d+)$')
227 if self.name is not None and self.name != '':245 if self.name is not None and self.name != '':
228 if reserved.search(self.name):246 res = resvd.search(self.name)
229 if (self.id is None or self.name != 'fabric-%d' % self.id):247 if res is not None:
248 num = res.group(1)
249 if self.id is None and num.startswith('0') and int(num) > 0:
250 # "fabric-0\d+ is only valid for creation names, and will
251 # get replaced in save with the default name.
252 pass
253 elif self.id is None or self.name != 'fabric-%d' % self.id:
230 raise ValidationError(254 raise ValidationError(
231 {'name': ["Reserved fabric name."]})255 {'name': ["Reserved fabric name."]})
232 elif self.id is not None:256 elif self.id is not None:
233257
=== modified file 'src/maasserver/models/space.py'
--- src/maasserver/models/space.py 2016-04-07 19:37:40 +0000
+++ src/maasserver/models/space.py 2016-04-11 20:53:39 +0000
@@ -9,6 +9,7 @@
9 ]9 ]
1010
11import datetime11import datetime
12import random
12import re13import re
1314
14from django.core.exceptions import (15from django.core.exceptions import (
@@ -155,10 +156,16 @@
155 return "space-%s" % self.id156 return "space-%s" % self.id
156157
157 def clean_name(self):158 def clean_name(self):
158 reserved = re.compile('^space-\d+$')159 resvd = re.compile('^space-(\d+)$')
159 if self.name is not None and self.name != '':160 if self.name is not None and self.name != '':
160 if reserved.search(self.name):161 res = resvd.search(self.name)
161 if (self.id is None or self.name != 'space-%d' % self.id):162 if res is not None:
163 num = res.group(1)
164 if self.id is None and num.startswith('0') and int(num) > 0:
165 # "space-0\d+ is only valid for creation names, and will
166 # get replaced in save with the default name.
167 pass
168 elif self.id is None or self.name != 'space-%d' % self.id:
162 raise ValidationError(169 raise ValidationError(
163 {'name': ["Reserved space name."]})170 {'name': ["Reserved space name."]})
164 elif self.id is not None:171 elif self.id is not None:
@@ -168,13 +175,29 @@
168175
169 def save(self, *args, **kwargs):176 def save(self, *args, **kwargs):
170 # Name will get set by clean_name() if None or empty, and there is an177 # Name will get set by clean_name() if None or empty, and there is an
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
172 super().save(*args, **kwargs)179 # that we could collide with another thread creating a space as well,
173 if self.name is None or self.name == '':180 # that gets resolved by picking random almost-correct (reserved) names
181 # until the save works, then renaming it to the proper default name.
182 saved = False
183 save_name = self.name
184 while not saved:
185 try:
186 super().save(*args, **kwargs)
187 saved = True
188 except IntegrityError as err:
189 # A leading zero means that we are creating a space with a
190 # default id.
191 if (err.args[0].startswith('duplicate key value violates')):
192 self.name = 'space-0%d' % random.randint(1, 999999)
193 spcl = re.compile('^space-0\d+$')
194 if (save_name is None or save_name == '' or spcl.search(save_name)):
174 # If we got here, then we have a newly created space that needs a195 # If we got here, then we have a newly created space that needs a
175 # default name.196 # default name. Since we may have strayed from None above, we need
176 self.name = "space-%d" % self.id197 # to explicitly set it, rather than relying on clean_name to do so.
177 space = Space.objects.get(id=self.id)198 space = Space.objects.get(id=self.id)
199 space.name = "space-%d" % self.id
200 self.name = space.name
178 space.save()201 space.save()
179202
180 def clean(self, *args, **kwargs):203 def clean(self, *args, **kwargs):
181204
=== modified file 'src/maasserver/models/tests/test_fabric.py'
--- src/maasserver/models/tests/test_fabric.py 2016-04-07 19:37:40 +0000
+++ src/maasserver/models/tests/test_fabric.py 2016-04-11 20:53:39 +0000
@@ -6,6 +6,9 @@
6__all__ = []6__all__ = []
77
88
9import random
10import re
11
9from django.core.exceptions import (12from django.core.exceptions import (
10 PermissionDenied,13 PermissionDenied,
11 ValidationError,14 ValidationError,
@@ -15,6 +18,7 @@
15 INTERFACE_TYPE,18 INTERFACE_TYPE,
16 NODE_PERMISSION,19 NODE_PERMISSION,
17)20)
21from maasserver.models.cleansave import CleanSave
18from maasserver.models.fabric import (22from maasserver.models.fabric import (
19 DEFAULT_FABRIC_NAME,23 DEFAULT_FABRIC_NAME,
20 Fabric,24 Fabric,
@@ -154,6 +158,22 @@
154 fabric = factory.make_Fabric(name=name)158 fabric = factory.make_Fabric(name=name)
155 self.assertEqual(name, fabric.get_name())159 self.assertEqual(name, fabric.get_name())
156160
161 def test_save_handles_collision(self):
162 real_save = CleanSave.save
163
164 def mock_save(inst, *args, **kwargs):
165 spcl = re.compile('^fabric-0\d+$')
166 if (inst.name is None or (
167 spcl.search(inst.name) and random.randint(0, 1) == 1)):
168 raise IntegrityError(
169 'duplicate key value violates unique constraint ')
170 else:
171 real_save(inst, *args, **kwargs)
172 CleanSave.save = mock_save
173 fabric = Fabric.objects.create(name=None)
174 CleanSave.save = real_save
175 self.assertEqual("fabric-%d" % fabric.id, fabric.name)
176
157 def test_creates_fabric_with_default_vlan(self):177 def test_creates_fabric_with_default_vlan(self):
158 name = factory.make_name('name')178 name = factory.make_name('name')
159 fabric = factory.make_Fabric(name=name)179 fabric = factory.make_Fabric(name=name)
160180
=== modified file 'src/maasserver/models/tests/test_space.py'
--- src/maasserver/models/tests/test_space.py 2016-04-07 19:37:40 +0000
+++ src/maasserver/models/tests/test_space.py 2016-04-11 20:53:39 +0000
@@ -7,6 +7,9 @@
7__all__ = []7__all__ = []
88
99
10import random
11import re
12
10from django.core.exceptions import (13from django.core.exceptions import (
11 PermissionDenied,14 PermissionDenied,
12 ValidationError,15 ValidationError,
@@ -14,6 +17,7 @@
14from django.db.models import ProtectedError17from django.db.models import ProtectedError
15from django.db.utils import IntegrityError18from django.db.utils import IntegrityError
16from maasserver.enum import NODE_PERMISSION19from maasserver.enum import NODE_PERMISSION
20from maasserver.models.cleansave import CleanSave
17from maasserver.models.space import (21from maasserver.models.space import (
18 DEFAULT_SPACE_NAME,22 DEFAULT_SPACE_NAME,
19 Space,23 Space,
@@ -165,6 +169,22 @@
165 factory.make_Space,169 factory.make_Space,
166 name='space-1999')170 name='space-1999')
167171
172 def test_save_handles_collision(self):
173 real_save = CleanSave.save
174
175 def mock_save(inst, *args, **kwargs):
176 spcl = re.compile('^space-0\d+$')
177 if (inst.name is None or (
178 spcl.search(inst.name) and random.randint(0, 1) == 1)):
179 raise IntegrityError(
180 'duplicate key value violates unique constraint ')
181 else:
182 real_save(inst, *args, **kwargs)
183 CleanSave.save = mock_save
184 space = Space.objects.create(name=None)
185 CleanSave.save = real_save
186 self.assertEqual("space-%d" % space.id, space.name)
187
168 def test_create_sets_name(self):188 def test_create_sets_name(self):
169 space = Space.objects.create(name=None)189 space = Space.objects.create(name=None)
170 self.assertEqual("space-%d" % space.id, space.name)190 self.assertEqual("space-%d" % space.id, space.name)