Files
rengine/shared/config.py
T
Yogesh OjhaandClaude Opus 5 9cdd132a20 fix(api): an event stream must not pin a pooled connection for its lifetime
The SSE route authenticated through CurrentUser, whose session dependency is
only released when the response ends — so every open stream left one connection
idle in transaction for as long as the tab stayed open. The engine was created
with no pool arguments at all, so the ceiling was SQLAlchemy's default 5+10:
fifteen tabs and every DB endpoint blocked for pool_timeout and then 500'd.
Reproduced exactly, and both pinned backends' last query was get_current_user's
SELECT users.

StreamUser opens and closes its own short session, so a stream holds nothing
pooled — 200 concurrent streams now report checked_out 1.

The pool is configured rather than defaulted (10+20, pre-ping, 30m recycle,
fail-fast timeout) and carries an application_name, because a blank one is why
this needed a live reproduction to diagnose. idle_in_transaction_session_timeout
is the backstop: any future leak from any path is reclaimed by Postgres instead
of accumulating, and pre-ping makes the next checkout succeed on a fresh socket.

A stream also no longer outlives its token. It re-checks expiry and revocation
on each heartbeat — JWT plus Redis, no DB — and emits `event: unauthorized`, so
logout actually ends the feed and the client refreshes at once instead of
burning three failed reconnects.

Worker pools are sized per prefork child (8 x 15 was over max_connections) and
the inherited engine is disposed on fork so a child never shares a parent socket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015biqVXTng75nnfKXMqm61H
2026-09-06 10:47:03 +05:30

58 lines
1.6 KiB
Python

from pydantic_settings import BaseSettings
class BaseAppSettings(BaseSettings):
APP_NAME: str = "reNgine"
DEBUG: bool = False
APP_VERSION: str = "3.0.0"
LOG_LEVEL: str = "INFO"
POSTGRES_HOST: str = "db"
POSTGRES_PORT: int = 5432
POSTGRES_USER: str = "rengine"
POSTGRES_PASSWORD: str = "rengine" # noqa: S105
POSTGRES_DB: str = "rengine"
DB_POOL_SIZE: int = 10
DB_MAX_OVERFLOW: int = 20
DB_POOL_TIMEOUT: int = 10
DB_POOL_RECYCLE: int = 1800
DB_IDLE_TX_TIMEOUT: int = 120
@property
def database_url_async(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@property
def database_url_sync(self) -> str:
return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
REDIS_HOST: str = "redis"
REDIS_PORT: int = 6379
REDIS_DB: int = 0
@property
def redis_url(self) -> str:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
@property
def celery_broker_url(self) -> str:
return self.redis_url
@property
def celery_result_backend(self) -> str:
# the scan canvas chains groups, which celery runs as chords — those need a backend
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB + 1}"
class Config:
env_file = ".env"
case_sensitive = True
extra = "ignore"