in_transaction() leaks a pool connection when begin() is cancelled
Maintainer thường phản hồi trong vòng 2 ngày
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 45/100
Hướng nghiên cứu
Start in TransactionContextPooled.__aenter__, where the connection is acquired from the pool before begin() runs outside any cleanup. Read __aexit__ to see how the release is handled, and check how the asyncpg pool's release is shielded from cancellation. Done means a cancelled begin() returns the connection, which the issue's reproduction script can confirm. Open PR #2293 edits the same lines, so check it before starting.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Describe the bug
TransactionContextPooled.__aenter__ takes a connection from the pool, sets the context variable, then awaits begin() with nothing around it. If the task is cancelled during begin(), for example by a request deadline, __aenter__ never finishes, so __aexit__ never runs and the connection is never given back. Each time, the pool loses one connection for good. With no acquire timeout set, once they are all gone every later query waits on pool.acquire(). In my runs the next request timed out after 5 s.
in_transaction() is also used inside the ORM (for example get_or_create, as in #1152), so code that never opens a transaction itself can hit this. #1152 mentions the same thing happening in __aenter__; the __aexit__ part of that issue is covered now by the try/finally.
To Reproduce
Postgres 16.2, asyncpg 0.32.0, anyio 4.16.0, Python 3.12, tortoise on develop at cdef122 (the class is the same in 1.1.9). This puts a 100 ms anyio deadline around an ordinary 20 ms transaction, with more requests than the pool has connections:
import asyncio
import anyio
from tortoise import Tortoise, connections, fields
from tortoise.models import Model
from tortoise.transactions import in_transaction
DB_URL = "postgres://[email protected]:5532/postgres?minsize=1&maxsize=10"
class Item(Model):
id = fields.IntField(primary_key=True)
async def handler():
async with in_transaction() as conn:
await conn.execute_query("SELECT pg_sleep(0.02)")
async def request():
# a 100 ms deadline around an ordinary 20 ms transaction
with anyio.move_on_after(0.1) as scope:
await handler()
return scope.cancelled_caught
async def main():
await Tortoise.init(db_url=DB_URL, modules={"models": ["__main__"]})
await handler()
timeouts = 0
for _ in range(20): # 20 waves of 150 concurrent requests
results = await asyncio.gather(*[asyncio.create_task(request()) for _ in range(150)])
timeouts += sum(results)
await asyncio.sleep(0.3)
pool = connections.get("default")._pool
print(f"timeouts: {timeouts}, pool size: {pool.get_size()}, idle: {pool.get_idle_size()}")
try:
async with asyncio.timeout(5):
await handler()
print("next request: ok")
except TimeoutError:
print("next request: timed out after 5 s waiting for a connection")
await Tortoise.close_connections()
asyncio.run(main())
One run on develop:
timeouts: 2413, pool size: 10, idle: 0
next request: timed out after 5 s waiting for a connection
The counts vary from run to run. Over 8 runs the leaked connections were 10, 9, 9, 9, 9, 7, 5 and 0. In 3 of those runs I wrapped begin() to count the CancelledErrors coming out of it, and the count matched the leaked connections each time (5, 9 and 9).
A plain asyncio.timeout() leaks the same way when the deadline lands inside BEGIN. With 1000 requests one after another, a pool of 50 and random deadlines of 0-5 ms, 16 connections leaked.
Expected behavior
A request that is cancelled while its transaction is starting gives the connection back, the same as one cancelled inside the block.
With this change to TransactionContextPooled.__aenter__, the same script leaked 0 connections in 6 runs out of 6, and the next request was ok each time:
async def __aenter__(self) -> TransactionalDBClient:
await self.ensure_connection()
self.client._connection = await self.client._parent._pool.acquire()
self.token = get_connections().set(self.connection_name, self.client)
try:
await self.client.begin()
except BaseException:
get_connections().reset(self.token)
await self.client._parent._pool.release(self.client._connection)
raise
return self.client
I have only measured this on asyncpg, where Pool.release() is shielded, so the release finishes while the task is being cancelled. The psycopg backend releases with a plain await self.putconn(connection), and I have not checked mysql, mssql or oracle, so those may also need a shield or some handling of a failed release.
Additional context
With the change, asyncpg logs Resetting connection with an active transaction for some of the cancelled requests, because BEGIN had already reached the server before the cancel. The same warning is the subject of #1094; I don't know whether it has the same cause there.
Possibly related, different trigger: #1438. Open PR #2293 edits these same lines; I can wait for it or rebase on it.
I can open a PR with this change and a test if you want one.
- Ngôn ngữ chính
- Python
- Star
- 5.6k
- Fork
- 540
- Merge trung bình
- 4 ngày 10 giờ
- Pull request đã merge (30 ngày)
- 31
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của tortoise/tortoise-orm
-
URLValidator ignores an explicit empty scheme allowlistCó thể đã có người làm @IlyaKhalafi đã nhận 2 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
tortoise/tortoise-orm#2393 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
tortoise/tortoise-orm#668 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
Model.save() returns normally and writes nothing after the instance's INSERT was rolled backĐang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 40/100
tortoise/tortoise-orm#2383 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 2 ngày
-
Prefetch with to_attr leaves the attribute missing when all forward relations are nullCó thể đã có người làm @IlyaKhalafi đã nhận 11 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 35/100
tortoise/tortoise-orm#2374 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
Adding a custom Prefetch to a cloned queryset mutates the original queryCó thể đã có người làm @IlyaKhalafi đã nhận 11 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 35/100
tortoise/tortoise-orm#2372 ·
Maintainer thường phản hồi trong vòng 2 ngày
Tất cả issue của tortoise/tortoise-orm
Issue tương tự
-
Server mode OAuth2: 'NoneType' object is not subscriptable on every request after re-submitting the OAuth2 login while already logged inCó thể đã có người làm @Rarasinhoo đã nhận hôm nay. Đang mở
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
pgadmin-org/pgadmin4#10733 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
aws-samples/sample-per-model-bedrock#752 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
lesson-submission pending-review
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 72/100
Ikalus1988/MisakaNet#4452 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
OCHA-DAP/topo-tools#250 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
Maintainer thường phản hồi trong vòng 1 ngày