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