mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +02:00
feat: GPU offload, signup limit, load tests & capacity planning
- Route all AI workloads (Ollama, STT, TTS, Image Gen) to GPU server (192.168.178.11) via LAN instead of host.docker.internal - Upgrade default model to gemma3:12b and max concurrent to 5 - Add daily signup limit service (MAX_DAILY_SIGNUPS env var) - Add GET /api/v1/auth/signup-status public endpoint - Add k6 load test suite (web-apps, auth, sync-websocket, ollama) - Add capacity planning documentation - Fix: add eslint-config to sveltekit-base and calendar Dockerfiles Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
16367384c7
commit
9276d9a212
12 changed files with 683 additions and 14 deletions
|
|
@ -23,6 +23,7 @@ COPY pnpm-lock.yaml ./
|
|||
# --- AUTO-GENERATED COPY STATEMENTS (do not edit manually) ---
|
||||
COPY patches/ ./patches/
|
||||
COPY packages/feedback ./packages/feedback
|
||||
COPY packages/eslint-config ./packages/eslint-config
|
||||
COPY packages/help ./packages/help
|
||||
COPY packages/local-store ./packages/local-store
|
||||
COPY packages/shared-api-client ./packages/shared-api-client
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@ services:
|
|||
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
||||
SMTP_PASS: ${SMTP_PASSWORD}
|
||||
SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-}
|
||||
MAX_DAILY_SIGNUPS: ${MAX_DAILY_SIGNUPS:-0}
|
||||
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
|
||||
ports:
|
||||
- "3001:3001"
|
||||
|
|
@ -419,8 +420,8 @@ services:
|
|||
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
|
||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
SEARCH_SERVICE_URL: http://mana-search:3012
|
||||
STT_SERVICE_URL: http://host.docker.internal:3026
|
||||
TTS_SERVICE_URL: http://host.docker.internal:3022
|
||||
STT_SERVICE_URL: ${STT_SERVICE_URL:-http://192.168.178.11:3020}
|
||||
TTS_SERVICE_URL: ${TTS_SERVICE_URL:-http://192.168.178.11:3022}
|
||||
CORS_ORIGINS: https://api.mana.how,https://mana.how
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
ports:
|
||||
|
|
@ -755,12 +756,12 @@ services:
|
|||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
|
||||
# Voice services
|
||||
STT_URL: http://host.docker.internal:3026
|
||||
TTS_URL: http://host.docker.internal:3022
|
||||
# AI
|
||||
OLLAMA_URL: http://host.docker.internal:11434
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b}
|
||||
# Voice services (GPU server via LAN)
|
||||
STT_URL: ${STT_SERVICE_URL:-http://192.168.178.11:3020}
|
||||
TTS_URL: ${TTS_SERVICE_URL:-http://192.168.178.11:3022}
|
||||
# AI (GPU server via LAN)
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://192.168.178.11:11434}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:12b}
|
||||
# Plugin tokens (all 21 bot identities)
|
||||
MATRIX_MANA_BOT_TOKEN: ${MATRIX_MANA_BOT_TOKEN}
|
||||
MATRIX_MANA_BOT_ROOMS: ${MATRIX_MANA_BOT_ROOMS:-}
|
||||
|
|
@ -1214,7 +1215,7 @@ services:
|
|||
DB_USER: postgres
|
||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
REPLICATE_API_TOKEN: ${REPLICATE_API_TOKEN}
|
||||
IMAGE_GEN_SERVICE_URL: http://host.docker.internal:3025
|
||||
IMAGE_GEN_SERVICE_URL: ${IMAGE_GEN_SERVICE_URL:-http://192.168.178.11:3023}
|
||||
APP_ID: picture-app
|
||||
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
|
|
@ -1298,8 +1299,8 @@ services:
|
|||
environment:
|
||||
PORT: 3020
|
||||
LOG_LEVEL: info
|
||||
OLLAMA_URL: http://host.docker.internal:11434
|
||||
OLLAMA_DEFAULT_MODEL: gemma3:4b
|
||||
OLLAMA_URL: ${OLLAMA_URL:-http://192.168.178.11:11434}
|
||||
OLLAMA_DEFAULT_MODEL: ${OLLAMA_MODEL:-gemma3:12b}
|
||||
OLLAMA_TIMEOUT: 120
|
||||
REDIS_URL: redis://redis:6379
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
|
|
@ -1308,7 +1309,7 @@ services:
|
|||
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
|
||||
GOOGLE_DEFAULT_MODEL: gemini-2.0-flash
|
||||
AUTO_FALLBACK_ENABLED: "true"
|
||||
OLLAMA_MAX_CONCURRENT: 3
|
||||
OLLAMA_MAX_CONCURRENT: 5
|
||||
CORS_ORIGINS: https://playground.mana.how,https://mana.how,https://chat.mana.how
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ COPY packages/shared-types ./packages/shared-types
|
|||
COPY packages/shared-ui ./packages/shared-ui
|
||||
COPY packages/shared-utils ./packages/shared-utils
|
||||
COPY packages/shared-vite-config ./packages/shared-vite-config
|
||||
COPY packages/eslint-config ./packages/eslint-config
|
||||
COPY packages/shared-hono ./packages/shared-hono
|
||||
COPY packages/shared-storage ./packages/shared-storage
|
||||
COPY packages/shared-landing-ui ./packages/shared-landing-ui
|
||||
|
|
|
|||
171
docs/CAPACITY_PLANNING.md
Normal file
171
docs/CAPACITY_PLANNING.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Kapazitaetsplanung & Hardware-Ressourcen
|
||||
|
||||
Stand: 2026-03-28
|
||||
|
||||
## Hardware-Uebersicht
|
||||
|
||||
### Mac Mini M4 (Produktionsserver)
|
||||
|
||||
| Ressource | Wert |
|
||||
|-----------|------|
|
||||
| **CPU** | Apple M4, 10 Cores (4P + 6E) |
|
||||
| **RAM** | 16 GB Unified Memory |
|
||||
| **GPU** | 10-Core Metal (geteilt mit CPU/RAM) |
|
||||
| **Storage intern** | 228 GB SSD |
|
||||
| **Storage extern** | 4 TB SSD (USB-C, ~1 GB/s) |
|
||||
| **Netzwerk** | Cloudflare Tunnel (kein direktes Port-Forwarding) |
|
||||
|
||||
### Windows GPU Server (AI-Workloads)
|
||||
|
||||
| Ressource | Wert |
|
||||
|-----------|------|
|
||||
| **GPU** | NVIDIA RTX 3090, 24 GB VRAM |
|
||||
| **Netzwerk** | LAN (192.168.178.11), Tunnel-Subdomains unter gpu-*.mana.how |
|
||||
| **Services** | Ollama (11434), STT (3020), TTS (3022), Image Gen (3023) |
|
||||
| **Status** | **Aktiv** — alle AI-Services vom Mac Mini hierher verlagert |
|
||||
|
||||
## Aktuelle Auslastung (Mac Mini)
|
||||
|
||||
### Container-Uebersicht (61 Container)
|
||||
|
||||
| Kategorie | Anzahl | Geschaetzter RAM |
|
||||
|-----------|--------|------------------|
|
||||
| Infrastruktur (Postgres, Redis, MinIO, Forgejo) | 7 | ~2.5 GB |
|
||||
| Core Services (Auth, Credits, User, Subs, Analytics) | 5 | ~0.8 GB |
|
||||
| Go Services (Gateway, Sync, Search, Notify, Crawler, Media) | 6 | ~0.3 GB |
|
||||
| Web Apps (SvelteKit, 19 Apps) | 19 | ~3.0 GB |
|
||||
| Backends (NestJS/Hono) | 3 | ~0.5 GB |
|
||||
| Matrix (Synapse, Element, Bots) | 4 | ~1.0 GB |
|
||||
| Monitoring (Grafana, Victoria, Loki, cAdvisor, etc.) | 13 | ~2.0 GB |
|
||||
| Sonstiges (Watchtower, Landing Builder, LLM) | 4 | ~0.5 GB |
|
||||
| **Gesamt** | **61** | **~10.6 GB** |
|
||||
|
||||
### Native Services
|
||||
|
||||
| Service | RAM (idle) | RAM (aktiv) |
|
||||
|---------|-----------|-------------|
|
||||
| Ollama (Gemma 3 4B) | ~0 MB (nach 5min entladen) | ~3.3 GB |
|
||||
| Ollama (Gemma 3 27B) | ~0 MB | ~16 GB (gesamter RAM!) |
|
||||
| FLUX.2 klein | ~0.5 GB | ~2 GB |
|
||||
|
||||
### RAM-Budget
|
||||
|
||||
```
|
||||
Verfuegbar: 16.0 GB
|
||||
Docker Container: -10.6 GB
|
||||
macOS Overhead: -1.5 GB
|
||||
─────────────────────────────
|
||||
Frei: 3.9 GB ← fuer Ollama, Builds, Peaks
|
||||
```
|
||||
|
||||
**Kritisch:** Bei aktivem Ollama (3.3 GB fuer 4B-Modell) bleiben nur ~0.6 GB fuer Peaks.
|
||||
Build-Script stoppt deshalb 13 Monitoring-Container (~2 GB) vor dem Bauen.
|
||||
|
||||
## Kapazitaetsschaetzung nach Workload-Typ
|
||||
|
||||
### Tier 1: Statische/Local-First Apps (wenig Server-Last)
|
||||
|
||||
Apps wie Todo, Calendar, Clock, Zitare, Contacts, etc.
|
||||
|
||||
| Metrik | Wert | Begruendung |
|
||||
|--------|------|-------------|
|
||||
| Gleichzeitige User | **100-200** | Local-first: Reads aus IndexedDB, Server nur fuer Sync |
|
||||
| Sync-Connections (WebSocket) | **~50 aktiv** | mana-sync (Go) ist sehr effizient, ~10 KB/Connection |
|
||||
| Bottleneck | Cloudflare Tunnel Latenz (~4s first byte) | Nicht die App selbst |
|
||||
|
||||
### Tier 2: API-lastige Apps (Chat, Questions, Context)
|
||||
|
||||
| Metrik | Wert | Begruendung |
|
||||
|--------|------|-------------|
|
||||
| Gleichzeitige User | **20-50** | Abhaengig von Postgres-Connections (max 20 pro Service) |
|
||||
| API Requests/sec | **~100-200** | NestJS/Hono koennen mehr, DB ist Limit |
|
||||
| Bottleneck | PostgreSQL Connections + RAM | |
|
||||
|
||||
### Tier 3: AI-Workloads (Ollama, FLUX.2)
|
||||
|
||||
| Metrik | Wert | Begruendung |
|
||||
|--------|------|-------------|
|
||||
| LLM gleichzeitig | **1** | OLLAMA_NUM_PARALLEL=1, Modell belegt 3-16 GB |
|
||||
| LLM Durchsatz | **~53 tokens/sec** (4B) | ~260 tokens/sec Prompt Processing |
|
||||
| Bildgenerierung | **1 gleichzeitig** | ~1.5s pro 1024x1024 Bild |
|
||||
| Bottleneck | **RAM** (Ollama + Container konkurrieren) | |
|
||||
|
||||
### Gesamtschaetzung
|
||||
|
||||
| Szenario | Max. gleichzeitige User |
|
||||
|----------|------------------------|
|
||||
| Nur Local-First Apps | ~200 |
|
||||
| Mixed (Local-First + API) | ~50-100 |
|
||||
| Mit aktiver LLM-Nutzung | ~20-30 |
|
||||
| Peak (alle Services + LLM + Bildgen) | **~10-20** |
|
||||
|
||||
## Bottleneck-Analyse
|
||||
|
||||
| Rang | Bottleneck | Auswirkung | Loesung |
|
||||
|------|-----------|------------|---------|
|
||||
| 1 | **RAM (16 GB)** | Ollama + Container kaempfen um Speicher | RAM-Upgrade (neuer Mac Mini) oder GPU-Server fuer LLM |
|
||||
| 2 | **Cloudflare Tunnel Latenz** | ~4s TTFB fuer erste Requests | CDN/Workers fuer statische Assets |
|
||||
| 3 | **PostgreSQL Connections** | Max 20 pro Service, shared DB | Connection Pooling (PgBouncer) |
|
||||
| 4 | **Single Server** | Kein Failover, kein horizontales Scaling | Zweiter Mac Mini oder Cloud-Burst |
|
||||
|
||||
## Scaling-Roadmap
|
||||
|
||||
### Phase 1: Optimierung (0 EUR)
|
||||
|
||||
- [x] GPU-Server ueber LAN anbinden → alle AI-Last vom Mac Mini verlagert
|
||||
- [x] Registrierungslimit implementiert (MAX_DAILY_SIGNUPS, default: unlimitiert)
|
||||
- [ ] PgBouncer fuer Connection Pooling einrichten
|
||||
- [ ] Cloudflare Cache Rules fuer statische Assets
|
||||
- [ ] Registrierungslimit aktivieren (5/Tag) in .env auf Server
|
||||
|
||||
### Phase 2: RAM-Upgrade (~700 EUR)
|
||||
|
||||
- [ ] Neuer Mac Mini M4 mit 32 GB → doppelte Kapazitaet
|
||||
- [ ] Oder: gebrauchter Mac Mini M2 als zweiter Server
|
||||
- [ ] Registrierungslimit auf 15/Tag erhoehen
|
||||
|
||||
### Phase 3: Horizontales Scaling (~50 EUR/Monat)
|
||||
|
||||
- [ ] Hetzner VPS fuer statische Web-Apps (CAX21: 8 GB, 4 vCPU, ~8 EUR/Monat)
|
||||
- [ ] Oder: Coolify/Kamal auf dediziertem Server
|
||||
- [ ] Registrierungslimit auf 50/Tag erhoehen
|
||||
|
||||
### Phase 4: Production-Grade (~200 EUR/Monat)
|
||||
|
||||
- [ ] Managed PostgreSQL (z.B. Supabase, Neon)
|
||||
- [ ] CDN fuer alle Web-Apps
|
||||
- [ ] Multi-Server mit Load Balancing
|
||||
- [ ] Registrierungslimit entfernen oder auf 500/Tag
|
||||
|
||||
## Registrierungslimit ("Organic Growth Gate")
|
||||
|
||||
Siehe Implementierung in `services/mana-auth/src/services/signup-limit.ts`.
|
||||
|
||||
### Konzept
|
||||
|
||||
Pro Tag koennen sich maximal X neue Nutzer registrieren. Das Limit ist konfigurierbar und waechst mit der Hardware.
|
||||
|
||||
### Vorteile
|
||||
|
||||
1. **Infrastruktur-Schutz:** Hardware waechst mit der Community
|
||||
2. **Exklusivitaet:** "Heute noch 2 Plaetze frei" erzeugt Nachfrage
|
||||
3. **Qualitaet:** Fruehe User geben besseres Feedback
|
||||
4. **Kostenlos:** Kein Over-Provisioning noetig
|
||||
|
||||
### Geplante Limits
|
||||
|
||||
| Phase | Limit | Kumuliert/30 Tage | Hardware |
|
||||
|-------|-------|-------------------|----------|
|
||||
| Start | 5/Tag | ~150 User | Mac Mini 16 GB |
|
||||
| Phase 2 | 15/Tag | ~450 User | Mac Mini 32 GB |
|
||||
| Phase 3 | 50/Tag | ~1500 User | Multi-Server |
|
||||
|
||||
## Load Testing
|
||||
|
||||
Load Tests liegen in `load-tests/`. Siehe `load-tests/README.md` fuer Ausfuehrung.
|
||||
|
||||
### Empfohlene Test-Zyklen
|
||||
|
||||
1. **Vor jedem Hardware-Upgrade:** Baseline messen
|
||||
2. **Nach Limit-Erhoehung:** Verifizieren dass Hardware haelt
|
||||
3. **Monatlich:** Regression erkennen
|
||||
58
load-tests/README.md
Normal file
58
load-tests/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Load Tests
|
||||
|
||||
k6-basierte Load Tests fuer die Mana-Infrastruktur.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# k6 installieren (macOS)
|
||||
brew install k6
|
||||
|
||||
# WebSocket-Extension (fuer Sync-Tests)
|
||||
# k6 hat WebSocket-Support eingebaut
|
||||
```
|
||||
|
||||
## Tests ausfuehren
|
||||
|
||||
```bash
|
||||
# Gegen lokale Umgebung
|
||||
k6 run load-tests/web-apps.js
|
||||
k6 run load-tests/auth-api.js
|
||||
k6 run load-tests/sync-websocket.js
|
||||
k6 run load-tests/llm-ollama.js
|
||||
|
||||
# Gegen Produktion (vorsichtig!)
|
||||
k6 run -e BASE_URL=https://mana.how load-tests/web-apps.js
|
||||
|
||||
# Mit mehr/weniger Last
|
||||
k6 run --vus 100 --duration 5m load-tests/web-apps.js
|
||||
|
||||
# JSON-Output fuer Grafana
|
||||
k6 run --out json=results.json load-tests/web-apps.js
|
||||
```
|
||||
|
||||
## Test-Szenarien
|
||||
|
||||
| Script | Ziel | Default VUs | Dauer |
|
||||
|--------|------|-------------|-------|
|
||||
| `web-apps.js` | SvelteKit Frontends (HTML-Responses) | 10→50→10 | 5 min |
|
||||
| `auth-api.js` | Login, Register, Token Validation | 5→20→5 | 4 min |
|
||||
| `sync-websocket.js` | mana-sync WebSocket Connections | 10→30→10 | 5 min |
|
||||
| `llm-ollama.js` | Ollama Chat Completions | 1→3→1 | 3 min |
|
||||
|
||||
## Metriken interpretieren
|
||||
|
||||
| Metrik | Gut | Akzeptabel | Schlecht |
|
||||
|--------|-----|-----------|---------|
|
||||
| http_req_duration (p95) | < 200ms | < 1s | > 2s |
|
||||
| http_req_failed | 0% | < 1% | > 5% |
|
||||
| ws_connecting (p95) | < 100ms | < 500ms | > 1s |
|
||||
| iterations | Steigend | Stabil | Fallend |
|
||||
|
||||
## Monitoring waehrend Tests
|
||||
|
||||
Grafana-Dashboard auf http://localhost:8080 (oder https://grafana.mana.how) beobachten:
|
||||
- Container CPU/RAM (cAdvisor)
|
||||
- PostgreSQL Connections
|
||||
- Redis Commands/sec
|
||||
- Netzwerk-Throughput
|
||||
81
load-tests/auth-api.js
Normal file
81
load-tests/auth-api.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/* eslint-disable no-undef */
|
||||
import http from 'k6/http';
|
||||
import { check, sleep, group } from 'k6';
|
||||
import { Rate, Counter } from 'k6/metrics';
|
||||
|
||||
const errorRate = new Rate('errors');
|
||||
const signupBlocked = new Counter('signup_blocked');
|
||||
|
||||
const AUTH_URL = __ENV.AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '30s', target: 5 },
|
||||
{ duration: '2m', target: 20 },
|
||||
{ duration: '30s', target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<3000'],
|
||||
errors: ['rate<0.10'],
|
||||
},
|
||||
};
|
||||
|
||||
// Generate unique test emails
|
||||
function testEmail(vuId, iter) {
|
||||
return `loadtest_vu${vuId}_${iter}_${Date.now()}@test.invalid`;
|
||||
}
|
||||
|
||||
export default function () {
|
||||
// Weighted random: 70% health, 20% login attempts, 10% register
|
||||
const roll = Math.random();
|
||||
|
||||
if (roll < 0.7) {
|
||||
// Health check — lightweight, tests baseline
|
||||
group('health', () => {
|
||||
const res = http.get(`${AUTH_URL}/health`);
|
||||
const ok = check(res, {
|
||||
'health 200': (r) => r.status === 200,
|
||||
});
|
||||
errorRate.add(!ok);
|
||||
});
|
||||
} else if (roll < 0.9) {
|
||||
// Login attempt with invalid credentials — tests lockout + DB
|
||||
group('login', () => {
|
||||
const res = http.post(
|
||||
`${AUTH_URL}/api/v1/auth/login`,
|
||||
JSON.stringify({
|
||||
email: 'loadtest@nonexistent.invalid',
|
||||
password: 'wrongpassword',
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
const ok = check(res, {
|
||||
'login returns 401 or 429': (r) => r.status === 401 || r.status === 429,
|
||||
});
|
||||
errorRate.add(!ok);
|
||||
});
|
||||
} else {
|
||||
// Registration — tests signup limit
|
||||
group('register', () => {
|
||||
const email = testEmail(__VU, __ITER);
|
||||
const res = http.post(
|
||||
`${AUTH_URL}/api/v1/auth/register`,
|
||||
JSON.stringify({
|
||||
email: email,
|
||||
password: 'TestPassword123!',
|
||||
name: `Load Test ${__VU}`,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
const ok = check(res, {
|
||||
'register returns 200 or 429': (r) => r.status === 200 || r.status === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
signupBlocked.add(1);
|
||||
}
|
||||
errorRate.add(!ok);
|
||||
});
|
||||
}
|
||||
|
||||
sleep(Math.random() * 1.5 + 0.5);
|
||||
}
|
||||
82
load-tests/llm-ollama.js
Normal file
82
load-tests/llm-ollama.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/* eslint-disable no-undef, @typescript-eslint/no-unused-vars */
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { Rate, Trend, Counter } from 'k6/metrics';
|
||||
|
||||
const errorRate = new Rate('errors');
|
||||
const tokensPerSec = new Trend('tokens_per_second', true);
|
||||
const totalTokens = new Counter('total_tokens_generated');
|
||||
|
||||
const OLLAMA_URL = __ENV.OLLAMA_URL || 'http://localhost:11434';
|
||||
const MODEL = __ENV.MODEL || 'gemma3:4b';
|
||||
|
||||
export const options = {
|
||||
// LLM is single-threaded effectively — test with few VUs
|
||||
stages: [
|
||||
{ duration: '30s', target: 1 },
|
||||
{ duration: '2m', target: 3 },
|
||||
{ duration: '30s', target: 1 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<30000'], // LLM responses can be slow
|
||||
errors: ['rate<0.10'],
|
||||
},
|
||||
};
|
||||
|
||||
const prompts = [
|
||||
'Was ist die Hauptstadt von Deutschland? Antworte in einem Satz.',
|
||||
'Erklaere Photosynthese in 2 Saetzen.',
|
||||
'Schreibe ein kurzes Haiku ueber Programmierung.',
|
||||
'Was ist der Unterschied zwischen TCP und UDP? Kurz.',
|
||||
'Nenne 3 Vorteile von Self-Hosting.',
|
||||
];
|
||||
|
||||
export default function () {
|
||||
const prompt = prompts[Math.floor(Math.random() * prompts.length)];
|
||||
|
||||
// Non-streaming request for easier metrics
|
||||
const res = http.post(
|
||||
`${OLLAMA_URL}/api/generate`,
|
||||
JSON.stringify({
|
||||
model: MODEL,
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
num_predict: 100, // Cap tokens to keep tests fast
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: '60s',
|
||||
}
|
||||
);
|
||||
|
||||
const ok = check(res, {
|
||||
'status is 200': (r) => r.status === 200,
|
||||
'has response text': (r) => {
|
||||
try {
|
||||
const body = JSON.parse(r.body);
|
||||
return body.response && body.response.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (ok && res.status === 200) {
|
||||
try {
|
||||
const body = JSON.parse(res.body);
|
||||
// Ollama returns eval_count and eval_duration
|
||||
if (body.eval_count && body.eval_duration) {
|
||||
const tps = body.eval_count / (body.eval_duration / 1e9);
|
||||
tokensPerSec.add(tps);
|
||||
totalTokens.add(body.eval_count);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
errorRate.add(!ok);
|
||||
|
||||
// Longer pause between LLM requests — realistic usage
|
||||
sleep(Math.random() * 5 + 3);
|
||||
}
|
||||
84
load-tests/sync-websocket.js
Normal file
84
load-tests/sync-websocket.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/* eslint-disable no-undef, no-console, @typescript-eslint/no-unused-vars */
|
||||
import ws from 'k6/ws';
|
||||
import { check, sleep } from 'k6';
|
||||
import { Rate, Counter, Trend } from 'k6/metrics';
|
||||
|
||||
const errorRate = new Rate('errors');
|
||||
const messagesReceived = new Counter('ws_messages_received');
|
||||
const messagesSent = new Counter('ws_messages_sent');
|
||||
const connectTime = new Trend('ws_connect_time', true);
|
||||
|
||||
const SYNC_URL = __ENV.SYNC_URL || 'ws://localhost:3050';
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '30s', target: 10 },
|
||||
{ duration: '3m', target: 30 },
|
||||
{ duration: '30s', target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
errors: ['rate<0.10'],
|
||||
ws_connect_time: ['p(95)<1000'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const url = `${SYNC_URL}/ws`;
|
||||
|
||||
const startTime = Date.now();
|
||||
const res = ws.connect(url, {}, function (socket) {
|
||||
const connected = Date.now() - startTime;
|
||||
connectTime.add(connected);
|
||||
|
||||
socket.on('open', () => {
|
||||
// Send a sync handshake (collection subscription)
|
||||
const handshake = JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collections: ['tasks', 'events', 'contacts'],
|
||||
userId: `loadtest-vu-${__VU}`,
|
||||
lastSyncTimestamp: new Date(Date.now() - 60000).toISOString(),
|
||||
});
|
||||
socket.send(handshake);
|
||||
messagesSent.add(1);
|
||||
});
|
||||
|
||||
socket.on('message', (data) => {
|
||||
messagesReceived.add(1);
|
||||
|
||||
// Parse and validate sync messages
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
check(msg, {
|
||||
'has type field': (m) => m.type !== undefined,
|
||||
});
|
||||
} catch (_) {
|
||||
// Binary or non-JSON message
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (e) => {
|
||||
errorRate.add(true);
|
||||
console.error(`WS error VU ${__VU}: ${e.error()}`);
|
||||
});
|
||||
|
||||
// Keep connection alive for 10-30 seconds (simulates real user session)
|
||||
const sessionDuration = Math.random() * 20 + 10;
|
||||
|
||||
// Send periodic sync pings
|
||||
const pingInterval = setInterval(() => {
|
||||
socket.send(JSON.stringify({ type: 'ping' }));
|
||||
messagesSent.add(1);
|
||||
}, 5000);
|
||||
|
||||
sleep(sessionDuration);
|
||||
clearInterval(pingInterval);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
const ok = check(res, {
|
||||
'WS connection status is 101': (r) => r && r.status === 101,
|
||||
});
|
||||
errorRate.add(!ok);
|
||||
|
||||
sleep(Math.random() * 2 + 1);
|
||||
}
|
||||
72
load-tests/web-apps.js
Normal file
72
load-tests/web-apps.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/* eslint-disable no-undef */
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
|
||||
const errorRate = new Rate('errors');
|
||||
const appLatency = new Trend('app_latency', true);
|
||||
|
||||
const BASE = __ENV.BASE_URL || 'http://localhost';
|
||||
|
||||
// All deployed SvelteKit web apps with their ports
|
||||
const apps = [
|
||||
{ name: 'dashboard', url: `${BASE}:5173` },
|
||||
{ name: 'chat', url: `${BASE}:3000` },
|
||||
{ name: 'todo', url: `${BASE}:5188` },
|
||||
{ name: 'zitare', url: `${BASE}:5185` },
|
||||
{ name: 'calendar', url: `${BASE}:5186` },
|
||||
{ name: 'clock', url: `${BASE}:5187` },
|
||||
{ name: 'contacts', url: `${BASE}:5176` },
|
||||
{ name: 'storage', url: `${BASE}:5178` },
|
||||
{ name: 'presi', url: `${BASE}:5180` },
|
||||
{ name: 'manadeck', url: `${BASE}:5181` },
|
||||
{ name: 'nutriphi', url: `${BASE}:5182` },
|
||||
{ name: 'skilltree', url: `${BASE}:5183` },
|
||||
{ name: 'photos', url: `${BASE}:5184` },
|
||||
{ name: 'mukke', url: `${BASE}:5189` },
|
||||
{ name: 'citycorners', url: `${BASE}:5190` },
|
||||
{ name: 'picture', url: `${BASE}:5174` },
|
||||
{ name: 'inventar', url: `${BASE}:5191` },
|
||||
];
|
||||
|
||||
// When testing against production, use subdomains
|
||||
const prodApps = [
|
||||
{ name: 'dashboard', url: 'https://mana.how' },
|
||||
{ name: 'chat', url: 'https://chat.mana.how' },
|
||||
{ name: 'todo', url: 'https://todo.mana.how' },
|
||||
{ name: 'calendar', url: 'https://calendar.mana.how' },
|
||||
{ name: 'clock', url: 'https://clock.mana.how' },
|
||||
];
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '30s', target: 10 }, // Ramp up
|
||||
{ duration: '3m', target: 50 }, // Hold at 50 VUs
|
||||
{ duration: '30s', target: 0 }, // Ramp down
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<2000'], // 95% under 2s
|
||||
errors: ['rate<0.05'], // <5% errors
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const targets = __ENV.BASE_URL?.startsWith('https') ? prodApps : apps;
|
||||
const app = targets[Math.floor(Math.random() * targets.length)];
|
||||
|
||||
const res = http.get(app.url, {
|
||||
tags: { app: app.name },
|
||||
timeout: '10s',
|
||||
});
|
||||
|
||||
const success = check(res, {
|
||||
'status is 200': (r) => r.status === 200,
|
||||
'response has body': (r) => r.body && r.body.length > 0,
|
||||
'response time < 2s': (r) => r.timings.duration < 2000,
|
||||
});
|
||||
|
||||
errorRate.add(!success);
|
||||
appLatency.add(res.timings.duration, { app: app.name });
|
||||
|
||||
sleep(Math.random() * 2 + 0.5); // 0.5-2.5s between requests
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { jwtAuth } from './middleware/jwt-auth';
|
|||
import { serviceAuth } from './middleware/service-auth';
|
||||
import { initializeEmail } from './email/send';
|
||||
import { SecurityEventsService, AccountLockoutService } from './services/security';
|
||||
import { SignupLimitService } from './services/signup-limit';
|
||||
import { ApiKeysService } from './services/api-keys';
|
||||
import { createAuthRoutes } from './routes/auth';
|
||||
import { createGuildRoutes } from './routes/guilds';
|
||||
|
|
@ -31,6 +32,7 @@ const auth = createBetterAuth(config.databaseUrl);
|
|||
initializeEmail(config.smtp);
|
||||
const security = new SecurityEventsService(db);
|
||||
const lockout = new AccountLockoutService(db);
|
||||
const signupLimit = new SignupLimitService(db);
|
||||
const apiKeysService = new ApiKeysService(db);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
|
@ -61,7 +63,7 @@ app.get('/.well-known/openid-configuration', async (c) => auth.handler(c.req.raw
|
|||
|
||||
// ─── Custom Auth Endpoints ──────────────────────────────────
|
||||
|
||||
app.route('/api/v1/auth', createAuthRoutes(auth, config, security, lockout));
|
||||
app.route('/api/v1/auth', createAuthRoutes(auth, config, security, lockout, signupLimit));
|
||||
|
||||
// ─── Guilds ─────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Hono } from 'hono';
|
|||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { BetterAuthInstance } from '../auth/better-auth.config';
|
||||
import type { SecurityEventsService, AccountLockoutService } from '../services/security';
|
||||
import type { SignupLimitService } from '../services/signup-limit';
|
||||
import type { Config } from '../config';
|
||||
import { sourceAppStore, passwordResetRedirectStore } from '../auth/stores';
|
||||
|
||||
|
|
@ -16,15 +17,37 @@ export function createAuthRoutes(
|
|||
auth: BetterAuthInstance,
|
||||
config: Config,
|
||||
security: SecurityEventsService,
|
||||
lockout: AccountLockoutService
|
||||
lockout: AccountLockoutService,
|
||||
signupLimit: SignupLimitService
|
||||
) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// ─── Registration ────────────────────────────────────────
|
||||
|
||||
// ─── Signup Status (public) ─────────────────────────────
|
||||
|
||||
app.get('/signup-status', async (c) => {
|
||||
const status = await signupLimit.getStatus();
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
app.post('/register', async (c) => {
|
||||
const body = await c.req.json();
|
||||
|
||||
// Check daily signup limit
|
||||
const limitCheck = await signupLimit.checkLimit();
|
||||
if (!limitCheck.allowed) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Registration limit reached',
|
||||
message: 'Das tägliche Registrierungslimit ist erreicht. Versuche es morgen wieder.',
|
||||
spotsRemaining: 0,
|
||||
resetsAt: limitCheck.resetsAt,
|
||||
},
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
// Store source app URL for email verification redirect
|
||||
if (body.sourceAppUrl && body.email) {
|
||||
sourceAppStore.set(body.email, body.sourceAppUrl);
|
||||
|
|
|
|||
93
services/mana-auth/src/services/signup-limit.ts
Normal file
93
services/mana-auth/src/services/signup-limit.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Signup Limit — Daily registration cap ("Organic Growth Gate")
|
||||
*
|
||||
* Limits new registrations per day to protect hardware and
|
||||
* enable organic growth. Uses PostgreSQL security_events table
|
||||
* (no Redis dependency needed).
|
||||
*
|
||||
* Configure via MAX_DAILY_SIGNUPS env var (default: 0 = unlimited).
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
|
||||
export class SignupLimitService {
|
||||
private maxDaily: number;
|
||||
|
||||
constructor(private db: Database) {
|
||||
this.maxDaily = parseInt(process.env.MAX_DAILY_SIGNUPS || '0', 10);
|
||||
}
|
||||
|
||||
/** Check if registration is allowed right now */
|
||||
async checkLimit(): Promise<{
|
||||
allowed: boolean;
|
||||
current: number;
|
||||
limit: number;
|
||||
resetsAt: string;
|
||||
}> {
|
||||
// 0 = unlimited (feature disabled)
|
||||
if (this.maxDaily <= 0) {
|
||||
return { allowed: true, current: 0, limit: 0, resetsAt: '' };
|
||||
}
|
||||
|
||||
const todayCount = await this.getTodayCount();
|
||||
const midnight = new Date();
|
||||
midnight.setHours(24, 0, 0, 0);
|
||||
|
||||
return {
|
||||
allowed: todayCount < this.maxDaily,
|
||||
current: todayCount,
|
||||
limit: this.maxDaily,
|
||||
resetsAt: midnight.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Count registrations today (UTC) */
|
||||
private async getTodayCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db.execute(
|
||||
sql`SELECT COUNT(*) as count
|
||||
FROM auth.security_events
|
||||
WHERE event_type = 'REGISTER'
|
||||
AND created_at >= CURRENT_DATE
|
||||
AND created_at < CURRENT_DATE + INTERVAL '1 day'`
|
||||
);
|
||||
const row = (result as any)[0];
|
||||
return row ? Number(row.count) : 0;
|
||||
} catch {
|
||||
// On error, allow registration (fail open)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Public status for the signup page */
|
||||
async getStatus(): Promise<{
|
||||
registrationOpen: boolean;
|
||||
spotsRemaining: number | null;
|
||||
totalToday: number;
|
||||
limit: number;
|
||||
resetsAt: string;
|
||||
}> {
|
||||
if (this.maxDaily <= 0) {
|
||||
return {
|
||||
registrationOpen: true,
|
||||
spotsRemaining: null,
|
||||
totalToday: 0,
|
||||
limit: 0,
|
||||
resetsAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
const todayCount = await this.getTodayCount();
|
||||
const midnight = new Date();
|
||||
midnight.setHours(24, 0, 0, 0);
|
||||
|
||||
return {
|
||||
registrationOpen: todayCount < this.maxDaily,
|
||||
spotsRemaining: Math.max(0, this.maxDaily - todayCount),
|
||||
totalToday: todayCount,
|
||||
limit: this.maxDaily,
|
||||
resetsAt: midnight.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue