Skip to content

Commit 08432ab

Browse files
authored
NOWAIT lock: raise ProgrammingError on concurrent connection use
1 parent aa774a0 commit 08432ab

2 files changed

Lines changed: 92 additions & 59 deletions

File tree

src/MySQLdb/_mysql.c

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,26 @@ _mysql_ConnectionObject_AllocateLock(_mysql_ConnectionObject *self)
122122
return 0;
123123
}
124124

125-
static void
125+
/* Try to acquire the connection lock without blocking.
126+
* Returns 0 on success, -1 if the lock is already held by another thread
127+
* (ProgrammingError is set). */
128+
static int
126129
_mysql_ConnectionObject_Lock(_mysql_ConnectionObject *self)
130+
{
131+
PyLockStatus status = PyThread_acquire_lock(self->lock, NOWAIT_LOCK);
132+
if (status != PY_LOCK_ACQUIRED) {
133+
PyErr_SetString(_mysql_ProgrammingError,
134+
"This connection is already in use from another thread. "
135+
"Do not use the same connection object from multiple threads simultaneously.");
136+
return -1;
137+
}
138+
return 0;
139+
}
140+
141+
/* Blocking variant used only during deallocation where we must acquire the
142+
* lock to call mysql_free_result() even if it means waiting. */
143+
static void
144+
_mysql_ConnectionObject_LockWait(_mysql_ConnectionObject *self)
127145
{
128146
Py_BEGIN_ALLOW_THREADS
129147
PyThread_acquire_lock(self->lock, WAIT_LOCK);
@@ -140,12 +158,12 @@ _mysql_ConnectionObject_Unlock(_mysql_ConnectionObject *self)
140158
#define END_CONNECTION_LOCK(c) _mysql_ConnectionObject_Unlock(c)
141159

142160
#define BEGIN_RESULT_CONNECTION_LOCK(r) \
143-
BEGIN_CONNECTION_LOCK(result_connection(r))
161+
_mysql_ConnectionObject_LockWait(result_connection(r))
144162
#define END_RESULT_CONNECTION_LOCK(r) \
145163
END_CONNECTION_LOCK(result_connection(r))
146164
#define BEGIN_CONNECTION_OPERATION(c, on_closed) \
147165
do { \
148-
BEGIN_CONNECTION_LOCK(c); \
166+
if (_mysql_ConnectionObject_Lock(c) < 0) return NULL; \
149167
if (!(c)->open) { \
150168
END_CONNECTION_LOCK(c); \
151169
on_closed; \
@@ -320,7 +338,9 @@ _mysql_ResultObject_Initialize(
320338
self->conn = (PyObject *) conn;
321339
Py_INCREF(conn);
322340
self->use = use;
323-
BEGIN_CONNECTION_LOCK(conn);
341+
if (BEGIN_CONNECTION_LOCK(conn) < 0) {
342+
return -1;
343+
}
324344
if (!conn->open) {
325345
END_CONNECTION_LOCK(conn);
326346
_mysql_Exception(conn);
@@ -1135,7 +1155,10 @@ _mysql_escape_string(
11351155
if (self && PyModule_Check((PyObject*)self))
11361156
self = NULL;
11371157
if (self) {
1138-
BEGIN_CONNECTION_LOCK(self);
1158+
if (BEGIN_CONNECTION_LOCK(self) < 0) {
1159+
Py_DECREF(str);
1160+
return NULL;
1161+
}
11391162
use_connection = self->open;
11401163
}
11411164
if (use_connection) {
@@ -1177,7 +1200,7 @@ _mysql_string_literal(
11771200
if (self && PyModule_Check((PyObject*)self))
11781201
self = NULL;
11791202
if (self) {
1180-
BEGIN_CONNECTION_LOCK(self);
1203+
if (BEGIN_CONNECTION_LOCK(self) < 0) return NULL;
11811204
use_connection = self->open;
11821205
}
11831206

@@ -1305,7 +1328,7 @@ _mysql_escape(
13051328
"argument 2 must be a mapping");
13061329
return NULL;
13071330
}
1308-
BEGIN_CONNECTION_LOCK((_mysql_ConnectionObject *)self);
1331+
if (BEGIN_CONNECTION_LOCK((_mysql_ConnectionObject *)self) < 0) return NULL;
13091332
converter = ((_mysql_ConnectionObject *) self)->converter;
13101333
Py_XINCREF(converter);
13111334
END_CONNECTION_LOCK((_mysql_ConnectionObject *)self);

tests/test_connection.py

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -29,68 +29,78 @@ def test_multi_statements_false():
2929
assert rows == ((17,),)
3030

3131

32-
def _assert_thread_id_blocked_during_operation(conn, func, min_wait=0.15):
33-
error = None
34-
done = threading.Event()
35-
thread_id_done = threading.Event()
36-
results = {}
37-
38-
def run():
39-
nonlocal error
40-
try:
41-
func()
42-
except Exception as exc: # pragma: no cover - error checked below
43-
error = exc
44-
finally:
45-
done.set()
46-
47-
def read_thread_id():
48-
try:
49-
results["thread_id"] = conn.thread_id()
50-
except Exception as exc: # pragma: no cover - assertion checked below
51-
results["error"] = exc
52-
finally:
53-
thread_id_done.set()
54-
55-
thread = threading.Thread(target=run)
56-
thread.start()
57-
time.sleep(0.05)
58-
assert not done.is_set()
59-
60-
blocker = threading.Thread(target=read_thread_id)
61-
blocker.start()
62-
63-
assert not thread_id_done.wait(min_wait)
64-
thread.join()
65-
blocker.join()
66-
assert error is None
67-
assert done.is_set()
68-
assert "error" not in results
69-
assert isinstance(results["thread_id"], int)
70-
71-
72-
def test_connection_methods_are_serialized():
32+
def test_connection_concurrent_use_raises():
33+
"""While a slow query holds the connection lock, any other access from
34+
a second thread must raise ProgrammingError immediately (not block)."""
7335
conn = connection_factory()
7436
try:
75-
def run_query():
76-
conn.query("SELECT SLEEP(0.2)")
77-
result = conn.store_result()
78-
assert result.fetch_row() == ((0,),)
79-
80-
_assert_thread_id_blocked_during_operation(conn, run_query)
37+
thread_error = None
38+
done = threading.Event()
39+
40+
def run_slow_query():
41+
nonlocal thread_error
42+
try:
43+
conn.query("SELECT SLEEP(0.5)")
44+
result = conn.store_result()
45+
result.fetch_row()
46+
except Exception as exc: # pragma: no cover
47+
thread_error = exc
48+
finally:
49+
done.set()
50+
51+
thread = threading.Thread(target=run_slow_query)
52+
thread.start()
53+
54+
# Give the background thread time to acquire the lock and enter SLEEP.
55+
time.sleep(0.1)
56+
57+
start = time.monotonic()
58+
with pytest.raises(ProgrammingError, match="already in use"):
59+
conn.thread_id()
60+
# Should fail immediately, not wait for the SLEEP to finish.
61+
assert time.monotonic() - start < 0.1
62+
63+
done.wait()
64+
thread.join()
65+
assert thread_error is None
8166
finally:
8267
conn.close()
8368

8469

85-
def test_result_methods_share_connection_lock():
70+
def test_result_concurrent_use_raises():
71+
"""While fetch_row holds the connection lock streaming a slow result,
72+
any other access from a second thread must raise ProgrammingError immediately."""
8673
conn = connection_factory()
8774
try:
88-
conn.query("SELECT 1 UNION ALL SELECT SLEEP(0.2)")
75+
conn.query("SELECT 1 UNION ALL SELECT SLEEP(0.5)")
8976
result = conn.use_result()
9077

91-
def fetch_all_rows():
92-
assert result.fetch_row(maxrows=0) == ((1,), (0,))
78+
thread_error = None
79+
done = threading.Event()
9380

94-
_assert_thread_id_blocked_during_operation(conn, fetch_all_rows)
81+
def fetch_all_rows():
82+
nonlocal thread_error
83+
try:
84+
assert result.fetch_row(maxrows=0) == ((1,), (0,))
85+
except Exception as exc: # pragma: no cover
86+
thread_error = exc
87+
finally:
88+
done.set()
89+
90+
thread = threading.Thread(target=fetch_all_rows)
91+
thread.start()
92+
93+
# Give the background thread time to acquire the lock and enter SLEEP.
94+
time.sleep(0.1)
95+
96+
start = time.monotonic()
97+
with pytest.raises(ProgrammingError, match="already in use"):
98+
conn.thread_id()
99+
# Should fail immediately, not wait for the SLEEP to finish.
100+
assert time.monotonic() - start < 0.1
101+
102+
done.wait()
103+
thread.join()
104+
assert thread_error is None
95105
finally:
96106
conn.close()

0 commit comments

Comments
 (0)