Skip to content

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

  1. Deterministische Startup-Phasen in fester Reihenfolge. Jede Phase hat Preconditions, Timeout, Failure-Verhalten.
  2. Vier Lifecycle-States: startingreadydrainingstopped. /health/ready reflektiert das.
  3. Graceful-Shutdown ist Framework-garantiert, nicht optional. SIGTERM → Drain → Exit in definierter Zeit.
  4. Zero-Downtime-Deploys funktionieren Out-of-the-box bei Rolling/Blue-Green wenn LB/Orchestrator /health/ready und SIGTERM respektiert.
  5. Background-Worker haben Heartbeat-Kontrakt. Stale-Worker werden automatisch erkannt und abgeloest.
  6. 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
+----------+
StateWas passiert/health/readyRequests
startingStartup-Phasen laufen503abgelehnt (503)
readyNormaler Betrieb200akzeptiert
drainingShutdown begonnen503neue abgelehnt, laufende fertig
stoppedAlles 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 aktiviert
9. 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 starten
14. 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

PhaseBei Fehler
Config, Polyfills, Feature-RegisterSofort Exit(1). Kein Retry sinnvoll.
Boot-ValidationSofort Exit(1) mit detaillierter Fehler-Liste
Observability-InitWarning, weiter mit Noop-Provider. Ausfall von Tracing darf Boot nicht blockieren.
DB-Connect, Redis-ConnectRetry mit Exponential-Backoff (1s, 2s, 4s, 8s, 16s, max 30s). Nach 5 Min Gesamt-Dauer: Exit(1).
Migrations-CheckKonfigurierbar: (a) Exit bei ausstehenden Migrations, (b) Auto-Apply, (c) Warning + Weiter
Search-Adapter, File-StorageWarning, weiter. Features die’s brauchen fallen ggf. aus. Nicht kritisch fuer Boot.
Outbox-Poller, Jobs-WorkerRetry 3x, dann Exit — Framework-Garantie braucht funktionale Poller
API-ListenerSofort 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)

  1. Orchestrator startet neue Instanz — State startingready (Boot-Dauer)
  2. Orchestrator addet neue Instanz zum LB sobald /health/ready 200
  3. Orchestrator sendet SIGTERM an alte Instanz
  4. Alte Instanz: draining/health/ready 503 → LB zieht Traffic ab
  5. Alte Instanz drainiert, exited
  6. 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

  1. Neues “green”-Deployment vollstaendig hoch
  2. LB swapped von blue zu green
  3. Blue bleibt warm (Sekunden bis Minuten), dann SIGTERM
  4. 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 auf

Framework 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_seconds pro Worker-Typ — Grafana-Alert wenn >20s
  • /health/ready includes 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):

RessourceRecovery-Mechanismus
Outbox-EventsUnfinished Rows werden vom naechsten Poller via FOR UPDATE SKIP LOCKED neu gepickt. attempts + Backoff verhindert Run-Away.
In-Flight-RequestsHTTP-Client bekommt Connection-Reset, retried selbst (idempotent via Idempotency-Key). Dispatcher-Client-SDK handelt das (siehe error-contract.md).
Laufende JobsBullMQ marked Job als stalled nach Timeout, re-enqueued. Job-Handler muss idempotent sein.
SessionsKein 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-CacheRedis 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

packages/framework/src/lifecycle/lifecycle.ts
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=12
INFO lifecycle phase=observability duration_ms=45
INFO lifecycle phase=featureRegister duration_ms=180
INFO lifecycle phase=bootValidation duration_ms=95
INFO lifecycle phase=dbConnect duration_ms=1420 (retried 2x)
INFO lifecycle phase=migrationsCheck duration_ms=50
INFO lifecycle phase=redisConnect duration_ms=30
INFO lifecycle phase=outboxPoller duration_ms=15
INFO lifecycle phase=jobsWorker duration_ms=20
INFO lifecycle phase=apiListener duration_ms=5
INFO lifecycle state_changed from=starting to=ready

Signal-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-Recovery
Unhandled-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/ready liefert 200 nach ready
  • DB-Down beim Boot: Retry 5x, dann Exit(1) mit klarer Meldung
  • SIGTERM-Flow: /health/ready wird 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

AusbauWarum
Lifecycle-Manager im Framework-KernZentrale State-Verwaltung
Startup-Phase-System mit Timeout/Retry/Fail-VerhaltenDeterministischer Boot
registerShutdownHook fuer Background-TasksGraceful-Teardown
Signal-Handler fuer SIGTERM/SIGINT/Unhandled/UncaughtSauberes Verhalten
/health/ready mit Checks pro KomponenteLB-Integration
Heartbeat-System fuer WorkerStale-Detection + Monitoring
Leader-Election via RedisExclusive-Tasks
Config-Keys fuer Timeouts + Migrations-ModeOperator-Steuerung
Observability-Integration (Spans + Metrics pro Phase)Beobachtbarkeit
lifecycle.state.changed-EventsReactive Features

Build-Reihenfolge

  1. Lifecycle-Manager mit 4 States + Event-Emitter
  2. Startup-Phase-System mit Reihenfolge + Timeouts
  3. Signal-Handler (SIGTERM/SIGINT/etc.)
  4. registerShutdownHook + LIFO-Execution
  5. Drain-Timeout + Force-Close
  6. /health + /health/ready Endpoints
  7. Heartbeat-System + Readiness-Checks
  8. Leader-Election via Redis
  9. Config-Integration
  10. Observability-Instrumentierung
  11. Migration des bestehenden SIGTERM-Handlers in infrastructure.md-Skizze auf neuen Mechanismus
  12. Update infrastructure.md: Shutdown-Abschnitt → Verweis auf lifecycle.md
  13. Integration-Tests inklusive Zwei-Instanz-Leader-Election
  14. Sample
  15. Post-Todos:
    • Kubernetes-Manifest-Template im Sample (PreStop-Hook, terminationGracePeriodSeconds, readinessProbe)
    • Docker-Compose-Beispiel mit mehreren Workern