Skip to content

Commit 46e66be

Browse files
committed
Fix three bugs in the pooling layer
* PooledDB.connection() raised an IndexError when a thread waiting for a shared connection that was in a transaction was woken up after that connection had become idle and left the shared cache. The waiter now re-evaluates the whole situation and falls back to the idle cache. * PooledPgConnection.reopen() wrapped another pooled connection instead of the underlying steady connection, so every close()/reopen() cycle nested the proxies one level deeper. The pool got a pooled_connection() method that hands out the bare steady connection for this purpose. * SharedDBConnection compared by value but hashed by identity. Equality is now identity, which also makes the list.remove() in unshare() remove the connection being unshared rather than the first equally ranked one.
1 parent d44f4d3 commit 46e66be

6 files changed

Lines changed: 161 additions & 44 deletions

File tree

dbutils/pooled_db.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -315,30 +315,37 @@ def connection(self, shareable=True):
315315
"""
316316
if shareable and self._maxshared:
317317
with self._lock:
318-
while (not self._shared_cache and self._maxconnections
319-
and self._connections >= self._maxconnections):
320-
self._wait_lock()
321-
if len(self._shared_cache) < self._maxshared:
322-
# shared cache is not full, get a dedicated connection
323-
try: # first try to get it from the idle cache
324-
con = self._idle_cache.pop(0)
325-
except IndexError: # else get a fresh connection
326-
con = self.steady_connection()
327-
else:
328-
con._ping_check() # check this connection
329-
con = SharedDBConnection(con)
330-
self._connections += 1
331-
else: # shared cache full or no more connections allowed
318+
while True:
319+
while (not self._shared_cache and self._maxconnections
320+
and self._connections >= self._maxconnections):
321+
self._wait_lock()
322+
if len(self._shared_cache) < self._maxshared:
323+
# shared cache is not full, get a dedicated connection
324+
try: # first try to get it from the idle cache
325+
con = self._idle_cache.pop(0)
326+
except IndexError: # else get a fresh connection
327+
con = self.steady_connection()
328+
else:
329+
con._ping_check() # check this connection
330+
con = SharedDBConnection(con)
331+
self._connections += 1
332+
break
333+
# shared cache full or no more connections allowed
332334
self._shared_cache.sort() # least shared connection first
333-
con = self._shared_cache.pop(0) # get it
334-
while con.con._transaction:
335-
# do not share connections which are in a transaction
336-
self._shared_cache.insert(0, con)
335+
# only look at it, but leave it in the shared cache
336+
# as long as we may still have to wait for it
337+
con = self._shared_cache[0]
338+
if con.con._transaction:
339+
# do not share connections which are in a transaction,
340+
# wait until the situation has changed and start over,
341+
# since by then the connection may have become idle
342+
# and been removed from the shared cache altogether
337343
self._wait_lock()
338-
self._shared_cache.sort()
339-
con = self._shared_cache.pop(0)
344+
continue
345+
del self._shared_cache[0] # get it
340346
con.con._ping_check() # check the underlying connection
341347
con.share() # increase share of this connection
348+
break
342349
# put the connection (back) into the shared cache
343350
self._shared_cache.append(con)
344351
self._lock.notify()
@@ -487,12 +494,17 @@ def __lt__(self, other):
487494

488495
def __eq__(self, other):
489496
"""Check whether this connection is the same as the other one."""
490-
return (self.con._transaction == other.con._transaction
491-
and self.shared == other.shared)
497+
# The ordering above only serves to pick the least shared connection,
498+
# it does not make different connections interchangeable. Therefore
499+
# equality is identity, so that the shared cache can be searched for
500+
# one particular connection (see the unshare method of the pool).
501+
return self is other
492502

493503
def __hash__(self):
494504
"""Get hash value of this connection."""
495-
return hash((self.con, self.shared))
505+
# must be based on identity as well, and must stay constant even
506+
# though the number of shares of the connection can change
507+
return id(self)
496508

497509
def share(self):
498510
"""Increase the share of this connection."""

dbutils/pooled_pg.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,23 @@ def steady_connection(self):
214214
return SteadyPgConnection(self._maxusage, self._setsession, True,
215215
*self._args, **self._kwargs)
216216

217-
def connection(self):
218-
"""Get a steady, cached PostgreSQL connection from the pool."""
217+
def pooled_connection(self):
218+
"""Get a steady PostgreSQL connection from the pool cache.
219+
220+
This is the unwrapped connection that is kept in the pool cache.
221+
It reserves one of the generally allowed connections, so it must
222+
be balanced with a corresponding call of the cache() method.
223+
"""
219224
if self._connections and not self._connections.acquire(self._blocking):
220225
raise TooManyConnectionsError
221226
try:
222-
con = self._cache.get_nowait()
227+
return self._cache.get_nowait()
223228
except Empty:
224-
con = self.steady_connection()
225-
return PooledPgConnection(self, con)
229+
return self.steady_connection()
230+
231+
def connection(self):
232+
"""Get a steady, cached PostgreSQL connection from the pool."""
233+
return PooledPgConnection(self, self.pooled_connection())
226234

227235
def cache(self, con):
228236
"""Put a connection back into the pool cache."""
@@ -289,7 +297,10 @@ def reopen(self):
289297
if self._con:
290298
self._con.reopen()
291299
else:
292-
self._con = self._pool.connection()
300+
# take the steady connection itself out of the pool, since
301+
# wrapping another pooled connection would nest the proxies
302+
# and put a proxy instead of a connection into the pool cache
303+
self._con = self._pool.pooled_connection()
293304

294305
def __getattr__(self, name):
295306
"""Proxy all members of the class."""

docs/changelog.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ <h2>3.2.0</h2>
3333
<li><p>Fixed the transaction flag being reset on the cursor instead of the
3434
connection when a connection could not be reopened during a transaction,
3535
which kept the failover mechanism suspended.</p></li>
36+
<li><p>Fixed an <span class="docutils literal">IndexError</span> in <span class="docutils literal">PooledDB.connection()</span> when a thread waiting
37+
for a shared connection that was in a transaction got woken up after that
38+
connection had become idle and left the shared cache.</p></li>
39+
<li><p>Fixed <span class="docutils literal">PooledPgConnection.reopen()</span> wrapping another pooled connection
40+
instead of the underlying steady connection when it was called after the
41+
connection had already been returned to the pool.</p></li>
42+
<li><p>Fixed <span class="docutils literal">pooled_db.SharedDBConnection</span> violating the contract between
43+
<span class="docutils literal">__eq__</span> and <span class="docutils literal">__hash__</span>; shared connections now compare by identity.</p></li>
3644
</ul>
3745
</section>
3846
<section id="section-2">

docs/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ Bugfixes:
2525
* Fixed the transaction flag being reset on the cursor instead of the
2626
connection when a connection could not be reopened during a transaction,
2727
which kept the failover mechanism suspended.
28+
* Fixed an ``IndexError`` in ``PooledDB.connection()`` when a thread waiting
29+
for a shared connection that was in a transaction got woken up after that
30+
connection had become idle and left the shared cache.
31+
* Fixed ``PooledPgConnection.reopen()`` wrapping another pooled connection
32+
instead of the underlying steady connection when it was called after the
33+
connection had already been returned to the pool.
34+
* Fixed ``pooled_db.SharedDBConnection`` violating the contract between
35+
``__eq__`` and ``__hash__``; shared connections now compare by identity.
2836

2937
3.1.2
3038
=====

tests/test_pooled_db.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,40 @@ def connection():
13791379
assert shared[0]._con is con
13801380

13811381

1382+
def test_shared_in_transaction_becoming_idle(dbapi):
1383+
"""Check that a thread waiting for a shared connection survives unshare."""
1384+
pool = PooledDB(dbapi, 0, 0, 1, 0, True)
1385+
db = pool.connection()
1386+
con = db._con
1387+
db.begin()
1388+
# the only connection that may be shared is in a transaction now
1389+
shared = []
1390+
errors = []
1391+
1392+
def connection():
1393+
try:
1394+
shared.append(pool.connection())
1395+
except Exception as error:
1396+
errors.append(error)
1397+
1398+
thread = Thread(target=connection)
1399+
thread.start()
1400+
thread.join(0.1)
1401+
# the thread cannot share that connection and blocks instead of failing
1402+
assert thread.is_alive()
1403+
assert not shared
1404+
# closing the connection takes it out of the shared cache completely,
1405+
# so the waiting thread is woken up to find an empty shared cache;
1406+
# it must then get the connection from the idle cache instead of
1407+
# trying to share a connection that is no longer there
1408+
db.close()
1409+
thread.join(0.1)
1410+
assert not thread.is_alive()
1411+
assert not errors
1412+
assert len(shared) == 1
1413+
assert shared[0]._con is con
1414+
1415+
13821416
def test_shared_in_transaction_with_two_connections(dbapi):
13831417
"""Check that sharing prefers connections without a transaction."""
13841418
pool = PooledDB(dbapi, 0, 2, 2)
@@ -1490,26 +1524,45 @@ def test_shared_db_connection_compare(dbapi):
14901524
con1.con._transaction = False
14911525
con2 = SharedDBConnection(dbapi.connect())
14921526
con2.con._transaction = False
1493-
assert con1 == con2
1494-
assert con1 <= con2
1495-
assert con1 >= con2
1496-
assert not con1 != con2 # noqa: SIM202
1527+
# connections with the same number of shares are ranked equally
14971528
assert not con1 < con2
1498-
assert not con1 > con2
1529+
assert not con2 < con1
1530+
# the connection with fewer shares is preferred
14991531
con2.share()
1500-
assert not con1 == con2 # noqa: SIM201
1532+
assert con1 < con2
1533+
assert not con2 < con1
15011534
assert con1 <= con2
15021535
assert not con1 >= con2
1503-
assert con1 != con2
1504-
assert con1 < con2
1505-
assert not con1 > con2
1536+
# but connections in a transaction always come last
15061537
con1.con._transaction = True
1507-
assert not con1 == con2 # noqa: SIM201
1508-
assert not con1 <= con2
1509-
assert con1 >= con2
1510-
assert con1 != con2
15111538
assert not con1 < con2
1539+
assert con2 < con1
15121540
assert con1 > con2
1541+
assert not con1 <= con2
1542+
1543+
1544+
def test_shared_db_connection_equality(dbapi):
1545+
"""Check that shared connections are only equal to themselves."""
1546+
con1 = SharedDBConnection(dbapi.connect())
1547+
con1.con._transaction = False
1548+
con2 = SharedDBConnection(dbapi.connect())
1549+
con2.con._transaction = False
1550+
same_as_con1 = con1
1551+
# the ordering only serves to pick the least shared connection,
1552+
# it does not make equally ranked connections interchangeable
1553+
assert con1 == same_as_con1
1554+
assert con1 != con2
1555+
# equal connections must therefore also have equal hash values
1556+
# (this used to be violated, since the equality was based on the
1557+
# values while the hash value was based on the identity)
1558+
assert hash(con1) == hash(same_as_con1)
1559+
assert len({con1, con2, same_as_con1}) == 2
1560+
# and removing one connection from a list of connections
1561+
# must not remove another connection that is ranked the same
1562+
cache = [con1, con2]
1563+
cache.remove(con2)
1564+
assert len(cache) == 1
1565+
assert cache[0] is con1
15131566

15141567

15151568
def test_shared_db_connection_hash(dbapi):
@@ -1518,9 +1571,10 @@ def test_shared_db_connection_hash(dbapi):
15181571
con = SharedDBConnection(dbapi.connect())
15191572
hashed = hash(con)
15201573
assert hash(con) == hashed
1521-
# the hash is derived from the underlying connection and the shares
1574+
# the hash is based on the identity, so it does not change
1575+
# when the number of shares of the connection changes
15221576
con.share()
1523-
assert hash(con) != hashed
1577+
assert hash(con) == hashed
15241578
con.unshare()
15251579
assert hash(con) == hashed
15261580

tests/test_pooled_pg.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,30 @@ def test_reopen_connection_after_closing():
370370
assert con.num_queries == 1
371371

372372

373+
def test_reopen_connection_does_not_nest_the_proxy():
374+
"""Check that reopening does not wrap another pooled connection."""
375+
pool = PooledPg(1, 2, 3)
376+
db = pool.connection()
377+
con = db._con
378+
db.close()
379+
db.reopen()
380+
# the proxy must wrap the steady connection itself, not another proxy,
381+
# since otherwise the pool cache would fill up with nested proxies
382+
assert db._con is con
383+
assert isinstance(db._con, SteadyPgConnection)
384+
db.close()
385+
cached = pool._cache.get_nowait()
386+
assert cached is con
387+
assert isinstance(cached, SteadyPgConnection)
388+
pool._cache.put_nowait(cached)
389+
# reopening must not disturb the accounting of the allowed connections
390+
dbs = [pool.connection() for _ in range(3)]
391+
with pytest.raises(TooManyConnectionsError):
392+
pool.connection()
393+
while dbs:
394+
dbs.pop().close()
395+
396+
373397
def test_context_manager():
374398
"""Check that the connection can be used as a context manager."""
375399
pool = PooledPg(1, 1, 1)

0 commit comments

Comments
 (0)