Spaces:
Running
Running
File size: 5,964 Bytes
ff3c2e9 bdcfad2 ab8ae7f ff3c2e9 bdcfad2 ff3c2e9 bdcfad2 ff3c2e9 ab8ae7f ff3c2e9 bdcfad2 ab8ae7f bdcfad2 ab8ae7f bdcfad2 831da15 ab8ae7f 060643f ff3c2e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | """
database.py — SQLAlchemy engine and session factory.
Two backends, selected purely by the DATABASE_URL scheme:
postgresql+psycopg://... Neon serverless Postgres. Real network latency and
Neon's autosuspend-on-idle behaviour on the compute, so
pool_pre_ping + pool_recycle matter -- see _make_engine()
below.
sqlite:///... Local/offline fallback for when the Neon connection
string is not configured. The SQLite file is EPHEMERAL
wherever the deploy target's disk is (e.g. a Hugging
Face Space container): it does not survive a restart or
rebuild.
The engine is built lazily. Alembic's env.py and the contract checker import
Base without wanting a connection, and a module-level engine would make those
fail at import time on a machine with no .env.
"""
from __future__ import annotations
from collections.abc import Iterator
from sqlalchemy import Engine, create_engine, event, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
pass
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def _ensure_sqlite_dir(url: str) -> None:
"""
SQLite refuses to open a file whose parent directory is missing.
urlparse().path keeps every slash after the "sqlite://" scheme separator,
so "sqlite:///data/outreach.db" (relative, 3 slashes total) parses to
"/data/outreach.db" and "sqlite:////app/data/outreach.db" (absolute, 4
slashes) parses to "//app/data/outreach.db" -- in both cases stripping
exactly the first character recovers the real path ("data/outreach.db"
relative, "/app/data/outreach.db" absolute).
"""
from pathlib import Path
from urllib.parse import urlparse
raw = urlparse(url).path
if not raw or raw in (":memory:", "/:memory:"):
return
Path(raw[1:]).parent.mkdir(parents=True, exist_ok=True)
def _make_engine() -> Engine:
url = settings.sqlalchemy_url
if not url:
raise RuntimeError(
"No database configured. Set DATABASE_URL in outreach-backend/.env"
)
is_sqlite = url.startswith("sqlite")
if is_sqlite:
_ensure_sqlite_dir(url)
# Fallback for when DATABASE_URL is not configured. SQLite keeps the
# service usable in that case.
#
# pool_size/max_overflow are QueuePool-specific and not guaranteed valid
# for whatever pool class SQLAlchemy picks for a file-based sqlite URL,
# so they are only passed on the Postgres path below.
#
# check_same_thread=False: FastAPI's sync endpoints and the job
# ThreadPoolExecutor each open sessions from their own thread; SQLite's
# per-connection default would reject that.
engine = create_engine(
url,
pool_pre_ping=True,
future=True,
connect_args={"check_same_thread": False},
)
# SQLite ignores FK constraints unless told otherwise per-connection.
# reveal_requests has no ORM relationship (no cascade="delete-orphan"),
# so without this pragma a deleted lead would leave its reveal rows
# orphaned instead of the ON DELETE CASCADE the schema declares.
@event.listens_for(engine, "connect")
def _enable_sqlite_fk(dbapi_connection, _record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
# DATABASE_URL may be Neon's pooled endpoint (host "ep-xxx-pooler...", PgBouncer in transaction mode) or the
# direct one -- nothing to change here either way. Neon's pooler supports protocol-level prepared
# statements (PgBouncer >= 1.22), which is what psycopg uses; this app takes no session-level state
# (no SET, advisory locks or LISTEN), so it is transaction-pooling safe. Alembic is the one caller that
# must NOT use the pooled URL -- see alembic/env.py and Settings.sqlalchemy_direct_url.
#
# pool_pre_ping matters here specifically because Neon's default (non-pooled)
# compute autosuspends after 5 minutes idle; the next checkout after a
# suspend would otherwise hand out a dead connection. pool_recycle keeps
# connections from going stale in the pool between requests.
#
# pool_size/max_overflow history (2026-10-19): a burst of unconditional per-card
# GET /leads/{id}/contacts calls on Pipeline/Contacts/Today (ContactsPanel fetched
# on mount with no gate) exhausted this pool live (QueuePool limit of size 5
# overflow 5 reached). Briefly raised to 20/30 as a stop-gap, then reverted back
# here once the real fix landed -- ContactsPanel is click-gated again
# (components/enrichment/contacts-panel.tsx), so no page fires more than one of
# these at a time anymore and 5+5 is back to being enough headroom.
return create_engine(
url,
pool_pre_ping=True,
pool_recycle=280,
pool_size=5,
max_overflow=5,
future=True,
)
def get_engine() -> Engine:
global _engine
if _engine is None:
_engine = _make_engine()
return _engine
def SessionLocal() -> Session: # noqa: N802 — kept callable-shaped, as before
global _session_factory
if _session_factory is None:
_session_factory = sessionmaker(
bind=get_engine(), autoflush=False, expire_on_commit=False, future=True
)
return _session_factory()
def get_db() -> Iterator[Session]:
"""FastAPI dependency — one session per request."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def ping() -> bool:
with get_engine().connect() as conn:
conn.execute(text("SELECT 1"))
return True
|