Skip to content

Commit 61b59ce

Browse files
committed
Fix incomplete error handling in advisory_lock wrapper
1 parent f93c8aa commit 61b59ce

File tree

2 files changed

+73
-12
lines changed

2 files changed

+73
-12
lines changed

ansible_base/lib/utils/db.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from contextlib import contextmanager
22
from zlib import crc32
3+
import logging
34

4-
from django.db import DEFAULT_DB_ALIAS, connection, connections, transaction
5+
from django.db import DEFAULT_DB_ALIAS, connection, connections, transaction, OperationalError
56
from django.db.migrations.executor import MigrationExecutor
67

78

9+
logger = logging.getLogger(__name__)
10+
11+
812
@contextmanager
913
def ensure_transaction():
1014
needs_new_transaction = not transaction.get_connection().in_atomic_block
@@ -129,6 +133,7 @@ def advisory_lock(*args, lock_session_timeout_milliseconds=0, **kwargs):
129133
@param: lock_session_timeout_milliseconds Postgres-level timeout
130134
@param: using django database identifier
131135
"""
136+
internal_error = False
132137
if connection.vendor == "postgresql":
133138
cur = None
134139
idle_in_transaction_session_timeout = None
@@ -139,12 +144,23 @@ def advisory_lock(*args, lock_session_timeout_milliseconds=0, **kwargs):
139144
idle_session_timeout = cur.execute("SHOW idle_session_timeout").fetchone()[0]
140145
cur.execute("SET idle_in_transaction_session_timeout = %s", (lock_session_timeout_milliseconds,))
141146
cur.execute("SET idle_session_timeout = %s", (lock_session_timeout_milliseconds,))
142-
with django_pglocks_advisory_lock(*args, **kwargs) as internal_lock:
143-
yield internal_lock
144-
if lock_session_timeout_milliseconds > 0:
147+
148+
try:
149+
with django_pglocks_advisory_lock(*args, **kwargs) as internal_lock:
150+
yield internal_lock
151+
except OperationalError:
152+
# Suspected case is that timeout happened due to the given timeout
153+
# this is _expected_ to leave the connection in an unusable state, so dropping it is better
154+
logger.info('Dropping connection due to suspected timeout inside advisory_lock')
155+
connection.close_if_unusable_or_obsolete()
156+
internal_error = True
157+
raise
158+
finally:
159+
if (not internal_error) and lock_session_timeout_milliseconds > 0:
145160
with connection.cursor() as cur:
146161
cur.execute("SET idle_in_transaction_session_timeout = %s", (idle_in_transaction_session_timeout,))
147162
cur.execute("SET idle_session_timeout = %s", (idle_session_timeout,))
163+
148164
elif connection.vendor == "sqlite":
149165
yield True
150166
else:

test_app/tests/lib/utils/test_db.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from django.db import connection
66
from django.db.utils import OperationalError
77

8-
from ansible_base.lib.utils.db import advisory_lock, migrations_are_complete
8+
from ansible_base.lib.utils.db import advisory_lock, migrations_are_complete, transaction
9+
from django.db import connection
910

1011

1112
@pytest.mark.django_db
@@ -15,6 +16,8 @@ def test_migrations_are_complete():
1516

1617

1718
class TestAdvisoryLock:
19+
THREAD_WAIT_TIME = 0.1
20+
1821
@pytest.fixture(autouse=True)
1922
def skip_if_sqlite(self):
2023
if connection.vendor == 'sqlite':
@@ -30,7 +33,7 @@ def background_task(django_db_blocker):
3033
# HACK: as a thread the pytest.mark.django_db will not work
3134
django_db_blocker.unblock()
3235
with advisory_lock('background_task_lock'):
33-
time.sleep(0.1)
36+
time.sleep(TestAdvisoryLock.THREAD_WAIT_TIME)
3437

3538
@pytest.mark.django_db
3639
def test_determine_lock_is_held(self, django_db_blocker):
@@ -40,7 +43,7 @@ def test_determine_lock_is_held(self, django_db_blocker):
4043
with advisory_lock('background_task_lock', wait=False) as held:
4144
if held is False:
4245
break
43-
time.sleep(0.01)
46+
time.sleep(TestAdvisoryLock.THREAD_WAIT_TIME / 5.0)
4447
else:
4548
raise RuntimeError('Other thread never obtained lock')
4649
thread.join()
@@ -56,10 +59,52 @@ def test_invalid_tuple_name(self):
5659
with advisory_lock(['test_invalid_tuple_name', 'foo']):
5760
pass
5861

59-
@pytest.mark.django_db
60-
def test_lock_session_timeout_milliseconds(self):
62+
63+
# Special transaction=True parameter is used, because we do not want in normal test transactions
64+
# because dropping the connection would break the transaction context, erroring at end of test
65+
@pytest.mark.django_db(transaction=True)
66+
class TestAdvisoryLockPostgresErrors:
67+
"""Tests related to connection management and the advisory_lock"""
68+
69+
def kick_connection(self):
70+
"""
71+
These (somewhat evil) tests are not gentle with the connection
72+
73+
At the start of one tests there is a good chance the connection is broken by the last test.
74+
"""
75+
connection.ensure_connection()
76+
# sanity checks
77+
assert connection.connection
78+
assert connection.connection.closed is False
79+
80+
def test_timeout_under_lock_by_sleep(self):
81+
self.kick_connection()
82+
6183
with pytest.raises(OperationalError) as exc:
6284
# uses miliseconds units
63-
with advisory_lock('test_lock_session_timeout_milliseconds', lock_session_timeout_milliseconds=2):
64-
time.sleep(3)
65-
assert 'the connection is lost' in str(exc)
85+
with advisory_lock('test_lock_session_timeout_milliseconds', lock_session_timeout_milliseconds=5):
86+
time.sleep(0.1)
87+
88+
# This exception comes from the __exit__, either releasing the lock or closing the cursor
89+
assert 'terminating connection due to idle-session timeout' in str(exc)
90+
91+
with connection.cursor() as cursor:
92+
cursor.execute('SELECT 1')
93+
94+
def test_idle_after_exception(self):
95+
self.kick_connection()
96+
97+
with pytest.raises(RuntimeError) as exc:
98+
with advisory_lock('test_timeout_under_lock_with_query', lock_session_timeout_milliseconds=5):
99+
with connection.cursor() as cursor:
100+
raise RuntimeError('exception from test')
101+
102+
assert 'exception from test' in str(exc)
103+
104+
time.sleep(0.1)
105+
106+
# The fact that this works shows that the timeout was reset in the context manager __exit__
107+
# Prior bug was giving exception with
108+
# consuming input failed: terminating connection due to idle-session timeout server closed the connection unexpectedly
109+
with connection.cursor() as cursor:
110+
cursor.execute('SELECT 1')

0 commit comments

Comments
 (0)