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/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/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: diff --git a/framework/hosting/simple_module_hosting/_inertia_setup.py b/framework/hosting/simple_module_hosting/_inertia_setup.py index bd3977eb..f4f34bdc 100644 --- a/framework/hosting/simple_module_hosting/_inertia_setup.py +++ b/framework/hosting/simple_module_hosting/_inertia_setup.py @@ -2,7 +2,10 @@ from __future__ import annotations +import hashlib +import json import logging +import tempfile from pathlib import Path from fastapi import FastAPI @@ -16,6 +19,59 @@ _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: + # No entry to re-key: degrade gracefully (same as no-manifest) + # rather than returning a path that KeyErrors at render time. + logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest) + return "" + data = {**data, expected_key: entry} + out = vite_manifest.parent / "inertia-manifest.json" + try: + out.write_text(json.dumps(data)) + except OSError: + # Build dir read-only (e.g. immutable container layer): fall back to + # a temp file keyed by the source manifest path so multiple apps on + # one host don't clobber each other's normalized manifests. + digest = hashlib.sha1(str(vite_manifest).encode()).hexdigest()[:12] + out = Path(tempfile.gettempdir()) / f"sm-inertia-manifest-{digest}.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 +138,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/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py index fd75ea88..5c199a5e 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,28 @@ 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) + # StaticFiles hands us an OS-separator path (backslashes on Windows), so + # normalize before matching the forward-slash asset prefix. + if response.status_code == 200 and path.replace("\\", "/").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_inertia_manifest.py b/framework/hosting/tests/test_inertia_manifest.py new file mode 100644 index 00000000..b324b5d0 --- /dev/null +++ b/framework/hosting/tests/test_inertia_manifest.py @@ -0,0 +1,71 @@ +"""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_returns_empty_when_no_entry_chunk(tmp_path: Path): + # A manifest with no isEntry chunk must degrade to "" (not return a path + # that would KeyError at render) — same graceful path as no-manifest. + manifest_dir = tmp_path / "host" / "static" / "dist" / ".vite" + manifest_dir.mkdir(parents=True) + (manifest_dir / "manifest.json").write_text(json.dumps({"pages/Foo.tsx": {"file": "f.js"}})) + 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" 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", "") diff --git a/host/client_app/vite.config.ts b/host/client_app/vite.config.ts index 614cec0d..f4954d35 100644 --- a/host/client_app/vite.config.ts +++ b/host/client_app/vite.config.ts @@ -182,9 +182,12 @@ export default defineConfig({ }, }, server: { - port: 5050, + // If you override SM_VITE_PORT, set the backend's SM_VITE_DEV_URL to the + // same port — the backend uses it for the dev