Skip to content

Commit 6964333

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents 5836483 + 246f969 commit 6964333

13 files changed

Lines changed: 212 additions & 64 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ BASE_URL=http://localhost:8000
77
# Database connection mode (0 = direct, 1 = pooled)
88
USE_POOL=0
99

10+
# SQLAlchemy pool (per process; keep small when using PgBouncer)
11+
# DB_POOL_SIZE=5
12+
# DB_MAX_OVERFLOW=5
13+
1014
# Shared database settings
1115
DB_HOST=127.0.0.1
1216
DB_SSLMODE=prefer

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
<!-- version list -->
99

10+
## v1.1.8 (2026-07-14)
11+
12+
### Bug Fixes
13+
14+
- **ci**: Keep Test workflow push triggers unfiltered
15+
([#229](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/229),
16+
[`2a88198`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/2a88198586079c45761afc69995a84586c76899d))
17+
18+
### Code Style
19+
20+
- **footer**: Improve site footer layout and typography
21+
([#226](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/226),
22+
[`85b5fe9`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/85b5fe92b266ae7459631c336275c82eeba3a610))
23+
24+
### Documentation
25+
26+
- Document stripe branch and add propagate/CI support
27+
([#229](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/229),
28+
[`2a88198`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/2a88198586079c45761afc69995a84586c76899d))
29+
30+
1031
## v1.1.7 (2026-07-06)
1132

1233
### Bug Fixes

main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
RateLimitError,
4747
)
4848
from exceptions.exceptions import NeedsNewTokens
49-
from utils.core.db import set_up_db
49+
from utils.core.db import set_up_db, clear_engine_cache
5050

5151
logger = logging.getLogger("uvicorn.error")
5252
logger.setLevel(logging.DEBUG)
@@ -58,7 +58,7 @@ async def lifespan(app: FastAPI):
5858
load_dotenv()
5959
set_up_db()
6060
yield
61-
# Optional shutdown logic
61+
clear_engine_cache()
6262

6363

6464
# Initialize the FastAPI app

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "1.1.7"
3+
version = "1.1.8"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

tests/browser/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from playwright.sync_api import Browser, Page
99
from tests.browser.db_helpers import browser_db_env
1010
from utils.core.db import (
11+
clear_engine_cache,
1112
ensure_database_exists,
1213
get_connection_url,
1314
set_up_db,
@@ -149,6 +150,7 @@ def live_server(browser_env):
149150
proc.wait(timeout=5)
150151
with _temporary_env(browser_env):
151152
tear_down_db()
153+
clear_engine_cache()
152154

153155

154156
@pytest.fixture(scope="session")
@@ -162,3 +164,4 @@ def live_server_csrf(browser_csrf_env):
162164
proc.wait(timeout=5)
163165
with _temporary_env(browser_csrf_env):
164166
tear_down_db()
167+
clear_engine_cache()

tests/browser/db_helpers.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
from datetime import timedelta
99

1010
from dotenv import load_dotenv
11-
from sqlmodel import Session, create_engine, select
11+
from sqlmodel import Session, select
1212

1313
from utils.core.auth import get_password_hash
14-
from utils.core.db import create_default_roles, get_connection_url
14+
from utils.core.db import create_default_roles, get_engine
1515
from utils.core.models import (
1616
Account,
1717
Invitation,
@@ -39,8 +39,7 @@ def browser_db_session():
3939
saved = {key: os.environ.get(key) for key in env}
4040
os.environ.update(env)
4141
try:
42-
engine = create_engine(get_connection_url())
43-
with Session(engine) as session:
42+
with Session(get_engine()) as session:
4443
yield session
4544
finally:
4645
for key, value in saved.items():
@@ -168,8 +167,7 @@ def browser_csrf_db_session():
168167
saved = {key: os.environ.get(key) for key in env}
169168
os.environ.update(env)
170169
try:
171-
engine = create_engine(get_connection_url())
172-
with Session(engine) as session:
170+
with Session(get_engine()) as session:
173171
yield session
174172
finally:
175173
for key, value in saved.items():

tests/conftest.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from typing import Generator, cast
44

55
pytest_plugins = ["tests.frontend.fixtures"]
6-
from sqlmodel import create_engine, Session, select
6+
from sqlmodel import Session, select
77
from fastapi.testclient import TestClient
88
from dotenv import load_dotenv
99
from utils.core.db import (
10+
clear_engine_cache,
1011
get_connection_url,
12+
get_engine,
1113
tear_down_db,
1214
set_up_db,
1315
create_default_roles,
@@ -74,15 +76,19 @@ def engine(env_vars):
7476
Create a new SQLModel engine for the test database.
7577
Use PostgreSQL for testing to match production environment.
7678
"""
77-
# Use PostgreSQL for testing to match production environment
79+
# Use PostgreSQL for testing to match production environment. get_engine()
80+
# returns the same cached pool the app itself uses via get_session(), so
81+
# TestClient requests and direct test queries share one pool instead of
82+
# opening a second one for the same database.
7883
ensure_database_exists(get_connection_url())
79-
engine = create_engine(get_connection_url())
84+
engine = get_engine()
8085
set_up_db(drop=True)
8186

8287
yield engine
8388

8489
# Clean up after tests
8590
tear_down_db()
91+
clear_engine_cache()
8692

8793

8894
@pytest.fixture

tests/utils/test_db.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from sqlalchemy import Engine
44
from utils.core.db import (
55
get_connection_url,
6+
get_engine,
7+
clear_engine_cache,
68
assign_permissions_to_role,
79
create_default_roles,
810
create_permissions,
@@ -146,6 +148,84 @@ def test_get_connection_url_missing_pool_vars(monkeypatch):
146148
get_connection_url()
147149

148150

151+
# --- Engine cache ---
152+
153+
154+
@pytest.fixture
155+
def engine_cache():
156+
"""Ensure engine-cache tests start clean and never leak cached engines,
157+
even when an assertion fails mid-test."""
158+
clear_engine_cache()
159+
yield
160+
clear_engine_cache()
161+
162+
163+
def _direct_db_env(monkeypatch, *, name: str = "testdb", password: str = "testpass"):
164+
for var in (
165+
"USE_POOL",
166+
"DB_HOST",
167+
"DB_PORT",
168+
"DB_NAME",
169+
"DB_USER",
170+
"DB_PASSWORD",
171+
"DB_POOL_PORT",
172+
"DB_POOL_NAME",
173+
"DB_APPUSER",
174+
"DB_APPUSER_PASSWORD",
175+
"DB_POOL_SIZE",
176+
"DB_MAX_OVERFLOW",
177+
):
178+
monkeypatch.delenv(var, raising=False)
179+
monkeypatch.setenv("DB_HOST", "localhost")
180+
monkeypatch.setenv("DB_PORT", "5432")
181+
monkeypatch.setenv("DB_NAME", name)
182+
monkeypatch.setenv("DB_USER", "testuser")
183+
monkeypatch.setenv("DB_PASSWORD", password)
184+
185+
186+
def test_get_engine_reuses_same_instance(engine_cache, monkeypatch):
187+
_direct_db_env(monkeypatch)
188+
assert get_engine() is get_engine()
189+
190+
191+
def test_get_engine_different_urls_get_different_engines(engine_cache, monkeypatch):
192+
_direct_db_env(monkeypatch, name="db_a")
193+
engine_a = get_engine()
194+
_direct_db_env(monkeypatch, name="db_b")
195+
engine_b = get_engine()
196+
assert engine_a is not engine_b
197+
198+
199+
def test_get_engine_not_keyed_by_masked_str_password(engine_cache, monkeypatch):
200+
"""str(URL) masks passwords; cache must still separate credentials."""
201+
_direct_db_env(monkeypatch, password="secretA")
202+
engine_a = get_engine()
203+
assert "***" in str(get_connection_url())
204+
_direct_db_env(monkeypatch, password="secretB")
205+
engine_b = get_engine()
206+
assert engine_a is not engine_b
207+
208+
209+
def test_clear_engine_cache_disposes_and_creates_new(engine_cache, monkeypatch):
210+
_direct_db_env(monkeypatch)
211+
first = get_engine()
212+
clear_engine_cache()
213+
second = get_engine()
214+
assert first is not second
215+
216+
217+
def test_get_engine_applies_pool_settings(engine_cache, monkeypatch):
218+
_direct_db_env(monkeypatch)
219+
monkeypatch.setenv("DB_POOL_SIZE", "3")
220+
monkeypatch.setenv("DB_MAX_OVERFLOW", "2")
221+
engine = get_engine()
222+
assert engine.pool.size() == 3
223+
# No public accessors for these; private attrs are stable in practice but
224+
# may need updating on a SQLAlchemy major upgrade.
225+
assert engine.pool._max_overflow == 2
226+
assert engine.pool._pre_ping is True
227+
228+
149229
# --- Permission and Role Tests ---
150230

151231

utils/core/auth.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from fastapi.templating import Jinja2Templates
1414
from fastapi import Cookie
1515
from starlette.responses import Response
16-
from utils.core.db import create_engine, get_connection_url
16+
from utils.core.db import get_engine
1717
from utils.core.models import (
1818
AccountRecoveryToken,
1919
EmailVerificationToken,
@@ -339,8 +339,7 @@ def send_reset_email_task(email: str) -> None:
339339
FastAPI background tasks should not reuse request-scoped resources from
340340
`yield` dependencies, because cleanup may run before the task executes.
341341
"""
342-
engine = create_engine(get_connection_url())
343-
with Session(engine) as session:
342+
with Session(get_engine()) as session:
344343
send_reset_email(email, session)
345344

346345

utils/core/db.py

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import os
22
import logging
3+
import threading
34
from itertools import chain
45
from typing import Union, Sequence
5-
from sqlalchemy.engine import URL
6+
from sqlalchemy.engine import Engine, URL
67
from sqlmodel import create_engine, Session, SQLModel, select, text
78
from utils.core.models import (
89
Account,
@@ -28,17 +29,60 @@
2829
# --- Database connection functions ---
2930

3031

32+
# Cache by URL object (not str(url)): str(URL) masks passwords as "***", so
33+
# different credentials would collide on one cache key. Guarded by a lock:
34+
# sync dependencies run in FastAPI's threadpool, so concurrent first requests
35+
# would otherwise race and orphan an undisposed engine.
36+
_engines: dict[URL, Engine] = {}
37+
_engines_lock = threading.Lock()
38+
39+
40+
def get_engine() -> Engine:
41+
"""Return a process-wide cached engine for the current connection URL.
42+
43+
Calling create_engine() per request leaks pooled connections until GC
44+
reclaims them. Cache one engine per URL so tests that swap DB_NAME still
45+
get a separate pool per database. Pool settings (DB_POOL_SIZE,
46+
DB_MAX_OVERFLOW) are read only when the engine for a URL is first
47+
created; later environment changes have no effect on a cached engine.
48+
"""
49+
url = get_connection_url()
50+
with _engines_lock:
51+
engine = _engines.get(url)
52+
if engine is None:
53+
engine = create_engine(
54+
url,
55+
pool_pre_ping=True,
56+
pool_size=int(os.getenv("DB_POOL_SIZE", "5")),
57+
max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "5")),
58+
)
59+
_engines[url] = engine
60+
return engine
61+
62+
63+
def clear_engine_cache() -> None:
64+
"""Dispose and drop all cached engines (shutdown / tests)."""
65+
with _engines_lock:
66+
engines = list(_engines.values())
67+
_engines.clear()
68+
for engine in engines:
69+
engine.dispose()
70+
71+
3172
def ensure_database_exists(url: URL) -> None:
3273
dbname = url.database
3374
server_url = url.set(database="postgres")
3475
engine = create_engine(server_url, isolation_level="AUTOCOMMIT")
35-
with engine.connect() as conn:
36-
exists = conn.execute(
37-
text("SELECT 1 FROM pg_database WHERE datname = :n"),
38-
{"n": dbname},
39-
).scalar()
40-
if not exists:
41-
conn.execute(text(f'CREATE DATABASE "{dbname}"'))
76+
try:
77+
with engine.connect() as conn:
78+
exists = conn.execute(
79+
text("SELECT 1 FROM pg_database WHERE datname = :n"),
80+
{"n": dbname},
81+
).scalar()
82+
if not exists:
83+
conn.execute(text(f'CREATE DATABASE "{dbname}"'))
84+
finally:
85+
engine.dispose()
4286

4387

4488
def get_connection_url() -> URL:
@@ -259,28 +303,32 @@ def set_up_db(drop: bool = False) -> None:
259303
drop (bool): If True, drops all existing tables before creating new ones.
260304
"""
261305
engine = create_engine(get_connection_url())
262-
if drop:
263-
SQLModel.metadata.drop_all(engine)
264-
# Ensure the private schema exists before creating tables
265-
with engine.connect() as conn:
266-
conn.execute(text("CREATE SCHEMA IF NOT EXISTS private"))
267-
conn.commit()
268-
SQLModel.metadata.create_all(engine)
269-
# Create default permissions and seed account emails
270-
with Session(engine) as session:
271-
create_permissions(session)
272-
session.commit()
273-
seed_account_emails(session)
274-
engine.dispose()
306+
try:
307+
if drop:
308+
SQLModel.metadata.drop_all(engine)
309+
# Ensure the private schema exists before creating tables
310+
with engine.connect() as conn:
311+
conn.execute(text("CREATE SCHEMA IF NOT EXISTS private"))
312+
conn.commit()
313+
SQLModel.metadata.create_all(engine)
314+
# Create default permissions and seed account emails
315+
with Session(engine) as session:
316+
create_permissions(session)
317+
session.commit()
318+
seed_account_emails(session)
319+
finally:
320+
engine.dispose()
275321

276322

277323
def tear_down_db() -> None:
278324
"""
279325
Tears down the database by dropping all tables and the private schema.
280326
"""
281327
engine = create_engine(get_connection_url())
282-
SQLModel.metadata.drop_all(engine)
283-
with engine.connect() as conn:
284-
conn.execute(text("DROP SCHEMA IF EXISTS private CASCADE"))
285-
conn.commit()
286-
engine.dispose()
328+
try:
329+
SQLModel.metadata.drop_all(engine)
330+
with engine.connect() as conn:
331+
conn.execute(text("DROP SCHEMA IF EXISTS private CASCADE"))
332+
conn.commit()
333+
finally:
334+
engine.dispose()

0 commit comments

Comments
 (0)