Files
Kemal Yaylali c25fb53666 feat(deploy): a Railway deployment of the analysed cases, behind one credential
Three services -- Postgres, API, UI -- with the API on Railway's private
network only, so the UI's /api proxy is the single public entry point and
there is no CORS.

The pipeline cannot run there. Nextflow shells out to `docker run` for VEP
and bcftools, and Railway gives you a container, not a Docker daemon. Rather
than leave a button that always fails, cases are annotated locally and copied
up by scripts/seed-remote.sh, and PUBLIC_PIPELINE_ENABLED=false hides the
analyse/score actions and the create-case form.

DATABASE_IDLE_CONNECTIONS=false is what makes idling work. Railway decides a
service is idle from its *outbound* traffic and sleeps it after ~5-10 minutes;
a pooled database connection is outbound traffic, so SQLAlchemy's default pool
would have kept the API awake and billable for ever. Setting it false switches
to NullPool, which costs a connection per request -- nothing at demo traffic,
the wrong trade under real load, hence the flag rather than a rewrite.

BASIC_AUTH_USER / BASIC_AUTH_PASSWORD put one shared credential in front of
the site. Nothing deployed is patient data, so this stops the URL being
wandered into rather than protecting anyone's privacy; unset, the site is
open, which is what local development wants. Compared in constant time, and
both halves of the credential are checked even when the first fails.
2026-09-12 12:23:19 +01:00

40 lines
1.4 KiB
Python

from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Depends
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
def normalize_async_url(url: str) -> str:
"""Translate libpq's `sslmode=` into the `ssl=` asyncpg understands.
Hosted Postgres (Neon, Supabase) hands out sslmode= URLs, which asyncpg rejects outright.
"""
parsed = make_url(url)
if "asyncpg" not in parsed.drivername or "sslmode" not in parsed.query:
return url
query = dict(parsed.query)
query["ssl"] = query.pop("sslmode")
return parsed.set(query=query).render_as_string(hide_password=False)
# NullPool opens and closes a connection per checkout, so an idle API sends nothing and a host
# that sleeps idle containers can actually do so. See settings.database_idle_connections.
_pool = {} if settings.database_idle_connections else {"poolclass": NullPool}
engine = create_async_engine(
normalize_async_url(settings.database_url), pool_pre_ping=True, **_pool
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
SessionDep = Annotated[AsyncSession, Depends(get_session)]