207 lines
8.9 KiB
Markdown
207 lines
8.9 KiB
Markdown
# Performance & Architektur Report (Pounce)
|
||
|
||
**Stand (Codebase):** `ceb4484a3dd2dd8a9289fef623e7395d7e26274b`
|
||
**Datum:** 2025-12-12
|
||
**Scope:** `frontend/` (Next.js 14 App Router) + `backend/` (FastAPI + async SQLAlchemy + APScheduler) + DB + Docker/Deploy.
|
||
|
||
---
|
||
|
||
## Executive Summary (die 5 größten Hebel)
|
||
|
||
1. **Scheduler aus dem API-Prozess herauslösen**
|
||
Aktuell startet der Scheduler in `backend/app/main.py` im App-Lifespan. Bei mehreren Uvicorn/Gunicorn-Workern laufen Jobs **mehrfach parallel** → doppelte Scrapes/Checks, DB-Last, E-Mail-Spam, inkonsistente Zustände.
|
||
|
||
2. **Market Feed Endpoint (`/api/v1/auctions/feed`) DB-seitig paginieren/sortieren**
|
||
`backend/app/api/auctions.py` lädt derzeit **alle aktiven Auktionen + alle aktiven Listings** in Python, berechnet Score, sortiert, und paginiert erst am Ende. Das skaliert schlecht sobald ihr > ein paar hundert Auktionen habt.
|
||
|
||
3. **Price Tracker N+1 eliminieren**
|
||
`backend/app/services/price_tracker.py::detect_price_changes()` macht aktuell: *distinct(tld, registrar) → pro Paar query(limit 2)*. Das ist ein klassischer N+1 und wird bei 800+ TLDs schnell sehr langsam.
|
||
|
||
4. **Health-Cache wirklich nutzen**
|
||
Es gibt `DomainHealthCache`, und der Scheduler schreibt Status/Score. Aber `GET /domains/{id}/health` macht immer einen **Live-Check** (`domain_health.py` mit HTTP/DNS/SSL). Für UI/Performance besser: default **cached**, live nur “Refresh”.
|
||
|
||
5. **Valuation im Request-Path reduzieren (Auctions)**
|
||
`backend/app/api/auctions.py` berechnet pro Auction im Response optional valuation, und `valuation_service` fragt pro Domain auch DB-Daten ab (TLD cost). Das ist pro Request potenziell **sehr teuer**.
|
||
|
||
---
|
||
|
||
## Messwerte (Frontend Build)
|
||
|
||
Aus `frontend/` → `npm run build` (Next.js 14.0.4):
|
||
|
||
- **First Load JS (shared):** ~81.9 kB
|
||
- **Größte Pages (First Load):**
|
||
- `/terminal/watchlist`: ~120 kB
|
||
- `/terminal/radar`: ~120 kB
|
||
- `/terminal/intel/[tld]`: ~115 kB
|
||
- `/terminal/market`: ~113 kB
|
||
- **Warnings:** Einige Routen “deopted into client-side rendering” (z.B. `/terminal/radar`, `/terminal/listing`, `/unsubscribe`, `/terminal/welcome`). Das ist nicht zwingend schlimm, aber ein Hinweis: dort wird kein echtes SSR/Static genutzt.
|
||
|
||
**Interpretation:** Das Frontend ist vom Bundle her bereits recht schlank. Die größten Performance-Risiken liegen aktuell eher im **Backend (Queries, Jobs, N+1, Caching)**.
|
||
|
||
---
|
||
|
||
## Backend – konkrete Hotspots & Fixes
|
||
|
||
### 1) Scheduler: Architektur & Skalierung
|
||
|
||
**Ist-Zustand**
|
||
- `backend/app/main.py`: `start_scheduler()` im `lifespan()` → Scheduler läuft im selben Prozess wie die API.
|
||
- `backend/app/scheduler.py`: viele Jobs (Domain Checks, Health Checks, TLD Scrape, Auction Scrape, Cleanup, Sniper Matching).
|
||
|
||
**Probleme**
|
||
- Multi-worker Deployment (Gunicorn/Uvicorn) → Scheduler läuft pro Worker → Job-Duplikate.
|
||
- Jobs sind teils sequentiell (Domain Checks), teils N+1 (Health Cache, Digests, Sniper Matching).
|
||
|
||
**Empfehlung (Best Practice)**
|
||
- **Scheduler als separater Service/Container** laufen lassen (z.B. eigener Docker Service `scheduler`, oder systemd/cron job, oder Celery Worker + Beat).
|
||
- Wenn Scheduler im selben Code bleiben soll: **Leader-Lock** (Redis/DB advisory lock) einbauen, sodass nur ein Prozess Jobs ausführt.
|
||
|
||
---
|
||
|
||
### 2) Market Feed (`backend/app/api/auctions.py::get_market_feed`)
|
||
|
||
**Ist-Zustand**
|
||
- Holt Listings und Auktionen ohne DB-Limit/Offset, baut `items` in Python, sortiert in Python, paginiert erst am Ende.
|
||
|
||
**Warum das weh tut**
|
||
- Bei z.B. 10’000 aktiven Auktionen ist jeder Request an `/feed` ein “Full table scan + Python sort + JSON build”.
|
||
|
||
**Fix-Strategie**
|
||
- **Score persistieren**: `pounce_score` in `DomainAuction` und `DomainListing` speichern/aktualisieren (beim Scrape bzw. beim Listing Create/Update).
|
||
Dann kann man DB-seitig `WHERE pounce_score >= :min_score` und `ORDER BY pounce_score DESC` machen.
|
||
- **DB-Pagination**: `LIMIT/OFFSET` in SQL, nicht in Python.
|
||
- **Filter DB-seitig**: `keyword`, `tld`, `price range`, `ending_within` in SQL.
|
||
- **Response caching**: Für public feed (oder häufige Filterkombos) Redis TTL 15–60s.
|
||
|
||
---
|
||
|
||
### 3) Auction Search (`backend/app/api/auctions.py::search_auctions`)
|
||
|
||
**Ist-Zustand**
|
||
- Nach Query werden Auktionen in Python gefiltert (Vanity Filter) und dann pro Auction in einer Schleife `valuation_service.estimate_value(...)` aufgerufen.
|
||
|
||
**Probleme**
|
||
- Valuation kann DB-Queries pro Item auslösen (TLD cost avg), und läuft seriell.
|
||
|
||
**Fix-Strategie**
|
||
- Valuations **vorberechnen** (Background Job) und in einer Tabelle/Spalte cachen.
|
||
- Alternativ: Valuation nur **für Top-N** (z.B. 20) berechnen und für den Rest weglassen.
|
||
- TLD-Cost als **in-memory cache** (LRU/TTL) oder einmal pro Request prefetchen.
|
||
|
||
---
|
||
|
||
### 4) Price Tracker (`backend/app/services/price_tracker.py`)
|
||
|
||
**Ist-Zustand**
|
||
- N+1 Queries: distinct(tld, registrar) → pro Paar 1 Query für die letzten 2 Preise.
|
||
|
||
**Fix-Strategie**
|
||
- SQL Window Function (Postgres & SQLite können das):
|
||
- `ROW_NUMBER() OVER (PARTITION BY tld, registrar ORDER BY recorded_at DESC)`
|
||
- dann self-join oder `LAG()` für vorherigen Preis.
|
||
- Zusätzlich DB-Index: `tld_prices(tld, registrar, recorded_at DESC)`
|
||
|
||
---
|
||
|
||
### 5) Domain Health (`backend/app/services/domain_health.py` + `backend/app/api/domains.py`)
|
||
|
||
**Ist-Zustand**
|
||
- Live Health Check macht pro Request echte DNS/HTTP/SSL Checks.
|
||
- Scheduler schreibt `DomainHealthCache`, aber Endpoint nutzt ihn nicht.
|
||
|
||
**Fix-Strategie**
|
||
- Neue Endpoints:
|
||
- `GET /domains/health-cache` → cached health für alle Domains eines Users (1 Request für UI)
|
||
- `POST /domains/{id}/health/refresh` → live refresh (asynchron, job queue)
|
||
- `DomainHealthCache` auch mit `dns_data/http_data/ssl_data` befüllen (ist im Model vorgesehen).
|
||
|
||
---
|
||
|
||
## Datenbank – Indexing & Query Patterns
|
||
|
||
### Empfohlene Indizes (High Impact)
|
||
|
||
- **Domain Checks**
|
||
- `domain_checks(domain_id, checked_at DESC)` für `/domains/{id}/history`
|
||
- **TLD Prices**
|
||
- `tld_prices(tld, registrar, recorded_at DESC)` für “latest two prices” und history queries
|
||
- **Health Cache**
|
||
- `domain_health_cache(domain_id)` (unique ist vorhanden), optional `checked_at`
|
||
|
||
### Query-Patterns (Quick Wins)
|
||
|
||
- In `backend/app/api/domains.py::add_domain()` wird aktuell `len(current_user.domains)` genutzt → lädt potenziell viele Rows.
|
||
Besser: `SELECT COUNT(*) FROM domains WHERE user_id = ...`.
|
||
|
||
- Admin “Users list”: vermeidet N+1 (Subscription + Domain Count pro User) → `JOIN` + `GROUP BY`.
|
||
|
||
---
|
||
|
||
## Frontend – Verbesserungen (gezielt, nicht “blind refactor”)
|
||
|
||
### 1) Reduziere API-Calls pro Screen (Dashboard/Watchlist)
|
||
|
||
Aktuell holen manche Screens mehrere Endpoints und rechnen Stats client-side:
|
||
- `/terminal/radar`: holt u.a. Auctions und `GET /listings/my` nur um Stats zu zählen.
|
||
|
||
**Empfehlung**
|
||
- Ein Endpoint: `GET /dashboard/summary` (counts + small previews) → 1 Request statt 3–5.
|
||
|
||
### 2) Tabellen/Listen skalieren
|
||
|
||
- Für sehr große Listen (Market Feed / TLDs / Admin Users) mittelfristig:
|
||
- Pagination + “infinite scroll”
|
||
- ggf. Virtualisierung (`react-window`) falls 1000+ Rows.
|
||
|
||
### 3) Kleine Code-Health Fixes (auch Performance)
|
||
|
||
- Achtung bei `.sort()` auf State-Arrays: `.sort()` mutiert. Immer vorher kopieren (`[...arr].sort(...)`), sonst entstehen subtile Bugs und unnötige Re-Renders.
|
||
|
||
---
|
||
|
||
## Deployment/Infra – “Production grade” Performance
|
||
|
||
### Backend
|
||
- **Gunicorn + Uvicorn Workers** (oder Uvicorn `--workers`) ist gut für CPU/IO – aber **nur wenn Scheduler separat läuft**.
|
||
- **DB Pooling**: `create_async_engine(..., pool_size=..., max_overflow=...)` für Postgres (nicht bei SQLite).
|
||
- **slowapi**: in Production Redis storage verwenden (sonst pro Worker eigener limiter state).
|
||
|
||
### Frontend
|
||
- Dockerfile erwartet `.next/standalone`. Dafür in `frontend/next.config.js` `output: 'standalone'` aktivieren (oder Dockerfile anpassen).
|
||
|
||
---
|
||
|
||
## Priorisierte Roadmap
|
||
|
||
### Phase 0 (0–1 Tag, Quick Wins)
|
||
- Scheduler entkoppeln ODER Leader-Lock einbauen
|
||
- `/auctions/feed`: DB-limit + offset + order_by (keine full scans)
|
||
- `PriceTracker.detect_price_changes`: Window-Query statt N+1
|
||
- Cached Health Endpoint für Watchlist
|
||
|
||
### Phase 1 (1–2 Wochen)
|
||
- Precompute `pounce_score` + valuations (Background Jobs), persistieren & cachen
|
||
- Admin N+1 entfernen (Users list)
|
||
- DB Indizes ergänzen (DomainCheck, TLDPrice)
|
||
- “Dashboard summary” Endpoint + Frontend umstellen
|
||
|
||
### Phase 2 (2–6 Wochen)
|
||
- Background-Job System (Celery/RQ/Dramatiq) + Redis
|
||
- Observability: Request timing, DB query timing, Prometheus metrics, tracing
|
||
- Load testing + Performance budgets (API p95, page LCP/TTFB)
|
||
|
||
---
|
||
|
||
## Mess-/Monitoring Plan (damit wir nicht im Dunkeln optimieren)
|
||
|
||
- **Backend**
|
||
- Log: Request duration + endpoint + status
|
||
- DB: slow query logging / EXPLAIN ANALYZE (prod-like)
|
||
- Metrics: p50/p95 latency pro endpoint, queue depth, job runtime
|
||
- **Frontend**
|
||
- Core Web Vitals Tracking (ist bereits angelegt in `frontend/src/lib/analytics.ts`)
|
||
- “API Timing” (TTFB + payload size) für Market/Watchlist
|
||
|
||
|