Skip to content

Commit 2e4ee9c

Browse files
committed
Fix ORM caching for with_for_update calls
The SQLAlchemy recommends to use populate_existing() when using with_for_update() [0], it fixed issues with the caching of the objects. This patch precisely fixes a bug when locking of a loadbalancer in the batch member update API call, the load balancer might not have been locked correctly and race conditions could have occurred (processing simultaneously 2 requests in the workers for the same load balancer). [0] https://docs.sqlalchemy.org/en/14/orm/query.html#sqlalchemy.orm.Query.with_for_update Story 2010646 Task 47642 Co-Authored-By: Gaudenz Steinlin <[email protected]> Change-Id: Ibd4da09079e83789d6cfe3658fcf1f266f5cf8b4
1 parent 5ed6f37 commit 2e4ee9c

File tree

3 files changed

+83
-26
lines changed

3 files changed

+83
-26
lines changed

octavia/db/repositories.py

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,11 @@ def check_quota_met(self, session, lock_session, _class, project_id,
406406
# Note: You cannot just use the current count as the in-use
407407
# value as we don't want to lock the whole resource table
408408
try:
409-
quotas = lock_session.query(models.Quotas).filter_by(
410-
project_id=project_id).with_for_update().first()
409+
quotas = (lock_session.query(models.Quotas)
410+
.filter_by(project_id=project_id)
411+
.populate_existing()
412+
.with_for_update()
413+
.first())
411414
if _class == data_models.LoadBalancer:
412415
# Decide which quota to use
413416
if quotas.load_balancer is None:
@@ -575,8 +578,11 @@ def decrement_quota(self, lock_session, _class, project_id, quantity=1):
575578

576579
# Lock the project record in the database to block other quota checks
577580
try:
578-
quotas = lock_session.query(models.Quotas).filter_by(
579-
project_id=project_id).with_for_update().first()
581+
quotas = (lock_session.query(models.Quotas)
582+
.filter_by(project_id=project_id)
583+
.populate_existing()
584+
.with_for_update()
585+
.first())
580586
if not quotas:
581587
if not CONF.api_settings.auth_strategy == consts.NOAUTH:
582588
LOG.error('Quota decrement on %(clss)s called on '
@@ -742,8 +748,10 @@ def test_and_set_provisioning_status(self, session, id, status,
742748
:returns: bool
743749
"""
744750
with session.begin(subtransactions=True):
745-
lb = session.query(self.model_class).with_for_update().filter_by(
746-
id=id).one()
751+
lb = (session.query(self.model_class)
752+
.populate_existing()
753+
.with_for_update()
754+
.filter_by(id=id).one())
747755
is_delete = status == consts.PENDING_DELETE
748756
acceptable_statuses = (
749757
consts.DELETABLE_STATUSES
@@ -774,8 +782,10 @@ def set_status_for_failover(self, session, id, status,
774782
:returns: bool
775783
"""
776784
with session.begin(subtransactions=True):
777-
lb = session.query(self.model_class).with_for_update().filter_by(
778-
id=id).one()
785+
lb = (session.query(self.model_class)
786+
.populate_existing()
787+
.with_for_update()
788+
.filter_by(id=id).one())
779789
if lb.provisioning_status not in consts.FAILOVERABLE_STATUSES:
780790
if raise_exception:
781791
raise exceptions.ImmutableObject(
@@ -1143,10 +1153,13 @@ def increment(self, session, delta_stats):
11431153
listener_id=delta_stats.listener_id,
11441154
amphora_id=delta_stats.amphora_id).count()
11451155
if count:
1146-
existing_stats = session.query(
1147-
self.model_class).with_for_update().filter_by(
1148-
listener_id=delta_stats.listener_id,
1149-
amphora_id=delta_stats.amphora_id).one()
1156+
existing_stats = (
1157+
session.query(self.model_class)
1158+
.populate_existing()
1159+
.with_for_update()
1160+
.filter_by(
1161+
listener_id=delta_stats.listener_id,
1162+
amphora_id=delta_stats.amphora_id).one())
11501163
existing_stats += delta_stats
11511164
existing_stats.active_connections = (
11521165
delta_stats.active_connections)
@@ -1229,8 +1242,10 @@ def allocate_and_associate(self, session, load_balancer_id,
12291242
filters['cached_zone'] = availability_zone
12301243

12311244
with session.begin(subtransactions=True):
1232-
amp = session.query(self.model_class).with_for_update().filter_by(
1233-
**filters).first()
1245+
amp = (session.query(self.model_class)
1246+
.populate_existing()
1247+
.with_for_update()
1248+
.filter_by(**filters).first())
12341249

12351250
if amp is None:
12361251
return None
@@ -1281,12 +1296,15 @@ def get_cert_expiring_amphora(self, session):
12811296
seconds=expired_seconds)
12821297

12831298
with session.begin(subtransactions=True):
1284-
amp = session.query(self.model_class).with_for_update().filter(
1285-
self.model_class.status.notin_(
1286-
[consts.DELETED, consts.PENDING_DELETE]),
1287-
self.model_class.cert_busy == false(),
1288-
self.model_class.cert_expiration < expired_date
1289-
).first()
1299+
amp = (session.query(self.model_class)
1300+
.populate_existing()
1301+
.with_for_update()
1302+
.filter(
1303+
self.model_class.status.notin_(
1304+
[consts.DELETED, consts.PENDING_DELETE]),
1305+
self.model_class.cert_busy == false(),
1306+
self.model_class.cert_expiration < expired_date)
1307+
.first())
12901308

12911309
if amp is None:
12921310
return None
@@ -1385,8 +1403,11 @@ def test_and_set_status_for_delete(self, lock_session, id):
13851403
:raises NoResultFound: The amphora was not found or already deleted.
13861404
:returns: None
13871405
"""
1388-
amp = lock_session.query(self.model_class).with_for_update().filter_by(
1389-
id=id).filter(self.model_class.status != consts.DELETED).one()
1406+
amp = (lock_session.query(self.model_class)
1407+
.populate_existing()
1408+
.with_for_update()
1409+
.filter_by(id=id)
1410+
.filter(self.model_class.status != consts.DELETED).one())
13901411
if amp.status not in [consts.AMPHORA_READY, consts.ERROR]:
13911412
raise exceptions.ImmutableObject(resource=consts.AMPHORA, id=id)
13921413
amp.status = consts.PENDING_DELETE
@@ -1587,6 +1608,7 @@ def get_stale_amphora(self,
15871608
# Pick one expired amphora for automatic failover
15881609
amp_health = lock_session.query(
15891610
self.model_class
1611+
).populate_existing(
15901612
).with_for_update(
15911613
).filter(
15921614
self.model_class.amphora_id.in_(expired_ids_query)
@@ -1911,8 +1933,11 @@ class QuotasRepository(BaseRepository):
19111933
def update(self, session, project_id, **model_kwargs):
19121934
with session.begin(subtransactions=True):
19131935
kwargs_quota = model_kwargs['quota']
1914-
quotas = session.query(self.model_class).filter_by(
1915-
project_id=project_id).with_for_update().first()
1936+
quotas = (
1937+
session.query(self.model_class)
1938+
.filter_by(project_id=project_id)
1939+
.populate_existing()
1940+
.with_for_update().first())
19161941
if not quotas:
19171942
quotas = models.Quotas(project_id=project_id)
19181943

@@ -1924,8 +1949,11 @@ def update(self, session, project_id, **model_kwargs):
19241949

19251950
def delete(self, session, project_id):
19261951
with session.begin(subtransactions=True):
1927-
quotas = session.query(self.model_class).filter_by(
1928-
project_id=project_id).with_for_update().first()
1952+
quotas = (
1953+
session.query(self.model_class)
1954+
.filter_by(project_id=project_id)
1955+
.populate_existing()
1956+
.with_for_update().first())
19291957
if not quotas:
19301958
raise exceptions.NotFound(
19311959
resource=data_models.Quotas._name(), id=project_id)

octavia/tests/functional/db/test_repositories.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3094,6 +3094,30 @@ def test_test_and_set_provisioning_status_error_on_delete(self):
30943094
lb = self.lb_repo.get(self.session, id=lb_id)
30953095
self.assertEqual(constants.PENDING_DELETE, lb.provisioning_status)
30963096

3097+
def test_test_and_set_provisioning_status_concurrent(self):
3098+
lb_id = uuidutils.generate_uuid()
3099+
lock_session1 = db_api.get_session(autocommit=False)
3100+
self.lb_repo.create(lock_session1, id=lb_id,
3101+
provisioning_status=constants.ACTIVE,
3102+
operating_status=constants.ONLINE,
3103+
enabled=True)
3104+
3105+
# Create a concurrent session
3106+
lock_session2 = db_api.get_session(autocommit=False)
3107+
3108+
# Load LB into lock_session2's identity map
3109+
lock_session2.query(db_models.LoadBalancer).filter_by(
3110+
id=lb_id).one()
3111+
3112+
# Update provisioning status in lock_session1
3113+
self.lb_repo.test_and_set_provisioning_status(
3114+
self.session, lb_id, constants.PENDING_UPDATE)
3115+
lock_session1.commit()
3116+
3117+
# Assert concurrent updates are rejected
3118+
self.assertFalse(self.lb_repo.test_and_set_provisioning_status(
3119+
lock_session2, lb_id, constants.PENDING_UPDATE))
3120+
30973121
def test_set_status_for_failover_immutable(self):
30983122
lb_id = uuidutils.generate_uuid()
30993123
self.lb_repo.create(self.session, id=lb_id,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed a potential race condition in the member batch update API call, the
5+
load balancers might not have been locked properly.

0 commit comments

Comments
 (0)