From 80bcd9050e787d83c16b3d1bc02faabb49968133 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 14:33:38 +0200 Subject: [PATCH 01/10] perf(users): stop loading oauth_accounts on every authenticated request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User.oauth_accounts was lazy="selectin", so every plain select(User) — the auth middleware/provider on *every* authenticated request, plus admin user lists — fired an extra selectin query for OAuth accounts it never reads. Profiling /api/users/me under load (py-spy) showed the SQLAlchemy selectin loader at ~25% of CPU; SQL echo confirmed 7 queries per request, 2 of them redundant users_oauth_account loads. Switch oauth_accounts to lazy="noload" (mirrors roles) and eager-load it explicitly only in UserDatabaseWithRoles.get_by_email — the entry point for fastapi-users' OAuth association flow that appends to the collection. The read-only auth path (get → current_user) no longer loads it; user deletion stays safe via the DB-level ondelete="CASCADE" on OAuthAccount.user_id. Result: /api/users/me 7→5 queries; throughput +10–25% across all authenticated endpoints (e.g. users/me 181→222 rps, settings/modules 331→414 rps), with the unauthenticated /health control unchanged. 79 users/oauth/auth/admin tests pass. Also make the Vite dev port configurable via SM_VITE_PORT so the dev stack can run without colliding with another local app on 5050. Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- host/client_app/vite.config.ts | 4 ++-- modules/users/users/db_adapter.py | 17 ++++++++++++++--- modules/users/users/models/user.py | 11 ++++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/host/client_app/vite.config.ts b/host/client_app/vite.config.ts index 614cec0d..61ffc1c9 100644 --- a/host/client_app/vite.config.ts +++ b/host/client_app/vite.config.ts @@ -182,9 +182,9 @@ export default defineConfig({ }, }, server: { - port: 5050, + port: Number(process.env.SM_VITE_PORT) || 5050, strictPort: true, - origin: 'http://localhost:5050', + origin: `http://localhost:${Number(process.env.SM_VITE_PORT) || 5050}`, fs: { allow: [projectRoot, ...moduleFsAllow], }, diff --git a/modules/users/users/db_adapter.py b/modules/users/users/db_adapter.py index 3e395c97..d87bb308 100644 --- a/modules/users/users/db_adapter.py +++ b/modules/users/users/db_adapter.py @@ -16,8 +16,16 @@ class UserDatabaseWithRoles(SQLAlchemyUserDatabase): - """Always eager-load User.roles so fastapi-users can read role names - without triggering implicit async lazy-loads.""" + """Eager-load User.roles so fastapi-users can read role names without + triggering implicit async lazy-loads. + + ``oauth_accounts`` is ``lazy="noload"`` on the model, so it is *not* loaded + on the read-only auth path (``get`` backs ``current_user`` on every + request). It is eager-loaded only in ``get_by_email`` — the entry point for + fastapi-users' OAuth association flow, which appends to the collection and + therefore needs it materialised. ``get_by_oauth_account`` (base class) runs + its own join and does not depend on the relationship being loaded. + """ async def get(self, id): stmt = ( @@ -31,7 +39,10 @@ async def get_by_email(self, email): stmt = ( select(self.user_table) .where(func.lower(self.user_table.email) == email.lower()) - .options(selectinload(self.user_table.roles)) + .options( + selectinload(self.user_table.roles), + selectinload(self.user_table.oauth_accounts), + ) ) return (await self.session.execute(stmt)).scalar_one_or_none() diff --git a/modules/users/users/models/user.py b/modules/users/users/models/user.py index e373423f..733ccc44 100644 --- a/modules/users/users/models/user.py +++ b/modules/users/users/models/user.py @@ -65,11 +65,16 @@ class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] # fastapi-users' SQLAlchemyUserDatabase.add_oauth_account does # ``user.oauth_accounts.append(...)``, so this attribute must exist. - # ``selectin`` so the OAuth router can read the list without an - # implicit async lazy-load. + # ``noload`` (like ``roles``) keeps it off the hot auth path: a plain + # ``select(User)`` — used on every authenticated request by the auth + # middleware/provider and by admin user lists — no longer fires an extra + # selectin query for OAuth accounts it never reads. The OAuth association + # flow eager-loads it explicitly via ``UserDatabaseWithRoles.get_by_email`` + # (see db_adapter.py); user deletion is covered by the DB-level + # ``ondelete="CASCADE"`` on ``OAuthAccount.user_id``. oauth_accounts: list["OAuthAccount"] = Relationship( sa_relationship_kwargs={ - "lazy": "selectin", + "lazy": "noload", "cascade": "all, delete-orphan", }, ) From 00d303d93678fb2c080d924f99cd7c212bed409a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 14:50:22 +0200 Subject: [PATCH 02/10] perf(users): drop EmailStr from response schemas (re-validation waste) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserRead and UserListItem are response models — their data comes straight from the DB (validated on write). Declaring email as EmailStr made FastAPI's response_model validation re-run email-validator for every serialized user. Under realistic load (k6, 10k seeded users, 20/page) py-spy showed validate_email at ~8% of total CPU on user-serializing endpoints; after the change it disappears from the profile entirely. EmailStr stays on the input schemas (UserCreate/UserUpdate/UserInvite) where format validation belongs. Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- modules/users/users/contracts/schemas.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/modules/users/users/contracts/schemas.py b/modules/users/users/contracts/schemas.py index 732145a2..6052254f 100644 --- a/modules/users/users/contracts/schemas.py +++ b/modules/users/users/contracts/schemas.py @@ -9,12 +9,19 @@ from pydantic import ConfigDict, EmailStr from sqlmodel import SQLModel +# NOTE on EmailStr: only *input* schemas (UserCreate/UserUpdate/UserInvite) use +# EmailStr — that is where an email must be format-validated. Response schemas +# (UserRead/UserListItem) use plain ``str``: their data comes straight from the +# DB (already validated on write), and FastAPI's response_model would otherwise +# re-run email-validator for every serialized user. Under load that validation +# was ~8% of total CPU on list endpoints (20 users/page) — pure waste. + class UserRead(CreateUpdateDictModel, SQLModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID - email: EmailStr + email: str is_active: bool = True is_superuser: bool = False is_verified: bool = False @@ -63,7 +70,7 @@ class UserDetailsUpdate(SQLModel): class UserListItem(SQLModel): id: uuid.UUID - email: EmailStr + email: str full_name: str | None = None is_active: bool is_verified: bool From 9381c09562843857a30a614163f19b27dd18ee3a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 15:14:50 +0200 Subject: [PATCH 03/10] test(loadtest): add faker data seed + the missing locustfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make loadtest` / `make loadtest-memray` referenced tests/loadtest/locustfile.py, which was never committed — both targets were broken. Add it: an AuthedUser locust scenario that uses the forged SM_LOADTEST_COOKIE (from scripts/loadtest_seed.py) and drives a weighted mix of the dashboard, paginated user/audit lists, search, and per-resource reads. /api/users/me is omitted (it needs the fastapi-users token, which the session-cookie path doesn't carry). Add tests/loadtest/seed.py: a faker bulk-data seed (default 10k users with role assignments + 100k audit entries) so list/search/pagination are exercised against realistic volumes instead of single-row tables. Idempotent; reuses existing data unless --force. Wired up as `make loadtest-seed`, with faker added to the dev dependency group and a tests/loadtest/README.md walking through the seed -> run -> profile flow. Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- Makefile | 8 +- pyproject.toml | 1 + tests/loadtest/README.md | 73 +++++++++++++++ tests/loadtest/locustfile.py | 82 +++++++++++++++++ tests/loadtest/seed.py | 171 +++++++++++++++++++++++++++++++++++ 5 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 tests/loadtest/README.md create mode 100644 tests/loadtest/locustfile.py create mode 100644 tests/loadtest/seed.py diff --git a/Makefile b/Makefile index ae499391..1c7ca9d2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-build-packages worker beat worker-docker # Install install: @@ -69,8 +69,14 @@ memray-flamegraph: ## Render $(MEMRAY_OUT) as an HTML flamegraph # Load testing. `make loadtest` assumes `make dev` is running separately. # `make loadtest-memray` starts uvicorn under memray, runs locust headless, # shuts down, and emits a flamegraph. Override locust args via LOCUST_ARGS=... +# Run `make loadtest-seed` once beforehand to fill the DB (faker) with realistic +# volumes — set SM_DATABASE_URL to a THROWAWAY database first. Override row +# counts via SEED_ARGS="5000 50000" (users, audit). See tests/loadtest/README.md. LOCUST_HOST ?= http://localhost:8000 LOCUST_ARGS ?= -u 20 -r 5 -t 30s +loadtest-seed: ## Seed realistic faker data into $$SM_DATABASE_URL (users + audit) + uv run python tests/loadtest/seed.py $(SEED_ARGS) + loadtest: ## Run locust against a server already on $(LOCUST_HOST) uv run locust -f tests/loadtest/locustfile.py --host $(LOCUST_HOST) --headless $(LOCUST_ARGS) diff --git a/pyproject.toml b/pyproject.toml index 57dca1d9..8b11cfaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "ty>=0.0.29", "memray>=1.14; sys_platform != 'win32'", "locust>=2.31", + "faker>=30", "aioboto3>=13", "moto[s3]>=5", "tomlkit>=0.13", diff --git a/tests/loadtest/README.md b/tests/loadtest/README.md new file mode 100644 index 00000000..49a5ab3a --- /dev/null +++ b/tests/loadtest/README.md @@ -0,0 +1,73 @@ +# Load testing & profiling + +Local load test for the backend using **locust** (traffic), **faker** (realistic +data volumes) and **memray** (allocation profiling). Run it against a +**throwaway database** so it never touches your dev data. + +## Files + +- `seed.py` — faker bulk data seed (users, role assignments, audit entries). +- `locustfile.py` — the `AuthedUser` browse scenario (weighted endpoint mix). +- Auth: `scripts/loadtest_seed.py` mints a forged session cookie + (`SM_LOADTEST_COOKIE`) so locust skips the login flow and its rate limiter. + +## Prerequisites + +Python deps installed (`make install` — `locust` and `faker` ship in the `dev` +group). A throwaway Postgres DB; with the shared dev-services stack up +(`make docker-up`): + +```sh +docker exec dev-services-postgres-1 psql -U postgres -c "CREATE DATABASE smpy_loadtest" +export SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/smpy_loadtest +``` + +## 1. Migrate + seed realistic data + +```sh +uv run --project host alembic -c host/alembic.ini upgrade heads +make loadtest-seed # 10k users + 100k audit rows (idempotent) +# or: make loadtest-seed SEED_ARGS="5000 50000" +docker exec dev-services-postgres-1 psql -U postgres -d smpy_loadtest -c "ANALYZE" +``` + +Seeding is idempotent — if the data is already present it is reused. Pass +`--force` (e.g. `uv run python tests/loadtest/seed.py 10000 100000 --force`) to +wipe and re-seed. + +## 2. Run the app on a dedicated port + +```sh +SM_MODULES_ENABLED='["Auth","FeatureFlags","Settings","FileStorage","Users","AuditLog","BackgroundTasks","Dashboard","Permissions"]' \ + uv run --project host uvicorn host.main:app --port 8000 --host 127.0.0.1 +``` + +(`SM_MODULES_ENABLED` excludes `Keycloak` so only one auth provider is active — +otherwise the boot doctor fails with `SM020`.) + +## 3. Load test (locust) + +```sh +eval "$(uv run python scripts/loadtest_seed.py)" # exports SM_LOADTEST_COOKIE +make loadtest # headless against $(LOCUST_HOST) +# or override: make loadtest LOCUST_ARGS="-u 50 -r 10 -t 60s" +``` + +## 4. Profile allocations under load (memray) + +`make loadtest-memray` seeds the auth cookie, starts uvicorn under memray, +drives it with locust, and renders a flamegraph. Run `make loadtest-seed` first +for realistic table sizes. + +```sh +make loadtest-memray LOCUST_ARGS="-u 50 -r 10 -t 60s" +# open .memray/memray-flamegraph-loadtest.html +``` + +For CPU profiling, launch the app as a child of py-spy (Linux `ptrace_scope=1` +blocks attaching to a running process without root) and drive it with locust: + +```sh +uvx py-spy record --format speedscope --subprocesses --duration 40 -o profile.json -- \ + uv run --project host uvicorn host.main:app --port 8000 --host 127.0.0.1 +``` diff --git a/tests/loadtest/locustfile.py b/tests/loadtest/locustfile.py new file mode 100644 index 00000000..cb7a8168 --- /dev/null +++ b/tests/loadtest/locustfile.py @@ -0,0 +1,82 @@ +"""Locust load scenario: realistic authenticated browse traffic. + +Auth uses the forged session cookie minted by ``scripts/loadtest_seed.py`` +(exported as ``SM_LOADTEST_COOKIE``) — this skips the login flow and its rate +limiter so the profile reflects steady-state authenticated traffic. That cookie +authenticates the middleware/permission path; ``/api/users/me`` (fastapi-users +token-only) is intentionally not exercised here. + +Drive it via ``make loadtest`` (server already running) or ``make loadtest-memray`` +(starts uvicorn under memray). Seed realistic data first with ``make loadtest-seed`` +so the list/search/pagination endpoints hit real volumes. +""" + +from __future__ import annotations + +import os +import random + +from locust import HttpUser, between, task + +_COOKIE = os.environ.get("SM_LOADTEST_COOKIE", "") +_INERTIA = {"X-Inertia": "true"} +_SEARCH_TERMS = ("john", "smith", "maria", "lee", "garcia", "son", "er", "a") + + +class AuthedUser(HttpUser): + """Authenticated browser-like user driving a weighted endpoint mix.""" + + wait_time = between(0.1, 0.5) + + def on_start(self) -> None: + if not _COOKIE: + raise RuntimeError( + 'SM_LOADTEST_COOKIE is unset — run `eval "$(uv run python ' + 'scripts/loadtest_seed.py)"` first (make loadtest-memray does this).' + ) + self.client.cookies.set("session", _COOKIE) + + # Weights approximate a real admin browsing session: lots of list/detail + # reads, fewer dashboard hits. `name=` groups stats so paginated URLs with + # varying query strings don't explode the Locust stats table. + @task(10) + def dashboard_view(self) -> None: + self.client.get("/dashboard/", headers=_INERTIA, name="/dashboard/") + + @task(5) + def dashboard_stats(self) -> None: + self.client.get("/api/dashboard/stats", name="/api/dashboard/stats") + + @task(18) + def users_list_api(self) -> None: + page = random.randint(1, 50) + self.client.get(f"/api/users/admin?page={page}&per_page=20", name="/api/users/admin") + + @task(10) + def users_list_view(self) -> None: + page = random.randint(1, 50) + self.client.get( + f"/users/admin?page={page}&per_page=20", headers=_INERTIA, name="/users/admin" + ) + + @task(8) + def users_search(self) -> None: + term = random.choice(_SEARCH_TERMS) + self.client.get(f"/api/users/admin?q={term}&page=1&per_page=20", name="/api/users/admin?q") + + @task(16) + def audit_list(self) -> None: + page = random.randint(1, 200) + self.client.get(f"/api/audit_log/?page={page}&page_size=20", name="/api/audit_log/") + + @task(5) + def settings_modules(self) -> None: + self.client.get("/api/settings/modules", name="/api/settings/modules") + + @task(3) + def permissions(self) -> None: + self.client.get("/api/permissions/", name="/api/permissions/") + + @task(3) + def feature_flags(self) -> None: + self.client.get("/api/feature_flags/", name="/api/feature_flags/") diff --git a/tests/loadtest/seed.py b/tests/loadtest/seed.py new file mode 100644 index 00000000..0ef7620f --- /dev/null +++ b/tests/loadtest/seed.py @@ -0,0 +1,171 @@ +"""Seed realistic bulk data (faker) into the load-test database. + +Bulk-inserts many users (with role assignments) and audit entries so the +list/search/pagination endpoints are exercised against real data volumes — +single-row tables hide the N+1s, missing indexes and serialization costs that +matter under load. + +This seeds *data only*. The authenticated load-test user (and its forged +session cookie) is created separately by ``scripts/loadtest_seed.py``, which is +run automatically by ``make loadtest-memray``. + +Run from the repo root against a THROWAWAY database (never your dev DB): + + SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/smpy_loadtest \\ + uv run python tests/loadtest/seed.py [n_users] [n_audit] [--force] + +Or via ``make loadtest-seed``. Defaults: 10000 users, 100000 audit entries. +Idempotent — skips if the marker user already exists; ``--force`` wipes prior +load-test rows and re-seeds. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import uuid +from datetime import UTC, datetime, timedelta + +from audit_log.models import AuditEntry +from faker import Faker +from fastapi_users.password import PasswordHelper +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import create_async_engine +from users.models import Role, User, UserRole + +USER_PASSWORD = "loadtest-password-123" +MARKER_EMAIL = "loadtest+0@example.com" +ROLE_NAMES = ("admin", "editor", "author", "viewer", "moderator", "support") +NOW = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + +fake = Faker() +Faker.seed(42) + + +def _audit_arg(idx: int, default: int) -> int: + args = [a for a in sys.argv[1:] if a.isdigit()] + return int(args[idx]) if len(args) > idx else default + + +async def main() -> None: + db_url = os.environ.get("SM_DATABASE_URL") + if not db_url: + raise SystemExit("set SM_DATABASE_URL to your throwaway load-test database first") + n_users = _audit_arg(0, 10_000) + n_audit = _audit_arg(1, 100_000) + force = "--force" in sys.argv + + pw_helper = PasswordHelper() + user_pw = pw_helper.hash(USER_PASSWORD) + engine = create_async_engine(db_url, pool_size=5, max_overflow=10) + + async with engine.begin() as conn: + marker = (await conn.execute(select(User.id).where(User.email == MARKER_EMAIL))).first() + if marker and not force: + total = (await conn.execute(select(func.count()).select_from(User))).scalar() + audit = (await conn.execute(select(func.count()).select_from(AuditEntry))).scalar() + print(f"already seeded (users={total}, audit={audit}); pass --force to re-seed") + await engine.dispose() + return + if force: + await conn.execute(delete(UserRole)) + await conn.execute(delete(User).where(User.email.like("loadtest+%@example.com"))) + await conn.execute(delete(AuditEntry).where(AuditEntry.correlation_id == "seed")) + + have_roles = {r for (r,) in (await conn.execute(select(Role.name))).all()} + new_roles = [ + { + "id": uuid.uuid4(), + "name": n, + "description": f"{n} role", + "created_at": NOW, + "updated_at": None, + "created_by": None, + "updated_by": None, + } + for n in ROLE_NAMES + if n not in have_roles + ] + if new_roles: + await conn.execute(Role.__table__.insert(), new_roles) + role_ids = [r for (r,) in (await conn.execute(select(Role.id))).all()] + + print(f"seeding {n_users} users ...") + user_ids: list[uuid.UUID] = [] + batch: list[dict] = [] + for i in range(n_users): + uid = uuid.uuid4() + user_ids.append(uid) + disabled = i % 17 == 0 + batch.append( + { + "id": uid, + "email": f"loadtest+{i}@example.com", + "hashed_password": user_pw, + "is_active": not disabled, + "is_superuser": False, + "is_verified": i % 3 != 0, + "full_name": fake.name(), + "tenant_id": None, + "disabled_at": NOW if disabled else None, + "last_login_at": NOW - timedelta(days=i % 90) if i % 4 else None, + "created_at": NOW - timedelta(days=i % 365), + "updated_at": None, + "created_by": None, + "updated_by": None, + } + ) + if len(batch) >= 2000: + await conn.execute(User.__table__.insert(), batch) + batch.clear() + if batch: + await conn.execute(User.__table__.insert(), batch) + + ur_rows: list[dict] = [] + for idx, uid in enumerate(user_ids): + if idx % 10 < 7: + r1 = role_ids[idx % len(role_ids)] + ur_rows.append( + {"user_id": uid, "role_id": r1, "assigned_at": NOW, "assigned_by": None} + ) + r2 = role_ids[(idx + 1) % len(role_ids)] + if idx % 5 == 0 and r2 != r1: + ur_rows.append( + {"user_id": uid, "role_id": r2, "assigned_at": NOW, "assigned_by": None} + ) + for j in range(0, len(ur_rows), 5000): + await conn.execute(UserRole.__table__.insert(), ur_rows[j : j + 5000]) + print(f"user_roles: {len(ur_rows)} assignments") + + print(f"seeding {n_audit} audit entries ...") + actions = ("create", "update", "delete") + entities = ("User", "Role", "Setting", "FeatureFlag", "File", "AuditEntry") + abatch: list[dict] = [] + for i in range(n_audit): + abatch.append( + { + "id": uuid.uuid4(), + "entity_type": entities[i % len(entities)], + "entity_id": str(user_ids[i % len(user_ids)]), + "action": actions[i % len(actions)], + "changes": [{"field": "name", "old": fake.word(), "new": fake.word()}], + "user_id": str(user_ids[i % len(user_ids)]), + "correlation_id": "seed", + "created_at": NOW - timedelta(minutes=i), + } + ) + if len(abatch) >= 5000: + await conn.execute(AuditEntry.__table__.insert(), abatch) + abatch.clear() + if abatch: + await conn.execute(AuditEntry.__table__.insert(), abatch) + + users_n = (await conn.execute(select(func.count()).select_from(User))).scalar() + audit_n = (await conn.execute(select(func.count()).select_from(AuditEntry))).scalar() + print(f"DONE — users={users_n}, audit={audit_n}") + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) From 53c1c9c4d994cb6364bae0527d3e4f8bf43c6d05 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 15:33:24 +0200 Subject: [PATCH 04/10] fix(users): restore OAuth delete-orphan cascade (regression from 43ac3ad) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 43ac3ad set User.oauth_accounts to lazy="noload", which disabled the ORM delete-orphan cascade. On Postgres the FK ondelete=CASCADE still cleans up, but SQLite (the default DB, and the test DB) doesn't enforce FKs — so deleting a user orphaned their OAuth accounts. test_oauth.py's round-trip/cascade test caught it (it wasn't run in that commit). Restore lazy="selectin" + delete-orphan so the cascade works in the ORM on every backend, and keep it off the hot read path by adding noload(User.oauth_accounts) to the queries that never read it: the auth provider's _load_user/_resolve_bearer and the fastapi-users adapter's get() (which backs current_user on every request). get_by_email keeps the default selectin so the OAuth association flow still materialises the collection. Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- modules/users/users/db_adapter.py | 20 +++++++++++--------- modules/users/users/models/user.py | 16 ++++++++-------- modules/users/users/provider.py | 18 ++++++++++++++---- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/modules/users/users/db_adapter.py b/modules/users/users/db_adapter.py index d87bb308..909897ef 100644 --- a/modules/users/users/db_adapter.py +++ b/modules/users/users/db_adapter.py @@ -10,7 +10,7 @@ from simple_module_db.deps import get_db from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import noload, selectinload from users.models import OAuthAccount, User, UserAccessToken @@ -19,19 +19,22 @@ class UserDatabaseWithRoles(SQLAlchemyUserDatabase): """Eager-load User.roles so fastapi-users can read role names without triggering implicit async lazy-loads. - ``oauth_accounts`` is ``lazy="noload"`` on the model, so it is *not* loaded - on the read-only auth path (``get`` backs ``current_user`` on every - request). It is eager-loaded only in ``get_by_email`` — the entry point for - fastapi-users' OAuth association flow, which appends to the collection and - therefore needs it materialised. ``get_by_oauth_account`` (base class) runs - its own join and does not depend on the relationship being loaded. + ``oauth_accounts`` is ``lazy="selectin"`` on the model (needed for the ORM + delete-orphan cascade, since SQLite doesn't enforce the FK). ``get`` backs + ``current_user`` on every request and never reads OAuth accounts, so it + suppresses that load with ``noload``. ``get_by_email`` — the entry point for + fastapi-users' OAuth association flow, which appends to the collection — + lets the model's default ``selectin`` materialise it. """ async def get(self, id): stmt = ( select(self.user_table) .where(self.user_table.id == id) - .options(selectinload(self.user_table.roles)) + .options( + selectinload(self.user_table.roles), + noload(self.user_table.oauth_accounts), + ) ) return (await self.session.execute(stmt)).scalar_one_or_none() @@ -41,7 +44,6 @@ async def get_by_email(self, email): .where(func.lower(self.user_table.email) == email.lower()) .options( selectinload(self.user_table.roles), - selectinload(self.user_table.oauth_accounts), ) ) return (await self.session.execute(stmt)).scalar_one_or_none() diff --git a/modules/users/users/models/user.py b/modules/users/users/models/user.py index 733ccc44..0d813f18 100644 --- a/modules/users/users/models/user.py +++ b/modules/users/users/models/user.py @@ -65,16 +65,16 @@ class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] # fastapi-users' SQLAlchemyUserDatabase.add_oauth_account does # ``user.oauth_accounts.append(...)``, so this attribute must exist. - # ``noload`` (like ``roles``) keeps it off the hot auth path: a plain - # ``select(User)`` — used on every authenticated request by the auth - # middleware/provider and by admin user lists — no longer fires an extra - # selectin query for OAuth accounts it never reads. The OAuth association - # flow eager-loads it explicitly via ``UserDatabaseWithRoles.get_by_email`` - # (see db_adapter.py); user deletion is covered by the DB-level - # ``ondelete="CASCADE"`` on ``OAuthAccount.user_id``. + # ``selectin`` + ``delete-orphan`` is required for the ORM cascade to remove + # a user's OAuth accounts when the user is deleted — SQLite (the default DB) + # does not enforce the ``ondelete="CASCADE"`` FK, so the cascade must happen + # in the ORM. To keep this off the hot read path, the auth provider and the + # ``current_user`` adapter add ``noload(User.oauth_accounts)`` to their + # queries (see provider.py / db_adapter.py) — only the OAuth association + # flow (get_by_email) actually materialises the collection. oauth_accounts: list["OAuthAccount"] = Relationship( sa_relationship_kwargs={ - "lazy": "noload", + "lazy": "selectin", "cascade": "all, delete-orphan", }, ) diff --git a/modules/users/users/provider.py b/modules/users/users/provider.py index e956c77c..cad90ff5 100644 --- a/modules/users/users/provider.py +++ b/modules/users/users/provider.py @@ -87,7 +87,7 @@ async def _resolve_bearer(self, scope, token: str) -> UserContext | None: """Look up an access token in users_access_token and return the user.""" try: from sqlalchemy import select - from sqlalchemy.orm import selectinload + from sqlalchemy.orm import noload, selectinload from users.models import User, UserAccessToken @@ -97,8 +97,12 @@ async def _resolve_bearer(self, scope, token: str) -> UserContext | None: access = (await db_session.execute(stmt)).scalar_one_or_none() if access is None: return None + # noload oauth_accounts: lazy="selectin" on the model would + # otherwise fire an extra query the UserContext never reads. stmt = ( - select(User).where(User.id == access.user_id).options(selectinload(User.roles)) + select(User) + .where(User.id == access.user_id) + .options(selectinload(User.roles), noload(User.oauth_accounts)) ) user = (await db_session.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: @@ -111,13 +115,19 @@ async def _resolve_bearer(self, scope, token: str) -> UserContext | None: async def _load_user(self, scope, user_id: uuid_mod.UUID) -> UserContext | None: try: from sqlalchemy import select - from sqlalchemy.orm import selectinload + from sqlalchemy.orm import noload, selectinload from users.models import User session_factory = scope["app"].state.sm.db.session_factory async with session_factory() as db_session: - stmt = select(User).where(User.id == user_id).options(selectinload(User.roles)) + # noload oauth_accounts: lazy="selectin" on the model would + # otherwise fire an extra query the UserContext never reads. + stmt = ( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles), noload(User.oauth_accounts)) + ) user = (await db_session.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: return None From 026c1465298a6e2404dd1c092cbcff6973b6a4bb Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 15:41:25 +0200 Subject: [PATCH 05/10] perf(audit_log): list entries via column query, drop count subquery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_entries selected full AuditEntry ORM objects and counted via a subquery wrapper (select(count()).select_from(base.subquery())). Select only the columns AuditEntryRead needs (plain rows, no ORM hydration) and count the same WHERE conditions directly. Smaller win than the users list (AuditEntry has no relationships, so hydration was lighter): saturated locust /api/audit_log/ RPS 73->77. Direct DB timing shows the real cost is the exact count(*) over 100k rows (~3.3ms) vs <1ms for the page query — kept exact (no approximate-count complexity). tests/test_audit_log.py (15) pass; functional check confirms total, ordering, filters, and JSON changes. Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- modules/audit_log/audit_log/service.py | 42 ++++++++++++++++++-------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/modules/audit_log/audit_log/service.py b/modules/audit_log/audit_log/service.py index 5a338c11..8ebf40c7 100644 --- a/modules/audit_log/audit_log/service.py +++ b/modules/audit_log/audit_log/service.py @@ -31,32 +31,48 @@ async def list_entries( page_size = min(max(page_size, 1), MAX_PAGE_SIZE) page = max(page, 1) - base = select(AuditEntry) - + conditions = [] if entity_type: - base = base.where(AuditEntry.entity_type == entity_type) + conditions.append(AuditEntry.entity_type == entity_type) if entity_id: - base = base.where(AuditEntry.entity_id == entity_id) + conditions.append(AuditEntry.entity_id == entity_id) if action: - base = base.where(AuditEntry.action == action) + conditions.append(AuditEntry.action == action) if user_id: - base = base.where(AuditEntry.user_id == user_id) + conditions.append(AuditEntry.user_id == user_id) if from_date: - base = base.where(AuditEntry.created_at >= from_date) + conditions.append(AuditEntry.created_at >= from_date) if to_date: - base = base.where(AuditEntry.created_at <= to_date) + conditions.append(AuditEntry.created_at <= to_date) + + # Select only AuditEntryRead's columns (plain rows, no ORM hydration) and + # count the same conditions directly — avoids hydrating full AuditEntry + # objects per page and the subquery wrapper around the count. + cols = select( + AuditEntry.id, + AuditEntry.entity_type, + AuditEntry.entity_id, + AuditEntry.action, + AuditEntry.changes, + AuditEntry.user_id, + AuditEntry.correlation_id, + AuditEntry.created_at, + ) + count_stmt = select(func.count()).select_from(AuditEntry) + for cond in conditions: + cols = cols.where(cond) + count_stmt = count_stmt.where(cond) - total_result = await self.db.execute(select(func.count()).select_from(base.subquery())) - total = total_result.scalar_one() + total = (await self.db.execute(count_stmt)).scalar_one() offset = (page - 1) * page_size stmt = ( - base.order_by(AuditEntry.created_at.desc(), AuditEntry.id) + cols.order_by(AuditEntry.created_at.desc(), AuditEntry.id) .offset(offset) .limit(page_size) ) - result = await self.db.execute(stmt) - items = [AuditEntryRead.model_validate(row) for row in result.scalars()] + rows = (await self.db.execute(stmt)).all() + items = [AuditEntryRead(**row._mapping) for row in rows] return AuditEntryList(items=items, total=total, page=page, page_size=page_size) From bd7c1af7bab7fbb5b5ed2f511579c468a37c1457 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 16:05:41 +0200 Subject: [PATCH 06/10] docs(deploy): size the DB pool to uvicorn --workers (avoid connection exhaustion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load testing showed the single-worker process is the throughput ceiling (CPU/GIL): 4 uvicorn workers raised aggregate throughput ~2.2x and dropped list-endpoint p50 from ~550ms to ~40ms. But the default per-process pool (SM_DB_POOL_SIZE 10 + SM_DB_MAX_OVERFLOW 20 = 30) means 4 workers want up to 120 connections > Postgres default max_connections 100, so workers threw asyncpg.TooManyConnectionsError under load (~3% of requests). Document the math (total = workers × (pool_size + max_overflow) ≤ max_connections) and a worked example (4 workers → pool 5 + overflow 10 = 60, which load-tested at 762 req/s with zero failures) in the deployment guide, the env-vars reference, and .env.example. No code/behavior change — defaults stay tuned for single-process dev. Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- .env.example | 9 +++++++++ docs/reference/deployment.md | 4 +++- docs/reference/env-vars.md | 10 ++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index b624c73c..851bcc85 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,15 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db # SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/simple_module_python +# Postgres connection pool (per process). Defaults: pool_size 10 + max_overflow +# 20 = up to 30 connections per process. When running several uvicorn --workers, +# the total is workers × (pool_size + max_overflow) and must stay under the +# server's max_connections (Postgres default 100) — otherwise workers hit +# "asyncpg.TooManyConnectionsError: sorry, too many clients already" under load. +# e.g. 4 workers → keep the per-worker pool small: +# SM_DB_POOL_SIZE=5 +# SM_DB_MAX_OVERFLOW=10 + # Celery against the shared ../dev-services Redis. Host-run worker/beat use # localhost; the docker-compose worker/beat override to the `redis` hostname. # This project owns Redis logical DBs 4 (broker) and 5 (result backend). diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 1dbe3a68..14b43b6b 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -44,7 +44,9 @@ COPY --from=frontend /app/static/dist /app/static/dist CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] ``` -Tune worker count with `--workers N` for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers. +Tune worker count with `--workers N` for multi-CPU boxes, or run behind a process manager like Gunicorn with Uvicorn workers. A single worker is CPU-bound (one process, the GIL) — multiple workers scale read throughput roughly linearly on a multi-core box. + +**Size the DB pool to the worker count.** Each worker keeps its own connection pool of up to `SM_DB_POOL_SIZE + SM_DB_MAX_OVERFLOW` (default `10 + 20 = 30`) connections, so the deployment's ceiling is `workers × (pool_size + max_overflow)`. Keep that under the database's `max_connections` (Postgres default `100`) or workers will throw `asyncpg.TooManyConnectionsError: sorry, too many clients already` under load. For example, 4 workers want roughly `SM_DB_POOL_SIZE=5`, `SM_DB_MAX_OVERFLOW=10` (≤ 60 connections). For larger fleets, put PgBouncer in front instead of growing every pool. ## Running migrations on deploy diff --git a/docs/reference/env-vars.md b/docs/reference/env-vars.md index 16d1061f..7ccd40b9 100644 --- a/docs/reference/env-vars.md +++ b/docs/reference/env-vars.md @@ -22,11 +22,17 @@ This is the full reference. See [Configuration](/guide/configuration) for a narr | Variable | Default | Notes | |---|---|---| -| `SM_DB_POOL_SIZE` | `10` | SQLAlchemy `pool_size`. | -| `SM_DB_MAX_OVERFLOW` | `20` | SQLAlchemy `max_overflow`. | +| `SM_DB_POOL_SIZE` | `10` | SQLAlchemy `pool_size` (per process). | +| `SM_DB_MAX_OVERFLOW` | `20` | SQLAlchemy `max_overflow` (per process). | | `SM_DB_POOL_PRE_PING` | `true` | Test connections before use. | | `SM_DB_POOL_RECYCLE` | `1800` | Recycle connections after N seconds (helps with LB idle drops). | +Pools are **per process**. With multiple `uvicorn --workers`, total connections = +`workers × (SM_DB_POOL_SIZE + SM_DB_MAX_OVERFLOW)`; keep it under the database's +`max_connections` (Postgres default 100) or workers raise +`asyncpg.TooManyConnectionsError` under load. See +[deployment](deployment.md#build) for sizing examples. + ## Host settings (DB-backed, not env) Multi-tenancy and i18n configuration live in the DB-backed host settings store (`HostSettings`, registered under `package="host"`), **not** in env vars. Edit them in the admin UI at `/settings/modules` under the host section. Their defaults: From 82ae6b915580fbfafe544049564c41e7605c87bb Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 19:06:03 +0200 Subject: [PATCH 07/10] fix(hosting): make production Inertia rendering work (asset manifest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In SM_ENVIRONMENT=production every Inertia page 500'd: setup_inertia never set InertiaConfig.manifest_json_path (defaults "") so fastapi-inertia did open("") → FileNotFoundError. Even with a path it would KeyError — fastapi-inertia looks the entry up by f"{root_directory}/{entrypoint}" = "./main.tsx", but Vite keys its manifest by the source path "main.tsx" — and assets_prefix="" produced /assets/... URLs instead of /static/dist/assets/.... Masked in dev (Vite dev server) and tests (testing env), so it was a latent prod-deploy blocker. Add _prod_manifest_path(): read the Vite manifest, re-key the isEntry chunk under "./main.tsx", write a normalized copy next to the build output (temp-file fallback if read-only), and set manifest_json_path + assets_prefix="static/dist" on the production branch only. Dev is untouched (still uses the Vite dev URL). Verified: prod pages 200 with /static/dist/assets/main-*.{js,css} that serve; dev still renders via the Vite dev server; 137 hosting tests pass + 3 new regression tests for the manifest re-keying (incl. scaffolded static/ layout). Found alongside (not in this commit): static assets are served uncompressed by the app (deployment relies on the reverse proxy for gzip/brotli). Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- .../simple_module_hosting/_inertia_setup.py | 53 ++++++++++++++++ .../hosting/tests/test_inertia_manifest.py | 62 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 framework/hosting/tests/test_inertia_manifest.py diff --git a/framework/hosting/simple_module_hosting/_inertia_setup.py b/framework/hosting/simple_module_hosting/_inertia_setup.py index bd3977eb..ef68e508 100644 --- a/framework/hosting/simple_module_hosting/_inertia_setup.py +++ b/framework/hosting/simple_module_hosting/_inertia_setup.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import logging +import tempfile from pathlib import Path from fastapi import FastAPI @@ -16,6 +18,53 @@ _ROOT_TEMPLATE_FILENAME = "index.html" _ENTRYPOINT_FILENAME = "main.tsx" _ROOT_DIRECTORY = "." +# Built assets are served from the "/static" mount under "dist/", so production +# asset URLs are prefixed with "static/dist". +_ASSETS_PREFIX = "static/dist" +_VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json" + + +def _prod_manifest_path(project_root: Path) -> str: + """Return a manifest path fastapi-inertia can read in production. + + fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"`` + (here ``"./main.tsx"``), but Vite keys its manifest by the entry's path + relative to the Vite root (``"main.tsx"``) — so the raw Vite manifest would + ``KeyError``. Read it, re-key the ``isEntry`` chunk under the key + fastapi-inertia expects, and write the normalized copy beside the build + output (falling back to a temp file if that dir is read-only). Returns ``""`` + when no built manifest exists, leaving production assets unconfigured rather + than crashing at import time. + """ + candidates = [ + project_root / "host" / _VITE_MANIFEST_RELPATH, + project_root / _VITE_MANIFEST_RELPATH, + ] + vite_manifest = next((p for p in candidates if p.is_file()), None) + if vite_manifest is None: + logger.warning( + "Production Vite manifest not found (looked in %s)", [str(c) for c in candidates] + ) + return "" + try: + data = json.loads(vite_manifest.read_text()) + expected_key = f"{_ROOT_DIRECTORY}/{_ENTRYPOINT_FILENAME}" + if expected_key not in data: + entry = next((v for v in data.values() if v.get("isEntry")), None) + if entry is None: + logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest) + return str(vite_manifest) + data = {**data, expected_key: entry} + out = vite_manifest.parent / "inertia-manifest.json" + try: + out.write_text(json.dumps(data)) + except OSError: + out = Path(tempfile.gettempdir()) / "sm-inertia-manifest.json" + out.write_text(json.dumps(data)) + return str(out) + except Exception: + logger.exception("Failed to prepare production Inertia manifest from %s", vite_manifest) + return "" def setup_inertia( @@ -82,6 +131,10 @@ def setup_inertia( environment=inertia_environment, version=_INERTIA_VERSION, dev_url=settings.vite_dev_url if use_dev_server else "", + # Production reads built assets from the Vite manifest; dev serves them + # from the Vite dev server, so these only matter when not use_dev_server. + manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root), + assets_prefix="" if use_dev_server else _ASSETS_PREFIX, templates=templates, root_template_filename=_ROOT_TEMPLATE_FILENAME, entrypoint_filename=_ENTRYPOINT_FILENAME, diff --git a/framework/hosting/tests/test_inertia_manifest.py b/framework/hosting/tests/test_inertia_manifest.py new file mode 100644 index 00000000..dbdb4605 --- /dev/null +++ b/framework/hosting/tests/test_inertia_manifest.py @@ -0,0 +1,62 @@ +"""Tests for production Inertia manifest normalization (_prod_manifest_path). + +fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"`` = +``"./main.tsx"``, but Vite keys its manifest by the entry's source path +(``"main.tsx"``). _prod_manifest_path bridges the two so production page +rendering doesn't KeyError. Regression guard for that bug. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from simple_module_hosting._inertia_setup import _prod_manifest_path + + +def _write_vite_manifest(project_root: Path) -> Path: + manifest_dir = project_root / "host" / "static" / "dist" / ".vite" + manifest_dir.mkdir(parents=True) + manifest = { + "main.tsx": { + "file": "assets/main-ABC123.js", + "css": ["assets/main-DEF456.css"], + "isEntry": True, + }, + "pages/Foo.tsx": {"file": "assets/Foo-XYZ.js"}, + } + path = manifest_dir / "manifest.json" + path.write_text(json.dumps(manifest)) + return path + + +def test_rekeys_entry_for_fastapi_inertia(tmp_path: Path): + _write_vite_manifest(tmp_path) + result = _prod_manifest_path(tmp_path) + + assert result, "expected a manifest path, got empty string" + data = json.loads(Path(result).read_text()) + # fastapi-inertia will look up f"{root_directory}/{entrypoint}" == "./main.tsx" + assert "./main.tsx" in data + assert data["./main.tsx"]["file"] == "assets/main-ABC123.js" + assert data["./main.tsx"]["css"] == ["assets/main-DEF456.css"] + # original keys are preserved (other chunks still resolvable) + assert "pages/Foo.tsx" in data + + +def test_returns_empty_when_no_built_manifest(tmp_path: Path): + # No host/static/dist/.vite/manifest.json present. + assert _prod_manifest_path(tmp_path) == "" + + +def test_scaffolded_layout_without_host_dir(tmp_path: Path): + # smpy-new apps keep static/ at the project root (no host/ subdir). + manifest_dir = tmp_path / "static" / "dist" / ".vite" + manifest_dir.mkdir(parents=True) + (manifest_dir / "manifest.json").write_text( + json.dumps({"main.tsx": {"file": "assets/main-A.js", "isEntry": True}}) + ) + result = _prod_manifest_path(tmp_path) + assert result + data = json.loads(Path(result).read_text()) + assert data["./main.tsx"]["file"] == "assets/main-A.js" From 984443b148a13ac5cb3701d501d999b41e6b1715 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 19:13:45 +0200 Subject: [PATCH 08/10] perf(hosting): cache Vite's content-hashed assets immutably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host /static mount served Vite's build assets with only ETag/Last-Modified, so every page visit re-validated each asset with a conditional GET (304s for the ~480 KB JS + ~140 KB CSS). Their filenames are content-hashed (main-.js), so the bytes for a URL never change. Add ImmutableStaticFiles (a StaticFiles subclass) that sets `Cache-Control: public, max-age=31536000, immutable` on responses under dist/assets/, and use it for the host static mount. Browsers now serve repeat visits straight from cache with no network round-trip; non-hashed paths (the manifest, etc.) keep the default. Dev is unaffected (assets come from the Vite dev server, not StaticFiles). Verified live in production mode; 139 hosting tests pass + 2 new regression tests. (Wire-size compression — gzip/brotli — is left to the reverse proxy per docs/reference/deployment.md.) Claude-Session: https://claude.ai/code/session_016taVe3VLcoixu32CeE35RC --- .../simple_module_hosting/_phase_helpers.py | 22 +++++++++++ .../simple_module_hosting/app_builder.py | 6 ++- .../hosting/tests/test_static_caching.py | 37 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 framework/hosting/tests/test_static_caching.py diff --git a/framework/hosting/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py index fd75ea88..50931358 100644 --- a/framework/hosting/simple_module_hosting/_phase_helpers.py +++ b/framework/hosting/simple_module_hosting/_phase_helpers.py @@ -23,6 +23,8 @@ from simple_module_core.exceptions import NotFoundError from starlette.exceptions import HTTPException from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import Response +from starlette.types import Scope from simple_module_hosting._error_handlers import ( http_exception_handler, @@ -46,6 +48,26 @@ logger = logging.getLogger(__name__) +_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable" + + +class ImmutableStaticFiles(StaticFiles): + """StaticFiles that marks Vite's content-hashed build assets immutable. + + Vite emits files under ``dist/assets/`` with a content hash in the filename + (e.g. ``main-3YbShAJ4.js``), so the bytes for a given URL never change — + browsers can cache them indefinitely and skip even the revalidation + round-trip. The default StaticFiles only sets ETag/Last-Modified, forcing a + conditional GET per asset on every visit. Non-hashed paths (the manifest, + etc.) keep the default behaviour. + """ + + async def get_response(self, path: str, scope: Scope) -> Response: + response = await super().get_response(path, scope) + if response.status_code == 200 and path.startswith("dist/assets/"): + response.headers["Cache-Control"] = _IMMUTABLE_CACHE_CONTROL + return response + def register_exception_handlers(app: FastAPI, modules: list) -> None: """Install framework-level exception handlers, then per-module handlers.""" diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 2a96040d..dbba56d0 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -10,7 +10,6 @@ from pathlib import Path from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics from simple_module_core.discovery import discover_modules, topological_sort from simple_module_core.events import EventBus @@ -26,6 +25,7 @@ from simple_module_hosting._host_services import _HostServices from simple_module_hosting._inertia_setup import setup_inertia from simple_module_hosting._phase_helpers import ( + ImmutableStaticFiles, attach_public_routes, check_settings_registration, install_middleware, @@ -277,7 +277,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME if static_dir.is_dir(): - app.mount(_STATIC_MOUNT_PATH, StaticFiles(directory=static_dir), name=_STATIC_DIR_NAME) + app.mount( + _STATIC_MOUNT_PATH, ImmutableStaticFiles(directory=static_dir), name=_STATIC_DIR_NAME + ) mount_module_static_dirs(app, modules) diff --git a/framework/hosting/tests/test_static_caching.py b/framework/hosting/tests/test_static_caching.py new file mode 100644 index 00000000..afdd27c2 --- /dev/null +++ b/framework/hosting/tests/test_static_caching.py @@ -0,0 +1,37 @@ +"""ImmutableStaticFiles marks Vite's content-hashed assets immutable. + +Hashed filenames (``main-3YbShAJ4.js``) are content-addressed, so browsers can +cache them forever and skip the per-asset revalidation round-trip. Non-hashed +paths keep StaticFiles' default (ETag/Last-Modified only). +""" + +from __future__ import annotations + +from pathlib import Path + +from simple_module_hosting._phase_helpers import ImmutableStaticFiles + +_GET_SCOPE = {"type": "http", "method": "GET", "headers": []} + + +def _make_tree(root: Path) -> None: + (root / "dist" / "assets").mkdir(parents=True) + (root / "dist" / "assets" / "main-ABC123.js").write_text("console.log(1)") + (root / "dist" / ".vite").mkdir(parents=True) + (root / "dist" / ".vite" / "manifest.json").write_text("{}") + + +async def test_hashed_asset_is_immutable(tmp_path: Path): + _make_tree(tmp_path) + static = ImmutableStaticFiles(directory=tmp_path) + resp = await static.get_response("dist/assets/main-ABC123.js", _GET_SCOPE) + assert resp.status_code == 200 + assert resp.headers["cache-control"] == "public, max-age=31536000, immutable" + + +async def test_non_asset_keeps_default_caching(tmp_path: Path): + _make_tree(tmp_path) + static = ImmutableStaticFiles(directory=tmp_path) + resp = await static.get_response("dist/.vite/manifest.json", _GET_SCOPE) + assert resp.status_code == 200 + assert "immutable" not in resp.headers.get("cache-control", "") From 43467a41a9afa64e0e765e4dabec9c46ae41a8ac Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 21 Jun 2026 21:21:14 +0200 Subject: [PATCH 09/10] fix: address code review findings (round 1) - _inertia_setup: when the Vite manifest has no isEntry chunk, return "" so production degrades gracefully instead of returning a path that KeyErrors at render time (matches the no-manifest path). - _inertia_setup: read-only-build-dir fallback now writes a temp file keyed by a hash of the source manifest path, so multiple apps on one host don't clobber each other's normalized manifest. - ImmutableStaticFiles: normalize OS path separators before the dist/assets/ prefix check so the immutable Cache-Control header also applies on Windows. - loadtest/seed.py: scope the --force UserRole delete to seeded users instead of truncating the whole table, so an accidental --force can't wipe real role links. - vite.config: note that SM_VITE_PORT must be kept in sync with the backend's SM_VITE_DEV_URL (dev