Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
24f556d
docs(perf): design spec for navigation performance measurement
antosubash Aug 2, 2026
6e9e175
docs(perf): implementation plan for navigation performance work
antosubash Aug 2, 2026
8480aa3
feat(catalog): scaffold sample module for perf benchmarking
antosubash Aug 2, 2026
7b30594
feat(catalog): rich product/category entity with list, search and detail
antosubash Aug 2, 2026
b2c4332
test(perf): navigation benchmark harness, catalog seed, locust + micr…
antosubash Aug 2, 2026
a96db62
perf(hosting): compress responses — halves First Contentful Paint
antosubash Aug 2, 2026
cb93e76
perf(menus): link sidebar items at canonical paths — kills a redirect…
antosubash Aug 2, 2026
c845d56
perf(build): group chunks — cold-load FCP down another 34%
antosubash Aug 2, 2026
5e78c9a
test(perf): guard cold-load request count against chunk-group regress…
antosubash Aug 2, 2026
09c1ec1
perf(audit_log): Postgres skip scan for distinct entity types
antosubash Aug 2, 2026
691b96b
ci: gate the asset-delivery perf guards
antosubash Aug 2, 2026
5004c26
perf(static): pre-compressed brotli/gzip assets
antosubash Aug 2, 2026
1f5737d
test(perf): layout-stability benchmark, with a self-verifying observer
antosubash Aug 2, 2026
5df6119
fix(build,hosting): correct asset base path and error-page shared props
antosubash Aug 2, 2026
ca134cb
perf(background_tasks): run worker inspect probes concurrently
antosubash Aug 2, 2026
484c497
chore: drop unrelated local config from this branch
antosubash Aug 2, 2026
57517ea
fix(scaffold): emit README, LICENSE and complete package metadata
antosubash Aug 2, 2026
80abfdd
revert: remove the catalog module
antosubash Aug 2, 2026
197ec1a
fix: restore package-lock.json to origin/main
antosubash Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,98 @@ jobs:
api.log
vite.log

# Guards the asset-delivery wins from docs/perf/2026-08-02-baseline.md:
# response compression (~70% of transfer) and chunk grouping (55 -> 13
# requests on cold load). Both are invisible to every other job — the app
# renders identically either way, just slower, so only a browser measuring
# the built bundle catches a regression.
#
# Runs against the PRODUCTION build deliberately: chunk groups only apply to
# `vite build`, and non-dev environments are what reference the built
# manifest. Asserts on structure (request count, compression ratio), never on
# milliseconds, so shared-runner noise cannot make it flaky.
#
# The other three guards (compression headers, canonical menu URLs, dialect
# branch selection) are plain pytest and already run in `python-tests`.
perf-guards:
name: Perf guards (Playwright)
runs-on: ubuntu-latest
env:
# SQLite keeps the job self-contained — these guards measure asset
# delivery, which does not depend on row volumes.
SM_DATABASE_URL: sqlite+aiosqlite:///./app.db
PERF_BASE_URL: http://localhost:8000
PERF_BUILD: ci-prod
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
cache-dependency-glob: ${{ env.UV_CACHE_GLOB }}
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- run: make install
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('uv.lock') }}
- name: Install Playwright chromium
run: |
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then
uv run --project host playwright install-deps chromium
else
uv run --project host playwright install --with-deps chromium
fi
- run: make gen-pages
- run: make build
- run: uv run --project host alembic -c host/alembic.ini upgrade heads
# The production config lives on THIS step only, never job-wide. Under
# SM_ENVIRONMENT=production the simple_module_test pytest plugin fails to
# import — it builds BackgroundTasksSettings() eagerly and those reject a
# localhost broker — so exporting it job-wide stops pytest from starting
# at all. The server needs it; the test process must not see it.
- name: Start API
env:
# A non-dev environment is what makes the host serve the built
# manifest instead of pointing at the Vite dev server.
SM_ENVIRONMENT: production
SM_SECRET_KEY: ci-perf-secret-key-not-a-real-secret-000000000000
SM_USERS_RESET_PASSWORD_TOKEN_SECRET: ci-perf-reset-secret-000000000000000000
SM_USERS_VERIFICATION_TOKEN_SECRET: ci-perf-verify-secret-00000000000000000
SM_USERS_BOOTSTRAP_EMAIL: admin@example.com
SM_USERS_BOOTSTRAP_PASSWORD: admin
# Keycloak excluded (SM020: one auth provider). BackgroundTasks
# excluded because its DB-hydrated settings reject a localhost broker
# under SM_ENVIRONMENT=production.
SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","FileStorage","FeatureFlags","AuditLog","Branding"]'
run: |
uv run --project host uvicorn host.main:app --port 8000 > api.log 2>&1 &
echo $! > api.pid
- name: Wait for API
run: |
for i in $(seq 1 60); do
curl -sf http://localhost:8000/health > /dev/null && echo "api ready" && exit 0
sleep 1
done
echo "api did not come up in time"; cat api.log || true; exit 1
- name: Verify built assets are being served
run: |
# If this regresses to the Vite dev path the guards would measure the
# wrong bundle and pass vacuously.
curl -sf http://localhost:8000/users/login | grep -q '/static/dist/assets/' \
|| { echo "server is not serving built assets"; exit 1; }
- run: uv run pytest -m "perf and e2e" tests/perf/test_page_load.py tests/perf/test_asset_integrity.py -v -s
- name: Upload server log on failure
if: failure()
uses: actions/upload-artifact@v6
with:
name: perf-guards-api-log
path: api.log

file-size-check:
name: File size (300-line cap)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -252,6 +344,7 @@ jobs:
- js-tests
- js-build
- e2e-smoke
- perf-guards
- file-size-check
- package-build
if: always()
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ modules/<name>/<name>/
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md).

**Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request:
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → app`. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names.
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → app`. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names.

**Database**: per-module `Base` via `create_module_base("<name>")`. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout.

Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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-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
.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 bench-nav 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:
Expand Down Expand Up @@ -52,6 +52,15 @@ test-e2e: ## Run end-to-end browser smoke tests (requires `mak
bench: ## Run pytest-benchmark suite (tests/benchmarks). Override args with BENCH_ARGS=...
uv run pytest -m perf --benchmark-enable --benchmark-columns=min,mean,median,max,stddev,ops,rounds $(BENCH_ARGS) tests/benchmarks

# Navigation benchmark — click-to-paint for Inertia client-side navigations.
# Separate from `bench` because it needs a live server and a browser, whereas
# `bench` runs in-process. Point PERF_BASE_URL at the server under test and set
# PERF_BUILD=dev|prod so the report records which build produced the numbers.
PERF_ROUNDS ?= 20
PERF_BUILD ?= dev
bench-nav: ## Navigation benchmark (needs a running server + `uv run playwright install chromium`)
PERF_ROUNDS=$(PERF_ROUNDS) PERF_BUILD=$(PERF_BUILD) uv run pytest -m "perf and e2e" tests/perf -v -s

# Memory profiling with memray. Point TARGET at any runnable script/module.
# Examples:
# make memray-run TARGET="-m pytest tests/benchmarks -m perf --benchmark-disable"
Expand All @@ -77,6 +86,7 @@ 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)

Expand Down
Loading
Loading