Skip to content

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 ──┘
PostgreSQL

Jobs: 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 ──────────────┘
PostgreSQL

Gruende:

  • 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:

OptionEnv-VarDefaultWann setzen
maxConnectionsDATABASE_POOL_MAX10Erhoehen wenn API-Instanz >10 parallele Requests halten soll. max × num_workers darf die DB-max_connections nicht ueberschreiten.
idleTimeoutSecondsDATABASE_POOL_IDLE_TIMEOUTkeep-alive30–60s setzen wenn die App hinter PgBouncer laeuft — sonst haelt ein einzelner Burst Connections auf unbestimmte Zeit.
connectTimeoutSecondsDATABASE_POOL_CONNECT_TIMEOUTkeins2–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 max pro Instanz. Ein einzelner Bun-Prozess mit max=200 verteilt 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:

ModeHTTPEvent-DispatcherJob-RunnerEinsatz
createApiEntrypoint❌ (disabled)N Replicas hinter LB, stateless
createWorkerEntrypoint✅ (gestartet)✅ (BullMQ)1–wenige Replicas, SKIP LOCKED serialisiert
createAllInOneEntrypointDev, 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:

AnforderungPatternAtomar mit Write?Retry?Cross-Aggregate?
Validierung / DB-Side-Effect der MIT dem Write rollback mussr.hook("preSave"|"postSave")✅ in-TX❌ (throw → Rollback)
Read-Model / Saga / Cross-Aggregate-Reaktionr.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

ConsumerrunInSemantik
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:

ModeMSP-FilterJob-Queue-Consume
createApiEntrypointDispatcher disabled — keine MSPs laufenenqueuer-only (schreibt in beide Queues); jobs.runLocalJobs: true startet zusätzlich einen Worker auf kumiko-jobs-api
createWorkerEntrypointMSPs mit runIn ∈ {"worker", "both", undefined-default}kumiko-jobs-worker
createAllInOneEntrypointAlle MSPs (Filter aus)Beide Queues (zwei interne Runner)

Boot-Validation

Die Entrypoint-Factories failen beim Boot wenn die Konfiguration inkonsistent ist:

  • createApiEntrypoint mit deklarierten Jobs aber ohne jobs-Block → Error (Event-getriggerte Enqueues würden silent droppen).
  • createApiEntrypoint mit Jobs runIn: "api" aber ohne runLocalJobs: 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 mit jobs.runLocalJobs: true. Ein Worker-only-Fleet konsumiert die kumiko-jobs-api-Queue nicht — egal ob der Job via Event-Trigger enqueued wird, via manuellem jobRunner.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), weil createApiEntrypoint den 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:

  • connectTimeout Default 10s — zu lang fuer /health/ready (2s Probe)
  • commandTimeout Default keins — unerreichbares Redis haengt Requests auf unbestimmte Zeit
  • maxRetriesPerRequest Default 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:

OptionEnv-VarDefault
connectTimeoutMsREDIS_CONNECT_TIMEOUT_MS5000
commandTimeoutMsREDIS_COMMAND_TIMEOUT_MS10000
maxRetriesPerRequestREDIS_MAX_RETRIES_PER_REQUEST3
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.