Skalierung
Prinzip
Der Feature-Code aendert sich nie — egal ob 1 Instanz oder 50. Alles stateless, Redis ist die einzige shared State.
API: Horizontal
Mehrere Bun-Instanzen hinter Load Balancer. Schon designed: Redis Pub/Sub fuer SSE, Distributed Locks, Idempotency.
Load Balancer ├── Bun API 1 ──┐ ├── Bun API 2 ──├── Redis (SSE Pub/Sub, Idempotency, Cache, Jobs) └── Bun API N ──┘ PostgreSQLJobs: Eigene Prozesse
Worker laufen nicht auf API-Instanzen. Getrennte Prozesse, unabhaengig skalierbar.
API Instanzen (Requests): Worker Instanzen (Jobs): ├── Bun API 1 ├── Bun Worker 1 ├── Bun API 2 ├── Bun Worker 2 └── Bun API 3 └── Bun Worker N │ │ └──────── Redis ──────────────┘ │ PostgreSQLGruende:
- CPU-intensiver Job blockiert keine API-Requests
- Unabhaengig skalierbar (3 APIs, 10 Worker oder umgekehrt)
- Worker-Crash killt keine API
- Verschiedene Skalierungsmerkmale (API = I/O, Worker = CPU)
DB: Read Replicas (vorbereitet)
Nicht von Anfang an, aber das Framework ist vorbereitet:
createApp({ db: { write: "postgres://primary:5432/kumiko", read: "postgres://replica:5432/kumiko", // Optional, spaeter },});WriteHandler → Primary. QueryHandler → Replica (wenn konfiguriert). Framework routet automatisch. Ohne Replica-Config geht alles an Primary.
DB: Connection Pooling
PgBouncer vor PostgreSQL wenn viele Instanzen laufen. Reine Ops-Konfiguration, kein Framework-Code noetig.
postgres.js-Pool tunen
Jede Bun-Instanz haelt einen eigenen postgres.js-Pool. createDbConnection(url, opts) nimmt drei Felder, dbConnectionOptionsFromEnv() liest sie aus Env-Vars:
| Option | Env-Var | Default | Wann setzen |
|---|---|---|---|
maxConnections | DATABASE_POOL_MAX | 10 | Erhoehen wenn API-Instanz >10 parallele Requests halten soll. max × num_workers darf die DB-max_connections nicht ueberschreiten. |
idleTimeoutSeconds | DATABASE_POOL_IDLE_TIMEOUT | keep-alive | 30–60s setzen wenn die App hinter PgBouncer laeuft — sonst haelt ein einzelner Burst Connections auf unbestimmte Zeit. |
connectTimeoutSeconds | DATABASE_POOL_CONNECT_TIMEOUT | keins | 2–5s setzen, damit /health/ready bei unerreichbarer DB innerhalb seines 2s-Probe-Budgets 503 zurueckgeben kann (statt zu haengen). |
Sizing-Heuristik fuer ein typisches Deployment:
- 3 Bun-API-Instanzen ×
maxConnections=20= 60 Verbindungen Richtung DB. - Managed-Postgres typisch: 100–400 max_connections. Genug Puffer fuer Worker + Ops-Zugriffe.
- Gegen mehr Last skalieren: mehr Instanzen, nicht
maxpro Instanz. Ein einzelner Bun-Prozess mitmax=200verteilt Last nicht besser als 10×20 Instanzen.
Connection-Exhaustion erkennen: Das Dispatcher-Lag + /health/ready Checks greifen schon, aber ein expliziter Check ist die db.connect_latency-Metric (Observability v1).
import { createDbConnection, dbConnectionOptionsFromEnv } from "@kumiko/framework/db";
const { db, client, close } = createDbConnection( process.env.DATABASE_URL, dbConnectionOptionsFromEnv(),);Prozess-Modes: API / Worker / All-in-one
@kumiko/framework/entrypoint bietet drei Factory-Funktionen, die ein und dieselbe context+registry-Konfiguration in unterschiedliche Prozess-Shapes bauen:
| Mode | HTTP | Event-Dispatcher | Job-Runner | Einsatz |
|---|---|---|---|---|
createApiEntrypoint | ✅ | ❌ (disabled) | ❌ | N Replicas hinter LB, stateless |
createWorkerEntrypoint | ❌ | ✅ (gestartet) | ✅ (BullMQ) | 1–wenige Replicas, SKIP LOCKED serialisiert |
createAllInOneEntrypoint | ✅ | ✅ | ✅ | Dev, Samples, Single-Instance Self-Hosts |
Alle drei liefern { lifecycle, start(), stop() } — das Wiring im main.ts ist mode-agnostisch:
import { createApiEntrypoint, createWorkerEntrypoint, createAllInOneEntrypoint,} from "@kumiko/framework/entrypoint";import { attachSignalHandlers } from "@kumiko/framework/lifecycle";
const mode = process.env.APP_MODE ?? "all-in-one";const sharedOpts = { registry, context, jwtSecret, ... };const redisUrl = process.env.REDIS_URL!;
const entry = mode === "api" ? createApiEntrypoint({ ...sharedOpts, jobs: { redisUrl } }) : mode === "worker" ? createWorkerEntrypoint({ ...sharedOpts, redisUrl }) : createAllInOneEntrypoint({ ...sharedOpts, redisUrl });
attachSignalHandlers(entry.lifecycle);await entry.start();
if ("app" in entry) { Bun.serve({ fetch: entry.app.fetch, port: 3000 });}runIn — Jobs und MSPs pro Lane routen
Wann nutze ich was? — Pick-Guide
Drei Async-Patterns, unterschiedliche Garantien. Entscheide zuerst was du brauchst, dann pick das Konstrukt:
| Anforderung | Pattern | Atomar mit Write? | Retry? | Cross-Aggregate? |
|---|---|---|---|---|
| Validierung / DB-Side-Effect der MIT dem Write rollback muss | r.hook("preSave"|"postSave") | ✅ in-TX | ❌ (throw → Rollback) | ❌ |
| Read-Model / Saga / Cross-Aggregate-Reaktion | r.multiStreamProjection | ❌ (async via Cursor) | ✅ (at-least-once + idempotency-Guard) | ✅ |
| Externer Side-Effect (HTTP-Call, PDF, Mail, Export) | r.job | ❌ (async via BullMQ) | ✅ (retries + backoff konfigurierbar) | nur via trigger: { on: event } |
Daumenregel: Wenn der Side-Effect bei Write-Rollback nicht auch rollbacked werden darf → Hook. Wenn er idempotent + DB-basiert ist → MSP. Wenn er I/O oder CPU-lastig ist und retry-fähig sein muss → Job.
runIn — Lane-Routing
Jeder Job und jede MultiStreamProjection kann über das runIn-Feld fest an eine Deploy-Lane gebunden werden:
defineFeature("orders", (r) => { // Event-emitter — läuft wo auch immer der Write-Command läuft (API) r.writeHandler("order.create", schema, async (event) => { return { isSuccess: true, data: { id: 1, customerName: event.payload.customerName, amount: event.payload.amount }, }; });
// Heavy: PDF-Generierung in einem dedizierten Worker-Container. // Beide Jobs triggern auf denselben Write-Event — Fan-out-Pattern. // BullMQ führt sie parallel aus (in der Reihenfolge der Queue-Picks). r.job("render-invoice-pdf", { trigger: { on: "orders:write:order:create" }, runIn: "worker", }, async (payload) => { await renderPdf(payload); });
// Heavy: Mail-Versand — triggert auf denselben Write-Event. r.job("send-order-mail", { trigger: { on: "orders:write:order:create" }, runIn: "worker", }, async (payload) => { await sendMail(payload); });});Hinweis zur Chain-Semantik: Jobs haben keinen ctx.appendEvent am JobContext (anders als Write-/Query-Handler). Wenn der PDF-Job einen follow-up Event schreiben soll, um einen separaten Mail-Job zu triggern, muss er entweder einen neuen Write via HTTP/System-Call machen oder den Folge-Job per jobRunner.dispatch(...) direkt starten. In der Regel reicht das Fan-out-Muster oben — mehrere Jobs auf demselben Event — und hält die Event-Kette im Event-Store deklarativ.
Semantik pro Consumer-Typ
| Consumer | runIn | Semantik |
|---|---|---|
r.job(...) | "api" | "worker" | Jobs sind queue-delivered via BullMQ mit einer dedizierten Queue pro Lane (kumiko-jobs-api vs kumiko-jobs-worker). "both" ist nicht erlaubt — eine Lane pro Job. Default = "worker". |
r.multiStreamProjection(...) | "api" | "worker" | "both" | MSPs teilen einen Cursor pro MSP-Name mit SKIP LOCKED. "both" ist semantisch sauber (API + Worker konkurrieren, exakt einer gewinnt). Default = "worker". |
r.hook("postSave", ...) | — | Hooks laufen in-TX im Command-Prozess. Kein runIn möglich (Splitting bricht Atomicity). |
Filter pro Mode
Die Entrypoint-Factory filtert MSP-Consumer und JobRunner-Lanes:
| Mode | MSP-Filter | Job-Queue-Consume |
|---|---|---|
createApiEntrypoint | Dispatcher disabled — keine MSPs laufen | enqueuer-only (schreibt in beide Queues); jobs.runLocalJobs: true startet zusätzlich einen Worker auf kumiko-jobs-api |
createWorkerEntrypoint | MSPs mit runIn ∈ {"worker", "both", undefined-default} | kumiko-jobs-worker |
createAllInOneEntrypoint | Alle MSPs (Filter aus) | Beide Queues (zwei interne Runner) |
Boot-Validation
Die Entrypoint-Factories failen beim Boot wenn die Konfiguration inkonsistent ist:
createApiEntrypointmit deklarierten Jobs aber ohnejobs-Block → Error (Event-getriggerte Enqueues würden silent droppen).createApiEntrypointmit JobsrunIn: "api"aber ohnerunLocalJobs: true→ Error (kein Container würde diese Queue konsumieren).r.job({ runIn: "both" })→ Registry-Error (TS enforced, Runtime-Guard für dynamische Konstruktion).
Wann welche Lane?
Default "worker" ist fast immer richtig:
- CPU-schwere Arbeit (Bild/PDF-Rendering, Exports, Imports)
- I/O-lastige Arbeit (externe APIs, Mail-Versand)
- Batch-Aggregationen
runIn: "api" nur für:
- Leichte, kurz-laufende Jobs (Token-Cleanup, In-Memory-Cache-Warmup) die einen separaten Worker-Container nicht rechtfertigen. Lange oder CPU-schwere Handler auf der API-Lane starven Request-Handler — BullMQ und Hono teilen sich den Event-Loop.
- Topology-Warnung (alle Trigger-Arten, nicht nur Event-Trigger):
runIn: "api"-Jobs brauchen irgendwo im Deploy einen API-Prozess mitjobs.runLocalJobs: true. Ein Worker-only-Fleet konsumiert diekumiko-jobs-api-Queue nicht — egal ob der Job via Event-Trigger enqueued wird, via manuellemjobRunner.dispatch(...)oder via Cron-Scheduler. Der Framework-Boot-Check fängt das im API-Entrypoint selbst (Welle 2.6.c), aber nicht für Worker-only-Deploys gegen denselben Registry-Bestand.
MSP runIn: "both" für:
- Load-Balancing zwischen API und Worker (seltener Use-Case)
- MSPs mit keiner Präferenz — SKIP LOCKED löst den Race sauber.
- Hinweis Welle 2.6: In der aktuellen Welle ist
"both"für MSPs praktisch identisch mit"worker"(Default), weilcreateApiEntrypointden EventDispatcher hart mit{ disabled: true }deaktiviert — im Split-Deploy läuft gar kein API-MSP-Dispatcher. Das Feld ist vorsorglich da für Welle 2.7 (delivery: "per-instance"), wo API-Prozesse einen lokalen Dispatcher für SSE-Broadcast-MSPs starten werden.
Caveat — SSE im Split-Deploy: Der eingebaute SSE-Broker ist pro-Prozess in-memory. Im split api/worker-Deploy erreichen SSE-System-Consumer, die auf dem Worker laufen, die Clients der API-Instanzen NICHT. Welle 2.7 löst das mit delivery: "per-instance" Broadcast. Bis dahin:
- All-in-one fahren, oder
- SSE via eigenem Transport (Redis Pub/Sub) routen.
Redis: Connection tunen
ioredis haelt pro Prozess eine einzige TCP-Verbindung mit einem internen Command-Queue. Das Default-Verhalten ist prod-gefaehrlich:
connectTimeoutDefault 10s — zu lang fuer/health/ready(2s Probe)commandTimeoutDefault keins — unerreichbares Redis haengt Requests auf unbestimmte ZeitmaxRetriesPerRequestDefault 20 — stapelt Retries auf einem toten Verbindungsziel
createRedisClient(url, opts) aus @kumiko/framework/redis setzt brauchbare Defaults (5s connect, 10s command, 3 retries). redisClientOptionsFromEnv() liest:
| Option | Env-Var | Default |
|---|---|---|
connectTimeoutMs | REDIS_CONNECT_TIMEOUT_MS | 5000 |
commandTimeoutMs | REDIS_COMMAND_TIMEOUT_MS | 10000 |
maxRetriesPerRequest | REDIS_MAX_RETRIES_PER_REQUEST | 3 |
import { createRedisClient, redisClientOptionsFromEnv } from "@kumiko/framework/redis";
const redis = createRedisClient(process.env.REDIS_URL, redisClientOptionsFromEnv());Sentinel / Cluster / TLS: extra durchreichen an ioredis:
createRedisClient(url, { extra: { sentinels: [...], name: "mymaster" } });Cache als Skalierungs-Hebel
Reduziert DB-Last massiv:
- Single-Entity Reads: automatisch gecached (Redis, TTL)
- Listen: explizit wenn gewuenscht
- Invalidierung bei Writes automatisch
Was der Entwickler tut
Nichts. Kein if (isCluster), kein manuelles Redis-Sync. Alles stateless by design.