Ahmad3g commited on
Commit
f7e4b81
Β·
1 Parent(s): c88cd08
Files changed (1) hide show
  1. database/db.py +44 -32
database/db.py CHANGED
@@ -1,14 +1,24 @@
1
  """
2
  database/db.py β€” Async SQLAlchemy engine and session factory.
3
 
4
- KEY FIX β€” Supabase uses PgBouncer as a connection pooler (Transaction mode).
5
- PgBouncer shares connections between clients, which conflicts with asyncpg's
6
- prepared-statement cache. The fix is:
7
-
8
- "statement_cache_size": 0
9
-
10
- This tells asyncpg NOT to cache prepared statements, which prevents the error:
11
- "prepared statement '__asyncpg_stmt_X__' already exists"
 
 
 
 
 
 
 
 
 
 
12
  """
13
 
14
  from sqlalchemy.ext.asyncio import (
@@ -16,40 +26,36 @@ from sqlalchemy.ext.asyncio import (
16
  create_async_engine,
17
  async_sessionmaker,
18
  )
 
 
19
  from config import config
20
  from database.models import Base
21
 
22
 
23
  engine = create_async_engine(
24
  config.DATABASE_URL,
25
- echo=False,
26
 
27
- # ── Pool settings ────────────────────────────────────────
28
- # Keep pool small β€” Supabase free tier has a connection limit.
29
- pool_size=5,
30
- max_overflow=10,
31
- pool_pre_ping=True, # Drop stale connections automatically
32
- pool_recycle=300, # Recycle connections every 5 minutes
33
 
34
- # ── asyncpg connect_args ─────────────────────────────────
35
  connect_args={
36
- # CRITICAL: Disable prepared-statement cache.
37
- # Required when using Supabase Transaction Pooler (PgBouncer).
38
- # Without this you get:
39
- # "prepared statement '__asyncpg_stmt_X__' already exists"
40
- "statement_cache_size": 0,
41
-
42
- # SSL is mandatory for Supabase connections.
43
- "ssl": "require",
44
 
45
- # Timeout in seconds for establishing a new connection.
46
- "timeout": 30,
47
-
48
- # Timeout for individual queries.
49
- "command_timeout": 60,
50
  },
 
 
51
  )
52
 
 
53
  AsyncSessionFactory = async_sessionmaker(
54
  bind=engine,
55
  class_=AsyncSession,
@@ -58,14 +64,20 @@ AsyncSessionFactory = async_sessionmaker(
58
  )
59
 
60
 
61
- async def init_db():
62
- """Create all database tables defined in models.py if they don't exist."""
63
  async with engine.begin() as conn:
64
  await conn.run_sync(Base.metadata.create_all)
65
  print("βœ… Database tables initialized.")
66
 
67
 
68
  async def get_session() -> AsyncSession:
69
- """Async context manager for a DB session."""
 
 
 
 
 
 
70
  async with AsyncSessionFactory() as session:
71
  yield session
 
1
  """
2
  database/db.py β€” Async SQLAlchemy engine and session factory.
3
 
4
+ ROOT CAUSE OF THE BUG:
5
+ Supabase uses PgBouncer in **Transaction Pooler** mode.
6
+ PgBouncer reuses the same underlying DB connection for many different
7
+ clients. asyncpg caches "prepared statements" per-connection. When
8
+ PgBouncer hands the same connection to another client, asyncpg tries
9
+ to create the same prepared statement again and PostgreSQL raises:
10
+
11
+ DuplicatePreparedStatementError:
12
+ prepared statement "__asyncpg_stmt_2__" already exists
13
+
14
+ THE FIX β€” use NullPool:
15
+ SQLAlchemy's NullPool opens a brand-new raw connection for every
16
+ session and closes it immediately when the session ends.
17
+ Because every connection is fresh, there is never a leftover prepared
18
+ statement to conflict with. PgBouncer handles the actual pooling on
19
+ its side, so SQLAlchemy doesn't need its own pool at all.
20
+
21
+ This is the officially recommended approach for Supabase + asyncpg.
22
  """
23
 
24
  from sqlalchemy.ext.asyncio import (
 
26
  create_async_engine,
27
  async_sessionmaker,
28
  )
29
+ from sqlalchemy.pool import NullPool # ← THE KEY FIX
30
+
31
  from config import config
32
  from database.models import Base
33
 
34
 
35
  engine = create_async_engine(
36
  config.DATABASE_URL,
 
37
 
38
+ # ── NullPool: no SQLAlchemy-level connection pooling ─────
39
+ # PgBouncer (Supabase) is the pool. SQLAlchemy should not
40
+ # add another caching layer on top of it.
41
+ poolclass=NullPool,
 
 
42
 
43
+ # ── asyncpg connection arguments ─────────────────────────
44
  connect_args={
45
+ "ssl" : "require", # Mandatory for Supabase
46
+ "timeout" : 30, # Connect timeout (seconds)
47
+ "command_timeout": 60, # Query timeout (seconds)
 
 
 
 
 
48
 
49
+ # Belt-and-suspenders: also disable the statement cache
50
+ # inside asyncpg itself (the NullPool already prevents
51
+ # the conflict, but this makes doubly sure).
52
+ "statement_cache_size": 0,
 
53
  },
54
+
55
+ echo=False, # Set True temporarily to log all SQL for debugging
56
  )
57
 
58
+
59
  AsyncSessionFactory = async_sessionmaker(
60
  bind=engine,
61
  class_=AsyncSession,
 
64
  )
65
 
66
 
67
+ async def init_db() -> None:
68
+ """Create all tables defined in models.py if they don't exist yet."""
69
  async with engine.begin() as conn:
70
  await conn.run_sync(Base.metadata.create_all)
71
  print("βœ… Database tables initialized.")
72
 
73
 
74
  async def get_session() -> AsyncSession:
75
+ """
76
+ Async context-manager that yields a DB session.
77
+
78
+ Usage:
79
+ async with AsyncSessionFactory() as session:
80
+ result = await session.execute(...)
81
+ """
82
  async with AsyncSessionFactory() as session:
83
  yield session