Lifecycle (Framework-Infrastruktur)
Wie ein Kumiko-Prozess startet, laeuft, und sauber stoppt. Betrifft API-Server, Worker, Outbox-Poller — alles was ein Prozess ist.
Kein Feature, sondern Framework-Infra. Jeder Code-Pfad durch die Pipeline, jeder Background-Worker haengt daran.
Ersetzt den skizzierten Abschnitt “Graceful Shutdown” in infrastructure.md — dort steht ab jetzt nur ein Verweis auf dieses Doc.
Prinzipien
- Deterministische Startup-Phasen in fester Reihenfolge. Jede Phase hat Preconditions, Timeout, Failure-Verhalten.
- Vier Lifecycle-States:
starting→ready→draining→stopped./health/readyreflektiert das. - Graceful-Shutdown ist Framework-garantiert, nicht optional. SIGTERM → Drain → Exit in definierter Zeit.
- Zero-Downtime-Deploys funktionieren Out-of-the-box bei Rolling/Blue-Green wenn LB/Orchestrator
/health/readyund SIGTERM respektiert. - Background-Worker haben Heartbeat-Kontrakt. Stale-Worker werden automatisch erkannt und abgeloest.
- Crash-Recovery nutzt bestehende Mechanismen (Outbox, distributed Locks, Idempotency). Kein eigenes Recovery-Zentrum.
Lifecycle-States
+---------+ startup complete +--------+ | starting| -----------------------> | ready | +---------+ +--------+ | | SIGTERM / SIGINT v +----------+ | draining | +----------+ | | drain complete or timeout v +----------+ | stopped | → process.exit +----------+| State | Was passiert | /health/ready | Requests |
|---|---|---|---|
starting | Startup-Phasen laufen | 503 | abgelehnt (503) |
ready | Normaler Betrieb | 200 | akzeptiert |
draining | Shutdown begonnen | 503 | neue abgelehnt, laufende fertig |
stopped | Alles zu, gleich Exit | (kein Server mehr) | — |
/health bleibt simpel 200 solange der Prozess lebt (fuer LB-Liveness). /health/ready ist der intelligente Check (fuer LB-Routing).
Startup-Phasen
Feste Reihenfolge, jede Phase wartet auf die vorherige:
1. Config laden (kumiko.config.ts, ENV, Defaults)2. Observability-Provider init (ab hier Tracing + Metrics + strukturierte Logs)3. Secrets-Master-Key-Provider (Env / Vault / KMS ready)4. Polyfills / Runtime-Checks (Temporal-Polyfill, Bun-Version-Check)5. Feature-Register (alle Features rufen defineFeature)6. Boot-Validation (siehe boot-validation.md + feature-graph-check)7. DB-Connect + Pool-Init (mit Retry-Backoff)8. Schema-Baseline-Check (api-evolution.md) — falls aktiviert9. Migrations-Check (ausstehende Migrations vorhanden? → konfigurierbar)10. Redis-Connect (mit Retry-Backoff)11. Search-Adapter-Init (Meilisearch-Healthcheck)12. File-Storage-Adapter-Init (S3-kompatibel)13. Outbox-Poller starten14. Jobs-Worker starten (wenn jobs-Feature geladen)15. API-Listener binden (HTTP-Socket offen)16. State → ready (Healthcheck antwortet 200)Pro Phase:
- Preconditions: was die vorherige Phase geliefert haben muss
- Timeout: Default 30s pro Phase
- Fail-Verhalten: siehe unten
Fail-Verhalten pro Phase
| Phase | Bei Fehler |
|---|---|
| Config, Polyfills, Feature-Register | Sofort Exit(1). Kein Retry sinnvoll. |
| Boot-Validation | Sofort Exit(1) mit detaillierter Fehler-Liste |
| Observability-Init | Warning, weiter mit Noop-Provider. Ausfall von Tracing darf Boot nicht blockieren. |
| DB-Connect, Redis-Connect | Retry mit Exponential-Backoff (1s, 2s, 4s, 8s, 16s, max 30s). Nach 5 Min Gesamt-Dauer: Exit(1). |
| Migrations-Check | Konfigurierbar: (a) Exit bei ausstehenden Migrations, (b) Auto-Apply, (c) Warning + Weiter |
| Search-Adapter, File-Storage | Warning, weiter. Features die’s brauchen fallen ggf. aus. Nicht kritisch fuer Boot. |
| Outbox-Poller, Jobs-Worker | Retry 3x, dann Exit — Framework-Garantie braucht funktionale Poller |
| API-Listener | Sofort Exit wenn Port blockiert. |
Jede Phase emittiert Observability-Events (Start, End, Duration). Operator sieht “Boot dauert 4.2s, davon 3.1s fuer DB-Connect”.
Startup-Timeout gesamt
Default 2 Minuten. Konfigurierbar via KUMIKO_STARTUP_TIMEOUT. Wenn Prozess in der Zeit nicht ready ist → Exit(1). Verhindert “haengt fuer immer beim Boot”-Zombies.
Graceful Shutdown
SIGTERM oder SIGINT empfangen │ ├─ state: ready → draining ├─ /health/ready liefert ab jetzt 503 │ (Load-Balancer stoppt Traffic innerhalb einiger Sekunden) │ ├─ LINGER 3s warten │ (damit LB Chance hat, Traffic umzulenken) │ ├─ API-Listener schliessen (keine neuen TCP-Connections) │ (bestehende Requests laufen weiter) │ ├─ Warte auf In-Flight-Requests │ Timeout: 30s (konfigurierbar) │ Nach Timeout: Force-Close + Warning-Log │ ├─ SSE-Broker alle Verbindungen schliessen │ ├─ Outbox-Poller stoppen │ (flush current batch, kein neuer Pick) │ ├─ Jobs-Worker stoppen │ (aktuelle Jobs zu Ende, keine neuen Picks) │ ├─ Redis-Connections schliessen │ ├─ DB-Pool schliessen │ (wartet auf in-flight Queries, dann close) │ ├─ Observability-Flush │ (Traces + Metrics + Logs an Collector schicken) │ ├─ state: draining → stopped │ └─ process.exit(0)Shutdown-Timeout gesamt
Default 40s (3s linger + 30s drain + Rest). Konfigurierbar via KUMIKO_SHUTDOWN_TIMEOUT.
Nach Gesamt-Timeout: Force-Exit(1). Muss passieren, sonst bleibt Prozess haengen und K8s schickt SIGKILL.
Graceful-Shutdown-Kontrakt fuer Background-Tasks
Jede Background-Komponente (Poller, Worker, Scheduler) muss einen Shutdown-Hook registrieren:
lifecycle.registerShutdownHook("outboxPoller", async (signal) => { await poller.drain(); // laufenden Batch abarbeiten await poller.close(); // keine neuen Picks});Framework ruft die Hooks in LIFO-Reihenfolge (zuletzt registriert wird zuerst gestoppt).
Zero-Downtime-Deploys
Das Lifecycle-Modell ist fuer Rolling-Updates gebaut:
Rolling (Default bei K8s/ECS)
- Orchestrator startet neue Instanz — State
starting→ready(Boot-Dauer) - Orchestrator addet neue Instanz zum LB sobald
/health/ready200 - Orchestrator sendet SIGTERM an alte Instanz
- Alte Instanz:
draining→/health/ready503 → LB zieht Traffic ab - Alte Instanz drainiert, exited
- Wiederhole fuer alle Instanzen
Wirkt solange Server stateless ist (keine in-Memory-Session-State etc.). Ist er in Kumiko (Redis/DB sind Source of Truth).
Blue-Green
- Neues “green”-Deployment vollstaendig hoch
- LB swapped von blue zu green
- Blue bleibt warm (Sekunden bis Minuten), dann SIGTERM
- Blue drainiert, exited
Selber Lifecycle — laeuft nur mit mehr gleichzeitigen Instanzen.
Gotcha: Kumiko-Specifics
- Outbox-Poller kann mehrere Prozesse haben → Zero-Downtime funktioniert ohne Sonderbehandlung (FOR UPDATE SKIP LOCKED teilt die Arbeit)
- Scheduled Jobs (Cron-basiert) muessen exclusively gelaufen werden → Leader-Election noetig (siehe unten)
Leader-Election fuer Exclusive-Tasks
Scheduled Jobs (r.job({ cron: ... })) duerfen nicht auf mehreren Instanzen gleichzeitig laufen — zu 09:00 soll ein Report versendet werden, nicht N.
Implementation
Redis-basiert, TTL-Lock:
Beim Boot (Jobs-Worker-Start): SET lifecycle:leader <instanceId> NX EX 15 → wenn erfolgreich: dieser Prozess ist Leader → wenn nicht: dieser Prozess ist Follower, macht keine Cron-Scheduling
Jede 5 Sekunden: SET lifecycle:leader <instanceId> XX EX 15 (TTL refresh) → wenn XX fehlschlaegt: Lock ist weg → nicht mehr Leader → wenn ok: bleibt Leader
Bei Shutdown: DEL lifecycle:leader (nur wenn selber Leader) → naechster Follower nimmt aufFramework macht’s automatisch, Feature-Code hat keinen Zugriff auf “bin ich Leader”.
Was damit geht
- Event-gesteuerte Jobs (
r.job({ trigger: { on: eventQn } })): jeder Worker kann — BullMQ macht Verteilung - Cron-Jobs: nur Leader scheduliert, dann pusht er in BullMQ, Worker picken verteilt
- Manual-Trigger-Jobs: jeder Worker kann
Heartbeat fuer Background-Worker
Alle Background-Komponenten schreiben regelmaessig einen Heartbeat:
Outbox-Poller: SETEX heartbeat:outbox:<instanceId> 10 <timestamp> (alle 5s)Jobs-Worker: SETEX heartbeat:jobs:<instanceId> 10 <timestamp> (alle 5s)Scheduler: SETEX heartbeat:scheduler:<leaderId> 10 <timestamp> (alle 5s)Monitoring
- Metric
kumiko_worker_heartbeat_age_secondspro Worker-Typ — Grafana-Alert wenn >20s /health/readyincludes heartbeat-checks: wenn eigener Worker-Heartbeat stale → 503- Operator-Query: welche Worker-Instanzen leben gerade? Liste aus Redis
Stale-Worker-Detection
Wenn Worker-Heartbeat abgelaufen:
- Andere Worker ignorieren “tote” Kollegen automatisch (kein Split-Brain)
- FOR UPDATE SKIP LOCKED in Outbox sorgt dafuer dass der tote Worker-Lock irgendwann befreit wird (Postgres)
- Leader-Lock expired nach 15s → neuer Leader wird gewaehlt
Crash-Recovery
Nach hartem Crash (OOM, K8s kills, Panic):
| Ressource | Recovery-Mechanismus |
|---|---|
| Outbox-Events | Unfinished Rows werden vom naechsten Poller via FOR UPDATE SKIP LOCKED neu gepickt. attempts + Backoff verhindert Run-Away. |
| In-Flight-Requests | HTTP-Client bekommt Connection-Reset, retried selbst (idempotent via Idempotency-Key). Dispatcher-Client-SDK handelt das (siehe error-contract.md). |
| Laufende Jobs | BullMQ marked Job als stalled nach Timeout, re-enqueued. Job-Handler muss idempotent sein. |
| Sessions | Kein Recovery noetig — DB ist Source of Truth, Redis-Cache fuellt sich bei naechstem Request wieder. |
| Distributed-Locks (Leader-Election) | TTL abgelaufen → andere Instanz waehlt sich selbst. |
| Idempotency-Cache | Redis mit TTL 5 Min — bei Crash gehen in-flight-Markierungen verloren, Retry entdeckt’s per DB-State. |
Kein eigenes Recovery-Zentrum noetig — jede Komponente hat ihren Mechanismus.
/health und /health/ready
/health — Liveness
GET /health → 200 OK (immer, solange Prozess lebt)Body: { "status": "alive" }Fuer LB/K8s Liveness-Probe. Einfachst moeglich.
/health/ready — Readiness
GET /health/ready → 200 oder 503
Body (200):{ "status": "ready", "state": "ready", "uptimeSec": 342, "checks": { "db": { "ok": true, "latencyMs": 12 }, "redis": { "ok": true, "latencyMs": 1 }, "observability": { "ok": true, "provider": "otlp" }, "outboxPoller": { "ok": true, "heartbeatAgoSec": 3 }, "jobsWorker": { "ok": true, "heartbeatAgoSec": 2 }, "schedulerLeader": { "ok": true, "isLeader": true } }}
Body (503):{ "status": "not_ready", "state": "draining" | "starting", "checks": { ... mit einem oder mehreren "ok": false ... }}Fuer LB-Routing-Decisions. 503 → LB entfernt Instanz aus Pool.
Framework-API
export type LifecycleState = "starting" | "ready" | "draining" | "stopped";
export interface Lifecycle { state(): LifecycleState; onStateChange(cb: (from: LifecycleState, to: LifecycleState) => void): () => void;
registerStartupPhase(name: string, fn: () => Promise<void>, opts?: { timeout?: number; onFail?: "exit" | "warn" | "retry"; }): void;
registerShutdownHook(name: string, fn: (signal: string) => Promise<void>): void;
registerHeartbeat(name: string): HeartbeatHandle; registerReadinessCheck(name: string, fn: () => Promise<{ ok: boolean; [k: string]: unknown }>): void;}
// Wird als `ctx.lifecycle` nicht bereitgestellt — Feature-Code braucht's nicht.// Framework-internes Tool.Feature-Autoren rufen das nicht. Wird ausschliesslich vom Framework selbst genutzt.
Ausnahme: ein Core-Feature (z.B. core-jobs) kann einen Shutdown-Hook registrieren weil es selbst Background-Arbeit macht. Aber App-Features haben das nicht noetig.
Config-Keys
In kumiko.config.ts:
export default { lifecycle: { startupTimeoutSec: 120, // Gesamt-Timeout fuer Boot shutdownTimeoutSec: 40, // Gesamt fuer Drain lingerSec: 3, // Linger zwischen SIGTERM und Listener-Close drainTimeoutSec: 30, // Max in-flight Request-Drain migrations: { mode: "exit-on-pending" | "auto-apply" | "warn-on-pending", }, leaderElection: { enabled: true, lockTtlSec: 15, heartbeatIntervalSec: 5, }, },};Defaults funktionieren out-of-the-box — nur wenn Deployment spezielle Bedingungen hat (sehr langer Boot, sehr lange Requests, etc.) muss Operator anpassen.
Observability-Integration
Jede Phase + Shutdown-Hook emittiert:
- Span
lifecycle.startup.<phase>mit Duration - Metric
kumiko_startup_phase_duration_seconds{phase=...} - Log Info pro Phase mit Duration
- Event
lifecycle.state.changed { from, to }— andere Features koennen reagieren (z.B. Delivery “Server ist gestartet”)
Beispiel-Startup-Log:
INFO lifecycle phase=config duration_ms=12INFO lifecycle phase=observability duration_ms=45INFO lifecycle phase=featureRegister duration_ms=180INFO lifecycle phase=bootValidation duration_ms=95INFO lifecycle phase=dbConnect duration_ms=1420 (retried 2x)INFO lifecycle phase=migrationsCheck duration_ms=50INFO lifecycle phase=redisConnect duration_ms=30INFO lifecycle phase=outboxPoller duration_ms=15INFO lifecycle phase=jobsWorker duration_ms=20INFO lifecycle phase=apiListener duration_ms=5INFO lifecycle state_changed from=starting to=readySignal-Handling
SIGTERM, SIGINT → graceful shutdown (state → draining)SIGHUP → ignoriert (keine Reload-Semantik in Kumiko — neuer Prozess statt Reload)SIGKILL → kann nicht gefangen werden (OS killt), Recovery ueber Crash-RecoveryUnhandled-Rejection → Log + Metric, kein Prozess-Kill (Node-default)Uncaught-Exception → Log + Metric + graceful shutdown (state → draining)Uncaught-Exceptions fuehren zu geordnetem Shutdown, weil Prozess-State jetzt unbekannt ist. K8s/Orchestrator restarted dann.
Was NICHT im Scope ist
- Hot-Reload von Features — Kumiko deployt neu statt reloaden. Simpler und zuverlaessiger.
- Phased Rollout / Canary — Orchestrator-Aufgabe (K8s, ECS), nicht Framework.
- Zero-Downtime-Migrations auf Schema-Ebene — das ist Migration-Strategie, hier verlinken: migrations.md. Framework garantiert nur dass Prozess zero-downtime deployed werden kann.
- Eigenes Monitoring-Dashboard — Observability-Integration liefert Metriken, Grafana macht Dashboards.
- K8s-spezifische CRDs / Operators — nicht Framework.
Tests — der Beweis
Unit
- State-Transitions: starting → ready → draining → stopped, keine Rueckwaertsbewegungen
- Phase-Fail-Verhalten: Config-Fail → Exit, DB-Fail → Retry mit Backoff
- Timeout: Phase laeuft 31s → abgebrochen, Exit
- Heartbeat-Lifecycle: registriert, updated, cleared bei Shutdown
Integration (full-stack)
- Normal-Boot: 16 Phasen gruen,
/health/readyliefert 200 nachready - DB-Down beim Boot: Retry 5x, dann Exit(1) mit klarer Meldung
- SIGTERM-Flow:
/health/readywird 503, laufende Requests werden sauber beendet, keine neue angenommen, Prozess exited in <40s - SIGTERM mit laufender Langzeit-Operation: Drain-Timeout greift, Force-Close mit Warning
- Zwei Instanzen: Leader-Election funktioniert, nur einer schedulet Crons
- Leader-Crash: Follower wird nach 15s Leader
- Outbox-Rows werden beim Start vom ersten Poller gepickt (auch wenn letzte Instanz crashed war)
- Observability-Spans fuer jede Phase emittiert
Sample
Sample kumiko-lifecycle-demo zeigt:
- Boot-Log mit allen Phasen
- SIGTERM-Handling live im Terminal
- Leader-Election-Demo mit zwei Instanzen parallel
Framework-Ausbau
| Ausbau | Warum |
|---|---|
Lifecycle-Manager im Framework-Kern | Zentrale State-Verwaltung |
| Startup-Phase-System mit Timeout/Retry/Fail-Verhalten | Deterministischer Boot |
registerShutdownHook fuer Background-Tasks | Graceful-Teardown |
| Signal-Handler fuer SIGTERM/SIGINT/Unhandled/Uncaught | Sauberes Verhalten |
/health/ready mit Checks pro Komponente | LB-Integration |
| Heartbeat-System fuer Worker | Stale-Detection + Monitoring |
| Leader-Election via Redis | Exclusive-Tasks |
| Config-Keys fuer Timeouts + Migrations-Mode | Operator-Steuerung |
| Observability-Integration (Spans + Metrics pro Phase) | Beobachtbarkeit |
lifecycle.state.changed-Events | Reactive Features |
Build-Reihenfolge
Lifecycle-Manager mit 4 States + Event-Emitter- Startup-Phase-System mit Reihenfolge + Timeouts
- Signal-Handler (SIGTERM/SIGINT/etc.)
registerShutdownHook+ LIFO-Execution- Drain-Timeout + Force-Close
/health+/health/readyEndpoints- Heartbeat-System + Readiness-Checks
- Leader-Election via Redis
- Config-Integration
- Observability-Instrumentierung
- Migration des bestehenden
SIGTERM-Handlers ininfrastructure.md-Skizze auf neuen Mechanismus - Update infrastructure.md: Shutdown-Abschnitt → Verweis auf lifecycle.md
- Integration-Tests inklusive Zwei-Instanz-Leader-Election
- Sample
- Post-Todos:
- Kubernetes-Manifest-Template im Sample (PreStop-Hook, terminationGracePeriodSeconds, readinessProbe)
- Docker-Compose-Beispiel mit mehreren Workern